mirror of
https://codeberg.org/armin/tcdweb.git
synced 2026-09-01 05:30:45 +02:00
Introduce a new dropdown to control how fast the peak-hold line decays on the live spectrum. Options: Off — peaks never decay (manual reset only) Slow — slow fade (0.005/frame) Medium — moderate fade (0.015/frame, matches previous fixed rate) Fast — quick fade (0.03/frame) Previously the decay rate was hardcoded at 0.015/frame.
1215 lines
48 KiB
JavaScript
1215 lines
48 KiB
JavaScript
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 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: • < 0.70 → ≤ 64 kbps source • 0.70-0.80 → 96-128 kbps source • 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());
|