From c985d235a3198efe7157ca4d25c54acf7bd003e0 Mon Sep 17 00:00:00 2001 From: Armin Date: Sun, 5 Jul 2026 00:44:08 +0200 Subject: [PATCH] init --- AGENTS.md | 25 + LICENSE | 9 + README.md | 157 +++++++ index.html | 155 +++++++ js/analyzer.js | 431 +++++++++++++++++ js/app.js | 1209 ++++++++++++++++++++++++++++++++++++++++++++++++ js/metadata.js | 318 +++++++++++++ style.css | 362 +++++++++++++++ 8 files changed, 2666 insertions(+) create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.html create mode 100644 js/analyzer.js create mode 100644 js/app.js create mode 100644 js/metadata.js create mode 100644 style.css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a147372 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +Implements a browser-based audio authenticity analyzer that detects the TRUE quality of audio files using psychoacoustic spectral analysis. Uses the same scientifically proven principles as the original tcd CLI: + +- ISO/IEC 11172-3:1993 - Coding of moving pictures and associated audio for + digital storage media at up to about 1.5 Mbit/s, Part 3: Audio (MPEG-1 + Audio Layer III, "MP3"). +- ISO/IEC 13818-7:2006 - Generic coding of moving pictures and associated + audio information, Part 7: Advanced Audio Coding (AAC). +- Zwicker, E. & Fastl, H. - *Psychoacoustics: Facts and Models*, Springer, + 1999 (Fletcher-Munson equal-loudness contours). +- Lerch, A. - *An Introduction to Audio Content Analysis*, Wiley, 2012 + (spectral features for audio forensics). + +The web version runs entirely in the browser via the Web Audio API — no server-side processing, no file uploads, no FFmpeg dependency. It implements: + +- Client-side audio decoding via Web Audio API (supports MP3, FLAC, WAV, AIFF, Ogg Vorbis, Opus, M4A/AAC, WMA, AC3, EAC3, MP2, ALAC, WavPack, APE, DSF, DFF) +- Full psychoacoustic analysis pipeline: Hann-windowed FFT (4096-point, 50% overlap), averaged power spectrum, cutoff detection, transition bandwidth (steepness), noise floor, spectral roughness (CoV), band ratio +- Transcode/upscale detection using the same two-layer verdict logic as tcd (primary: cutoff+steepness, secondary: roughness+band ratio) +- Confidence scoring (0-100%) +- Realtime FFT spectrum analyzer with playback, peak hold, and hold-max-cutoff +- BPM detection via autocorrelation of onset detection function +- Metadata tag parsing: ID3v1, ID3v2, FLAC Vorbis comments, APE tags +- Stereo mode detection (MP3: Joint Stereo, Dual Channel, etc.) and VBR/CBR detection +- Logarithmic power spectrum visualization with noise floor and cutoff markers + +Always implement only what is scientifically provable. As it is possible to view on a spectrum analysis of the audio to see the hard cutoff introduced by compression algorithms as MP3, it is possible to tell if we have a 96k MP3 file that just was re-encoded as 320k, for example, or a 128k as 320k, or a 320k as FLAC, and so on. This tool should allow to spot exactly those re-encoded / transcoded files. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..223bf78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2026 armin@codeberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae0012f --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +tcdweb — Transcode Detector (Web Version) +========================================= + +**tcdweb** is a browser-based audio authenticity analyzer that determines whether +an audio file is a genuine native encode or a *transcode* (lossy → lossless +re-encode). It can also detect *upscaling* (a lossy file re-encoded at a higher +bitrate by the same lossy codec, e.g. 128 → 320 kbps MP3). + +This is the **web version** of [tcd](https://codeberg.org/armin/tcd), the +original CLI tool. At this point **tcdweb is a complete, independent fork** with +its own codebase, its own analysis engine, and its own UI. It runs entirely in +the browser via the **Web Audio API** — no server-side processing, no file +uploads, no FFmpeg dependency, and no command line needed. + +--- + +Obligatory AI-slop disclaimer +----------------------------- + +tcdweb is 98% vibe-coded (a.k.a. "ai slop"). If that's a problem for you, please +kindly just use a different tool. There is also absolutely *NO* guarantee this +will work reliably, be useful in any way, or even make any sense whatsoever. + +--- + +What it does +------------ + +Drag or load an audio file, and tcdweb will: + +1. Decode it client-side using the browser's built-in decoder +2. Compute the frequency spectrum via a Hann-windowed FFT (4096-point, 50% overlap) +3. Extract five key metrics (see below) +4. Apply a two-layer verdict system to classify the file +5. Display a detailed decision log, confidence score, and interactive charts + +It includes a **realtime FFT analyzer**, a **waveform viewer with playback**, +**BPM detection**, **metadata tag parsing** (ID3v1/v2, FLAC, APE), and a full +data table with every computed value. + +--- + +Key metrics +----------- + +- **Cutoff**: highest frequency with measurable energy (as Hz and % of Nyquist). + Lossy codecs chop off high frequencies — the lower the cutoff, the more + aggressive the compression. +- **Steepness (Transition Bandwidth)**: how abruptly the spectrum drops at the + cutoff point. Lossy encoders produce sharp brick-wall filters (low steepness). +- **Noise Floor**: average noise level in the top quartile of the spectrum. + Lossy quantization raises the noise floor. +- **Roughness (Coefficient of Variation)**: how jagged/bumpy the spectrum is + before the cutoff. Lossy encoding introduces quantization noise that creates + spectral scalloping. +- **Band Ratio**: energy ratio between 16–20 kHz and 12–16 kHz. Transcodes show + an unnatural dip in the top octave. + +--- + +Supported formats +----------------- + +| Lossy | Lossless | +|---|---| +| MP3 | FLAC | +| AAC / M4A | WAV / AIFF | +| Ogg Vorbis | ALAC | +| Opus | WavPack (WV) | +| WMA | APE | +| AC3 / EAC3 | DSF / DFF | +| MP2 / MP1 | | + +Support depends on the browser's built-in decoder (all modern browsers support +the most common formats). + +--- + +Verdicts +-------- + +| Verdict | Input codec | Meaning | +|---|---|---| +| **NATIVE** | lossy | Single encode at the stated bitrate — genuine | +| **UPSCALED** | lossy | Re-encoded from a lower bitrate (e.g. 128 → 320 kbps) | +| **GENUINE** | lossless | No evidence of lossy origin | +| **TRANSCODE** | lossless | Originated from a lossy source, decoded to lossless | +| **SILENT** | any | No detectable audio content | + +--- + +Detection logic +--------------- + +The verdict is determined in two layers: + +**Layer 1 (Primary)** — Cutoff + Steepness. A low cutoff combined with a sharp +drop (low steepness) is a definitive sign of a lossy encoder's lowpass filter. + +**Layer 2 (Secondary)** — Roughness + Band Ratio. Applied when the cutoff is +high enough to pass Layer 1. Catches transcodes where the cutoff is near +Nyquist but the spectrum still shows quantization artifacts. + +Lossy files are checked for upscaling by comparing the cutoff ratio to +bitrate-specific expectations. + +--- + +Confidence score (0–100%) +------------------------- + +A continuous score based on how far the metrics deviate from the thresholds. +Higher confidence means stronger evidence supporting the verdict. + +--- + +How it differs from the original tcd CLI +----------------------------------------- + +| Feature | tcd (CLI) | tcdweb | +|---|---|---| +| Platform | C program, command line | Pure JavaScript, browser | +| Decoder | FFmpeg (system dep) | Web Audio API (built-in) | +| Analysis | 4096 FFT, same algorithm | 4096 FFT, same algorithm | +| Realtime spectrum | Terminal TUI | Canvas-based interactive chart | +| Playback | No | Yes, with waveform and seek | +| BPM detection | No | Yes (autocorrelation) | +| Metadata parsing | No | ID3v1/v2, FLAC, APE | +| Stereo mode / VBR info | No | Yes (MP3 header parsing) | +| File size limit | None | Browser memory (~2 GB typical) | + +--- + +Limitations +----------- + +- **Browser audio decoding** limits which formats work (depends on `AudioContext.decodeAudioData`). +- **Very short files** (< 4096 samples) cannot be analyzed. +- **High-bitrate lossy encodes** (320 kbps MP3, 256 kbps AAC) may not be + distinguishable from lossless by cutoff alone. +- **Already-filtered material** (e.g. deliberate 15 kHz LPF during mastering) + may produce false positives. + +--- + +References +---------- + +- Nyquist–Shannon sampling theorem — Wikipedia + https://en.wikipedia.org/wiki/Nyquist–Shannon_sampling_theorem +- Equal-loudness contour (Fletcher–Munson curves) — Wikipedia + https://en.wikipedia.org/wiki/Equal-loudness_contour +- LAME MP3 encoder psychoacoustic model — Hydrogenaudio Knowledge Base + https://wiki.hydrogenaudio.org/index.php?title=LAME +- "Audio Authentication Using Spectral Analysis" — University of Michigan + open-access thesis, 2025 + https://doi.org/10.7302/28306 diff --git a/index.html b/index.html new file mode 100644 index 0000000..725fc3a --- /dev/null +++ b/index.html @@ -0,0 +1,155 @@ + + + + + +tcd — Audio Transcode Detector + + + + +
+ + + +
+ +
+ +
+
+

Player & Realtime Analysis

+
Audio Authenticity Analyzer
Psychoacoustic transcode detection via spectral analysis
+
+
+ + + + + + 0:00.0 / 0:00.0 + +
+
+ +
+
+
Processing...
+
+
+
+ + +
+
+
+
Live Peak
+
+
+
+
Live Cutoff
+
+
+
+
+ +
+

File Information

+
+
Filename
+
Format
+
Sample Rate
+
Channels
+
Duration
+
Bitrate
+
File Size
+
BPM
+
+
+ +
+

Metadata Tags

+ + +
+ +
+

Power Spectrum

+
+ +
+
+ +
+

Key Metrics

+
+
+
Peak
+
+
+
+
Cutoff
+
+
+
+
Steepness
+
+
+
+
Noise Floor
+
+
+
+
Roughness
+
+
+
+
Band Ratio
+
+
+
+
+ Windows: + FFT: +
+
+ +
+

Decision

+
+
+
Validity Confidence
+
+
0%
+
+
+
+
+ +
+ +
+

Analysis Data

+
+ + + + + +
ParameterValue
+
+
+ +
+ +
+

Based on tcd — Psychoacoustic audio authenticity analysis

+
+ +
+ + + + + + diff --git a/js/analyzer.js b/js/analyzer.js new file mode 100644 index 0000000..e81c1e6 --- /dev/null +++ b/js/analyzer.js @@ -0,0 +1,431 @@ +const Analyzer = { + + detectBPM(pcmData, sampleRate, channels) { + const frameSize = 2048; + const hopSize = 256; + const maxSecs = 60; + const totalSamples = pcmData.length / channels; + const limitSamples = Math.min(totalSamples, Math.floor(sampleRate * maxSecs)); + + if (limitSamples < sampleRate * 3) return 0; + + const numFrames = Math.max(1, Math.floor((limitSamples - frameSize) / hopSize) + 1); + + const onset = new Float64Array(numFrames); + for (let f = 0; f < numFrames; f++) { + let energy = 0; + const start = f * hopSize * channels; + const end = Math.min(start + frameSize * channels, limitSamples * channels); + for (let i = start; i < end; i += channels) { + const s = pcmData[i]; + energy += s * s; + } + onset[f] = energy; + } + + const odf = new Float64Array(numFrames); + for (let f = 1; f < numFrames; f++) { + const d = onset[f] - onset[f - 1]; + odf[f] = d > 0 ? d : 0; + } + + let maxOdf = 0; + for (let i = 0; i < numFrames; i++) if (odf[i] > maxOdf) maxOdf = odf[i]; + if (maxOdf > 0) for (let i = 0; i < numFrames; i++) odf[i] /= maxOdf; + + let meanOdf = 0; + for (let i = 0; i < numFrames; i++) meanOdf += odf[i]; + meanOdf /= numFrames; + for (let i = 0; i < numFrames; i++) odf[i] -= meanOdf; + + const minBPM = 30; + const maxBPM = 300; + const secsPerHop = hopSize / sampleRate; + const minLag = Math.ceil(60 / (maxBPM * secsPerHop)); + const maxLag = Math.floor(60 / (minBPM * secsPerHop)); + + if (minLag >= numFrames || maxLag < minLag) return 0; + + const acLen = maxLag - minLag + 1; + const ac = new Float64Array(acLen); + + for (let lag = minLag; lag <= maxLag; lag++) { + let s = 0; + const n = numFrames - lag; + for (let i = 0; i < n; i++) s += odf[i] * odf[i + lag]; + ac[lag - minLag] = n > 0 ? s / n : 0; + } + + const interpAC = (lag) => { + const idx = lag - minLag; + const i = Math.floor(idx); + const f = idx - i; + if (i < 0 || i + 1 >= acLen) return 0; + return ac[i] + f * (ac[i + 1] - ac[i]); + }; + + const peaks = []; + for (let i = 1; i < acLen - 1; i++) { + if (ac[i] > ac[i - 1] && ac[i] >= ac[i + 1]) { + const a = ac[i - 1]; + const b = ac[i]; + const c = ac[i + 1]; + const denom = a - 2 * b + c; + if (Math.abs(denom) < 1e-12) continue; + + const p = 0.5 * (a - c) / denom; + const peakLag = (i + minLag) + p; + if (peakLag <= 0) continue; + + const bpm = 60 / (peakLag * secsPerHop); + if (bpm >= minBPM && bpm <= maxBPM) { + const interpVal = b + 0.25 * (a - c) * p; + peaks.push({ bpm, lag: peakLag, score: interpVal }); + } + } + } + + if (peaks.length === 0) return 0; + + peaks.sort((a, b) => b.score - a.score); + + const fastBPM = (lag) => 60 / (lag * secsPerHop); + + let bestBPM = peaks[0].bpm; + let bestScore = peaks[0].score; + + for (const pk of peaks) { + const lag = pk.lag; + const acBase = pk.score; + let hScore = acBase; + + for (let div = 2; div <= 8; div *= 2) { + const fl = lag / div; + if (fl >= minLag && fl <= maxLag) { + hScore += interpAC(fl) * (1.0 / div); + } + } + + if (hScore > bestScore) { + bestScore = hScore; + bestBPM = pk.bpm; + } + } + + for (const pk of peaks) { + const lag = pk.lag; + for (let div = 2; div <= 8; div *= 2) { + const fl = lag / div; + if (fl >= minLag && fl <= maxLag) { + const acDiv = interpAC(fl); + if (acDiv > pk.score * 0.35) { + const candBPM = fastBPM(fl); + if (candBPM >= minBPM && candBPM <= maxBPM) { + bestBPM = candBPM; + return bestBPM; + } + } + } + } + } + + return bestBPM; + }, + + applyHann(buf) { + const n = buf.length; + for (let i = 0; i < n; i++) + buf[i] *= 0.5 * (1.0 - Math.cos(2.0 * Math.PI * i / (n - 1))); + }, + + fftRadix2(re, im, n, inv) { + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) + j ^= bit; + j ^= bit; + if (i < j) { + let tr = re[i]; re[i] = re[j]; re[j] = tr; + let ti = im[i]; im[i] = im[j]; im[j] = ti; + } + } + for (let len = 2; len <= n; len <<= 1) { + const ang = 2.0 * Math.PI / len * (inv ? -1 : 1); + const wr = Math.cos(ang), wi = Math.sin(ang); + for (let i = 0; i < n; i += len) { + let cr = 1.0, ci = 0.0; + for (let j = 0; j < len / 2; j++) { + const a = i + j, b = a + len / 2; + const tr = cr * re[b] - ci * im[b]; + const ti = cr * im[b] + ci * re[b]; + re[b] = re[a] - tr; im[b] = im[a] - ti; + re[a] += tr; im[a] += ti; + const ncr = cr * wr - ci * wi; + const nci = cr * wi + ci * wr; + cr = ncr; ci = nci; + } + } + } + if (inv) + for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; } + }, + + analyzeAudio(pcmData, sampleRate, channels, fftSize) { + const power = new Float64Array(fftSize / 2); + let count = 0; + const frame = pcmData; + + const buf = new Float64Array(fftSize); + const re = new Float64Array(fftSize); + const im = new Float64Array(fftSize); + const overlap = 2; + const step = fftSize / overlap; + + for (let ch = 0; ch < channels; ch++) { + for (let pos = 0; pos + fftSize <= frame.length / channels; pos += step) { + for (let i = 0; i < fftSize; i++) + buf[i] = frame[(pos + i) * channels + ch]; + this.applyHann(buf); + re.set(buf); + im.fill(0); + this.fftRadix2(re, im, fftSize, 0); + for (let i = 0; i < fftSize / 2; i++) + power[i] += re[i] * re[i] + im[i] * im[i]; + count++; + } + } + + if (count === 0) return null; + return { power, count, sampleRate, channels, fftSize }; + }, + + detectCutoff(a, thresholdDb, sensitivity) { + const n = a.fftSize / 2; + const sr = a.sampleRate; + + const mag = new Float64Array(n); + let peak = 0.0; + for (let i = 0; i < n; i++) { + mag[i] = Math.sqrt(a.power[i] / a.count); + if (mag[i] > peak) peak = mag[i]; + } + if (peak < 1e-12) return { score: 0, cutoff: 0 }; + + const threshold = peak * Math.pow(10.0, thresholdDb / 20.0); + + let cutoffHz = 0; + for (let i = n - 1; i >= 0; i--) { + if (mag[i] >= threshold) { cutoffHz = i * sr / a.fftSize; break; } + } + + const lowThresh60 = peak * 0.001; + let cutoff60Hz = 0; + let cutoff60Bin = n - 1; + for (let i = n - 1; i >= 0; i--) { + if (mag[i] >= lowThresh60) { cutoff60Hz = i * sr / a.fftSize; cutoff60Bin = i; break; } + } + + const highThresh = peak * 0.1; + let cutoffHighHz = 0; + const startBin = cutoff60Bin > 0 ? cutoff60Bin : n - 1; + for (let i = startBin; i >= 0; i--) { + if (mag[i] >= highThresh) { cutoffHighHz = i * sr / a.fftSize; break; } + } + + let transitionBw = (cutoff60Hz > 0) ? cutoff60Hz - cutoffHighHz : 0; + if (transitionBw < 0) transitionBw = 0; + const steepness = transitionBw; + + let noiseSum = 0, noiseCount = 0; + for (let i = n * 3 / 4; i < n; i++) { + if (mag[i] > 0) { noiseSum += mag[i]; noiseCount++; } + } + const noiseFloor = (noiseCount > 0) ? (noiseSum / noiseCount) : 1e-12; + const noiseDb = 20.0 * Math.log10(noiseFloor / peak); + + let extDb = noiseDb + 6.0; + const minExtDb = thresholdDb - 30.0; + if (extDb < minExtDb) extDb = minExtDb; + const extPeak = peak * Math.pow(10.0, extDb / 20.0); + let extCutoff = 0; + for (let i = n - 1; i >= 0; i--) { + if (mag[i] >= extPeak) { extCutoff = i * sr / a.fftSize; break; } + } + const extendedCutoff = extCutoff; + + let roughCutoff = cutoffHz; + if (noiseDb > thresholdDb) { + const adjDb = noiseDb + 10.0; + const adjThresh = peak * Math.pow(10.0, adjDb / 20.0); + let adjCutoff = 0; + for (let i = n - 1; i >= 0; i--) { + if (mag[i] >= adjThresh) { adjCutoff = i * sr / a.fftSize; break; } + } + if (adjCutoff > 0) roughCutoff = adjCutoff; + } + + let roughness = 0.0; + const cutoffIdx = roughCutoff * a.fftSize / sr; + let lo = Math.floor(cutoffIdx * 0.60); + let hi = Math.floor(cutoffIdx * 0.95); + if (hi >= n) hi = n - 1; + if (lo < 1) lo = 1; + + const noiseMag = Math.pow(10.0, noiseDb / 20.0) * peak; + const regionTotal = hi - lo + 1; + let aboveNoise = 0; + for (let i = lo; i <= hi; i++) { + if (mag[i] > noiseMag * 2.0) aboveNoise++; + } + if (aboveNoise / regionTotal < 0.30) { + roughness = 0.01; + } else if (hi > lo) { + let sum = 0; + for (let i = lo; i <= hi; i++) sum += mag[i]; + const mean = sum / (hi - lo + 1); + if (mean > 1e-12) { + let varSum = 0; + for (let i = lo; i <= hi; i++) { + const dev = (mag[i] - mean) / mean; + varSum += dev * dev; + } + roughness = Math.sqrt(varSum / (hi - lo)); + } + } + if (roughness < 0.01) roughness = 0.01; + + let energyLow = 0, energyHigh = 0; + let elCount = 0, ehCount = 0; + for (let i = 0; i < n; i++) { + const f = i * sr / a.fftSize; + if (f >= 12000 && f < 16000) { energyLow += mag[i]; elCount++; } + if (f >= 16000 && f < 20000) { energyHigh += mag[i]; ehCount++; } + } + const bandRatio = (elCount > 0 && ehCount > 0) + ? (energyHigh / ehCount) / (energyLow / elCount + 1e-12) + : 0.5; + + const nyquist = sr / 2.0; + + let decisionCutoff = cutoffHz; + if (noiseDb > thresholdDb) { + const dcDb = noiseDb + 10.0; + const dcThresh = peak * Math.pow(10.0, dcDb / 20.0); + let dc = 0; + for (let i = n - 1; i >= 0; i--) { + if (mag[i] >= dcThresh) { dc = i * sr / a.fftSize; break; } + } + if (dc > 0) decisionCutoff = dc; + } + const cutoffRatio = decisionCutoff / nyquist; + const effCutoffRatio = (extendedCutoff > cutoffHz && noiseDb >= -100.0) + ? extendedCutoff / nyquist : cutoffRatio; + + const bwFactor = Math.max(0.25, 2.0 * (1.0 - sensitivity)); + const r1 = 0.40 * (1.0 + (0.5 - sensitivity) * 1.5); + const r2 = 0.30 * (1.0 + (0.5 - sensitivity) * 1.5); + const r3 = 0.20 * (1.0 + (0.5 - sensitivity) * 1.5); + const b1 = 0.90 - (0.5 - sensitivity) * 0.10; + const b2 = 0.85 - (0.5 - sensitivity) * 0.10; + const bypass = 0.99 - (0.5 - sensitivity) * 0.02; + + let score = 0; + + if (decisionCutoff <= 0 || cutoffRatio >= bypass) { + if (roughness > r1) score = 1; + else if (roughness > r2 && bandRatio < b1) score = 1; + else if (roughness > r3 && bandRatio < b2) score = 1; + return { score, cutoff: cutoffHz, steepness, noiseDb, roughness, bandRatio, extendedCutoff, + cutoffRatio, bypassed: true, mag, peak }; + } + + let maxBw; + if (cutoffRatio < 0.50) maxBw = 4000.0 * bwFactor; + else if (cutoffRatio < 0.70) maxBw = 3000.0 * bwFactor; + else if (cutoffRatio < 0.80) maxBw = 2000.0 * bwFactor; + else if (cutoffRatio < 0.90) maxBw = 1200.0 * bwFactor; + else maxBw = 500.0 * bwFactor; + + if (transitionBw < maxBw) score = 1; + + if (!score && cutoffRatio > 0.80) { + if (roughness > r1) score = 1; + else if (roughness > r2 && bandRatio < b1) score = 1; + else if (roughness > r3 && bandRatio < b2) score = 1; + } + + return { score, cutoff: cutoffHz, steepness, noiseDb, roughness, bandRatio, extendedCutoff, + cutoffRatio, maxBw, transitionBw, bypassed: false, mag, peak }; + }, + + computeConfidence(cutoffRatio, steepness, roughness, bandRatio, isNativeLossy) { + let conf = 0; + let count = 0; + + if (isNativeLossy) { + if (cutoffRatio < 0.70) { + const margin = Math.min(1, (0.70 - cutoffRatio) / 0.70); + conf += 0.50 + 0.50 * margin; + count++; + } else if (cutoffRatio < 0.80) { + const margin = Math.min(1, (0.80 - cutoffRatio) / 0.80); + conf += 0.40 + 0.60 * margin; + count++; + } else if (cutoffRatio < 0.85) { + const margin = Math.min(1, (0.85 - cutoffRatio) / 0.85); + conf += 0.20 + 0.60 * margin; + count++; + } else { + conf += 0.70; + count++; + } + } else { + if (cutoffRatio < 0.50) { + const margin = Math.min(1, (0.50 - cutoffRatio) / 0.50); + const sMargin = Math.max(0, Math.min(1, (4000.0 - steepness) / 4000.0)); + conf += 0.50 + 0.50 * (margin * 0.5 + sMargin * 0.5); + count++; + } else if (cutoffRatio < 0.70) { + const rMargin = Math.min(1, (0.70 - cutoffRatio) / 0.70); + const sMargin = Math.max(0, Math.min(1, (3000.0 - steepness) / 3000.0)); + conf += 0.30 + 0.70 * (rMargin * 0.4 + sMargin * 0.6); + count++; + } else if (cutoffRatio < 0.80) { + const rMargin = Math.min(1, (0.80 - cutoffRatio) / 0.80); + const sMargin = Math.max(0, Math.min(1, (2000.0 - steepness) / 2000.0)); + conf += 0.20 + 0.80 * (rMargin * 0.4 + sMargin * 0.6); + count++; + } else if (cutoffRatio < 0.90) { + const rMargin = Math.min(1, (0.90 - cutoffRatio) / 0.90); + const sMargin = Math.max(0, Math.min(1, (1200.0 - steepness) / 1200.0)); + conf += 0.10 + 0.90 * (rMargin * 0.4 + sMargin * 0.6); + count++; + } else { + const sMargin = Math.max(0, Math.min(1, (500.0 - steepness) / 500.0)); + conf += 0.20 + 0.80 * sMargin; + count++; + } + + if (cutoffRatio > 0.80) { + if (roughness > 0.40) { + const margin = Math.min(1, (roughness - 0.40) / 0.40); + conf += 0.40 + 0.60 * margin; + count++; + } else if (roughness > 0.30 && bandRatio < 0.90) { + const rMargin = Math.min(1, (roughness - 0.30) / 0.10); + const bMargin = Math.min(1, (0.90 - bandRatio) / 0.90); + conf += 0.20 + 0.80 * (rMargin * 0.5 + bMargin * 0.5); + count++; + } else if (roughness > 0.20 && bandRatio < 0.85) { + const rMargin = Math.min(1, (roughness - 0.20) / 0.10); + const bMargin = Math.min(1, (0.85 - bandRatio) / 0.85); + conf += 0.10 + 0.90 * (rMargin * 0.5 + bMargin * 0.5); + count++; + } + } + } + + if (count === 0) return 0; + return Math.max(0, Math.min(100, conf / count * 100.0)); + } +}; diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..1748c29 --- /dev/null +++ b/js/app.js @@ -0,0 +1,1209 @@ +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 'blackman': return Math.min(1, Math.pow(r, 0.4) * 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 decay = 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 = `UPSCALED — re-encoded from lower bitrate`; + this.renderFactors(dr, isLossy, true, sourceHint); + } else { + el.innerHTML = `NATIVE — single encode at this bitrate`; + this.renderFactors(dr, isLossy, false, ''); + } + } else { + if (dr.score) { + el.innerHTML = `TRANSCODE — lossy → lossless re-encode detected`; + } else { + el.innerHTML = `GENUINE — likely native lossless`; + } + this.renderFactors(dr, false, false, ''); + } + + const bar = document.getElementById('confidenceBar'); + const label = document.getElementById('confidenceLabel'); + const fillPct = Math.min(100, Math.max(0, confidence)); + bar.style.width = `${fillPct}%`; + bar.textContent = `${fillPct.toFixed(0)}%`; + + let confLabel; + if (fillPct >= 85) confLabel = 'Very strong evidence'; + else if (fillPct >= 70) confLabel = 'Strong evidence'; + else if (fillPct >= 50) confLabel = 'Moderate evidence'; + else if (fillPct >= 30) confLabel = 'Weak evidence'; + else confLabel = 'Borderline / inconclusive'; + label.textContent = confLabel; + }, + + renderFactors(dr, isLossy, upscaled, sourceHint) { + const el = document.getElementById('factors'); + let html = '

Factor Analysis

'; + el.innerHTML = html; + }, + + renderChart(result, detectResult, effectiveCutoff, sampleRate) { + const canvas = document.getElementById('spectrumChart'); + const ctx = canvas.getContext('2d'); + canvas.width = canvas.parentElement.clientWidth * window.devicePixelRatio; + canvas.height = 360 * window.devicePixelRatio; + canvas.style.width = canvas.parentElement.clientWidth + 'px'; + canvas.style.height = '360px'; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + const W = canvas.width / window.devicePixelRatio; + const H = 360; + const pad = { top: 20, right: 30, bottom: 40, left: 55 }; + const chartW = W - pad.left - pad.right; + const chartH = H - pad.top - pad.bottom; + + const n = result.fftSize / 2; + const nyquist = sampleRate / 2.0; + + const mag = new Float64Array(n); + let peak = 0; + for (let i = 0; i < n; i++) { + mag[i] = Math.sqrt(result.power[i] / result.count); + if (mag[i] > peak) peak = mag[i]; + } + if (peak < 1e-12) peak = 1e-12; + + for (let i = 0; i < n; i++) + mag[i] = 20.0 * Math.log10(mag[i] / peak); + + ctx.clearRect(0, 0, W, H); + + this.drawGrid(ctx, pad, chartW, chartH, nyquist); + + const minDb = -100; + const maxDb = 0; + + function freqToX(freq) { + const minFreq = 20; + const maxFreq = nyquist; + const norm = (Math.log(freq / minFreq) / Math.log(maxFreq / minFreq)); + return pad.left + norm * chartW; + } + + function dbToY(db) { + return pad.top + chartH - ((db - minDb) / (maxDb - minDb)) * chartH; + } + + ctx.beginPath(); + let started = false; + for (let i = 0; i < n; i++) { + const freq = i * sampleRate / result.fftSize; + if (freq < 20) continue; + const x = freqToX(freq); + const y = dbToY(Math.max(minDb, mag[i])); + if (!started) { ctx.moveTo(x, y); started = true; } + else ctx.lineTo(x, y); + } + ctx.lineTo(freqToX(nyquist), dbToY(minDb)); + ctx.lineTo(freqToX(20), dbToY(minDb)); + ctx.closePath(); + + const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + chartH); + grad.addColorStop(0, 'rgba(0, 188, 212, 0.35)'); + grad.addColorStop(0.5, 'rgba(0, 188, 212, 0.15)'); + grad.addColorStop(1, 'rgba(0, 188, 212, 0.02)'); + ctx.fillStyle = grad; + ctx.fill(); + + ctx.beginPath(); + started = false; + for (let i = 0; i < n; i++) { + const freq = i * sampleRate / result.fftSize; + if (freq < 20) continue; + const x = freqToX(freq); + const y = dbToY(Math.max(minDb, mag[i])); + if (!started) { ctx.moveTo(x, y); started = true; } + else ctx.lineTo(x, y); + } + ctx.strokeStyle = '#00bcd4'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + if (effectiveCutoff > 0) { + const cx = freqToX(effectiveCutoff); + ctx.beginPath(); + ctx.moveTo(cx, pad.top); + ctx.lineTo(cx, pad.top + chartH); + ctx.strokeStyle = '#ffc107'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 4]); + ctx.stroke(); + ctx.setLineDash([]); + + const label = effectiveCutoff >= 1000 + ? `${(effectiveCutoff/1000).toFixed(1)} kHz` + : `${effectiveCutoff.toFixed(0)} Hz`; + ctx.fillStyle = '#ffc107'; + ctx.font = 'bold 12px "SF Mono", "Fira Code", monospace'; + ctx.textAlign = 'center'; + ctx.fillText(label, cx, pad.top - 5); + } + + const contourDb = detectResult.noiseDb; + if (contourDb > minDb) { + const ny = dbToY(contourDb); + ctx.beginPath(); + ctx.moveTo(pad.left, ny); + ctx.lineTo(pad.left + chartW, ny); + ctx.strokeStyle = 'rgba(255, 82, 82, 0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255, 82, 82, 0.7)'; + ctx.font = '11px "SF Mono", "Fira Code", monospace'; + ctx.textAlign = 'left'; + ctx.fillText(`Noise floor ${contourDb.toFixed(1)} dB`, pad.left + 5, ny - 4); + } + }, + + renderDataTable(result, detectResult, peakDb, isLossy, upscaled, confidence, effectiveCutoff, sampleRate) { + const nyquist = sampleRate / 2.0; + const tbody = document.querySelector('#dataTable tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + + function addRow(label, value) { + const tr = document.createElement('tr'); + tr.innerHTML = `${label}${value}`; + tbody.appendChild(tr); + } + + addRow('Sample Rate', `${sampleRate} Hz`); + addRow('Nyquist Frequency', `${nyquist.toFixed(0)} Hz`); + addRow('FFT Size', `${result.fftSize}`); + addRow('Windows Analyzed', `${result.count}`); + addRow('Peak Magnitude', `${peakDb.toFixed(1)} dBFS`); + addRow('Cutoff Frequency', `${detectResult.cutoff.toFixed(0)} Hz (${(100 * detectResult.cutoff / nyquist).toFixed(1)}% Nyquist)`); + addRow('Extended Cutoff', `${detectResult.extendedCutoff.toFixed(0)} Hz (${(100 * detectResult.extendedCutoff / nyquist).toFixed(1)}% Nyquist)`); + addRow('Effective Cutoff', `${effectiveCutoff.toFixed(0)} Hz (${(100 * effectiveCutoff / nyquist).toFixed(1)}% Nyquist)`); + addRow('Cutoff Ratio', `${detectResult.cutoffRatio.toFixed(4)}`); + addRow('Transition Bandwidth (Steepness)', `${detectResult.steepness.toFixed(0)} Hz`); + addRow('Max Bandwidth Threshold', detectResult.maxBw ? `${detectResult.maxBw.toFixed(0)} Hz` : 'N/A (bypass)'); + addRow('Noise Floor', `${detectResult.noiseDb.toFixed(1)} dB`); + addRow('Roughness (CoV)', `${detectResult.roughness.toFixed(4)}`); + addRow('Band Ratio (16-20kHz)/(12-16kHz)', `${detectResult.bandRatio.toFixed(4)}`); + addRow('Detection Score', `${detectResult.score}`); + addRow('Confidence', `${confidence.toFixed(1)}%`); + addRow('Verdict', isLossy ? (upscaled ? 'UPSCALED' : 'NATIVE') : (detectResult.score ? 'TRANSCODE' : 'GENUINE')); + }, + + drawGrid(ctx, pad, chartW, chartH, nyquist) { + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, ctx.canvas.width / window.devicePixelRatio, 360); + + const tickFreqs = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 20000]; + const tickLabels = ['20', '50', '100', '200', '500', '1k', '2k', '5k', '10k', '15k', '20k']; + const minFreq = 20; + + function freqToX(freq) { + const maxFreq = nyquist; + const norm = (Math.log(freq / minFreq) / Math.log(maxFreq / minFreq)); + return pad.left + norm * chartW; + } + + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.lineWidth = 1; + for (let db = -100; db <= 0; db += 10) { + const y = pad.top + chartH - ((db - (-100)) / (0 - (-100))) * chartH; + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + chartW, y); + ctx.stroke(); + } + + for (const f of tickFreqs) { + if (f >= nyquist) break; + const x = freqToX(f); + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, pad.top + chartH); + ctx.stroke(); + } + + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = '10px "SF Mono", "Fira Code", monospace'; + ctx.textAlign = 'center'; + for (let i = 0; i < tickFreqs.length; i++) { + if (tickFreqs[i] >= nyquist) break; + const x = freqToX(tickFreqs[i]); + ctx.fillText(tickLabels[i], x, pad.top + chartH + 16); + } + + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '10px "SF Mono", "Fira Code", monospace'; + ctx.textAlign = 'right'; + for (let db = -100; db <= 0; db += 20) { + const y = pad.top + chartH - ((db - (-100)) / (0 - (-100))) * chartH; + ctx.fillText(`${db} dB`, pad.left - 6, y + 4); + } + + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '11px "SF Mono", "Fira Code", monospace'; + ctx.textAlign = 'center'; + ctx.fillText('Frequency', pad.left + chartW / 2, pad.top + chartH + 34); + ctx.save(); + ctx.translate(14, pad.top + chartH / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText('Magnitude', 0, 0); + ctx.restore(); + }, + + showLoading(msg) { + const ov = document.getElementById('loadingOverlay'); + if (ov) ov.style.display = 'flex'; + document.getElementById('loadingMsg').textContent = msg; + document.getElementById('errorMsg').style.display = 'none'; + }, + + showError(msg) { + const ov = document.getElementById('loadingOverlay'); + if (ov) ov.style.display = 'none'; + const el = document.getElementById('errorMsg'); + el.textContent = msg; + el.style.display = 'block'; + }, + + formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }, + + sleep(ms) { return new Promise(r => setTimeout(r, ms)); } +}; + +document.addEventListener('DOMContentLoaded', () => App.init()); diff --git a/js/metadata.js b/js/metadata.js new file mode 100644 index 0000000..3f557f4 --- /dev/null +++ b/js/metadata.js @@ -0,0 +1,318 @@ +const Metadata = { + parse(arrayBuffer, fileName) { + const dv = new DataView(arrayBuffer); + const size = arrayBuffer.byteLength; + const tags = []; + + if (size >= 10 && this._str(dv, 0, 3) === 'ID3') + this._id3v2(dv, size, tags); + + if (size >= 4 && this._str(dv, 0, 4) === 'fLaC') + this._flac(dv, size, tags); + + if (size >= 32 && this._str(dv, 0, 8) === 'APETAGEX') + this._ape(dv, size, 0, tags); + + if (size >= 128) { + const off = size - 128; + if (this._str(dv, off, 3) === 'TAG') + this._id3v1(dv, off, tags); + } + + return tags; + }, + + _str(dv, off, len) { + const b = new Uint8Array(dv.buffer, off, len); + const end = b.indexOf(0); + return new TextDecoder('latin1').decode(end < 0 ? b : b.subarray(0, end)); + }, + + _utf16(dv, off, max) { + if (max < 2) return ''; + const bom = dv.getUint16(off, false); + const le = bom !== 0xFEFF; + let s = '', i = off + 2; + while (i + 1 < off + max) { + const c = dv.getUint16(i, le); + if (c === 0) break; + if (c >= 0xD800 && c <= 0xDFFF && i + 3 < off + max) { + const c2 = dv.getUint16(i + 2, le); + s += String.fromCodePoint(0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00)); + i += 4; + } else { + s += String.fromCharCode(c); + i += 2; + } + } + return s; + }, + + _latin1(dv, off, max) { + const b = new Uint8Array(dv.buffer, off, max); + const end = b.indexOf(0); + return new TextDecoder('latin1').decode(end < 0 ? b : b.subarray(0, end)); + }, + + _ssint(dv, off) { + return (dv.getUint8(off) << 21) | (dv.getUint8(off + 1) << 14) | + (dv.getUint8(off + 2) << 7) | dv.getUint8(off + 3); + }, + + _fid(id) { + const m = { + TIT2: 'Title', TPE1: 'Artist', TPE2: 'Album Artist', TALB: 'Album', + TYER: 'Year', TDRC: 'Year', TDRL: 'Release Time', TRCK: 'Track', + TPOS: 'Disc', TCON: 'Genre', COMM: 'Comment', TCOP: 'Copyright', + TPUB: 'Publisher', TENC: 'Encoded By', TSSE: 'Encoder', + TCOM: 'Composer', TEXT: 'Lyricist', TLAN: 'Language', TBPM: 'BPM', + TKEY: 'Initial Key', USLT: 'Lyrics', + TT2: 'Title', TP1: 'Artist', TP2: 'Album Artist', TAL: 'Album', + TYE: 'Year', TRK: 'Track', TPA: 'Disc', TCO: 'Genre', + COM: 'Comment', TCR: 'Copyright', TPB: 'Publisher', + TEN: 'Encoded By', TSS: 'Encoder', TCM: 'Composer', + }; + return m[id] || id; + }, + + _rdFrm(dv, off, id, sz) { + const enc = dv.getUint8(off); + + if (id === 'COMM' || id === 'COM') { + let p = off + 4; + if (enc === 0 || enc === 3) { + while (p < off + sz && dv.getUint8(p) !== 0) p++; + p++; + return enc === 0 + ? this._latin1(dv, p, off + sz - p) + : this._str(dv, p, off + sz - p); + } + while (p + 1 < off + sz) { + if (dv.getUint16(p, enc === 2) === 0) { p += 2; break; } + p += 2; + } + return this._utf16(dv, p, off + sz - p); + } + + if (id === 'WXXX') { + let p = off + 1; + if (enc === 0 || enc === 3) { + while (p < off + sz && dv.getUint8(p) !== 0) p++; + return this._str(dv, p + 1, off + sz - p - 1); + } + while (p + 1 < off + sz) { + if (dv.getUint16(p, enc === 2) === 0) { p += 2; break; } + p += 2; + } + return this._str(dv, p, off + sz - p); + } + + if (id.startsWith('T') || (id.length === 3 && id.startsWith('T'))) { + if (enc === 0) return this._latin1(dv, off + 1, sz - 1); + if (enc === 3) return this._str(dv, off + 1, sz - 1); + return this._utf16(dv, off + 1, sz - 1); + } + + if (id === 'APIC' || id === 'PIC') return null; + + return ''; + }, + + _id3v2(dv, size, tags) { + try { + const ver = dv.getUint8(3); + const fl = dv.getUint8(5); + const tagSz = this._ssint(dv, 6); + let off = 10, end = off + tagSz; + if (end > size) return; + const vs = `ID3v2.${ver}.${dv.getUint8(4)}`; + + if (fl & 0x40) { + off += ver >= 4 ? this._ssint(dv, off) : dv.getUint32(off, false); + } + + while (off + (ver === 2 ? 6 : 10) <= end) { + const fid = this._str(dv, off, ver === 2 ? 3 : 4); + if (fid.length < (ver === 2 ? 3 : 4) || fid.indexOf('\x00') >= 0) break; + + let fsz; + if (ver === 2) { + fsz = (dv.getUint8(off + 3) << 16) | (dv.getUint8(off + 4) << 8) | dv.getUint8(off + 5); + off += 6; + } else { + fsz = ver >= 4 ? this._ssint(dv, off + 4) : dv.getUint32(off + 4, false); + off += 10; + } + if (fsz === 0 || off + fsz > end) break; + + const val = this._rdFrm(dv, off, fid, fsz); + if (val) tags.push({ source: vs, key: this._fid(fid), value: val }); + off += fsz; + } + } catch (_) {} + }, + + _id3v1(dv, off, tags) { + try { + const src = 'ID3v1'; + const title = this._str(dv, off + 3, 30).trim(); + const artist = this._str(dv, off + 33, 30).trim(); + const album = this._str(dv, off + 63, 30).trim(); + const year = this._str(dv, off + 93, 4).trim(); + const comment = this._str(dv, off + 97, 30).trim(); + const genre = dv.getUint8(off + 127); + const genres = [ + 'Blues','Classic Rock','Country','Dance','Disco','Funk','Grunge','Hip-Hop', + 'Jazz','Metal','New Age','Oldies','Other','Pop','R&B','Rap','Reggae','Rock', + 'Techno','Industrial','Alternative','Ska','Death Metal','Pranks','Soundtrack', + 'Euro-Techno','Ambient','Trip-Hop','Vocal','Jazz+Funk','Fusion','Trance', + 'Classical','Instrumental','Acid','House','Game','Sound Clip','Gospel', + 'Noise','Alt Rock','Bass','Soul','Punk','Space','Meditative','Instrumental Pop', + 'Instrumental Rock','Ethnic','Gothic','Darkwave','Techno-Industrial','Electronic', + 'Pop-Folk','Eurodance','Dream','Southern Rock','Comedy','Cult','Gangsta Rap', + 'Top 40','Christian Rap','Pop/Funk','Jungle','Native American','Cabaret', + 'New Wave','Psychedelic','Rave','Showtunes','Trailer','Lo-Fi','Tribal', + 'Acid Punk','Acid Jazz','Polka','Retro','Musical','Rock & Roll','Hard Rock', + 'Folk','Folk/Rock','National Folk','Swing','Fast-Fusion','Bebob','Latin', + 'Revival','Celtic','Bluegrass','Avantgarde','Gothic Rock','Progressive Rock', + 'Psychedelic Rock','Symphonic Rock','Slow Rock','Big Band','Chorus', + 'Easy Listening','Acoustic','Humour','Speech','Chanson','Opera','Chamber Music', + 'Sonata','Symphony','Booty Bass','Primus','Porn Groove','Satire','Slow Jam', + 'Club','Tango','Samba','Folklore','Ballad','Power Ballad','Rhythmic Soul', + 'Freestyle','Duet','Punk Rock','Drum Solo','A Cappella','Euro-House','Dance Hall', + 'Goa','Drum & Bass','Club-House','Hardcore','Terror','Indie','BritPop', + 'Negerpunk','Polsk Punk','Beat','Christian Gangsta Rap','Heavy Metal','Black Metal', + 'Crossover','Contemporary Christian','Christian Rock','Merengue','Salsa', + 'Thrash Metal','Anime','JPop','Synthpop' + ]; + if (title) tags.push({ source: src, key: 'Title', value: title }); + if (artist) tags.push({ source: src, key: 'Artist', value: artist }); + if (album) tags.push({ source: src, key: 'Album', value: album }); + if (year) tags.push({ source: src, key: 'Year', value: year }); + if (comment) tags.push({ source: src, key: 'Comment', value: comment }); + if (genre >= 0 && genre < genres.length) + tags.push({ source: src, key: 'Genre', value: genres[genre] }); + } catch (_) {} + }, + + _flac(dv, size, tags) { + try { + let off = 4; + let last = 0; + while (!last && off + 4 <= size) { + last = dv.getUint8(off) >> 7; + const type = dv.getUint8(off) & 0x7F; + const blen = (dv.getUint8(off + 1) << 16) | (dv.getUint8(off + 2) << 8) | dv.getUint8(off + 3); + off += 4; + if (off + blen > size) break; + + if (type === 0) { + const bps = ((dv.getUint16(off + 13) & 0xFF) >> 4) + 1; + const sr = (dv.getUint16(off + 10) & 0xFFFF) | ((dv.getUint16(off + 9) & 0x0F) << 16); + const ch = ((dv.getUint16(off + 12) & 0x0E) >> 1) + 1; + tags.push({ source: 'FLAC', key: 'Sample Rate', value: `${sr} Hz` }); + tags.push({ source: 'FLAC', key: 'Channels', value: `${ch}` }); + tags.push({ source: 'FLAC', key: 'Bit Depth', value: `${bps}` }); + } + + if (type === 4) { + const vlen = dv.getUint32(off, true); + off += 4 + vlen; + const ntags = dv.getUint32(off, true); + off += 4; + for (let i = 0; i < ntags && off < size; i++) { + const tlen = dv.getUint32(off, true); + off += 4; + if (tlen === 0 || off + tlen > size) break; + const raw = this._str(dv, off, tlen); + const eq = raw.indexOf('='); + if (eq > 0) { + const k = raw.substring(0, eq); + const v = raw.substring(eq + 1); + tags.push({ source: 'FLAC', key: k, value: v }); + } + off += tlen; + } + } else { + off += blen; + } + } + } catch (_) {} + }, + + _ape(dv, size, off, tags) { + try { + const ver = dv.getUint32(off + 8, false); + const tagSz = dv.getUint32(off + 12, false); + const nItems = dv.getUint32(off + 16, false); + const flags = dv.getUint32(off + 20, false); + let p = off + (ver >= 2000 ? 32 : 26); + + const src = `APE${ver}`; + for (let i = 0; i < nItems && p + 8 <= size; i++) { + const valSz = dv.getUint32(p, true); + const itemFlags = dv.getUint32(p + 4, false); + p += 8; + if (valSz === 0 || p + valSz > size) break; + + const keyEnd = p; + let kp = keyEnd; + while (kp < size && dv.getUint8(kp) !== 0) kp++; + const key = this._str(dv, keyEnd, kp - keyEnd); + if (!key) break; + kp++; + + const val = this._str(dv, kp, valSz); + tags.push({ source: src, key, value: val }); + p = kp + valSz; + } + } catch (_) {} + }, + + _mp3Sync(dv, off, size) { + while (off + 4 <= size) { + if (dv.getUint8(off) === 0xFF && (dv.getUint8(off + 1) & 0xE0) === 0xE0) return off; + off++; + } + return -1; + }, + + getFormatInfo(arrayBuffer, codecName) { + const info = { bitrateMode: null, stereoMode: null }; + if (codecName !== 'MP3') return info; + + try { + const dv = new DataView(arrayBuffer); + const size = arrayBuffer.byteLength; + + let off = 0; + const tagEnd = this._id3v2Size(dv, size); + if (tagEnd > 0) off = tagEnd; + + const sync = this._mp3Sync(dv, off, size); + if (sync < 0) return info; + + const h = dv.getUint16(sync + 2, false); + const chMode = (h >> 6) & 3; + const chNames = ['Stereo', 'Joint Stereo', 'Dual Channel', 'Mono']; + info.stereoMode = chNames[chMode] || null; + + const scanEnd = Math.min(sync + 200, size - 4); + for (let p = sync + 4; p < scanEnd; p++) { + const tag = this._str(dv, p, 4); + if (tag === 'Xing' || tag === 'Info') { + const flags = dv.getUint32(p + 4, false); + info.bitrateMode = (flags & 1) ? 'VBR' : 'CBR'; + return info; + } + } + info.bitrateMode = 'CBR'; + } catch (_) {} + return info; + }, + + _id3v2Size(dv, size) { + if (size < 10 || this._str(dv, 0, 3) !== 'ID3') return 0; + return 10 + this._ssint(dv, 6); + }, +}; diff --git a/style.css b/style.css new file mode 100644 index 0000000..ff32dca --- /dev/null +++ b/style.css @@ -0,0 +1,362 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0d0d1a; + --bg2: #14142a; + --bg3: #1a1a2e; + --bg4: #222242; + --fg: #e0e0f0; + --fg2: #a0a0c0; + --accent: #00bcd4; + --accent2: #26c6da; + --warn: #ffc107; + --danger: #ff5252; + --success: #4caf50; + --green: #66bb6a; + --orange: #ffa726; + --border: #2a2a4a; + --font: "SF Mono", "Fira Code", "Cascadia Code", "JetBrains Mono", monospace; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--fg); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 24px 20px; +} + +.logo { color: var(--accent); font-weight: 700; } +.subtitle { color: var(--fg2); font-size: 11px; font-weight: 300; } +.tagline { color: var(--fg2); font-size: 9px; opacity: 0.5; } + +.section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 10px; +} +.section-header h2 { margin: 0; } +.section-brand { text-align: right; line-height: 1.4; } + +.loading-overlay { + display: none; + position: absolute; + inset: 0; + background: rgba(13,13,26,0.75); + backdrop-filter: blur(4px); + align-items: center; + justify-content: center; + flex-direction: column; + z-index: 10; + color: var(--fg2); + font-size: 13px; + gap: 10px; +} +.spinner { + width: 28px; height: 28px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +#errorMsg { + display: none; + background: rgba(255,82,82,0.1); + border: 1px solid var(--danger); + border-radius: 8px; + padding: 16px; + color: var(--danger); + text-align: center; + margin-bottom: 24px; +} + +#results { display: block; } + +section { + background: linear-gradient(180deg, #1c1c34 0%, #14142a 50%, #0e0e1e 100%); + border: 1px solid var(--border); + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; +} +section h2 { + font-size: 13px; + font-weight: 600; + color: var(--fg2); + text-transform: uppercase; + letter-spacing: 1.5px; + margin-bottom: 16px; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} +.info-item { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.info-item.fullwidth { grid-column: 1 / -1; } +.info-label { font-size: 10px; color: var(--fg2); text-transform: uppercase; letter-spacing: 0.8px; } +.info-value { font-size: 14px; color: var(--fg); font-weight: 500; word-break: break-all; overflow-wrap: break-word; } +.bpm-value { display: flex; align-items: center; gap: 6px; } +.bpm-buttons { display: flex; gap: 3px; } +.bpm-btn { + background: var(--bg4); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--fg2); + font-family: var(--font); + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + cursor: pointer; + line-height: 1.4; + transition: all 0.15s; +} +.bpm-btn:hover { background: var(--accent); border-color: var(--accent); color: #000; } + +.metadata-grid { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 4px 16px; + font-size: 12px; +} +.metadata-item { + display: contents; +} +.metadata-item > span { + padding: 4px 0; + border-bottom: 1px solid rgba(42,42,74,0.3); +} +.metadata-source { + color: var(--accent); + font-weight: 500; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.metadata-key { + color: var(--fg2); +} +.metadata-value { + color: var(--fg); + word-break: break-all; +} +.metadata-empty { + display: none; + color: var(--fg2); + font-size: 12px; + font-style: italic; + text-align: center; + padding: 12px; +} + +.chart-container { + position: relative; + border-radius: 8px; + overflow: hidden; + background: rgba(13, 13, 26, 0.55); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.07); + box-shadow: 0 2px 12px rgba(0,0,0,0.3); +} +.chart-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient(180deg, rgba(255,255,255,0.06) 0%, transparent 100%); + pointer-events: none; + z-index: 1; +} +.chart-container > canvas { + position: relative; + z-index: 0; +} +#spectrumChart, #waveformChart, #liveSpectrum { + display: block; + width: 100%; +} +#spectrumChart { height: 360px; } + +.player-controls { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} +.player-btn { + background: var(--bg4); + border: none; + border-radius: 6px; + color: var(--fg); + font-size: 15px; + width: 34px; + height: 34px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + line-height: 1; +} +.player-btn:hover { background: var(--accent); color: #000; } +.player-btn.active { background: var(--danger); color: #fff; } +.load-btn { + background: var(--accent); + color: #000; + font-weight: 700; + font-size: 12px; + width: auto; + padding: 0 14px; + letter-spacing: 0.5px; +} +.load-btn:hover { background: var(--accent2); } +.info-value-tag { + font-size: 9px; + color: var(--accent); + background: rgba(0,255,255,0.1); + border: 1px solid rgba(0,255,255,0.2); + border-radius: 3px; + padding: 0 4px; + font-family: var(--font); + margin-left: 4px; + vertical-align: middle; +} +.player-time { font-size: 12px; color: var(--fg2); font-family: var(--font); margin-left: 4px; } +.window-select { + background: var(--bg4); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--fg); + font-family: var(--font); + font-size: 10px; + padding: 2px 4px; + cursor: pointer; + margin-left: auto; +} +.window-select:hover { border-color: var(--accent); } + +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} +.metric-card { + background: linear-gradient(180deg, #1c1c34 0%, #14142a 50%, #0e0e1e 100%); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + text-align: center; +} +.metric-label { font-size: 10px; color: var(--fg2); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px; } +.metric-value { font-size: 18px; font-weight: 600; color: var(--accent); } + +.analysis-info { + display: flex; + gap: 24px; + justify-content: center; + font-size: 12px; + color: var(--fg2); +} +.analysis-info strong { color: var(--fg); } + +.decision-text { font-size: 20px; font-weight: 700; margin-bottom: 16px; } + +.verdict { padding: 2px 10px; border-radius: 4px; font-size: 18px; } +.verdict.native, .verdict.genuine { color: var(--success); } +.verdict.transcode, .verdict.upscaled { color: var(--warn); } + +.confidence-container { + margin-top: 8px; +} +.confidence-label { font-size: 11px; color: var(--fg2); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; } +.confidence-bar-bg { + background: var(--bg4); + border-radius: 10px; + height: 22px; + overflow: hidden; + position: relative; +} +.confidence-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--danger), var(--warn), var(--success)); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: #000; + transition: width 0.6s ease; + min-width: 0; +} +.confidence-label-small { font-size: 11px; color: var(--fg2); margin-top: 4px; font-style: italic; } + +#factors h3 { + font-size: 11px; + font-weight: 600; + color: var(--fg2); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 8px; +} +#factors ul { list-style: none; } +#factors li { + font-size: 12px; + color: var(--fg); + padding: 3px 0; + font-family: var(--font); +} +#factors li.hint { color: var(--fg2); font-style: italic; padding-left: 16px; } +#factors .pass { color: var(--success); } +#factors .fail { color: var(--danger); } + +.table-container { overflow-x: auto; } +#dataTable { width: 100%; border-collapse: collapse; font-size: 12px; } +#dataTable th { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + color: var(--fg2); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 10px; +} +#dataTable td { + padding: 6px 12px; + border-bottom: 1px solid rgba(42,42,74,0.4); +} +#dataTable td:first-child { color: var(--fg2); } +#dataTable td:last-child { color: var(--fg); font-weight: 500; font-family: var(--font); } +#dataTable tr:last-child td { border-bottom: none; } + +footer { + text-align: center; + padding: 32px 0 16px; + font-size: 11px; + color: var(--fg2); + opacity: 0.5; +} +footer a { color: var(--accent); text-decoration: none; } +footer a:hover { text-decoration: underline; } + +@media (max-width: 600px) { + .container { padding: 16px 12px; } + .metrics-grid { grid-template-columns: repeat(3, 1fr); } + .info-grid { grid-template-columns: repeat(2, 1fr); } + header h1 { font-size: 22px; } +}