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 = '
Status
Uploading...
'; 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 = `
${label}
${value}
`; analysisGrid.appendChild(card); } function addDetail(parent, label, value) { const row = document.createElement("div"); row.className = "detail-row"; row.innerHTML = `${label}${value}`; parent.appendChild(row); } // Track checkboxes function buildTrackCheckboxes() { trackCheckboxes.innerHTML = ""; if (!currentAnalysis) return; currentAnalysis.tracks.forEach((track, idx) => { const label = document.createElement("label"); label.innerHTML = ` ${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 = '
Loading...
'; trackDetail.classList.remove("hidden"); try { const resp = await fetch(`/api/session/${sessionId}/track/${index}`); if (!resp.ok) { graphContainer.innerHTML = '
Failed to load track data
'; return; } const data = await resp.json(); renderGraphs(data); } catch (e) { graphContainer.innerHTML = '
Error loading data
'; } } 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 = '
No control data in this track
'; } } 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 = `${Math.round(minVal)}${Math.round(maxVal)}`; 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"); }