Created
June 17, 2026 01:48
-
-
Save EncodeTheCode/14575eeedb46a0cbcf3ec7e1938a6ee0 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>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