(() => { "use strict"; const $ = (selector) => document.querySelector(selector); const fileInput = $("#audio-file"); const dropzone = $("#dropzone"); const fileCard = $("#file-card"); const fileName = $("#file-name"); const fileMeta = $("#file-meta"); const removeFile = $("#remove-file"); const convertButton = $("#convert-button"); const sensitivity = $("#sensitivity"); const sensitivityValue = $("#sensitivity-value"); const resultTitle = $("#result-title"); const statusPill = $("#status-pill"); const emptyState = $("#empty-state"); const progressWrap = $("#progress-wrap"); const progressText = $("#progress-text"); const progressPercent = $("#progress-percent"); const progressBar = $("#progress-bar"); const resultContent = $("#result-content"); const errorState = $("#error-state"); const errorMessage = $("#error-message"); const downloadButton = $("#download-button"); const startOver = $("#start-over"); const canvas = $("#piano-roll"); let selectedFile = null; let midiBlob = null; let midiUrl = null; let resultBaseName = "audio"; const labels = { 1: "Cleaner", 2: "Balanced", 3: "More notes" }; sensitivity.addEventListener("input", () => { sensitivityValue.value = labels[sensitivity.value]; }); ["dragenter", "dragover"].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.add("is-dragging"); }); }); ["dragleave", "drop"].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.remove("is-dragging"); }); }); dropzone.addEventListener("drop", (event) => { const file = event.dataTransfer.files[0]; if (file) chooseFile(file); }); fileInput.addEventListener("change", () => { if (fileInput.files[0]) chooseFile(fileInput.files[0]); }); removeFile.addEventListener("click", resetAll); startOver.addEventListener("click", resetAll); convertButton.addEventListener("click", convertAudio); downloadButton.addEventListener("click", (event) => { if (!midiUrl) event.preventDefault(); }); function chooseFile(file) { selectedFile = file; resultBaseName = file.name.replace(/\.[^.]+$/, "") || "audio"; fileName.textContent = file.name; fileMeta.textContent = `${formatBytes(file.size)} · Ready to analyze`; dropzone.hidden = true; fileCard.hidden = false; convertButton.disabled = false; clearResult(); } function clearResult() { midiBlob = null; if (midiUrl) URL.revokeObjectURL(midiUrl); midiUrl = null; downloadButton.href = "#"; downloadButton.setAttribute("aria-disabled", "true"); resultContent.hidden = true; errorState.hidden = true; progressWrap.hidden = true; emptyState.hidden = false; resultTitle.textContent = "Your MIDI will appear here"; statusPill.textContent = selectedFile ? "Ready" : "Waiting for audio"; statusPill.classList.toggle("ready", Boolean(selectedFile)); } function resetAll() { selectedFile = null; midiBlob = null; fileInput.value = ""; fileCard.hidden = true; dropzone.hidden = false; convertButton.disabled = true; clearResult(); } function setProgress(value, label) { const safeValue = Math.max(0, Math.min(100, Math.round(value))); progressBar.style.width = `${safeValue}%`; progressPercent.textContent = `${safeValue}%`; progressText.textContent = label; } async function convertAudio() { if (!selectedFile) return; convertButton.disabled = true; emptyState.hidden = true; resultContent.hidden = true; errorState.hidden = true; progressWrap.hidden = false; resultTitle.textContent = "Reading your melody"; statusPill.textContent = "Working locally"; statusPill.classList.add("ready"); setProgress(3, "Decoding audio…"); let audioContext; try { audioContext = new (window.AudioContext || window.webkitAudioContext)(); const bytes = await selectedFile.arrayBuffer(); const audioBuffer = await audioContext.decodeAudioData(bytes.slice(0)); fileMeta.textContent = `${formatBytes(selectedFile.size)} · ${formatTime(audioBuffer.duration)} · ${audioBuffer.numberOfChannels === 1 ? "Mono" : "Stereo"}`; setProgress(10, "Preparing a mono analysis track…"); const mono = mixToMono(audioBuffer); const downsampled = downsample(mono, audioBuffer.sampleRate, 11025); const notes = await detectNotes(downsampled.samples, downsampled.sampleRate, Number(sensitivity.value)); if (!notes.length) { throw new Error("No stable melody notes were detected. Try a louder, cleaner single-note recording or move the sensitivity toward “More notes”."); } setProgress(94, "Writing the MIDI file…"); midiBlob = new Blob([buildMidi(notes)], { type: "audio/midi" }); midiUrl = URL.createObjectURL(midiBlob); downloadButton.href = midiUrl; downloadButton.download = `${resultBaseName}-transcribed.mid`; downloadButton.setAttribute("aria-disabled", "false"); drawPianoRoll(notes, audioBuffer.duration); populateStats(notes, audioBuffer.duration); setProgress(100, "Done"); await nextFrame(); progressWrap.hidden = true; resultContent.hidden = false; resultTitle.textContent = "MIDI ready to review"; statusPill.textContent = "Download ready"; } catch (error) { progressWrap.hidden = true; errorState.hidden = false; resultTitle.textContent = "Conversion stopped"; statusPill.textContent = "Needs another file"; statusPill.classList.remove("ready"); errorMessage.textContent = error && error.message ? error.message : "Try MP3, WAV, OGG, M4A, AAC, or FLAC."; } finally { convertButton.disabled = false; if (audioContext) audioContext.close(); } } function mixToMono(buffer) { const mono = new Float32Array(buffer.length); for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) { const data = buffer.getChannelData(channel); for (let i = 0; i < data.length; i += 1) mono[i] += data[i] / buffer.numberOfChannels; } return mono; } function downsample(input, sourceRate, targetRate) { if (sourceRate <= targetRate) return { samples: input, sampleRate: sourceRate }; const ratio = sourceRate / targetRate; const length = Math.floor(input.length / ratio); const output = new Float32Array(length); for (let i = 0; i < length; i += 1) { const start = Math.floor(i * ratio); const end = Math.min(input.length, Math.floor((i + 1) * ratio)); let total = 0; for (let j = start; j < end; j += 1) total += input[j]; output[i] = total / Math.max(1, end - start); } return { samples: output, sampleRate: targetRate }; } async function detectNotes(samples, sampleRate, mode) { const frameSize = 2048; const hop = 512; const totalFrames = Math.max(0, Math.floor((samples.length - frameSize) / hop)); const rmsGate = mode === 1 ? 0.018 : mode === 2 ? 0.011 : 0.006; const confidenceGate = mode === 1 ? 0.78 : mode === 2 ? 0.69 : 0.60; const minFrames = mode === 1 ? 5 : mode === 2 ? 4 : 3; const frames = []; for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) { const offset = frameIndex * hop; const frame = samples.subarray(offset, offset + frameSize); let sumSquares = 0; for (let i = 0; i < frame.length; i += 1) sumSquares += frame[i] * frame[i]; const rms = Math.sqrt(sumSquares / frame.length); if (rms >= rmsGate) { const pitch = autoCorrelate(frame, sampleRate); if (pitch && pitch.confidence >= confidenceGate) { const midi = Math.round(69 + 12 * Math.log2(pitch.frequency / 440)); if (midi >= 24 && midi <= 108) frames.push({ index: frameIndex, midi, rms }); } } if (frameIndex % 24 === 0) { setProgress(12 + (frameIndex / Math.max(1, totalFrames)) * 78, "Detecting stable notes…"); await nextFrame(); } } return groupFrames(frames, hop / sampleRate, minFrames); } function autoCorrelate(frame, sampleRate) { let mean = 0; for (let i = 0; i < frame.length; i += 1) mean += frame[i]; mean /= frame.length; const minLag = Math.floor(sampleRate / 1400); const maxLag = Math.min(Math.floor(sampleRate / 55), frame.length >> 1); let bestLag = -1; let best = 0; const scores = []; for (let lag = minLag; lag <= maxLag; lag += 1) { let cross = 0; let leftEnergy = 0; let rightEnergy = 0; const limit = frame.length - lag; for (let i = 0; i < limit; i += 2) { const left = frame[i] - mean; const right = frame[i + lag] - mean; cross += left * right; leftEnergy += left * left; rightEnergy += right * right; } const score = cross / Math.sqrt((leftEnergy * rightEnergy) || 1); scores[lag] = score; if (score > best) { best = score; bestLag = lag; } } if (bestLag < 0) return null; const strongPeak = Math.max(0.72, best * 0.92); for (let lag = minLag + 1; lag < maxLag; lag += 1) { if (scores[lag] >= strongPeak && scores[lag] >= scores[lag - 1] && scores[lag] >= scores[lag + 1]) { bestLag = lag; best = scores[lag]; break; } } return { frequency: sampleRate / bestLag, confidence: best }; } function groupFrames(frames, secondsPerHop, minFrames) { if (!frames.length) return []; const groups = []; let current = { startIndex: frames[0].index, lastIndex: frames[0].index, pitches: [frames[0].midi], levels: [frames[0].rms] }; for (let i = 1; i < frames.length; i += 1) { const frame = frames[i]; const medianPitch = median(current.pitches); const closeInTime = frame.index - current.lastIndex <= 2; const closeInPitch = Math.abs(frame.midi - medianPitch) <= 1; if (closeInTime && closeInPitch) { current.lastIndex = frame.index; current.pitches.push(frame.midi); current.levels.push(frame.rms); } else { groups.push(current); current = { startIndex: frame.index, lastIndex: frame.index, pitches: [frame.midi], levels: [frame.rms] }; } } groups.push(current); return groups .filter((group) => group.lastIndex - group.startIndex + 1 >= minFrames) .map((group) => { const start = group.startIndex * secondsPerHop; const duration = Math.max(.08, (group.lastIndex - group.startIndex + 1) * secondsPerHop); const avgLevel = group.levels.reduce((sum, level) => sum + level, 0) / group.levels.length; return { pitch: Math.round(median(group.pitches)), start, duration, velocity: Math.max(42, Math.min(118, Math.round(48 + avgLevel * 500))) }; }); } function buildMidi(notes) { const division = 480; const ticksPerSecond = 960; const events = []; notes.forEach((note) => { const startTick = Math.max(0, Math.round(note.start * ticksPerSecond)); const endTick = Math.max(startTick + 24, Math.round((note.start + note.duration) * ticksPerSecond)); events.push({ tick: startTick, order: 1, data: [0x90, note.pitch, note.velocity] }); events.push({ tick: endTick, order: 0, data: [0x80, note.pitch, 0] }); }); events.sort((a, b) => a.tick - b.tick || a.order - b.order); const track = [0x00, 0xff, 0x51, 0x03, 0x07, 0xa1, 0x20]; let previousTick = 0; events.forEach((event) => { track.push(...variableLength(event.tick - previousTick), ...event.data); previousTick = event.tick; }); track.push(0x00, 0xff, 0x2f, 0x00); return new Uint8Array([ 0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, (division >> 8) & 0xff, division & 0xff, 0x4d, 0x54, 0x72, 0x6b, (track.length >>> 24) & 0xff, (track.length >>> 16) & 0xff, (track.length >>> 8) & 0xff, track.length & 0xff, ...track ]); } function variableLength(value) { let buffer = value & 0x7f; const bytes = []; while ((value >>= 7)) { buffer <<= 8; buffer |= (value & 0x7f) | 0x80; } while (true) { bytes.push(buffer & 0xff); if (buffer & 0x80) buffer >>= 8; else break; } return bytes; } function drawPianoRoll(notes, audioDuration) { const ctx = canvas.getContext("2d"); const cssWidth = canvas.clientWidth || 860; const cssHeight = canvas.clientHeight || 250; const ratio = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(cssWidth * ratio); canvas.height = Math.round(cssHeight * ratio); ctx.scale(ratio, ratio); ctx.clearRect(0, 0, cssWidth, cssHeight); ctx.fillStyle = "#080e15"; ctx.fillRect(0, 0, cssWidth, cssHeight); const pitches = notes.map((note) => note.pitch); const low = Math.min(...pitches) - 2; const high = Math.max(...pitches) + 2; const span = Math.max(12, high - low); const duration = Math.max(audioDuration, ...notes.map((note) => note.start + note.duration)); ctx.strokeStyle = "rgba(147,162,175,.12)"; ctx.lineWidth = 1; for (let i = 1; i < 8; i += 1) { const y = (cssHeight / 8) * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssWidth, y); ctx.stroke(); } for (let i = 1; i < 10; i += 1) { const x = (cssWidth / 10) * i; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cssHeight); ctx.stroke(); } notes.forEach((note) => { const x = (note.start / duration) * cssWidth; const width = Math.max(3, (note.duration / duration) * cssWidth); const y = cssHeight - ((note.pitch - low + 1) / (span + 2)) * cssHeight; const height = Math.max(6, Math.min(11, cssHeight / (span + 5))); ctx.fillStyle = "#6ef2c2"; ctx.fillRect(x, y - height / 2, width, height); }); } function populateStats(notes, duration) { const pitches = notes.map((note) => note.pitch); $("#stat-notes").textContent = String(notes.length); $("#stat-range").textContent = `${noteName(Math.min(...pitches))}–${noteName(Math.max(...pitches))}`; $("#stat-duration").textContent = formatTime(duration); } function noteName(number) { const names = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]; return `${names[number % 12]}${Math.floor(number / 12) - 1}`; } function median(values) { const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatTime(seconds) { const rounded = Math.max(0, Math.round(seconds)); const minutes = Math.floor(rounded / 60); return `${minutes}:${String(rounded % 60).padStart(2, "0")}`; } function nextFrame() { return new Promise((resolve) => requestAnimationFrame(resolve)); } })();