Created
June 17, 2026 03:29
-
-
Save EncodeTheCode/db5f6d548fe88691caafc7d760e9a801 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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