Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created June 17, 2026 01:48
Show Gist options
  • Select an option

  • Save EncodeTheCode/14575eeedb46a0cbcf3ec7e1938a6ee0 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/14575eeedb46a0cbcf3ec7e1938a6ee0 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>Audio to MIDI Melodic Analyzer</title>
<style>
:root {
--bg: #0f1220;
--panel: #161b2e;
--panel-2: #1d2440;
--text: #e9ecff;
--muted: #9aa3c7;
--accent: #7aa2ff;
--accent-2: #56d39b;
--warn: #ffb86b;
--danger: #ff6b88;
--grid: rgba(255,255,255,0.08);
--grid-strong: rgba(255,255,255,0.14);
--note: #7df1a8;
--note-2: #66c2ff;
--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;
letter-spacing: 0.2px;
}
.subtitle {
color: var(--muted);
margin-top: 6px;
max-width: 900px;
line-height: 1.45;
}
.topbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.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.3fr 1fr 1fr 1fr;
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"] {
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;
letter-spacing: 0.2px;
}
.meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.meta .item {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
}
.meta .item .k {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 5px;
}
.meta .item .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: 1100px) {
.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>Audio → MIDI Melodic Analyzer</h1>
<div class="subtitle">
Upload an MP3 or WAV file, detect a best-effort melodic line, estimate key/tonality, preview notes in a piano-roll style grid, and export standard MIDI for use in FL Studio.
</div>
</div>
<div class="topbar">
<div class="value-pill" id="fileNamePill">No file loaded</div>
</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>Analysis options</label>
<div class="small">Best results on monophonic leads, vocals, basslines, solos, or clear melodies. Polyphonic mixes are approximated.</div>
</div>
<div class="control">
<label>Actions</label>
<div class="btn-row">
<button id="analyzeBtn" disabled>Analyze</button>
<button id="exportBtn" class="success" disabled>Export MIDI</button>
<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">Notes extracted</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>
<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 grid is displayed as a 30-bar section with bar lines, beat lines, and detected note blocks. This is a best-effort melody reduction, not a full stem separation/transcription engine.
</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 exportBtn = document.getElementById('exportBtn');
const clearBtn = document.getElementById('clearBtn');
const threshold = document.getElementById('threshold');
const thresholdValue = document.getElementById('thresholdValue');
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 notesTableBody = document.getElementById('notesTableBody');
const canvas = document.getElementById('roll');
const ctx = canvas.getContext('2d');
let audioBuffer = null;
let detectedNotes = [];
let estimatedKey = null;
let estimatedTempo = 120;
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 SETTINGS = {
minFreq: 55,
maxFreq: 1200,
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);
});
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;
exportBtn.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 melody.');
} 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;
exportBtn.disabled = true;
try {
const thr = Number(threshold.value);
setStatus('Analyzing audio frames ...');
await waitFrame();
const result = await analyzeBuffer(audioBuffer, thr, (progress) => {
setStatus(`Analyzing audio frames ... ${progress}%`);
});
detectedNotes = result.notes;
estimatedKey = result.key;
estimatedTempo = result.tempo;
renderSummary(result);
renderNotesTable(detectedNotes);
drawPianoRoll(detectedNotes, audioBuffer.duration, estimatedTempo);
setStatus(`Analysis complete. Detected ${detectedNotes.length} note events.`);
exportBtn.disabled = detectedNotes.length === 0;
} 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;
}
});
exportBtn.addEventListener('click', () => {
if (!detectedNotes.length) return;
const midi = buildMidiFile(detectedNotes, estimatedTempo, estimatedKey?.name || 'C');
const blob = new Blob([midi], { type: 'audio/midi' });
const a = document.createElement('a');
const base = (currentFileName || 'melody').replace(/\.[^.]+$/, '');
a.href = URL.createObjectURL(blob);
a.download = `${base}_melody.mid`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 1000);
});
clearBtn.addEventListener('click', () => {
fileInput.value = '';
audioBuffer = null;
detectedNotes = [];
estimatedKey = null;
estimatedTempo = 120;
currentFileName = '';
fileNamePill.textContent = 'No file loaded';
keyOut.textContent = '—';
tempoOut.textContent = '—';
notesOut.textContent = '—';
durationOut.textContent = '—';
summaryText.textContent = 'No analysis yet.';
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;
exportBtn.disabled = true;
});
drawEmptyCanvas('Load an audio file to begin.');
function setStatus(text) {
statusEl.textContent = text;
}
function waitFrame() {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
function getAudioContext() {
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!window._audioContext) window._audioContext = new AudioContextCtor();
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) {
const n = NOTE_NAMES[((midi % 12) + 12) % 12];
const oct = Math.floor(midi / 12) - 1;
return `${n}${oct}`;
}
function hzToMidi(hz) { return 69 + 12 * Math.log2(hz / 440); }
function midiToHz(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function roundToGrid(value, grid) { return Math.round(value / grid) * grid; }
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 analyzeBuffer(buffer, thresholdValue, onProgress) {
const mono = monoFromBuffer(buffer);
const sr = buffer.sampleRate;
const frameSize = SETTINGS.frameSize;
const hopSize = SETTINGS.hopSize;
const minLag = Math.max(2, Math.floor(sr / SETTINGS.maxFreq));
const maxLag = Math.min(frameSize - 2, Math.floor(sr / SETTINGS.minFreq));
const window = hannWindow(frameSize);
const totalFrames = Math.max(1, Math.floor((mono.length - frameSize) / hopSize) + 1);
const frames = [];
const chroma = new Float64Array(12);
const onsetEnergy = [];
const pitchCandidates = [];
let prevMidi = null;
let prevTime = 0;
let prevConf = 0;
let currentFrameMaxEnergy = 1e-9;
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
if (frameIndex % 32 === 0) {
onProgress?.(Math.round((frameIndex / totalFrames) * 100));
await waitFrame();
}
const start = frameIndex * hopSize;
const segment = new Float32Array(frameSize);
let rms = 0;
for (let i = 0; i < frameSize; i++) {
const s = (start + i < mono.length) ? mono[start + i] : 0;
const v = s * window[i];
segment[i] = v;
rms += v * v;
}
rms = Math.sqrt(rms / frameSize);
currentFrameMaxEnergy = Math.max(currentFrameMaxEnergy, rms);
const acResult = detectPitchAutocorr(segment, sr, minLag, maxLag);
const time = start / sr;
const energyNorm = clamp(rms / (currentFrameMaxEnergy || 1), 0, 1);
const score = acResult.confidence * (0.3 + 0.7 * energyNorm);
const active = score >= thresholdValue && acResult.midi !== null;
frames.push({
time,
midi: active ? acResult.midi : null,
confidence: score,
rms,
freq: acResult.freq,
rawConfidence: acResult.confidence,
});
if (active) {
const pc = ((Math.round(acResult.midi) % 12) + 12) % 12;
chroma[pc] += score * Math.max(0.25, rms);
pitchCandidates.push({ time, midi: acResult.midi, score });
}
if (rms > 0.015) onsetEnergy.push({ time, rms });
prevMidi = acResult.midi;
prevTime = time;
prevConf = score;
}
onProgress?.(100);
const notes = groupFramesToNotes(frames, thresholdValue, sr, hopSize, buffer.duration);
const tempo = estimateTempoFromNotes(notes) || estimateTempoFromOnsets(onsetEnergy) || 120;
const key = estimateKeyFromChroma(chroma);
// Gentle note quantization for a cleaner piano-roll look and MIDI export.
const beat = 60 / tempo;
const grid = beat / 4; // 16th-note grid
const quantizedNotes = 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 { notes: quantizedNotes, tempo, key };
}
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 detectPitchAutocorr(frame, sampleRate, minLag, maxLag) {
// Remove DC and normalize energy.
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;
let 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 };
}
// Parabolic interpolation around the peak for smoother pitch tracking.
let refinedLag = bestLag;
if (bestLag > minLag && bestLag < maxLag) {
const a = ac[bestLag - 1];
const b = ac[bestLag];
const c = ac[bestLag + 1];
const denom = (a - 2 * b + c);
if (Math.abs(denom) > 1e-9) {
const delta = 0.5 * (a - c) / denom;
refinedLag = bestLag + clamp(delta, -1, 1);
}
}
const freq = sampleRate / refinedLag;
const midiFloat = hzToMidi(freq);
const midi = clamp(Math.round(midiFloat), 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 (let i = 0; i < frames.length; i++) {
const f = frames[i];
const nextTime = f.time + frameSec;
if (f.midi !== null && f.confidence >= thresholdValue) {
const midi = f.midi;
const 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) {
const gap = f.time - lastGoodTime;
if (gap <= maxGap) {
active.end = nextTime;
} else {
flush(f.time);
}
}
}
flush(totalDuration);
// Merge tiny fragments and normalize pitch values.
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 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 diffs = [];
const times = onsets.map(o => o.time).sort((a, b) => a - b);
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;
let asum = 0;
let 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`;
notesOut.textContent = String(result.notes.length);
durationOut.textContent = audioBuffer ? formatTime(audioBuffer.duration) : '—';
const keyName = result.key?.name || 'Unknown';
const confidence = result.key ? Math.round(result.key.confidence * 100) : 0;
summaryText.textContent = `Detected key: ${keyName} (${confidence}% match). The pitch tracker extracted ${result.notes.length} note events and estimated the tempo at ${result.tempo} BPM.`;
}
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 melody.</td></tr>';
return;
}
const rows = notes.slice(0, 500).map((n, i) => {
return `<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('');
notesTableBody.innerHTML = rows;
}
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) {
const width = canvas.width;
const height = canvas.height;
const left = SETTINGS.canvasLeftPad;
const top = SETTINGS.canvasTopPad;
const 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);
// Determine visible pitch range.
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;
// Background rows.
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);
}
// Horizontal pitch lines.
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();
}
// Vertical beat/bar lines.
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);
}
}
// Pitch labels.
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);
}
}
// Notes.
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);
const alpha = 0.24 + 0.48 * intensity;
ctx.fillStyle = `rgba(125, 241, 168, ${alpha})`;
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);
}
// Playhead-ish 30-bar cap marker.
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '12px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'left';
ctx.fillText(`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 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 buildMidiFile(notes, tempoBpm, keyName) {
const ppq = SETTINGS.ppq;
const events = [];
const tempoMicro = Math.round(60000000 / tempoBpm);
// Tempo meta event at tick 0.
events.push({ tick: 0, data: [0xFF, 0x51, 0x03, (tempoMicro >> 16) & 0xFF, (tempoMicro >> 8) & 0xFF, tempoMicro & 0xFF] });
// 4/4 time signature.
events.push({ tick: 0, data: [0xFF, 0x58, 0x04, 0x04, 0x02, 0x18, 0x08] });
// Track name.
const trackName = stringMetaEvent(0x03, `Audio Melody (${keyName})`);
events.push({ tick: 0, data: trackName });
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] });
}
// Sort by tick, note-offs before note-ons at same tick for cleaner playback.
events.sort((a, b) => a.tick - b.tick || eventOrder(a.data) - eventOrder(b.data));
const trackBytes = [];
let lastTick = 0;
for (const ev of events) {
const delta = ev.tick - lastTick;
trackBytes.push(...writeVLQ(delta));
trackBytes.push(...ev.data);
lastTick = ev.tick;
}
// End of track.
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 eventOrder(data) {
// Meta/text events first, note-offs before note-ons.
if (data[0] === 0xFF) return 0;
const status = data[0] & 0xF0;
if (status === 0x80) return 1;
if (status === 0x90) return 2;
return 3;
}
function stringMetaEvent(type, text) {
const bytes = new TextEncoder().encode(text);
return [0xFF, type, ...writeVLQ(bytes.length), ...bytes];
}
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