tcdweb/js/app.js

1222 lines
48 KiB
JavaScript
Raw Normal View History

2026-07-05 00:44:08 +02:00
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);
2026-07-05 00:44:08 +02:00
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);
2026-07-05 00:44:08 +02:00
}
};
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;
2026-07-05 00:44:08 +02:00
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;
}
2026-07-05 00:44:08 +02:00
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());