tcdweb/js/app.js
Armin 1972add309 Add cutoff max falloff decay selector
Introduce a new dropdown to control how fast the max-cutoff marker
decays toward the live cutoff on the realtime spectrum. Options:
  Off   — max cutoff holds indefinitely (manual reset only)
  Slow  — 1% gap reduction per frame
  Medium — 3% gap reduction per frame
  Fast   — 8% gap reduction per frame

Previously the max-cutoff line only went up and never came back down
unless the user clicked Reset.
2026-07-06 18:10:05 +02:00

1222 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = '96128 kbps source';
else if (effCutoffRatio < 0.88) sourceHint = '128192 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 = `<span class="verdict upscaled">UPSCALED</span> — re-encoded from lower bitrate`;
this.renderFactors(dr, isLossy, true, sourceHint);
} else {
el.innerHTML = `<span class="verdict native">NATIVE</span> — single encode at this bitrate`;
this.renderFactors(dr, isLossy, false, '');
}
} else {
if (dr.score) {
el.innerHTML = `<span class="verdict transcode">TRANSCODE</span> — lossy → lossless re-encode detected`;
} else {
el.innerHTML = `<span class="verdict genuine">GENUINE</span> — 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 = '<h3>Factor Analysis</h3><ul>';
if (isLossy) {
if (upscaled) {
html += `<li title="Cutoff ratio compares the spectral cutoff to Nyquist frequency. A lossy file from a low-bitrate source has a cutoff well below what the current bitrate would normally allow. The ratio is checked against adaptive thresholds for the bitrate range.">cutoff_ratio=${dr.cutoffRatio.toFixed(3)} below expected for bitrate ✓</li>`;
if (sourceHint) html += `<li class="hint" title="The cutoff ratio maps to estimated source bitrate ranges:&#10;• < 0.70 → ≤ 64 kbps source&#10;• 0.70-0.80 → 96-128 kbps source&#10;• 0.80-0.88 → 128-192 kbps source">→ Suggests ${sourceHint}</li>`;
} else {
html += `<li title="For a single-encode lossy file, the cutoff ratio should match the expected range for its bitrate. If the ratio falls within the natural range for that codec/bitrate, the file is classified as a native encode.">cutoff_ratio=${dr.cutoffRatio.toFixed(3)} within expected range ✓</li>`;
}
} else {
const fr = dr.cutoffRatio;
let maxBwDisp;
if (fr < 0.50) maxBwDisp = 4000;
else if (fr < 0.70) maxBwDisp = 3000;
else if (fr < 0.80) maxBwDisp = 2000;
else if (fr < 0.90) maxBwDisp = 1200;
else maxBwDisp = 500;
const primaryHit = (fr >= 0.99) ? false : (dr.maxBw !== undefined && dr.steepness < dr.maxBw);
const secondaryApplies = (fr > 0.80);
if (fr >= 0.99) {
html += `<li title="A cutoff_ratio ≥ 0.99 means the spectrum extends to the full Nyquist frequency with no detectable lowpass filter. This is typical of analog masters or high-quality digital recordings. When the spectrum is full, transition bandwidth cannot be measured, so this check is bypassed (neither pass nor fail).">① cutoff_ratio=${fr.toFixed(3)} ≥ 0.99 <span class="fail">✗</span> (full spectrum, no cutoff)</li>`;
} else {
if (primaryHit) {
html += `<li title="PRIMARY CRITERION PASSED. The transition bandwidth (${dr.steepness.toFixed(0)} Hz) is below the adaptive threshold (${maxBwDisp} Hz) for this cutoff ratio range. A sharp transition means the spectrum drops unnaturally fast — a classic sign of a lossy encoder's lowpass filter. This strongly suggests the file was transcoded from a lossy source.">① cutoff_ratio=${fr.toFixed(3)}, transition_bw=${dr.steepness.toFixed(0)}Hz < ${maxBwDisp}Hz threshold <span class="pass">✓</span></li>`;
html += `<li class="hint" title="When the transition bandwidth is below threshold, it indicates the high-frequency cutoff is artificially sharp — a pattern introduced by lossy compression filters. This is the primary evidence for transcode detection.">→ Transition bandwidth indicates transcode</li>`;
} else {
html += `<li title="PRIMARY CRITERION NOT MET. The transition bandwidth (${dr.steepness.toFixed(0)} Hz) is at or above the adaptive threshold (${maxBwDisp} Hz). A gradual high-frequency rolloff is consistent with natural audio or lossless encoding. No transcode signature detected from steepness alone.">① cutoff_ratio=${fr.toFixed(3)}, transition_bw=${dr.steepness.toFixed(0)}Hz ≥ ${maxBwDisp}Hz <span class="fail">✗</span></li>`;
}
}
if (secondaryApplies) {
const r1 = 0.40;
const r2 = 0.30;
const r3 = 0.20;
const b1 = 0.90;
const b2 = 0.85;
let secHit = false;
if (dr.roughness > r1) {
html += `<li title="SECONDARY CRITERION PASSED (strong). Roughness (CoV) measures spectral scalloping — the 'comb filter' pattern from lossy quantization. At > ${r1}, the spectrum is clearly uneven, confirming lossy encoding even when the cutoff ratio is high.">② roughness=${dr.roughness.toFixed(3)} > ${r1} <span class="pass">✓</span> (high roughness → transcode)</li>`;
secHit = true;
} else if (dr.roughness > r2 && dr.bandRatio < b1) {
html += `<li title="SECONDARY CRITERION PASSED (moderate). Roughness > ${r2} indicates moderate spectral scalloping. Band ratio < ${b1} confirms the high-frequency energy distribution is natural. Together they support the transcode hypothesis.">② roughness=${dr.roughness.toFixed(3)} > ${r2} <span class="pass">✓</span>, band_ratio=${dr.bandRatio.toFixed(3)} < ${b1} <span class="pass">✓</span></li>`;
secHit = true;
} else if (dr.roughness > r3 && dr.bandRatio < b2) {
html += `<li title="SECONDARY CRITERION PASSED (weak). Mild roughness > ${r3} combined with a healthy band ratio < ${b2} provides marginal evidence of lossy encoding. May be a false positive on very processed audio.">② roughness=${dr.roughness.toFixed(3)} > ${r3} <span class="pass">✓</span>, band_ratio=${dr.bandRatio.toFixed(3)} < ${b2} <span class="pass">✓</span></li>`;
secHit = true;
}
if (secHit) {
html += `<li class="hint" title="When secondary criteria (roughness + band ratio) also trigger, the transcode detection is confirmed even if the transition bandwidth alone was ambiguous (e.g., at high cutoff ratios where steepness is naturally low).">→ Secondary criteria triggered: transcode confirmed</li>`;
} else if (!primaryHit) {
html += `<li title="SECONDARY CRITERION NOT MET. Roughness is low and band ratio is in the normal range. No spectral evidence of lossy quantization artifacts in the high frequencies.">② secondary: roughness=${dr.roughness.toFixed(3)}, band_ratio=${dr.bandRatio.toFixed(3)} <span class="fail">✗</span></li>`;
html += `<li class="hint" title="Neither primary (transition bandwidth) nor secondary (roughness + band ratio) criteria detected any transcode signature. The file behaves like native lossless across all spectral checks.">→ No transcode criteria met: genuine lossless</li>`;
}
} else if (!primaryHit) {
html += `<li class="hint" title="The cutoff ratio is too low for secondary criteria to apply (≤ 0.80). Since the primary criterion also did not trigger, no transcode signature was detected. However, the low cutoff may warrant investigation — it could be a low-bitrate lossy file or a recording with naturally limited bandwidth.">→ No transcode criteria met: genuine lossless</li>`;
}
}
html += '</ul>';
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 = `<td>${label}</td><td>${value}</td>`;
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());