You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
897 lines
30 KiB
897 lines
30 KiB
const GENERAL_MIDI_PROGRAMS = [
|
|
"Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano",
|
|
"Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet",
|
|
"Celesta", "Glockenspiel", "Music Box", "Vibraphone",
|
|
"Marimba", "Xylophone", "Tubular Bells", "Dulcimer",
|
|
"Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ",
|
|
"Reed Organ", "Accordion", "Harmonica", "Tango Accordion",
|
|
"Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)",
|
|
"Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar Harmonics",
|
|
"Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass",
|
|
"Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2",
|
|
"Violin", "Viola", "Cello", "Contrabass",
|
|
"Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani",
|
|
"String Ensemble 1", "String Ensemble 2", "Synth Strings 1", "Synth Strings 2",
|
|
"Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit",
|
|
"Trumpet", "Trombone", "Tuba", "Muted Trumpet",
|
|
"French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2",
|
|
"Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax",
|
|
"Oboe", "English Horn", "Bassoon", "Clarinet",
|
|
"Piccolo", "Flute", "Recorder", "Pan Flute",
|
|
"Blown Bottle", "Shakuhachi", "Whistle", "Ocarina",
|
|
"Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)",
|
|
"Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)",
|
|
"Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)",
|
|
"Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)",
|
|
"FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)",
|
|
"FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)",
|
|
"Sitar", "Banjo", "Shamisen", "Koto",
|
|
"Kalimba", "Bag Pipe", "Fiddle", "Shanai",
|
|
"Tinkle Bell", "Agogo", "Steel Drums", "Woodblock",
|
|
"Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal",
|
|
"Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet",
|
|
"Telephone Ring", "Helicopter", "Applause", "Gunshot"
|
|
];
|
|
|
|
const TOOLS = {
|
|
baketempo: {
|
|
label: "Bake Tempo",
|
|
description: "Removes all tempo change messages and bakes the playback speed into absolute event timing. The output file uses a single constant tempo while preserving the original playback speed.",
|
|
channelSelect: false,
|
|
params: []
|
|
},
|
|
monofy: {
|
|
label: "Monofy (Split Polyphonic)",
|
|
description: "Splits polyphonic tracks into separate monophonic tracks while preserving channel assignments. Useful for splitting chords across multiple Tesla Coil outputs.",
|
|
channelSelect: false,
|
|
trackSelect: true,
|
|
params: []
|
|
},
|
|
reduncheck: {
|
|
label: "Remove Redundancy",
|
|
description: "Detects and removes redundant MIDI data such as consecutive duplicate messages and repeated control change values.",
|
|
channelSelect: false,
|
|
trackSelect: true,
|
|
params: []
|
|
},
|
|
velfix: {
|
|
label: "Velocity Fix",
|
|
description: "Remaps note velocities into a target range. The existing velocity spread is scaled to fit between the min and max values, preserving relative dynamics.",
|
|
channelSelect: false,
|
|
trackSelect: true,
|
|
params: ["velocity"]
|
|
},
|
|
type0: {
|
|
label: "Convert to Type 0",
|
|
description: "Merges all tracks into a single track, converting the file to MIDI Type 0 format.",
|
|
channelSelect: false,
|
|
params: []
|
|
}
|
|
};
|
|
|
|
// DOM elements
|
|
const dropZone = document.getElementById("drop-zone");
|
|
const fileInput = document.getElementById("file-input");
|
|
const workspace = document.getElementById("workspace");
|
|
const fileName = document.getElementById("file-name");
|
|
const clearBtn = document.getElementById("clear-file");
|
|
const downloadBtn = document.getElementById("download-btn");
|
|
const toolSelect = document.getElementById("tool");
|
|
const toolDescription = document.getElementById("tool-description");
|
|
const paramsDiv = document.getElementById("params");
|
|
const paramVelocity = document.getElementById("param-velocity");
|
|
const processBtn = document.getElementById("process-btn");
|
|
const statusDiv = document.getElementById("status");
|
|
const analysisGrid = document.getElementById("analysis-grid");
|
|
const trackList = document.getElementById("track-list");
|
|
const historySection = document.getElementById("history-section");
|
|
const historyList = document.getElementById("history-list");
|
|
const undoBtn = document.getElementById("undo-btn");
|
|
|
|
const trackDetail = document.getElementById("track-detail");
|
|
const detailTrackName = document.getElementById("detail-track-name");
|
|
const graphContainer = document.getElementById("graph-container");
|
|
const closeDetailBtn = document.getElementById("close-detail");
|
|
const midiPlayer = document.getElementById("midi-player");
|
|
const trackSection = document.getElementById("track-section");
|
|
const trackCheckboxes = document.getElementById("track-checkboxes");
|
|
const trackToggle = document.getElementById("track-toggle");
|
|
const trackChannelSelect = document.getElementById("track-channel");
|
|
const trackInstrumentSelect = document.getElementById("track-instrument");
|
|
const trackEditSaveBtn = document.getElementById("track-edit-save");
|
|
const deleteTrackBtn = document.getElementById("delete-track-btn");
|
|
const mergeBtn = document.getElementById("merge-btn");
|
|
|
|
// Populate channel dropdown (1-16)
|
|
for (let i = 1; i <= 16; i++) {
|
|
const opt = document.createElement("option");
|
|
opt.value = i;
|
|
opt.textContent = i;
|
|
trackChannelSelect.appendChild(opt);
|
|
}
|
|
|
|
// Populate instrument dropdown
|
|
GENERAL_MIDI_PROGRAMS.forEach((name, idx) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = idx;
|
|
opt.textContent = `${idx}: ${name}`;
|
|
trackInstrumentSelect.appendChild(opt);
|
|
});
|
|
|
|
// State
|
|
let sessionId = null;
|
|
let selectedTrackIndex = null;
|
|
let currentBlobUrl = null;
|
|
let currentAnalysis = null;
|
|
|
|
// File upload
|
|
dropZone.addEventListener("click", () => fileInput.click());
|
|
fileInput.addEventListener("change", (e) => {
|
|
if (e.target.files.length) uploadFile(e.target.files[0]);
|
|
});
|
|
|
|
dropZone.addEventListener("dragover", (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.add("dragover");
|
|
});
|
|
|
|
dropZone.addEventListener("dragleave", () => {
|
|
dropZone.classList.remove("dragover");
|
|
});
|
|
|
|
dropZone.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove("dragover");
|
|
if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
|
|
});
|
|
|
|
clearBtn.addEventListener("click", () => {
|
|
sessionId = null;
|
|
fileInput.value = "";
|
|
workspace.classList.add("hidden");
|
|
dropZone.classList.remove("hidden");
|
|
statusDiv.classList.add("hidden");
|
|
updatePlayerSource();
|
|
});
|
|
|
|
async function uploadFile(file) {
|
|
const name = file.name.toLowerCase();
|
|
if (!name.endsWith(".mid") && !name.endsWith(".midi")) {
|
|
showStatus("Please select a .mid or .midi file", "error");
|
|
return;
|
|
}
|
|
|
|
fileName.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
|
|
dropZone.classList.add("hidden");
|
|
workspace.classList.remove("hidden");
|
|
processBtn.disabled = true;
|
|
analysisGrid.innerHTML = '<div class="metric-card"><div class="label">Status</div><div class="value">Uploading...</div></div>';
|
|
trackList.innerHTML = "";
|
|
historySection.classList.add("hidden");
|
|
statusDiv.classList.add("hidden");
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
try {
|
|
const resp = await fetch("/api/session/upload", {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
let msg = `Error ${resp.status}`;
|
|
try { const err = await resp.json(); msg = err.detail || msg; } catch {}
|
|
showStatus(msg, "error");
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
sessionId = data.session_id;
|
|
renderAnalysis(data.analysis);
|
|
renderHistory(data.history);
|
|
updatePlayerSource();
|
|
processBtn.disabled = false;
|
|
} catch (e) {
|
|
showStatus(`Upload failed: ${e.message}`, "error");
|
|
}
|
|
}
|
|
|
|
// Player source management
|
|
async function updatePlayerSource() {
|
|
if (midiPlayer.playing) {
|
|
midiPlayer.stop();
|
|
}
|
|
if (currentBlobUrl) {
|
|
URL.revokeObjectURL(currentBlobUrl);
|
|
currentBlobUrl = null;
|
|
}
|
|
if (!sessionId) {
|
|
midiPlayer.src = "";
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/download`);
|
|
if (!resp.ok) return;
|
|
const blob = await resp.blob();
|
|
currentBlobUrl = URL.createObjectURL(blob);
|
|
midiPlayer.src = currentBlobUrl;
|
|
} catch (e) {
|
|
console.warn("Failed to load MIDI for playback:", e);
|
|
}
|
|
}
|
|
|
|
// Download
|
|
downloadBtn.addEventListener("click", async () => {
|
|
if (!sessionId) return;
|
|
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/download`);
|
|
if (!resp.ok) {
|
|
showStatus("Download failed", "error");
|
|
return;
|
|
}
|
|
const blob = await resp.blob();
|
|
const disposition = resp.headers.get("Content-Disposition");
|
|
let downloadName = "output.mid";
|
|
if (disposition) {
|
|
const match = disposition.match(/filename="?(.+?)"?$/);
|
|
if (match) downloadName = match[1];
|
|
}
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = downloadName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
showStatus(`Download failed: ${e.message}`, "error");
|
|
}
|
|
});
|
|
|
|
// Analysis rendering
|
|
function renderAnalysis(data) {
|
|
currentAnalysis = data;
|
|
analysisGrid.innerHTML = "";
|
|
|
|
addMetric("Song Title", data.song_title || "Unknown");
|
|
|
|
if (data.tempo.min_bpm === data.tempo.max_bpm) {
|
|
addMetric("Tempo", `${Math.round(data.tempo.min_bpm)} BPM`);
|
|
} else {
|
|
addMetric("Tempo", `${Math.round(data.tempo.min_bpm)} - ${Math.round(data.tempo.max_bpm)} BPM`);
|
|
}
|
|
|
|
addMetric("Tracks", `${data.tracks.length}`);
|
|
|
|
const allChannels = new Set();
|
|
data.tracks.forEach(t => t["Channel Assignment"].forEach(ch => allChannels.add(ch)));
|
|
const channelList = [...allChannels].sort((a, b) => a - b);
|
|
addMetric("Channels", channelList.length > 0 ? channelList.join(", ") : "None");
|
|
|
|
if (data.pitch_bend.min_semitones !== 0 || data.pitch_bend.max_semitones !== 0) {
|
|
addMetric("Pitch Bend", `${data.pitch_bend.min_semitones} to ${data.pitch_bend.max_semitones} st`);
|
|
}
|
|
|
|
trackList.innerHTML = "";
|
|
trackDetail.classList.add("hidden");
|
|
selectedTrackIndex = null;
|
|
|
|
data.tracks.forEach((track, idx) => {
|
|
const card = document.createElement("div");
|
|
card.className = "track-card";
|
|
const displayName = `${idx + 1}. ${track.track_name}`;
|
|
card.addEventListener("click", (e) => {
|
|
if (e.target.type === "checkbox") return; // Don't toggle detail when clicking merge checkbox
|
|
selectTrack(idx, displayName, card);
|
|
});
|
|
|
|
// Merge checkbox
|
|
if (data.tracks.length > 1) {
|
|
const cb = document.createElement("input");
|
|
cb.type = "checkbox";
|
|
cb.className = "merge-checkbox";
|
|
cb.dataset.trackIdx = idx;
|
|
cb.addEventListener("change", updateMergeButton);
|
|
card.appendChild(cb);
|
|
}
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "track-header";
|
|
header.textContent = displayName;
|
|
|
|
track["Channel Assignment"].forEach(ch => {
|
|
const badge = document.createElement("span");
|
|
badge.className = "channel-badge";
|
|
badge.textContent = `CH ${ch}`;
|
|
header.appendChild(badge);
|
|
});
|
|
|
|
card.appendChild(header);
|
|
|
|
if (track["Max Note Velocity"] !== "N/A") {
|
|
if (track["Min Note Velocity"] === track["Max Note Velocity"]) {
|
|
addDetail(card, "Velocity", `${track["Min Note Velocity"]}`);
|
|
} else {
|
|
addDetail(card, "Velocity", `${track["Min Note Velocity"]} - ${track["Max Note Velocity"]}`);
|
|
}
|
|
}
|
|
|
|
if (track["Program Changes"] && track["Program Changes"].length > 0) {
|
|
const instruments = [...new Set(track["Program Changes"].map(pc => pc.instrument_name))];
|
|
addDetail(card, "Instrument", instruments.join(", "));
|
|
}
|
|
|
|
if (track.pitch_bend && (track.pitch_bend.min_semitones !== 0 || track.pitch_bend.max_semitones !== 0)) {
|
|
addDetail(card, "Pitch Bend", `${track.pitch_bend.min_semitones} to ${track.pitch_bend.max_semitones} st`);
|
|
}
|
|
|
|
const sensitivities = Object.entries(track["Pitch Bend Sensitivity"] || {});
|
|
if (sensitivities.length > 0) {
|
|
const vals = sensitivities.map(([ch, val]) => `${ch}: \u00B1${val}`).join(", ");
|
|
addDetail(card, "PB Sensitivity", vals);
|
|
}
|
|
|
|
trackList.appendChild(card);
|
|
});
|
|
|
|
buildTrackCheckboxes();
|
|
updateTool();
|
|
}
|
|
|
|
function addMetric(label, value) {
|
|
const card = document.createElement("div");
|
|
card.className = "metric-card";
|
|
card.innerHTML = `<div class="label">${label}</div><div class="value">${value}</div>`;
|
|
analysisGrid.appendChild(card);
|
|
}
|
|
|
|
function addDetail(parent, label, value) {
|
|
const row = document.createElement("div");
|
|
row.className = "detail-row";
|
|
row.innerHTML = `<span>${label}</span><span class="detail-value">${value}</span>`;
|
|
parent.appendChild(row);
|
|
}
|
|
|
|
// Track checkboxes
|
|
function buildTrackCheckboxes() {
|
|
trackCheckboxes.innerHTML = "";
|
|
if (!currentAnalysis) return;
|
|
currentAnalysis.tracks.forEach((track, idx) => {
|
|
const label = document.createElement("label");
|
|
label.innerHTML = `<input type="checkbox" value="${idx}" checked> ${idx + 1}. ${track.track_name}`;
|
|
trackCheckboxes.appendChild(label);
|
|
});
|
|
}
|
|
|
|
function getSelectedTracks() {
|
|
const checked = trackCheckboxes.querySelectorAll("input:checked");
|
|
return [...checked].map(cb => parseInt(cb.value));
|
|
}
|
|
|
|
trackToggle.addEventListener("click", () => {
|
|
const boxes = trackCheckboxes.querySelectorAll("input");
|
|
const allChecked = [...boxes].every(cb => cb.checked);
|
|
boxes.forEach(cb => cb.checked = !allChecked);
|
|
trackToggle.textContent = allChecked ? "Select All" : "Deselect All";
|
|
});
|
|
|
|
// Tool selection
|
|
toolSelect.addEventListener("change", updateTool);
|
|
updateTool();
|
|
|
|
function updateTool() {
|
|
const tool = TOOLS[toolSelect.value];
|
|
toolDescription.textContent = tool.description;
|
|
|
|
if (tool.trackSelect && currentAnalysis && currentAnalysis.tracks.length > 0) {
|
|
trackSection.classList.remove("hidden");
|
|
} else {
|
|
trackSection.classList.add("hidden");
|
|
}
|
|
|
|
if (tool.params.includes("velocity")) {
|
|
paramsDiv.classList.remove("hidden");
|
|
paramVelocity.classList.remove("hidden");
|
|
} else {
|
|
paramsDiv.classList.add("hidden");
|
|
paramVelocity.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
// Apply tool
|
|
processBtn.addEventListener("click", async () => {
|
|
if (!sessionId) return;
|
|
|
|
const toolKey = toolSelect.value;
|
|
const tool = TOOLS[toolKey];
|
|
|
|
const body = { tool: toolKey };
|
|
|
|
if (tool.trackSelect && currentAnalysis && currentAnalysis.tracks.length > 0) {
|
|
body.tracks = getSelectedTracks();
|
|
if (body.tracks.length === 0) {
|
|
showStatus("Select at least one track", "error");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (tool.params.includes("velocity")) {
|
|
body.vel_min = parseInt(document.getElementById("vel-min").value);
|
|
body.vel_max = parseInt(document.getElementById("vel-max").value);
|
|
if (isNaN(body.vel_min) || isNaN(body.vel_max) || body.vel_min < 0 || body.vel_max > 127) {
|
|
showStatus("Velocities must be 0-127", "error");
|
|
return;
|
|
}
|
|
if (body.vel_min > body.vel_max) {
|
|
showStatus("Min velocity must be <= max", "error");
|
|
return;
|
|
}
|
|
}
|
|
|
|
processBtn.disabled = true;
|
|
showStatus("Processing...", "loading");
|
|
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/apply`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
let msg = `Error ${resp.status}`;
|
|
try { const err = await resp.json(); msg = err.detail || msg; } catch {}
|
|
showStatus(msg, "error");
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
renderAnalysis(data.analysis);
|
|
renderHistory(data.history);
|
|
updatePlayerSource();
|
|
showStatus("Applied successfully", "success");
|
|
} catch (e) {
|
|
showStatus(`Error: ${e.message}`, "error");
|
|
} finally {
|
|
processBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// Undo
|
|
undoBtn.addEventListener("click", async () => {
|
|
if (!sessionId) return;
|
|
|
|
undoBtn.disabled = true;
|
|
showStatus("Undoing...", "loading");
|
|
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/undo`, {
|
|
method: "POST"
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
let msg = `Error ${resp.status}`;
|
|
try { const err = await resp.json(); msg = err.detail || msg; } catch {}
|
|
showStatus(msg, "error");
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
renderAnalysis(data.analysis);
|
|
renderHistory(data.history);
|
|
updatePlayerSource();
|
|
showStatus("Undone", "success");
|
|
} catch (e) {
|
|
showStatus(`Error: ${e.message}`, "error");
|
|
} finally {
|
|
undoBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// History
|
|
function renderHistory(history) {
|
|
if (history.length === 0) {
|
|
historySection.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
historySection.classList.remove("hidden");
|
|
historyList.innerHTML = "";
|
|
history.forEach(entry => {
|
|
const li = document.createElement("li");
|
|
li.textContent = entry;
|
|
historyList.appendChild(li);
|
|
});
|
|
|
|
undoBtn.disabled = false;
|
|
}
|
|
|
|
// Track detail
|
|
closeDetailBtn.addEventListener("click", () => {
|
|
trackDetail.classList.add("hidden");
|
|
document.querySelectorAll(".track-card.selected").forEach(c => c.classList.remove("selected"));
|
|
selectedTrackIndex = null;
|
|
});
|
|
|
|
trackEditSaveBtn.addEventListener("click", async () => {
|
|
if (!sessionId || selectedTrackIndex === null) return;
|
|
|
|
const body = {
|
|
channel: parseInt(trackChannelSelect.value),
|
|
program: parseInt(trackInstrumentSelect.value)
|
|
};
|
|
|
|
trackEditSaveBtn.disabled = true;
|
|
trackEditSaveBtn.textContent = "Saving...";
|
|
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/track/${selectedTrackIndex}/edit`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
let msg = `Error ${resp.status}`;
|
|
try { const err = await resp.json(); msg = err.detail || msg; } catch {}
|
|
showStatus(msg, "error");
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
const reopenIndex = selectedTrackIndex;
|
|
renderAnalysis(data.analysis);
|
|
renderHistory(data.history);
|
|
updatePlayerSource();
|
|
showStatus("Track updated", "success");
|
|
|
|
// Re-open the track detail panel
|
|
const trackCards = trackList.querySelectorAll(".track-card");
|
|
if (trackCards[reopenIndex] && currentAnalysis.tracks[reopenIndex]) {
|
|
const trackName = `${reopenIndex + 1}. ${currentAnalysis.tracks[reopenIndex].track_name}`;
|
|
selectTrack(reopenIndex, trackName, trackCards[reopenIndex]);
|
|
}
|
|
} catch (e) {
|
|
showStatus(`Error: ${e.message}`, "error");
|
|
} finally {
|
|
trackEditSaveBtn.disabled = false;
|
|
trackEditSaveBtn.textContent = "Save";
|
|
}
|
|
});
|
|
|
|
// Merge button state
|
|
function updateMergeButton() {
|
|
const checked = trackList.querySelectorAll(".merge-checkbox:checked");
|
|
mergeBtn.disabled = checked.length < 2;
|
|
}
|
|
|
|
mergeBtn.addEventListener("click", async () => {
|
|
if (!sessionId) return;
|
|
const checked = trackList.querySelectorAll(".merge-checkbox:checked");
|
|
const indices = [...checked].map(cb => parseInt(cb.dataset.trackIdx));
|
|
if (indices.length < 2) return;
|
|
|
|
if (!confirm(`Merge ${indices.length} tracks into one?`)) return;
|
|
|
|
mergeBtn.disabled = true;
|
|
showStatus("Merging...", "loading");
|
|
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/merge`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ tracks: indices })
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
let msg = `Error ${resp.status}`;
|
|
try { const err = await resp.json(); msg = err.detail || msg; } catch {}
|
|
showStatus(msg, "error");
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
renderAnalysis(data.analysis);
|
|
renderHistory(data.history);
|
|
updatePlayerSource();
|
|
showStatus("Tracks merged", "success");
|
|
} catch (e) {
|
|
showStatus(`Error: ${e.message}`, "error");
|
|
} finally {
|
|
mergeBtn.disabled = true;
|
|
}
|
|
});
|
|
|
|
// Delete track
|
|
deleteTrackBtn.addEventListener("click", async () => {
|
|
if (!sessionId || selectedTrackIndex === null) return;
|
|
|
|
const trackName = detailTrackName.textContent;
|
|
if (!confirm(`Delete "${trackName}"?`)) return;
|
|
|
|
deleteTrackBtn.disabled = true;
|
|
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/track/${selectedTrackIndex}/delete`, {
|
|
method: "POST"
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
let msg = `Error ${resp.status}`;
|
|
try { const err = await resp.json(); msg = err.detail || msg; } catch {}
|
|
showStatus(msg, "error");
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
selectedTrackIndex = null;
|
|
renderAnalysis(data.analysis);
|
|
renderHistory(data.history);
|
|
updatePlayerSource();
|
|
showStatus("Track deleted", "success");
|
|
} catch (e) {
|
|
showStatus(`Error: ${e.message}`, "error");
|
|
} finally {
|
|
deleteTrackBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
async function selectTrack(index, name, cardEl) {
|
|
// Toggle selection
|
|
document.querySelectorAll(".track-card.selected").forEach(c => c.classList.remove("selected"));
|
|
|
|
if (selectedTrackIndex === index) {
|
|
trackDetail.classList.add("hidden");
|
|
selectedTrackIndex = null;
|
|
return;
|
|
}
|
|
|
|
selectedTrackIndex = index;
|
|
cardEl.classList.add("selected");
|
|
detailTrackName.textContent = name;
|
|
|
|
// Pre-populate edit controls
|
|
if (currentAnalysis && currentAnalysis.tracks[index]) {
|
|
const trackData = currentAnalysis.tracks[index];
|
|
const channels = trackData["Channel Assignment"];
|
|
if (channels && channels.length > 0) {
|
|
trackChannelSelect.value = channels[0];
|
|
}
|
|
const pcs = trackData["Program Changes"];
|
|
if (pcs && pcs.length > 0) {
|
|
trackInstrumentSelect.value = pcs[0].program_number;
|
|
} else {
|
|
trackInstrumentSelect.value = 0;
|
|
}
|
|
}
|
|
|
|
graphContainer.innerHTML = '<div class="graph-card"><div class="graph-label">Loading...</div></div>';
|
|
trackDetail.classList.remove("hidden");
|
|
|
|
try {
|
|
const resp = await fetch(`/api/session/${sessionId}/track/${index}`);
|
|
if (!resp.ok) {
|
|
graphContainer.innerHTML = '<div class="graph-card"><div class="graph-label">Failed to load track data</div></div>';
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
renderGraphs(data);
|
|
} catch (e) {
|
|
graphContainer.innerHTML = '<div class="graph-card"><div class="graph-label">Error loading data</div></div>';
|
|
}
|
|
}
|
|
|
|
function renderGraphs(data) {
|
|
graphContainer.innerHTML = "";
|
|
|
|
const totalTicks = data.total_ticks || 1;
|
|
|
|
// Piano roll
|
|
if (data.notes && data.notes.length > 0) {
|
|
addPianoRoll(data.notes, totalTicks);
|
|
}
|
|
|
|
// Velocity graph
|
|
if (data.velocities.length > 0) {
|
|
addGraph("Note Velocity", data.velocities, totalTicks, 0, 127, "#69db7c");
|
|
}
|
|
|
|
// Pitch bend graph
|
|
if (data.pitch_bend.length > 0) {
|
|
const minPB = Math.min(...data.pitch_bend.map(p => p[1]));
|
|
const maxPB = Math.max(...data.pitch_bend.map(p => p[1]));
|
|
addGraph("Pitch Bend", data.pitch_bend, totalTicks, Math.min(minPB, -1), Math.max(maxPB, 1), "#ffd43b", true);
|
|
}
|
|
|
|
// Control change graphs
|
|
for (const [cc, info] of Object.entries(data.control_changes)) {
|
|
if (info.data.length > 0) {
|
|
addGraph(`${info.name} (CC${cc})`, info.data, totalTicks, 0, 127, "#74c0fc");
|
|
}
|
|
}
|
|
|
|
if (graphContainer.children.length === 0) {
|
|
graphContainer.innerHTML = '<div class="graph-card"><div class="graph-label">No control data in this track</div></div>';
|
|
}
|
|
}
|
|
|
|
const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
|
|
function addPianoRoll(notes, totalTicks) {
|
|
const card = document.createElement("div");
|
|
card.className = "graph-card";
|
|
|
|
const labelDiv = document.createElement("div");
|
|
labelDiv.className = "graph-label";
|
|
labelDiv.textContent = `Piano Roll (${notes.length} notes)`;
|
|
card.appendChild(labelDiv);
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.className = "piano-roll";
|
|
canvas.height = 300;
|
|
card.appendChild(canvas);
|
|
|
|
graphContainer.appendChild(card);
|
|
|
|
requestAnimationFrame(() => {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const h = 300;
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = h * dpr;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const w = rect.width;
|
|
const pad = 2;
|
|
|
|
// Find note range with padding
|
|
let minNote = 127, maxNote = 0;
|
|
for (const n of notes) {
|
|
if (n[0] < minNote) minNote = n[0];
|
|
if (n[0] > maxNote) maxNote = n[0];
|
|
}
|
|
minNote = Math.max(0, minNote - 2);
|
|
maxNote = Math.min(127, maxNote + 2);
|
|
const noteRange = maxNote - minNote + 1;
|
|
const noteHeight = Math.max(1, (h - pad * 2) / noteRange);
|
|
|
|
// Background
|
|
ctx.fillStyle = "#0f0f23";
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// Octave gridlines and labels
|
|
ctx.fillStyle = "#888";
|
|
ctx.font = "9px sans-serif";
|
|
ctx.textBaseline = "middle";
|
|
for (let n = minNote; n <= maxNote; n++) {
|
|
if (n % 12 === 0) {
|
|
const y = h - pad - ((n - minNote + 0.5) / noteRange) * (h - pad * 2);
|
|
ctx.strokeStyle = "#333";
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(w, y);
|
|
ctx.stroke();
|
|
|
|
const octave = Math.floor(n / 12) - 1;
|
|
ctx.fillStyle = "#555";
|
|
ctx.fillText(`C${octave}`, 2, y);
|
|
}
|
|
}
|
|
|
|
// Draw notes
|
|
for (const n of notes) {
|
|
const noteNum = n[0], start = n[1], end = n[2], vel = n[3];
|
|
const x = (start / totalTicks) * w;
|
|
const nw = Math.max(1, ((end - start) / totalTicks) * w);
|
|
const y = h - pad - ((noteNum - minNote + 1) / noteRange) * (h - pad * 2);
|
|
|
|
// Velocity-based brightness
|
|
const lightness = 30 + (vel / 127) * 35;
|
|
ctx.fillStyle = `hsl(145, 60%, ${lightness}%)`;
|
|
ctx.fillRect(x, y, nw, Math.max(1, noteHeight - 1));
|
|
}
|
|
});
|
|
}
|
|
|
|
function addGraph(label, points, totalTicks, minVal, maxVal, color, showZero = false) {
|
|
const card = document.createElement("div");
|
|
card.className = "graph-card";
|
|
|
|
const labelDiv = document.createElement("div");
|
|
labelDiv.className = "graph-label";
|
|
labelDiv.textContent = label;
|
|
card.appendChild(labelDiv);
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.height = 80;
|
|
card.appendChild(canvas);
|
|
|
|
const rangeDiv = document.createElement("div");
|
|
rangeDiv.className = "graph-range";
|
|
rangeDiv.innerHTML = `<span>${Math.round(minVal)}</span><span>${Math.round(maxVal)}</span>`;
|
|
card.appendChild(rangeDiv);
|
|
|
|
graphContainer.appendChild(card);
|
|
|
|
// Draw after DOM insertion so width is resolved
|
|
requestAnimationFrame(() => {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = 80 * dpr;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const w = rect.width;
|
|
const h = 80;
|
|
const pad = 2;
|
|
const range = maxVal - minVal || 1;
|
|
|
|
// Background
|
|
ctx.fillStyle = "#0f0f23";
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// Zero line for pitch bend
|
|
if (showZero && minVal < 0 && maxVal > 0) {
|
|
const zeroY = h - pad - ((0 - minVal) / range) * (h - pad * 2);
|
|
ctx.strokeStyle = "#333";
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, zeroY);
|
|
ctx.lineTo(w, zeroY);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// Data line
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
|
|
for (let i = 0; i < points.length; i++) {
|
|
const x = (points[i][0] / totalTicks) * w;
|
|
const y = h - pad - ((points[i][1] - minVal) / range) * (h - pad * 2);
|
|
|
|
// Step-style for CC data (hold value until next change)
|
|
if (i > 0 && !showZero) {
|
|
const prevY = h - pad - ((points[i - 1][1] - minVal) / range) * (h - pad * 2);
|
|
ctx.lineTo(x, prevY);
|
|
}
|
|
|
|
if (i === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Dot markers if few points
|
|
if (points.length <= 50) {
|
|
ctx.fillStyle = color;
|
|
for (const pt of points) {
|
|
const x = (pt[0] / totalTicks) * w;
|
|
const y = h - pad - ((pt[1] - minVal) / range) * (h - pad * 2);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function showStatus(message, type) {
|
|
statusDiv.textContent = message;
|
|
statusDiv.className = `status ${type}`;
|
|
statusDiv.classList.remove("hidden");
|
|
}
|