MIDI Tools - Tesla Coil MIDI Processing Suite
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

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 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");
}