const App = { audioContext: null, fileInfo: null, metadataTags: null, formatInfo: null, audioBuffer: null, waveformPcm: null, waveformChannels: 0, bpmRaw: 0, isPlaying: false, playOffset: 0, playStartTime: 0, animFrameId: null, source: null, analyser: null, waveformCache: null, holdPeaks: null, alltimePeaks: null, peakBinCount: 0, cutoffMaxHz: 0, cutoffHold: false, windowType: 'hanning', liveHover: null, init() { const fileInput = document.getElementById('fileInput'); const loadBtn = document.getElementById('loadBtn'); loadBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) this.loadFile(e.target.files[0]); }); this.initBPMButtons(); this.initPlayer(); this.initLiveHover(); const vh = window.innerHeight; this.waveformHeight = Math.min(240, Math.round(vh * 0.3)); this.spectrumHeight = Math.min(280, Math.round(vh * 0.35)); this.renderWaveform(); this.renderLiveSpectrum(); }, async loadFile(file) { if (this.isPlaying) this.stopPlayback(); this.stopSource(); this.holdPeaks = null; this.alltimePeaks = null; this.peakBinCount = 0; const validExts = ['mp3','flac','wav','aiff','aif','ogg','opus','m4a','wma','ac3','eac3','aac','alac','wv','mp2','mp1','ape','dsf','dff']; const ext = file.name.split('.').pop().toLowerCase(); if (!validExts.includes(ext)) { this.showError(`Unsupported format: .${ext}`); return; } this.showLoading(`Decoding ${file.name}...`); document.getElementById('fileName').textContent = file.name; try { const arrayBuffer = await file.arrayBuffer(); const codecMap = { 'mp3':'MP3','aac':'AAC','m4a':'AAC','ogg':'Vorbis','opus':'Opus', 'wma':'WMA','ac3':'AC3','eac3':'EAC3','mp2':'MP2','mp1':'MP1', 'flac':'FLAC','wav':'PCM','aiff':'AIFF','aif':'AIFF','alac':'ALAC', 'wv':'WavPack','ape':'APE','dsf':'DSF','dff':'DFF' }; let codecName = codecMap[ext] || ext; if (!this.audioContext) this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer.slice(0)); this.fileInfo = { name: file.name, codec: codecName, size: file.size }; this.audioBuffer = audioBuffer; this.metadataTags = Metadata.parse(arrayBuffer, file.name); this.formatInfo = Metadata.getFormatInfo(arrayBuffer, codecName); this.showLoading('Running spectral analysis...'); await this.analyze(audioBuffer, codecName); } catch (err) { this.showError(`Failed to decode: ${err.message}`); } finally { const lov = document.getElementById('loadingOverlay'); if (lov) lov.style.display = 'none'; } }, async analyze(audioBuffer, codecName) { const sampleRate = audioBuffer.sampleRate; const channels = audioBuffer.numberOfChannels; const duration = audioBuffer.duration; const maxSecs = 120; const fftSize = 4096; const totalSamples = Math.min(Math.floor(sampleRate * maxSecs), audioBuffer.length); const interleaved = new Float32Array(totalSamples * channels); for (let ch = 0; ch < channels; ch++) { const chData = audioBuffer.getChannelData(ch); const len = Math.min(chData.length, totalSamples); for (let i = 0; i < len; i++) interleaved[i * channels + ch] = chData[i]; } this.waveformPcm = interleaved; this.waveformChannels = channels; const bitrateKbps = duration > 0 ? (this.fileInfo.size * 8) / (duration * 1000) : 0; document.getElementById('fileFormat').textContent = codecName; document.getElementById('fileSampleRate').textContent = `${sampleRate} Hz`; const stereoMode = this.formatInfo && this.formatInfo.stereoMode; document.getElementById('fileChannels').textContent = `${channels}`; const smEl = document.getElementById('fileStereoMode'); if (stereoMode) { smEl.textContent = stereoMode; smEl.style.display = 'inline'; } else { smEl.textContent = ''; smEl.style.display = 'none'; } document.getElementById('fileDuration').textContent = `${duration.toFixed(1)}s`; const brText = bitrateKbps > 0 ? `${bitrateKbps.toFixed(0)} kbps` : '—'; const brMode = this.formatInfo && this.formatInfo.bitrateMode; document.getElementById('fileBitrate').textContent = brText; const brmEl = document.getElementById('fileBitrateMode'); if (brMode) { brmEl.textContent = brMode; brmEl.style.display = 'inline'; } else { brmEl.textContent = ''; brmEl.style.display = 'none'; } document.getElementById('fileSize').textContent = this.formatSize(this.fileInfo.size); this.bpmRaw = 0; document.getElementById('fileBPM').textContent = 'Analyzing...'; await this.sleep(10); const result = Analyzer.analyzeAudio(interleaved, sampleRate, channels, fftSize); if (!result) { this.showError('File too short for analysis.'); return; } document.getElementById('windowsCount').textContent = `${result.count}`; document.getElementById('fftSize').textContent = `${fftSize}`; this.bpmRaw = Analyzer.detectBPM(interleaved, sampleRate, channels); this.updateBPMDisplay(); const lossyCodecs = ['MP3','AAC','Vorbis','Opus','WMA','AC3','EAC3','MP2','MP1']; const isLossy = lossyCodecs.includes(codecName); const detectResult = Analyzer.detectCutoff(result, -50, 0.5); const nyquist = sampleRate / 2.0; const effectiveCutoff = (detectResult.extendedCutoff > detectResult.cutoff && detectResult.noiseDb >= -100.0) ? detectResult.extendedCutoff : detectResult.cutoff; const effCutoffRatio = effectiveCutoff / nyquist; let peakDb = -200; if (detectResult.peak > 1e-12) peakDb = 20.0 * Math.log10(detectResult.peak); const confidence = Analyzer.computeConfidence( isLossy ? effCutoffRatio : detectResult.cutoffRatio, detectResult.steepness, detectResult.roughness, detectResult.bandRatio, isLossy); let upscaled = false; let sourceHint = ''; if (isLossy) { let expectedMin = 0.90; if (detectResult.noiseDb < -80.0) expectedMin -= 0.05; if (effCutoffRatio > 0 && effCutoffRatio < expectedMin - 0.08) upscaled = true; if (effCutoffRatio < 0.70) sourceHint = '≤ 64 kbps source'; else if (effCutoffRatio < 0.80) sourceHint = '96–128 kbps source'; else if (effCutoffRatio < 0.88) sourceHint = '128–192 kbps source'; } this.renderResults(result, detectResult, peakDb, isLossy, upscaled, sourceHint, confidence, effectiveCutoff, effCutoffRatio, sampleRate); }, renderResults(result, detectResult, peakDb, isLossy, upscaled, sourceHint, confidence, effectiveCutoff, effCutoffRatio, sampleRate) { const lov = document.getElementById('loadingOverlay'); if (lov) lov.style.display = 'none'; this.renderMetadata(); this.renderWaveform(sampleRate); this.renderMetrics(detectResult, peakDb, effectiveCutoff, effCutoffRatio, sampleRate); this.renderDecision(detectResult, isLossy, upscaled, sourceHint, confidence); this.renderChart(result, detectResult, effectiveCutoff, sampleRate); this.renderDataTable(result, detectResult, peakDb, isLossy, upscaled, confidence, effectiveCutoff, sampleRate); }, renderWaveform(sampleRate) { const canvas = document.getElementById('waveformChart'); if (!canvas) return; const dpr = window.devicePixelRatio; const ctx = canvas.getContext('2d'); const parentW = canvas.parentElement.clientWidth; canvas.width = parentW * dpr; canvas.height = this.waveformHeight * dpr; canvas.style.width = parentW + 'px'; canvas.style.height = this.waveformHeight + 'px'; const W = parentW; const H = this.waveformHeight; if (!this.waveformPcm) return; const pad = { top: 22, right: 20, bottom: 24, left: 68 }; const chartW = W - pad.left - pad.right; const chartH = H - pad.top - pad.bottom; const midY = pad.top + chartH / 2; ctx.fillStyle = '#0d0d1a'; ctx.fillRect(0, 0, W, H); const channels = this.waveformChannels; const data = this.waveformPcm; const totalFrames = data.length / channels; const samplesPerPixel = Math.max(1, Math.ceil(totalFrames / chartW)); const env = new Float32Array(chartW * 2); let globalPeak = 0; for (let x = 0; x < chartW; x++) { let min = Infinity, max = -Infinity; const start = x * samplesPerPixel * channels; const end = Math.min((x + 1) * samplesPerPixel * channels, data.length); for (let i = start; i < end; i += channels) { let sum = 0; for (let ch = 0; ch < channels; ch++) sum += data[i + ch]; const s = sum / channels; if (s < min) min = s; if (s > max) max = s; } if (min === Infinity) { min = 0; max = 0; } env[x * 2] = min; env[x * 2 + 1] = max; const absPeak = Math.max(Math.abs(min), Math.abs(max)); if (absPeak > globalPeak) globalPeak = absPeak; } const norm = globalPeak > 1e-12 ? globalPeak / 0.9 : 1; const drawWaveformTo = (c) => { const cx = c.getContext('2d'); cx.scale(dpr, dpr); cx.fillStyle = '#1a1a2e'; cx.fillRect(0, 0, W, H); cx.beginPath(); cx.moveTo(pad.left, midY); for (let x = 0; x < chartW; x++) { const min = env[x * 2] / norm; const max = env[x * 2 + 1] / norm; cx.lineTo(pad.left + x, midY - (max * chartH / 2)); } for (let x = chartW - 1; x >= 0; x--) { const min = env[x * 2] / norm; const max = env[x * 2 + 1] / norm; cx.lineTo(pad.left + x, midY - (min * chartH / 2)); } cx.closePath(); const grad = cx.createLinearGradient(0, pad.top, 0, pad.top + chartH); grad.addColorStop(0, 'rgba(0, 188, 212, 0.5)'); grad.addColorStop(0.5, 'rgba(0, 188, 212, 0.25)'); grad.addColorStop(1, 'rgba(0, 188, 212, 0.5)'); cx.fillStyle = grad; cx.fill(); cx.beginPath(); cx.moveTo(pad.left, midY); for (let x = 0; x < chartW; x++) { const max = env[x * 2 + 1] / norm; cx.lineTo(pad.left + x, midY - (max * chartH / 2)); } for (let x = chartW - 1; x >= 0; x--) { const min = env[x * 2] / norm; cx.lineTo(pad.left + x, midY - (min * chartH / 2)); } cx.closePath(); cx.strokeStyle = '#00bcd4'; cx.lineWidth = 1; cx.stroke(); cx.strokeStyle = 'rgba(255,255,255,0.06)'; cx.lineWidth = 1; cx.beginPath(); cx.moveTo(pad.left, midY); cx.lineTo(pad.left + chartW, midY); cx.stroke(); const duration = this.audioBuffer ? this.audioBuffer.duration : 0; cx.fillStyle = 'rgba(255,255,255,0.4)'; cx.font = '10px "SF Mono", "Fira Code", monospace'; cx.textAlign = 'center'; cx.textBaseline = 'top'; const numLabels = Math.max(2, Math.floor(chartW / 100)); for (let i = 0; i <= numLabels; i++) { const t = (i / numLabels) * duration; const x = pad.left + (i / numLabels) * chartW; const label = t >= 60 ? `${(t / 60).toFixed(0)}m${(t % 60).toFixed(0)}s` : `${t.toFixed(1)}s`; cx.fillText(label, x, pad.top + chartH + 4); } cx.textAlign = 'right'; cx.textBaseline = 'middle'; cx.fillStyle = 'rgba(255,255,255,0.3)'; for (const amp of [-1, -0.5, 0, 0.5, 1]) { const y = midY - (amp * chartH / 2); if (y >= pad.top && y <= pad.top + chartH) { cx.fillText(amp.toFixed(1), pad.left - 16, y); } } cx.fillStyle = 'rgba(255,255,255,0.35)'; cx.font = '10px "SF Mono", "Fira Code", monospace'; cx.textAlign = 'center'; cx.textBaseline = 'top'; cx.fillText('Time', pad.left + chartW / 2, pad.top + chartH + 18); cx.save(); cx.translate(8, pad.top + chartH / 2); cx.rotate(-Math.PI / 2); cx.textAlign = 'center'; cx.textBaseline = 'middle'; cx.fillText('Amplitude', 0, 0); cx.restore(); }; drawWaveformTo(canvas); this.waveformCache = document.createElement('canvas'); this.waveformCache.width = canvas.width; this.waveformCache.height = canvas.height; drawWaveformTo(this.waveformCache); const phCanvas = document.getElementById('waveformPlayhead'); if (phCanvas) { phCanvas.width = canvas.width; phCanvas.height = canvas.height; phCanvas.style.width = canvas.style.width; phCanvas.style.height = canvas.style.height; } const container = canvas.parentElement; container.style.cursor = 'pointer'; container.addEventListener('click', (e) => { if (!this.audioBuffer) return; const rect = canvas.getBoundingClientRect(); const pad = { left: 68, right: 20 }; const chartW = rect.width - pad.left - pad.right; const x = e.clientX - rect.left; const relX = (x - pad.left) / chartW; const time = Math.max(0, Math.min(relX * this.audioBuffer.duration, this.audioBuffer.duration)); this.seekAudio(time); if (!this.isPlaying) this.togglePlay(); }); document.getElementById('playTime').textContent = `0:00.0 / 0:00.0`; }, initBPMButtons() { document.getElementById('bpmHalf').addEventListener('click', () => { if (this.bpmRaw > 0) { this.bpmRaw /= 2; this.updateBPMDisplay(); } }); document.getElementById('bpmDouble').addEventListener('click', () => { if (this.bpmRaw > 0) { this.bpmRaw *= 2; this.updateBPMDisplay(); } }); }, updateBPMDisplay() { document.getElementById('fileBPM').textContent = this.bpmRaw > 0 ? `${this.bpmRaw.toFixed(2)}` : '—'; }, initPlayer() { document.getElementById('playBtn').addEventListener('click', () => this.togglePlay()); document.getElementById('stopBtn').addEventListener('click', () => this.stopPlayback()); const resetBtn = document.getElementById('resetBtn'); if (resetBtn) resetBtn.addEventListener('click', () => this.resetPeaks()); const holdBtn = document.getElementById('holdBtn'); if (holdBtn) holdBtn.addEventListener('click', () => this.toggleCutoffHold()); const winSel = document.getElementById('windowSelect'); if (winSel) winSel.addEventListener('change', () => { this.windowType = winSel.value; this.renderLiveSpectrum(); }); }, togglePlay() { if (!this.audioBuffer) return; if (this.isPlaying) { this.playOffset += this.audioContext.currentTime - this.playStartTime; this.stopSource(); this.isPlaying = false; document.getElementById('playBtn').textContent = '▶'; if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; } this.drawPlayhead(this.playOffset); } else { if (this.audioContext.state === 'suspended') this.audioContext.resume(); this.startSource(this.playOffset); this.isPlaying = true; document.getElementById('playBtn').textContent = '⏸'; this.animFrameId = requestAnimationFrame(() => this.playbackLoop()); } }, stopPlayback() { if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; } this.stopSource(); this.isPlaying = false; this.playOffset = 0; document.getElementById('playBtn').textContent = '▶'; this.drawPlayhead(0); document.getElementById('playTime').textContent = '0:00.0 / 0:00.0'; }, startSource(offset) { this.stopSource(); this.source = this.audioContext.createBufferSource(); this.source.buffer = this.audioBuffer; this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 2048; this.analyser.smoothingTimeConstant = 0.85; this.source.connect(this.analyser); this.analyser.connect(this.audioContext.destination); this.playStartTime = this.audioContext.currentTime; this.source.start(0, offset); this.peakBinCount = this.analyser.frequencyBinCount; if (!this.holdPeaks || this.holdPeaks.length !== this.peakBinCount) { this.holdPeaks = new Float32Array(this.peakBinCount); } if (!this.alltimePeaks || this.alltimePeaks.length !== this.peakBinCount) { this.alltimePeaks = new Float32Array(this.peakBinCount); } }, stopSource() { if (this.source) { try { this.source.stop(); } catch (e) {} try { this.source.disconnect(); } catch (e) {} this.source = null; } if (this.analyser) { try { this.analyser.disconnect(); } catch (e) {} this.analyser = null; } }, seekAudio(time) { if (!this.audioBuffer) return; this.playOffset = Math.max(0, Math.min(time, this.audioBuffer.duration)); this.drawPlayhead(this.playOffset); if (this.isPlaying) { this.startSource(this.playOffset); } }, playbackLoop() { if (!this.isPlaying) return; const elapsed = this.audioContext.currentTime - this.playStartTime; const pos = this.playOffset + elapsed; if (pos >= this.audioBuffer.duration) { this.stopPlayback(); return; } this.drawPlayhead(pos); this.renderLiveSpectrum(); this.updateLiveMetrics(); this.updatePlayTime(pos); this.animFrameId = requestAnimationFrame(() => this.playbackLoop()); }, drawPlayhead(pos) { const canvas = document.getElementById('waveformChart'); if (!canvas || !this.waveformCache || !this.audioBuffer) return; const dpr = window.devicePixelRatio; const W = canvas.width / dpr; const H = canvas.height / dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H); ctx.drawImage(this.waveformCache, 0, 0, canvas.width, canvas.height, 0, 0, W, H); const pad = { left: 68, right: 20, top: 22, bottom: 24 }; const chartW = W - pad.left - pad.right; const ratio = this.audioBuffer.duration > 0 ? pos / this.audioBuffer.duration : 0; const x = pad.left + ratio * chartW; ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, H - pad.bottom); ctx.strokeStyle = '#ff5252'; ctx.lineWidth = 2; ctx.stroke(); }, updatePlayTime(pos) { const dur = this.audioBuffer ? this.audioBuffer.duration : 0; const fmt = (t) => { if (!t || t < 0) return '0:00.0'; const m = Math.floor(t / 60); const s = Math.floor(t % 60); const ds = Math.floor((t - Math.floor(t)) * 10); return `${m}:${s.toString().padStart(2, '0')}.${ds}`; }; document.getElementById('playTime').textContent = `${fmt(pos)} / ${fmt(dur)}`; }, renderLiveSpectrum() { const canvas = document.getElementById('liveSpectrum'); if (!canvas) return; const dpr = window.devicePixelRatio; const parentW = canvas.parentElement.clientWidth; canvas.width = parentW * dpr; canvas.height = this.spectrumHeight * dpr; canvas.style.width = parentW + 'px'; canvas.style.height = this.spectrumHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); const W = parentW; const H = this.spectrumHeight; const pad = { top: 22, right: 20, bottom: 22, left: 50 }; const chartW = W - pad.left - pad.right; const chartH = H - pad.top - pad.bottom; if (!this.analyser) { const ov = document.getElementById('liveSpectrumOverlay'); if (ov) { ov.width = 0; ov.height = 0; } return; } ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, W, H); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = '9px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (const db of [0, -6, -12, -24, -36]) { const val = Math.pow(10, db / 20); const y = pad.top + chartH - val * chartH; if (y >= pad.top && y <= pad.top + chartH) { ctx.fillText(`${db}`, pad.left - 6, y); ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + chartW, y); ctx.stroke(); } } ctx.restore(); const data = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(data); const len = data.length; ctx.beginPath(); ctx.moveTo(pad.left, pad.top + chartH); const scaleVal = (raw) => { const r = raw / 255; switch (this.windowType) { case 'none': return r; case 'hanning': return Math.min(1, Math.sqrt(r) * 0.85); case 'hamming': return Math.min(1, Math.pow(r, 0.48) * 0.85); case 'blackman': return Math.min(1, Math.pow(r, 0.4) * 0.85); case 'bartlett': return Math.min(1, Math.pow(r, 0.55) * 0.85); case 'gaussian': return Math.min(1, Math.pow(r, 0.43) * 0.85); case 'kaiser': return Math.min(1, Math.pow(r, 0.33) * 0.85); case 'flatTop': return Math.min(1, Math.pow(r, 0.3) * 0.85); default: return Math.min(1, Math.sqrt(r) * 0.85); } }; for (let x = 0; x < chartW; x++) { const idx = Math.min(Math.floor(x * len / chartW), len - 1); const val = scaleVal(data[idx]); const y = pad.top + chartH - val * chartH; ctx.lineTo(pad.left + x, y); } ctx.lineTo(pad.left + chartW, pad.top + chartH); ctx.closePath(); const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + chartH); grad.addColorStop(0, 'rgba(0, 188, 212, 0.4)'); grad.addColorStop(1, 'rgba(0, 188, 212, 0.05)'); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); for (let x = 0; x < chartW; x++) { const idx = Math.min(Math.floor(x * len / chartW), len - 1); const val = scaleVal(data[idx]); const y = pad.top + chartH - val * chartH; if (x === 0) ctx.moveTo(pad.left, y); else ctx.lineTo(pad.left + x, y); } ctx.strokeStyle = '#00bcd4'; ctx.lineWidth = 1.5; ctx.stroke(); if (this.holdPeaks) { const decaySel = document.getElementById('peakHoldDecay'); const decayMap = { off: 0, slow: 0.005, medium: 0.015, fast: 0.03 }; const decay = decaySel ? (decayMap[decaySel.value] ?? 0.015) : 0.015; ctx.beginPath(); let started = false; for (let x = 0; x < chartW; x++) { const idx = Math.min(Math.floor(x * len / chartW), len - 1); const raw = data[idx] / 255; if (raw > this.holdPeaks[idx]) this.holdPeaks[idx] = raw; else this.holdPeaks[idx] = Math.max(0, this.holdPeaks[idx] - decay); const y = pad.top + chartH - scaleVal(this.holdPeaks[idx] * 255) * chartH; if (!started) { ctx.moveTo(pad.left + x, y); started = true; } else ctx.lineTo(pad.left + x, y); } ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.stroke(); } if (this.alltimePeaks) { if (this.isPlaying) { for (let i = 0; i < len; i++) { const raw = data[i] / 255; if (raw > this.alltimePeaks[i]) this.alltimePeaks[i] = raw; } } ctx.beginPath(); let started = false; for (let x = 0; x < chartW; x++) { const idx = Math.min(Math.floor(x * len / chartW), len - 1); const y = pad.top + chartH - scaleVal(this.alltimePeaks[idx] * 255) * chartH; if (!started) { ctx.moveTo(pad.left + x, y); started = true; } else ctx.lineTo(pad.left + x, y); } ctx.strokeStyle = 'rgba(255, 183, 77, 0.5)'; ctx.lineWidth = 1.5; ctx.stroke(); } let maxVal = 0; for (let i = 0; i < len; i++) { if (data[i] > maxVal) maxVal = data[i]; } const threshold = Math.max(5, maxVal * 0.08); let cutoffBin = 0; for (let i = len - 1; i >= 0; i--) { if (data[i] >= threshold) { cutoffBin = i; break; } } const sr = this.audioBuffer ? this.audioBuffer.sampleRate : 44100; const nyquist = sr / 2; const cutoffHz = (cutoffBin / len) * nyquist; if (cutoffHz > this.cutoffMaxHz) this.cutoffMaxHz = cutoffHz; const falloffSel = document.getElementById('cutoffFalloff'); if (falloffSel && falloffSel.value !== 'off' && this.isPlaying && cutoffHz < this.cutoffMaxHz) { const falloffMap = { slow: 0.01, medium: 0.03, fast: 0.08 }; const rate = falloffMap[falloffSel.value] ?? 0; const gap = this.cutoffMaxHz - cutoffHz; if (gap > 1) this.cutoffMaxHz -= gap * rate; } const displayHz = this.cutoffHold && this.cutoffMaxHz > 0 ? this.cutoffMaxHz : cutoffHz; const displayBin = Math.round((displayHz / nyquist) * len); if (displayBin > 0) { const cx = pad.left + (displayBin / len) * chartW; ctx.beginPath(); ctx.moveTo(cx, pad.top); ctx.lineTo(cx, pad.top + chartH); ctx.strokeStyle = this.cutoffHold ? 'rgba(255, 82, 82, 0.95)' : 'rgba(255, 82, 82, 0.7)'; ctx.lineWidth = this.cutoffHold ? 2 : 1.5; ctx.setLineDash(this.cutoffHold ? [4, 3] : []); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = this.cutoffHold ? '#ff8a80' : '#ff5252'; ctx.font = 'bold 10px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; const label = this.cutoffHold ? `Max ${this.cutoffMaxHz.toFixed(0)} Hz` : `${cutoffHz.toFixed(0)} Hz`; ctx.fillText(label, cx, pad.top - 1); } ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '9px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const freqLabels = [20, 100, 1000, 10000, 20000]; for (const f of freqLabels) { if (f >= nyquist) break; const x = pad.left + (f / nyquist) * chartW; ctx.fillText(f >= 1000 ? `${(f/1000).toFixed(0)}k` : `${f}`, x, pad.top + chartH + 4); } ctx.save(); ctx.translate(10, pad.top + chartH / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '9px "SF Mono", "Fira Code", monospace'; ctx.fillText('dBFS', 0, 0); ctx.restore(); ctx.fillStyle = 'rgba(255,255,255,0.12)'; ctx.font = '9px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('Realtime FFT', pad.left + chartW, pad.top - 6); }, updateLiveMetrics() { if (!this.analyser) { document.getElementById('livePeak').textContent = '—'; document.getElementById('liveCutoff').textContent = '—'; return; } const data = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(data); const len = data.length; const sr = this.audioBuffer ? this.audioBuffer.sampleRate : 44100; const nyquist = sr / 2; let maxVal = 0; let cutoffBin = 0; for (let i = 0; i < len; i++) { if (data[i] > maxVal) maxVal = data[i]; } const threshold = Math.max(5, maxVal * 0.08); for (let i = len - 1; i >= 0; i--) { if (data[i] >= threshold) { cutoffBin = i; break; } } const peakDb = maxVal > 0 ? (maxVal / 255) * 100 - 100 : -100; const cutoffHz = (cutoffBin / len) * nyquist; document.getElementById('livePeak').textContent = `${peakDb.toFixed(1)} dBFS`; document.getElementById('liveCutoff').textContent = cutoffHz > 0 ? `${cutoffHz.toFixed(0)} Hz` : '—'; }, resetPeaks() { if (this.holdPeaks) this.holdPeaks.fill(0); if (this.alltimePeaks) this.alltimePeaks.fill(0); this.cutoffMaxHz = 0; this.renderLiveSpectrum(); }, initLiveHover() { const canvas = document.getElementById('liveSpectrum'); if (!canvas) return; canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const sr = this.audioBuffer ? this.audioBuffer.sampleRate : 44100; const nyquist = sr / 2; const len = this.analyser ? this.analyser.frequencyBinCount : 0; if (!len) { this.liveHover = null; this.drawLiveHoverOverlay(); return; } const pad = { left: 50, right: 20, top: 22, bottom: 22 }; const W = rect.width; const H = canvas.offsetHeight || this.spectrumHeight; const chartW = W - pad.left - pad.right; const chartH = H - pad.top - pad.bottom; const relX = (x - pad.left) / chartW; const idx = Math.min(Math.floor(relX * len), len - 1); const relY = 1 - (y - pad.top) / chartH; const freq = (idx / len) * nyquist; const amp = Math.max(0, Math.min(1, relY)); const data = new Uint8Array(len); if (this.analyser) this.analyser.getByteFrequencyData(data); const actualVal = idx >= 0 && idx < len ? data[idx] / 255 : 0; const actualY = pad.top + chartH - actualVal * chartH; this.liveHover = { x: pad.left + (idx / len) * chartW, y: actualY, freq, amp: actualVal, idx }; this.drawLiveHoverOverlay(); }); canvas.addEventListener('mouseleave', () => { this.liveHover = null; this.drawLiveHoverOverlay(); }); }, drawLiveHoverOverlay() { const main = document.getElementById('liveSpectrum'); const overlay = document.getElementById('liveSpectrumOverlay'); if (!main || !overlay) return; const dpr = window.devicePixelRatio; const parentW = main.parentElement.clientWidth; overlay.width = parentW * dpr; overlay.height = (main.offsetHeight || this.spectrumHeight) * dpr; overlay.style.width = parentW + 'px'; overlay.style.height = (main.offsetHeight || this.spectrumHeight) + 'px'; const ctx = overlay.getContext('2d'); ctx.clearRect(0, 0, overlay.width, overlay.height); ctx.scale(dpr, dpr); if (!this.liveHover) return; const { x, y, freq, amp } = this.liveHover; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fillStyle = '#ff5252'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.fillStyle = 'rgba(0,0,0,0.75)'; ctx.fillRect(x + 10, y - 22, 120, 32); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.strokeRect(x + 10, y - 22, 120, 32); ctx.fillStyle = '#fff'; ctx.font = '10px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(`${freq.toFixed(0)} Hz`, x + 18, y - 10); ctx.fillStyle = '#00bcd4'; ctx.fillText(`${(amp * 100).toFixed(1)}%`, x + 18, y + 6); }, toggleCutoffHold() { this.cutoffHold = !this.cutoffHold; const btn = document.getElementById('holdBtn'); if (btn) btn.classList.toggle('active', this.cutoffHold); if (this.cutoffHold) this.cutoffMaxHz = 0; this.renderLiveSpectrum(); }, renderMetadata() { const grid = document.getElementById('metadataGrid'); const empty = document.getElementById('metadataEmpty'); grid.innerHTML = ''; if (!this.metadataTags || this.metadataTags.length === 0) { grid.style.display = 'none'; empty.style.display = 'block'; return; } empty.style.display = 'none'; grid.style.display = 'grid'; for (const tag of this.metadataTags) { const el = document.createElement('div'); el.className = 'metadata-item'; const src = document.createElement('span'); src.className = 'metadata-source'; src.textContent = tag.source; const key = document.createElement('span'); key.className = 'metadata-key'; key.textContent = tag.key; const val = document.createElement('span'); val.className = 'metadata-value'; val.textContent = tag.value; el.appendChild(src); el.appendChild(key); el.appendChild(val); grid.appendChild(el); } }, renderMetrics(dr, peakDb, effCutoff, effCutoffRatio, sampleRate) { document.getElementById('mPeak').textContent = `${peakDb.toFixed(1)} dBFS`; document.getElementById('mCutoff').textContent = `${effCutoff.toFixed(0)} Hz (${(100 * effCutoffRatio).toFixed(1)}%)`; document.getElementById('mSteepness').textContent = `${dr.steepness.toFixed(0)} Hz`; document.getElementById('mNoise').textContent = `${dr.noiseDb.toFixed(1)} dB`; document.getElementById('mRoughness').textContent = `${dr.roughness.toFixed(3)}`; document.getElementById('mBandRatio').textContent = `${dr.bandRatio.toFixed(3)}`; }, renderDecision(dr, isLossy, upscaled, sourceHint, confidence) { const el = document.getElementById('decision'); const nyquist = 24000; /* approx, used for display only */ if (isLossy) { if (upscaled) { el.innerHTML = `UPSCALED — re-encoded from lower bitrate`; this.renderFactors(dr, isLossy, true, sourceHint); } else { el.innerHTML = `NATIVE — single encode at this bitrate`; this.renderFactors(dr, isLossy, false, ''); } } else { if (dr.score) { el.innerHTML = `TRANSCODE — lossy → lossless re-encode detected`; } else { el.innerHTML = `GENUINE — likely native lossless`; } this.renderFactors(dr, false, false, ''); } const bar = document.getElementById('confidenceBar'); const label = document.getElementById('confidenceLabel'); const fillPct = Math.min(100, Math.max(0, confidence)); bar.style.width = `${fillPct}%`; bar.textContent = `${fillPct.toFixed(0)}%`; let confLabel; if (fillPct >= 85) confLabel = 'Very strong evidence'; else if (fillPct >= 70) confLabel = 'Strong evidence'; else if (fillPct >= 50) confLabel = 'Moderate evidence'; else if (fillPct >= 30) confLabel = 'Weak evidence'; else confLabel = 'Borderline / inconclusive'; label.textContent = confLabel; }, renderFactors(dr, isLossy, upscaled, sourceHint) { const el = document.getElementById('factors'); let html = '

Factor Analysis

'; el.innerHTML = html; }, renderChart(result, detectResult, effectiveCutoff, sampleRate) { const canvas = document.getElementById('spectrumChart'); const ctx = canvas.getContext('2d'); canvas.width = canvas.parentElement.clientWidth * window.devicePixelRatio; canvas.height = 360 * window.devicePixelRatio; canvas.style.width = canvas.parentElement.clientWidth + 'px'; canvas.style.height = '360px'; ctx.scale(window.devicePixelRatio, window.devicePixelRatio); const W = canvas.width / window.devicePixelRatio; const H = 360; const pad = { top: 20, right: 30, bottom: 40, left: 55 }; const chartW = W - pad.left - pad.right; const chartH = H - pad.top - pad.bottom; const n = result.fftSize / 2; const nyquist = sampleRate / 2.0; const mag = new Float64Array(n); let peak = 0; for (let i = 0; i < n; i++) { mag[i] = Math.sqrt(result.power[i] / result.count); if (mag[i] > peak) peak = mag[i]; } if (peak < 1e-12) peak = 1e-12; for (let i = 0; i < n; i++) mag[i] = 20.0 * Math.log10(mag[i] / peak); ctx.clearRect(0, 0, W, H); this.drawGrid(ctx, pad, chartW, chartH, nyquist); const minDb = -100; const maxDb = 0; function freqToX(freq) { const minFreq = 20; const maxFreq = nyquist; const norm = (Math.log(freq / minFreq) / Math.log(maxFreq / minFreq)); return pad.left + norm * chartW; } function dbToY(db) { return pad.top + chartH - ((db - minDb) / (maxDb - minDb)) * chartH; } ctx.beginPath(); let started = false; for (let i = 0; i < n; i++) { const freq = i * sampleRate / result.fftSize; if (freq < 20) continue; const x = freqToX(freq); const y = dbToY(Math.max(minDb, mag[i])); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.lineTo(freqToX(nyquist), dbToY(minDb)); ctx.lineTo(freqToX(20), dbToY(minDb)); ctx.closePath(); const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + chartH); grad.addColorStop(0, 'rgba(0, 188, 212, 0.35)'); grad.addColorStop(0.5, 'rgba(0, 188, 212, 0.15)'); grad.addColorStop(1, 'rgba(0, 188, 212, 0.02)'); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); started = false; for (let i = 0; i < n; i++) { const freq = i * sampleRate / result.fftSize; if (freq < 20) continue; const x = freqToX(freq); const y = dbToY(Math.max(minDb, mag[i])); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.strokeStyle = '#00bcd4'; ctx.lineWidth = 1.5; ctx.stroke(); if (effectiveCutoff > 0) { const cx = freqToX(effectiveCutoff); ctx.beginPath(); ctx.moveTo(cx, pad.top); ctx.lineTo(cx, pad.top + chartH); ctx.strokeStyle = '#ffc107'; ctx.lineWidth = 2; ctx.setLineDash([5, 4]); ctx.stroke(); ctx.setLineDash([]); const label = effectiveCutoff >= 1000 ? `${(effectiveCutoff/1000).toFixed(1)} kHz` : `${effectiveCutoff.toFixed(0)} Hz`; ctx.fillStyle = '#ffc107'; ctx.font = 'bold 12px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'center'; ctx.fillText(label, cx, pad.top - 5); } const contourDb = detectResult.noiseDb; if (contourDb > minDb) { const ny = dbToY(contourDb); ctx.beginPath(); ctx.moveTo(pad.left, ny); ctx.lineTo(pad.left + chartW, ny); ctx.strokeStyle = 'rgba(255, 82, 82, 0.5)'; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(255, 82, 82, 0.7)'; ctx.font = '11px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'left'; ctx.fillText(`Noise floor ${contourDb.toFixed(1)} dB`, pad.left + 5, ny - 4); } }, renderDataTable(result, detectResult, peakDb, isLossy, upscaled, confidence, effectiveCutoff, sampleRate) { const nyquist = sampleRate / 2.0; const tbody = document.querySelector('#dataTable tbody'); if (!tbody) return; tbody.innerHTML = ''; function addRow(label, value) { const tr = document.createElement('tr'); tr.innerHTML = `${label}${value}`; tbody.appendChild(tr); } addRow('Sample Rate', `${sampleRate} Hz`); addRow('Nyquist Frequency', `${nyquist.toFixed(0)} Hz`); addRow('FFT Size', `${result.fftSize}`); addRow('Windows Analyzed', `${result.count}`); addRow('Peak Magnitude', `${peakDb.toFixed(1)} dBFS`); addRow('Cutoff Frequency', `${detectResult.cutoff.toFixed(0)} Hz (${(100 * detectResult.cutoff / nyquist).toFixed(1)}% Nyquist)`); addRow('Extended Cutoff', `${detectResult.extendedCutoff.toFixed(0)} Hz (${(100 * detectResult.extendedCutoff / nyquist).toFixed(1)}% Nyquist)`); addRow('Effective Cutoff', `${effectiveCutoff.toFixed(0)} Hz (${(100 * effectiveCutoff / nyquist).toFixed(1)}% Nyquist)`); addRow('Cutoff Ratio', `${detectResult.cutoffRatio.toFixed(4)}`); addRow('Transition Bandwidth (Steepness)', `${detectResult.steepness.toFixed(0)} Hz`); addRow('Max Bandwidth Threshold', detectResult.maxBw ? `${detectResult.maxBw.toFixed(0)} Hz` : 'N/A (bypass)'); addRow('Noise Floor', `${detectResult.noiseDb.toFixed(1)} dB`); addRow('Roughness (CoV)', `${detectResult.roughness.toFixed(4)}`); addRow('Band Ratio (16-20kHz)/(12-16kHz)', `${detectResult.bandRatio.toFixed(4)}`); addRow('Detection Score', `${detectResult.score}`); addRow('Confidence', `${confidence.toFixed(1)}%`); addRow('Verdict', isLossy ? (upscaled ? 'UPSCALED' : 'NATIVE') : (detectResult.score ? 'TRANSCODE' : 'GENUINE')); }, drawGrid(ctx, pad, chartW, chartH, nyquist) { ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, ctx.canvas.width / window.devicePixelRatio, 360); const tickFreqs = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 20000]; const tickLabels = ['20', '50', '100', '200', '500', '1k', '2k', '5k', '10k', '15k', '20k']; const minFreq = 20; function freqToX(freq) { const maxFreq = nyquist; const norm = (Math.log(freq / minFreq) / Math.log(maxFreq / minFreq)); return pad.left + norm * chartW; } ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; for (let db = -100; db <= 0; db += 10) { const y = pad.top + chartH - ((db - (-100)) / (0 - (-100))) * chartH; ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + chartW, y); ctx.stroke(); } for (const f of tickFreqs) { if (f >= nyquist) break; const x = freqToX(f); ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, pad.top + chartH); ctx.stroke(); } ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '10px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'center'; for (let i = 0; i < tickFreqs.length; i++) { if (tickFreqs[i] >= nyquist) break; const x = freqToX(tickFreqs[i]); ctx.fillText(tickLabels[i], x, pad.top + chartH + 16); } ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '10px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'right'; for (let db = -100; db <= 0; db += 20) { const y = pad.top + chartH - ((db - (-100)) / (0 - (-100))) * chartH; ctx.fillText(`${db} dB`, pad.left - 6, y + 4); } ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '11px "SF Mono", "Fira Code", monospace'; ctx.textAlign = 'center'; ctx.fillText('Frequency', pad.left + chartW / 2, pad.top + chartH + 34); ctx.save(); ctx.translate(14, pad.top + chartH / 2); ctx.rotate(-Math.PI / 2); ctx.fillText('Magnitude', 0, 0); ctx.restore(); }, showLoading(msg) { const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex'; document.getElementById('loadingMsg').textContent = msg; document.getElementById('errorMsg').style.display = 'none'; }, showError(msg) { const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'none'; const el = document.getElementById('errorMsg'); el.textContent = msg; el.style.display = 'block'; }, formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }, sleep(ms) { return new Promise(r => setTimeout(r, ms)); } }; document.addEventListener('DOMContentLoaded', () => App.init());