Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created June 17, 2026 02:10
Show Gist options
  • Select an option

  • Save EncodeTheCode/1542984cb1a1d6f2484916e3cd1b0d39 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/1542984cb1a1d6f2484916e3cd1b0d39 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multi-Track Audio to MIDI Analyzer</title>
<style>
:root {
--bg: #0f1220;
--panel: #161b2e;
--text: #e9ecff;
--muted: #9aa3c7;
--accent: #7aa2ff;
--accent-2: #56d39b;
--shadow: 0 18px 45px rgba(0,0,0,0.35);
--radius: 18px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, Monaco, monospace;
--sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: radial-gradient(1200px 800px at 20% 0%, #1b2240 0%, var(--bg) 55%);
color: var(--text);
font-family: var(--sans);
}
.app {
max-width: 1500px;
margin: 0 auto;
padding: 22px;
}
header {
display: flex;
gap: 14px;
align-items: end;
justify-content: space-between;
margin-bottom: 18px;
flex-wrap: wrap;
}
h1 { margin: 0; font-size: 28px; }
.subtitle { color: var(--muted); margin-top: 6px; max-width: 980px; line-height: 1.45; }
.card {
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.09);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.controls {
display: grid;
grid-template-columns: 1.25fr 0.9fr 0.9fr 1.2fr 0.95fr;
gap: 14px;
padding: 16px;
margin-bottom: 16px;
}
.control {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 12px;
}
.control label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
letter-spacing: 0.3px;
text-transform: uppercase;
}
input[type="file"], select { width: 100%; color: var(--muted); }
input[type="range"] { width: 100%; }
.range-row { display: flex; align-items: center; gap: 10px; }
.value-pill {
min-width: 62px;
text-align: center;
padding: 6px 10px;
border-radius: 999px;
font-family: var(--mono);
font-size: 12px;
background: rgba(122,162,255,0.12);
border: 1px solid rgba(122,162,255,0.25);
color: #dce6ff;
}
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; }
button {
appearance: none;
border: 0;
border-radius: 12px;
padding: 11px 14px;
font-weight: 700;
cursor: pointer;
background: linear-gradient(180deg, #2b66ff, #2755d1);
color: white;
box-shadow: 0 10px 24px rgba(39,85,209,0.25);
transition: transform 0.08s ease, filter 0.15s ease;
}
button:hover { filter: brightness(1.06); }
button:active { transform: translateY(1px); }
button.secondary {
background: linear-gradient(180deg, #2b334d, #20273d);
box-shadow: none;
border: 1px solid rgba(255,255,255,0.08);
}
button.success {
background: linear-gradient(180deg, #27b36d, #1d8a54);
box-shadow: 0 10px 24px rgba(29,138,84,0.22);
}
button:disabled { opacity: 0.55; cursor: not-allowed; }
.status {
margin: 0 0 16px;
color: var(--muted);
font-family: var(--mono);
white-space: pre-wrap;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 12px 14px;
min-height: 58px;
}
.layout {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 16px;
align-items: start;
}
.panel { padding: 14px; }
.panel h2 { margin: 0 0 10px; font-size: 16px; }
.meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.item {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
}
.k { font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 5px; }
.v { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere; }
.canvas-wrap {
position: relative;
width: 100%;
overflow: auto;
background: rgba(0,0,0,0.12);
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.08);
}
canvas {
display: block;
width: 100%;
min-width: 1200px;
height: 640px;
background: linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.01));
}
.note-list {
max-height: 640px;
overflow: auto;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
}
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
th, td { padding: 9px 10px; border-bottom: 1px solid rgba(255,255,255,0.08); text-align: left; white-space: nowrap; }
th { position: sticky; top: 0; background: #18203a; z-index: 1; color: #d9e1ff; }
td { color: #d4dbf4; }
.small { color: var(--muted); font-size: 12px; line-height: 1.45; }
.footer-note { margin-top: 14px; color: var(--muted); font-size: 12px; line-height: 1.5; }
@media (max-width: 1200px) {
.controls { grid-template-columns: 1fr 1fr; }
.layout { grid-template-columns: 1fr; }
canvas { min-width: 900px; }
.meta { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 680px) { .controls { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="app">
<header>
<div>
<h1>Multi-Track Audio → MIDI Analyzer</h1>
<div class="subtitle">
Upload MP3 or WAV, estimate key and tempo, then split the song into playable piano-roll buckets such as lead, bass, mid-range, and high-range content. Use the active track selector to preview and export each MIDI lane separately.
</div>
</div>
<div class="value-pill" id="fileNamePill">No file loaded</div>
</header>
<div class="card controls">
<div class="control">
<label>Audio file</label>
<input id="fileInput" type="file" accept="audio/mpeg,audio/mp3,audio/wav,audio/x-wav,audio/*" />
</div>
<div class="control">
<label>Detection threshold</label>
<div class="range-row">
<input id="threshold" type="range" min="0.01" max="1.00" value="0.28" step="0.01" />
<div class="value-pill" id="thresholdValue">0.28</div>
</div>
</div>
<div class="control">
<label>Active piano roll</label>
<select id="trackSelect" disabled>
<option value="lead">Lead</option>
<option value="bass">Bass</option>
<option value="mid">Mid</option>
<option value="high">High</option>
</select>
</div>
<div class="control">
<label>Actions</label>
<div class="btn-row">
<button id="analyzeBtn" disabled>Analyze</button>
<button id="exportActiveBtn" class="success" disabled>Export Active MIDI</button>
<button id="exportAllBtn" class="secondary" disabled>Export All MIDI</button>
</div>
</div>
<div class="control">
<label>Reset</label>
<div class="btn-row">
<button id="clearBtn" class="secondary">Clear</button>
</div>
</div>
</div>
<div class="status card" id="status">Load an audio file to begin.</div>
<div class="card panel" style="margin-bottom:16px;">
<h2>Detected musical summary</h2>
<div class="meta">
<div class="item"><div class="k">Estimated key</div><div class="v" id="keyOut">—</div></div>
<div class="item"><div class="k">Estimated tempo</div><div class="v" id="tempoOut">—</div></div>
<div class="item"><div class="k">Active track notes</div><div class="v" id="notesOut">—</div></div>
<div class="item"><div class="k">Song length</div><div class="v" id="durationOut">—</div></div>
</div>
<div class="small" id="summaryText">No analysis yet.</div>
<div class="small" id="trackSummary" style="margin-top:8px;"></div>
</div>
<div class="layout">
<div class="card panel">
<h2>Piano roll preview</h2>
<div class="canvas-wrap"><canvas id="roll" width="1600" height="640"></canvas></div>
<div class="footer-note">
The active track preview uses a 30-bar piano-roll view. Bass, mid, and high lanes are derived from frequency-bucket filtering, so they behave like practical arrangement lanes rather than perfectly isolated stems.
</div>
</div>
<div class="card panel">
<h2>Detected note events</h2>
<div class="note-list">
<table>
<thead>
<tr>
<th>#</th><th>Pitch</th><th>Start</th><th>End</th><th>Dur</th><th>Vel</th><th>Conf</th>
</tr>
</thead>
<tbody id="notesTableBody">
<tr><td colspan="7" style="color:var(--muted);">No notes yet.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(() => {
const fileInput = document.getElementById('fileInput');
const analyzeBtn = document.getElementById('analyzeBtn');
const exportActiveBtn = document.getElementById('exportActiveBtn');
const exportAllBtn = document.getElementById('exportAllBtn');
const clearBtn = document.getElementById('clearBtn');
const threshold = document.getElementById('threshold');
const thresholdValue = document.getElementById('thresholdValue');
const trackSelect = document.getElementById('trackSelect');
const statusEl = document.getElementById('status');
const fileNamePill = document.getElementById('fileNamePill');
const keyOut = document.getElementById('keyOut');
const tempoOut = document.getElementById('tempoOut');
const notesOut = document.getElementById('notesOut');
const durationOut = document.getElementById('durationOut');
const summaryText = document.getElementById('summaryText');
const trackSummary = document.getElementById('trackSummary');
const notesTableBody = document.getElementById('notesTableBody');
const canvas = document.getElementById('roll');
const ctx = canvas.getContext('2d');
let audioBuffer = null;
let analysis = null;
let currentFileName = '';
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const MAJOR_PROFILE = [6.35,2.23,3.48,2.33,4.38,4.09,2.52,5.19,2.39,3.66,2.29,2.88];
const MINOR_PROFILE = [6.33,2.68,3.52,5.38,2.60,3.53,2.54,4.75,3.98,2.69,3.34,3.17];
const TRACKS = {
lead: { label: 'Lead', minFreq: 55, maxFreq: 1200, bandLow: 120, bandHigh: 5000 },
bass: { label: 'Bass', minFreq: 30, maxFreq: 260, bandLow: 40, bandHigh: 320 },
mid: { label: 'Mid', minFreq: 120, maxFreq: 1000, bandLow: 180, bandHigh: 1600 },
high: { label: 'High', minFreq: 300, maxFreq: 2200, bandLow: 900, bandHigh: 7000 },
};
const SETTINGS = {
frameSize: 2048,
hopSize: 512,
minNoteMs: 120,
maxGapMs: 80,
sustainMergeMs: 110,
tempoClampMin: 60,
tempoClampMax: 180,
barsToShow: 30,
beatsPerBar: 4,
ppq: 480,
canvasLeftPad: 56,
canvasTopPad: 16,
canvasBottomPad: 28,
};
threshold.addEventListener('input', () => {
thresholdValue.textContent = Number(threshold.value).toFixed(2);
});
trackSelect.addEventListener('change', () => {
if (!analysis) return;
renderActiveTrack();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
currentFileName = file.name;
fileNamePill.textContent = file.name;
setStatus(`Loading ${file.name} ...`);
analyzeBtn.disabled = true;
exportActiveBtn.disabled = true;
exportAllBtn.disabled = true;
trackSelect.disabled = true;
try {
const arrayBuffer = await file.arrayBuffer();
const ac = getAudioContext();
audioBuffer = await ac.decodeAudioData(arrayBuffer.slice(0));
setStatus(`Loaded ${file.name}. Ready to analyze.`);
analyzeBtn.disabled = false;
drawEmptyCanvas('File loaded. Press Analyze to detect lanes.');
} catch (err) {
console.error(err);
setStatus('Failed to decode audio. Please try a different MP3/WAV file.');
audioBuffer = null;
analyzeBtn.disabled = true;
}
});
analyzeBtn.addEventListener('click', async () => {
if (!audioBuffer) return;
analyzeBtn.disabled = true;
exportActiveBtn.disabled = true;
exportAllBtn.disabled = true;
trackSelect.disabled = true;
try {
const thr = Number(threshold.value);
setStatus('Analyzing tracks ... 0%');
await waitFrame();
analysis = await analyzeAudioBuffer(audioBuffer, thr, (p) => setStatus(`Analyzing tracks ... ${p}%`));
trackSelect.disabled = false;
exportActiveBtn.disabled = false;
exportAllBtn.disabled = false;
if (!trackSelect.value) trackSelect.value = 'lead';
renderSummary(analysis);
renderActiveTrack();
setStatus(`Analysis complete. Extracted ${Object.values(analysis.tracks).reduce((a,t)=>a+t.notes.length,0)} total note events.`);
} catch (err) {
console.error(err);
setStatus('Analysis failed. The file may be unsupported or too noisy for reliable pitch tracking.');
drawEmptyCanvas('Analysis failed.');
} finally {
analyzeBtn.disabled = !audioBuffer;
}
});
exportActiveBtn.addEventListener('click', () => {
if (!analysis) return;
const trackName = trackSelect.value;
downloadMidiForTrack(trackName, analysis.tracks[trackName], analysis.tempo, analysis.key?.name || 'C');
});
exportAllBtn.addEventListener('click', async () => {
if (!analysis) return;
const base = (currentFileName || 'melody').replace(/\.[^.]+$/, '');
for (const [trackName, track] of Object.entries(analysis.tracks)) {
if (!track.notes.length) continue;
await waitFrame();
downloadMidiForTrack(trackName, track, analysis.tempo, analysis.key?.name || 'C', `${base}_${trackName}.mid`);
}
});
clearBtn.addEventListener('click', () => {
fileInput.value = '';
audioBuffer = null;
analysis = null;
currentFileName = '';
fileNamePill.textContent = 'No file loaded';
keyOut.textContent = '—';
tempoOut.textContent = '—';
notesOut.textContent = '—';
durationOut.textContent = '—';
summaryText.textContent = 'No analysis yet.';
trackSummary.textContent = '';
notesTableBody.innerHTML = '<tr><td colspan="7" style="color:var(--muted);">No notes yet.</td></tr>';
drawEmptyCanvas('Load an audio file to begin.');
setStatus('Cleared. Load a new file to begin.');
analyzeBtn.disabled = true;
exportActiveBtn.disabled = true;
exportAllBtn.disabled = true;
trackSelect.disabled = true;
});
drawEmptyCanvas('Load an audio file to begin.');
function setStatus(text) { statusEl.textContent = text; }
function waitFrame() { return new Promise(r => requestAnimationFrame(() => r())); }
function getAudioContext() {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!window._audioContext) window._audioContext = new Ctor();
return window._audioContext;
}
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function midiToName(midi) { return `${NOTE_NAMES[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`; }
function hzToMidi(hz) { return 69 + 12 * Math.log2(hz / 440); }
function roundToGrid(value, grid) { return Math.round(value / grid) * grid; }
function formatTime(seconds) { if (!isFinite(seconds)) return '—'; const m = Math.floor(seconds / 60); const s = (seconds % 60).toFixed(1).padStart(4, '0'); return `${m}:${s}`; }
function monoFromBuffer(buffer) {
const ch0 = buffer.getChannelData(0);
const ch1 = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : null;
const out = new Float32Array(buffer.length);
if (ch1) { for (let i = 0; i < buffer.length; i++) out[i] = 0.5 * (ch0[i] + ch1[i]); }
else out.set(ch0);
return out;
}
async function analyzeAudioBuffer(buffer, thresholdValue, onProgress) {
const sr = buffer.sampleRate;
const mono = monoFromBuffer(buffer);
const totalFrames = Math.max(1, Math.floor((mono.length - SETTINGS.frameSize) / SETTINGS.hopSize) + 1);
const combinedChroma = new Float64Array(12);
const tracks = {};
const bandDefinitions = [
['lead', TRACKS.lead],
['bass', TRACKS.bass],
['mid', TRACKS.mid],
['high', TRACKS.high],
];
for (let i = 0; i < bandDefinitions.length; i++) {
const [trackName, cfg] = bandDefinitions[i];
onProgress?.(Math.round((i / bandDefinitions.length) * 100));
await waitFrame();
const filtered = applyBandFilter(mono, sr, cfg.bandLow, cfg.bandHigh);
const trackResult = analyzeBandSignal(filtered, sr, cfg.minFreq, cfg.maxFreq, thresholdValue, totalFrames, onProgress, i / bandDefinitions.length);
tracks[trackName] = trackResult;
for (let p = 0; p < 12; p++) combinedChroma[p] += trackResult.chroma[p];
}
const key = estimateKeyFromChroma(combinedChroma);
const allNotes = Object.values(tracks).flatMap(t => t.notes);
const tempo = estimateTempoFromNotes(allNotes) || estimateTempoFromOnsets(allNotes.map(n => ({ time: n.start }))) || 120;
// Quantize all tracks relative to the shared tempo.
const beat = 60 / tempo;
const grid = beat / 4;
for (const track of Object.values(tracks)) {
track.notes = track.notes.map(n => {
const start = Math.max(0, roundToGrid(n.start, grid));
const end = Math.max(start + 0.05, roundToGrid(n.end, grid));
return {
...n,
start,
end,
duration: end - start,
velocity: clamp(Math.round(35 + 90 * n.confidence), 1, 127),
};
}).filter(n => n.duration >= SETTINGS.minNoteMs / 1000);
}
return { tracks, tempo, key, duration: buffer.duration };
}
function applyBandFilter(input, sampleRate, lowCut, highCut) {
// Two-stage biquad cascade: high-pass then low-pass for a stable band focus.
const hp = biquadCoefficients('highpass', sampleRate, lowCut, 0.707);
const lp = biquadCoefficients('lowpass', sampleRate, highCut, 0.707);
const out1 = processBiquad(input, hp);
return processBiquad(out1, lp);
}
function biquadCoefficients(type, sampleRate, frequency, q) {
const w0 = 2 * Math.PI * (frequency / sampleRate);
const cosw = Math.cos(w0);
const sinw = Math.sin(w0);
const alpha = sinw / (2 * q);
let b0, b1, b2, a0, a1, a2;
if (type === 'highpass') {
b0 = (1 + cosw) / 2; b1 = -(1 + cosw); b2 = (1 + cosw) / 2;
a0 = 1 + alpha; a1 = -2 * cosw; a2 = 1 - alpha;
} else {
b0 = (1 - cosw) / 2; b1 = 1 - cosw; b2 = (1 - cosw) / 2;
a0 = 1 + alpha; a1 = -2 * cosw; a2 = 1 - alpha;
}
return { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0 };
}
function processBiquad(input, c) {
const out = new Float32Array(input.length);
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (let i = 0; i < input.length; i++) {
const x0 = input[i];
const y0 = c.b0 * x0 + c.b1 * x1 + c.b2 * x2 - c.a1 * y1 - c.a2 * y2;
out[i] = y0;
x2 = x1; x1 = x0; y2 = y1; y1 = y0;
}
return out;
}
function analyzeBandSignal(signal, sampleRate, minFreq, maxFreq, thresholdValue, totalFrames, onProgress, progressBase) {
const frameSize = SETTINGS.frameSize;
const hopSize = SETTINGS.hopSize;
const window = hannWindow(frameSize);
const minLag = Math.max(2, Math.floor(sampleRate / maxFreq));
const maxLag = Math.min(frameSize - 2, Math.floor(sampleRate / minFreq));
const chroma = new Float64Array(12);
const frames = [];
let maxRms = 1e-9;
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
if (frameIndex % 64 === 0) {
const p = Math.round(progressBase * 100 + ((frameIndex / totalFrames) * 100) / 4);
onProgress?.(clamp(p, 0, 99));
}
const start = frameIndex * hopSize;
const segment = new Float32Array(frameSize);
let rms = 0;
for (let i = 0; i < frameSize; i++) {
const s = (start + i < signal.length) ? signal[start + i] : 0;
const v = s * window[i];
segment[i] = v;
rms += v * v;
}
rms = Math.sqrt(rms / frameSize);
maxRms = Math.max(maxRms, rms);
const pitch = detectPitchAutocorr(segment, sampleRate, minLag, maxLag);
const time = start / sampleRate;
const energyNorm = clamp(rms / maxRms, 0, 1);
const score = pitch.confidence * (0.25 + 0.75 * energyNorm);
const active = score >= thresholdValue && pitch.midi !== null;
const note = {
time,
midi: active ? pitch.midi : null,
score,
confidence: score,
rms,
freq: pitch.freq,
};
frames.push(note);
if (active) chroma[((Math.round(pitch.midi) % 12) + 12) % 12] += score * Math.max(0.25, rms);
}
const notes = groupFramesToNotes(frames, thresholdValue, sampleRate, hopSize, signal.length / sampleRate);
return { notes, chroma };
}
function detectPitchAutocorr(frame, sampleRate, minLag, maxLag) {
let mean = 0; for (let i = 0; i < frame.length; i++) mean += frame[i]; mean /= frame.length;
for (let i = 0; i < frame.length; i++) frame[i] -= mean;
let energy = 0; for (let i = 0; i < frame.length; i++) energy += frame[i] * frame[i];
if (energy < 1e-8) return { midi: null, freq: null, confidence: 0 };
const ac = new Float32Array(maxLag + 1);
for (let lag = minLag; lag <= maxLag; lag++) {
let sum = 0;
for (let i = 0; i < frame.length - lag; i++) sum += frame[i] * frame[i + lag];
ac[lag] = sum / energy;
}
let bestLag = -1, bestVal = -1;
for (let lag = minLag; lag <= maxLag; lag++) {
const val = ac[lag];
if (val > bestVal) { bestVal = val; bestLag = lag; }
}
if (bestLag < 0 || bestVal < 0.18) return { midi: null, freq: null, confidence: 0 };
let refinedLag = bestLag;
if (bestLag > minLag && bestLag < maxLag) {
const a = ac[bestLag - 1], b = ac[bestLag], c = ac[bestLag + 1];
const denom = (a - 2 * b + c);
if (Math.abs(denom) > 1e-9) refinedLag = bestLag + clamp(0.5 * (a - c) / denom, -1, 1);
}
const freq = sampleRate / refinedLag;
const midi = clamp(Math.round(hzToMidi(freq)), 0, 127);
const confidence = clamp((bestVal - 0.15) / 0.85, 0, 1);
return { midi, freq, confidence };
}
function groupFramesToNotes(frames, thresholdValue, sampleRate, hopSize, totalDuration) {
const notes = [];
let active = null;
let lastGoodTime = 0;
const frameSec = hopSize / sampleRate;
const maxGap = SETTINGS.maxGapMs / 1000;
function flush(endTime) {
if (!active) return;
active.end = Math.max(active.start + 0.05, endTime);
active.duration = active.end - active.start;
if (active.duration >= SETTINGS.minNoteMs / 1000) notes.push({ ...active });
active = null;
}
for (const f of frames) {
const nextTime = f.time + frameSec;
if (f.midi !== null && f.confidence >= thresholdValue) {
const midi = f.midi, confidence = f.confidence;
if (!active) {
active = { pitch: midi, start: f.time, end: nextTime, confidence, velocity: clamp(Math.round(30 + 95 * confidence), 1, 127) };
lastGoodTime = f.time;
} else {
const samePitch = Math.abs(midi - active.pitch) <= 1;
const closeEnough = (f.time - lastGoodTime) <= maxGap;
if (samePitch && closeEnough) {
active.end = nextTime;
active.pitch = Math.round(lerp(active.pitch, midi, 0.35));
active.confidence = Math.max(active.confidence, confidence);
active.velocity = Math.max(active.velocity, clamp(Math.round(30 + 95 * confidence), 1, 127));
lastGoodTime = f.time;
} else {
flush(f.time);
active = { pitch: midi, start: f.time, end: nextTime, confidence, velocity: clamp(Math.round(30 + 95 * confidence), 1, 127) };
lastGoodTime = f.time;
}
}
} else if (active) {
if ((f.time - lastGoodTime) <= maxGap) active.end = nextTime;
else flush(f.time);
}
}
flush(totalDuration);
const merged = [];
for (const n of notes) {
const pitch = clamp(Math.round(n.pitch), 0, 127);
const prev = merged[merged.length - 1];
if (prev && prev.pitch === pitch && (n.start - prev.end) <= SETTINGS.sustainMergeMs / 1000) {
prev.end = Math.max(prev.end, n.end);
prev.duration = prev.end - prev.start;
prev.confidence = Math.max(prev.confidence, n.confidence);
prev.velocity = Math.max(prev.velocity, n.velocity);
} else {
merged.push({ ...n, pitch });
}
}
return merged;
}
function hannWindow(size) {
const w = new Float32Array(size);
for (let i = 0; i < size; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
return w;
}
function estimateTempoFromNotes(notes) {
if (notes.length < 3) return null;
const onsets = notes.map(n => n.start).sort((a,b) => a - b);
const diffs = [];
for (let i = 1; i < onsets.length; i++) {
const d = onsets[i] - onsets[i - 1];
if (d >= 0.18 && d <= 1.5) diffs.push(d);
}
if (diffs.length < 2) return null;
diffs.sort((a,b) => a - b);
const med = diffs[Math.floor(diffs.length / 2)];
let bpm = 60 / med;
while (bpm < SETTINGS.tempoClampMin) bpm *= 2;
while (bpm > SETTINGS.tempoClampMax) bpm /= 2;
return clamp(Math.round(bpm), SETTINGS.tempoClampMin, SETTINGS.tempoClampMax);
}
function estimateTempoFromOnsets(onsets) {
if (onsets.length < 6) return null;
const times = onsets.map(o => o.time).sort((a,b) => a - b);
const diffs = [];
for (let i = 1; i < times.length; i++) {
const d = times[i] - times[i - 1];
if (d >= 0.18 && d <= 1.5) diffs.push(d);
}
if (diffs.length < 3) return null;
diffs.sort((a,b) => a - b);
const med = diffs[Math.floor(diffs.length / 2)];
let bpm = 60 / med;
while (bpm < SETTINGS.tempoClampMin) bpm *= 2;
while (bpm > SETTINGS.tempoClampMax) bpm /= 2;
return clamp(Math.round(bpm), SETTINGS.tempoClampMin, SETTINGS.tempoClampMax);
}
function estimateKeyFromChroma(chroma) {
const total = chroma.reduce((a,b) => a + b, 0) || 1;
const norm = Array.from(chroma, v => v / total);
let best = { score: -1e9, root: 0, mode: 'major' };
for (let root = 0; root < 12; root++) {
const majorScore = profileCorrelation(norm, rotate(MAJOR_PROFILE, root));
const minorScore = profileCorrelation(norm, rotate(MINOR_PROFILE, root));
if (majorScore > best.score) best = { score: majorScore, root, mode: 'major' };
if (minorScore > best.score) best = { score: minorScore, root, mode: 'minor' };
}
return { name: `${NOTE_NAMES[best.root]} ${best.mode}`, root: best.root, mode: best.mode, confidence: best.score, chroma: norm };
}
function rotate(arr, semitones) { const out = new Array(12); for (let i = 0; i < 12; i++) out[(i + semitones) % 12] = arr[i]; return out; }
function profileCorrelation(a, b) { let sum = 0, asum = 0, bsum = 0; for (let i = 0; i < 12; i++) { sum += a[i] * b[i]; asum += a[i] * a[i]; bsum += b[i] * b[i]; } return sum / Math.sqrt((asum || 1e-9) * (bsum || 1e-9)); }
function renderSummary(result) {
keyOut.textContent = result.key?.name || 'Unknown';
tempoOut.textContent = `${result.tempo} BPM`;
durationOut.textContent = formatTime(result.duration);
summaryText.textContent = `Detected key: ${result.key?.name || 'Unknown'} (${result.key ? Math.round(result.key.confidence * 100) : 0}% match). The analyzer split the song into separate playable lanes.`;
renderTrackSummary(result);
}
function renderTrackSummary(result) {
const lines = Object.entries(result.tracks).map(([name, track]) => {
return `${name.toUpperCase()}: ${track.notes.length} notes, range ${track.notes.length ? midiToName(Math.min(...track.notes.map(n => n.pitch))) + '–' + midiToName(Math.max(...track.notes.map(n => n.pitch))) : '—'}`;
});
trackSummary.textContent = lines.join(' | ');
}
function renderActiveTrack() {
const trackName = trackSelect.value || 'lead';
const track = analysis?.tracks?.[trackName];
if (!track) return;
notesOut.textContent = String(track.notes.length);
renderNotesTable(track.notes);
drawPianoRoll(track.notes, analysis.duration, analysis.tempo, trackName);
trackSummary.textContent = `Active track: ${TRACKS[trackName].label}. Notes are based on a frequency bucket centered on ${TRACKS[trackName].bandLow}–${TRACKS[trackName].bandHigh} Hz.`;
}
function renderNotesTable(notes) {
if (!notes.length) {
notesTableBody.innerHTML = '<tr><td colspan="7" style="color:var(--muted);">No notes were detected. Lower the threshold or try a clearer source.</td></tr>';
return;
}
notesTableBody.innerHTML = notes.slice(0, 500).map((n, i) => `
<tr>
<td>${i + 1}</td>
<td>${midiToName(n.pitch)} (${n.pitch})</td>
<td>${n.start.toFixed(3)}</td>
<td>${n.end.toFixed(3)}</td>
<td>${n.duration.toFixed(3)}</td>
<td>${n.velocity}</td>
<td>${Math.round((n.confidence || 0) * 100)}%</td>
</tr>`).join('');
}
function drawEmptyCanvas(text) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.font = '20px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
ctx.font = '13px ' + getComputedStyle(document.body).fontFamily;
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.fillText('Detected notes will appear here after analysis.', canvas.width / 2, canvas.height / 2 + 28);
}
function drawPianoRoll(notes, duration, tempo, trackName) {
const width = canvas.width, height = canvas.height;
const left = SETTINGS.canvasLeftPad, top = SETTINGS.canvasTopPad, bottom = SETTINGS.canvasBottomPad;
const rollWidth = width - left - 16;
const rollHeight = height - top - bottom;
const bars = SETTINGS.barsToShow;
const beatsPerBar = SETTINGS.beatsPerBar;
const beatsTotal = bars * beatsPerBar;
const beatSec = 60 / tempo;
const visibleSec = beatsTotal * beatSec;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = 'rgba(255,255,255,0.02)';
ctx.fillRect(0, 0, width, height);
const pitches = notes.length ? notes.map(n => n.pitch) : [60,72];
const minPitch = clamp(Math.min(...pitches) - 3, 24, 96);
const maxPitch = clamp(Math.max(...pitches) + 3, 36, 108);
const pitchCount = maxPitch - minPitch + 1;
const rowH = rollHeight / pitchCount;
for (let p = minPitch; p <= maxPitch; p++) {
const y = top + (maxPitch - p) * rowH;
const isBlack = [1,3,6,8,10].includes(((p % 12) + 12) % 12);
ctx.fillStyle = isBlack ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.04)';
ctx.fillRect(left, y, rollWidth, rowH);
}
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
for (let p = minPitch; p <= maxPitch; p++) {
const y = top + (maxPitch - p) * rowH;
ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(left + rollWidth, y); ctx.stroke();
}
for (let b = 0; b <= beatsTotal; b++) {
const x = left + (b / beatsTotal) * rollWidth;
const strong = b % beatsPerBar === 0;
ctx.strokeStyle = strong ? 'rgba(122,162,255,0.34)' : 'rgba(255,255,255,0.10)';
ctx.lineWidth = strong ? 1.5 : 1;
ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + rollHeight); ctx.stroke();
if (strong) {
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = '12px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'center';
ctx.fillText(String(b / beatsPerBar + 1), x + 16, 12);
}
}
ctx.fillStyle = 'rgba(255,255,255,0.78)';
ctx.font = '11px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'right';
for (let p = minPitch; p <= maxPitch; p++) {
if (p % 12 === 0) {
const y = top + (maxPitch - p) * rowH + rowH * 0.72;
ctx.fillText(midiToName(p), left - 6, y);
}
}
for (const n of notes) {
const x = left + (n.start / visibleSec) * rollWidth;
const w = Math.max(2, ((n.end - n.start) / visibleSec) * rollWidth);
if (x > left + rollWidth || x + w < left) continue;
const y = top + (maxPitch - n.pitch) * rowH + 1;
const h = Math.max(2, rowH - 2);
const intensity = clamp(n.confidence || 0.5, 0.1, 1);
ctx.fillStyle = `rgba(125, 241, 168, ${0.24 + 0.48 * intensity})`;
roundRect(ctx, x, y, w, h, 4, true, false);
ctx.strokeStyle = 'rgba(255,255,255,0.20)';
ctx.lineWidth = 1;
roundRect(ctx, x, y, w, h, 4, false, true);
}
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '12px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'left';
ctx.fillText(`Active: ${TRACKS[trackName].label} · 30 bars shown · ${tempo} BPM · ${notes.length} notes`, left, height - 8);
}
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
if (fill) ctx.fill();
if (stroke) ctx.stroke();
}
function buildMidiFile(notes, tempoBpm, trackTitle) {
const ppq = SETTINGS.ppq;
const events = [];
const tempoMicro = Math.round(60000000 / tempoBpm);
events.push({ tick: 0, data: [0xFF, 0x51, 0x03, (tempoMicro >> 16) & 0xFF, (tempoMicro >> 8) & 0xFF, tempoMicro & 0xFF] });
events.push({ tick: 0, data: [0xFF, 0x58, 0x04, 0x04, 0x02, 0x18, 0x08] });
events.push({ tick: 0, data: stringMetaEvent(0x03, trackTitle) });
for (const n of notes) {
const startTick = Math.max(0, Math.round(n.start * tempoBpm / 60 * ppq));
const endTick = Math.max(startTick + 1, Math.round(n.end * tempoBpm / 60 * ppq));
const pitch = clamp(Math.round(n.pitch), 0, 127);
const velocity = clamp(Math.round(n.velocity || 80), 1, 127);
events.push({ tick: startTick, data: [0x90, pitch, velocity] });
events.push({ tick: endTick, data: [0x80, pitch, 0] });
}
events.sort((a,b) => a.tick - b.tick || eventOrder(a.data) - eventOrder(b.data));
const trackBytes = [];
let lastTick = 0;
for (const ev of events) {
trackBytes.push(...writeVLQ(ev.tick - lastTick));
trackBytes.push(...ev.data);
lastTick = ev.tick;
}
trackBytes.push(...writeVLQ(0), 0xFF, 0x2F, 0x00);
const header = [0x4D,0x54,0x68,0x64, 0x00,0x00,0x00,0x06, 0x00,0x00, 0x00,0x01, (ppq>>8)&0xFF, ppq&0xFF];
const trackHeader = [0x4D,0x54,0x72,0x6B, (trackBytes.length>>24)&0xFF, (trackBytes.length>>16)&0xFF, (trackBytes.length>>8)&0xFF, trackBytes.length&0xFF];
return new Uint8Array([...header, ...trackHeader, ...trackBytes]);
}
function downloadMidiForTrack(trackName, track, tempo, keyName, fileNameOverride) {
if (!track?.notes?.length) {
setStatus(`No notes available in ${trackName} track.`);
return;
}
const midi = buildMidiFile(track.notes, tempo, `${TRACKS[trackName].label} (${keyName})`);
const blob = new Blob([midi], { type: 'audio/midi' });
const a = document.createElement('a');
const base = (currentFileName || 'melody').replace(/\.[^.]+$/, '');
const fileName = fileNameOverride || `${base}_${trackName}.mid`;
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}
function stringMetaEvent(type, text) {
const bytes = new TextEncoder().encode(text);
return [0xFF, type, ...writeVLQ(bytes.length), ...bytes];
}
function eventOrder(data) { if (data[0] === 0xFF) return 0; const status = data[0] & 0xF0; if (status === 0x80) return 1; if (status === 0x90) return 2; return 3; }
function writeVLQ(value) {
let buffer = value & 0x7F;
const out = [];
while ((value >>= 7)) { buffer <<= 8; buffer |= ((value & 0x7F) | 0x80); }
while (true) { out.push(buffer & 0xFF); if (buffer & 0x80) buffer >>= 8; else break; }
return out;
}
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment