mirror of
https://codeberg.org/armin/tcdweb.git
synced 2026-09-01 05:30:45 +02:00
init
This commit is contained in:
commit
c985d235a3
8 changed files with 2666 additions and 0 deletions
25
AGENTS.md
Normal file
25
AGENTS.md
Normal file
|
|
@ -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.
|
||||||
9
LICENSE
Normal file
9
LICENSE
Normal file
|
|
@ -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.
|
||||||
157
README.md
Normal file
157
README.md
Normal file
|
|
@ -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
|
||||||
155
index.html
Normal file
155
index.html
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>tcd — Audio Transcode Detector</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<input type="file" id="fileInput" accept=".mp3,.flac,.wav,.aiff,.aif,.ogg,.opus,.m4a,.wma,.ac3,.eac3,.aac,.alac,.wv,.mp2,.mp1,.ape,.dsf,.dff,audio/*" hidden>
|
||||||
|
|
||||||
|
<div id="errorMsg"></div>
|
||||||
|
|
||||||
|
<div id="results">
|
||||||
|
|
||||||
|
<section id="playerSection">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Player & Realtime Analysis</h2>
|
||||||
|
<div class="section-brand"><span class="logo">tcd</span> <span class="subtitle">Audio Authenticity Analyzer</span><br><span class="tagline">Psychoacoustic transcode detection via spectral analysis</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="player-controls">
|
||||||
|
<button id="loadBtn" class="player-btn load-btn" title="Load Audio File">📂 Load File</button>
|
||||||
|
<button id="playBtn" class="player-btn" title="Play/Pause">▶</button>
|
||||||
|
<button id="stopBtn" class="player-btn" title="Stop">⏹</button>
|
||||||
|
<button id="holdBtn" class="player-btn" title="Hold Max Cutoff">⊡</button>
|
||||||
|
<button id="resetBtn" class="player-btn" title="Reset Peaks">↺</button>
|
||||||
|
<span id="playTime" class="player-time">0:00.0 / 0:00.0</span>
|
||||||
|
<select id="windowSelect" class="window-select" title="Spectrum window function"><option value="none">None</option><option value="hanning" selected>Hanning</option><option value="blackman">Blackman-Harris</option><option value="flatTop">Flat Top</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container" style="position:relative">
|
||||||
|
<canvas id="waveformChart"></canvas>
|
||||||
|
<div id="loadingOverlay" class="loading-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div id="loadingMsg">Processing...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container" style="margin-top:10px;position:relative">
|
||||||
|
<canvas id="liveSpectrum"></canvas>
|
||||||
|
<canvas id="liveSpectrumOverlay" style="position:absolute;top:0;left:0;pointer-events:none"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="metrics-grid" style="margin-top:10px">
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Live Peak</div>
|
||||||
|
<div class="metric-value" id="livePeak">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Live Cutoff</div>
|
||||||
|
<div class="metric-value" id="liveCutoff">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="fileInfo">
|
||||||
|
<h2>File Information</h2>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item fullwidth"><span class="info-label">Filename</span><span id="fileName" class="info-value">—</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">Format</span><span id="fileFormat" class="info-value">—</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">Sample Rate</span><span id="fileSampleRate" class="info-value">—</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">Channels</span><span class="info-value"><span id="fileChannels">—</span> <span id="fileStereoMode" class="info-value-tag"></span></span></div>
|
||||||
|
<div class="info-item"><span class="info-label">Duration</span><span id="fileDuration" class="info-value">—</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">Bitrate</span><span class="info-value"><span id="fileBitrate">—</span> <span id="fileBitrateMode" class="info-value-tag"></span></span></div>
|
||||||
|
<div class="info-item"><span class="info-label">File Size</span><span id="fileSize" class="info-value">—</span></div>
|
||||||
|
<div class="info-item"><span class="info-label">BPM</span><span class="info-value bpm-value"><span id="fileBPM">—</span><span class="bpm-buttons"><button class="bpm-btn" id="bpmHalf" title="Halve">÷2</button><button class="bpm-btn" id="bpmDouble" title="Double">×2</button></span></span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="metadataSection">
|
||||||
|
<h2>Metadata Tags</h2>
|
||||||
|
<div id="metadataGrid" class="metadata-grid"></div>
|
||||||
|
<div id="metadataEmpty" class="metadata-empty">No metadata tags found</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="spectrumSection">
|
||||||
|
<h2>Power Spectrum</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="spectrumChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="metricsSection">
|
||||||
|
<h2>Key Metrics</h2>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-card" title="Peak Magnitude — highest spectral amplitude in dBFS (0 dBFS = full scale). How to read: • > -1 dBFS: Heavily limited/mastered, extremely loud • -6 to -1 dBFS: Typical modern mastering • -12 to -6 dBFS: Conservative, good headroom • < -12 dBFS: Quiet or attenuated Significance: Combined with noise floor, indicates the effective dynamic range of the audio.">
|
||||||
|
<div class="metric-label">Peak</div>
|
||||||
|
<div class="metric-value" id="mPeak">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card" title="Spectral Cutoff — highest frequency bin with significant energy above floor, as Hz and % of Nyquist (sample rate / 2). How to read: • ≥ 20 kHz (≥ 90%): Full bandwidth — lossless or high-bitrate lossy (320k MP3, 256k AAC) • 16-19 kHz (70-85%): ~192k MP3 / ~128k AAC • 14-16 kHz (60-70%): ~128k MP3 • 10-14 kHz (45-60%): ~64-96k lossy • < 10 kHz (< 45%): Very low bitrate (< 64k) Significance: The single strongest indicator of lossy compression. Lower cutoff = more aggressive compression.">
|
||||||
|
<div class="metric-label">Cutoff</div>
|
||||||
|
<div class="metric-value" id="mCutoff">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card" title="Transition Bandwidth (Steepness) — frequency gap between the -20 dBFS and -60 dBFS points. Measures how abruptly high-frequencies drop off. How to read: • < 500 Hz: Brick-wall cutoff — transcode or low-bitrate lossy • 500-1500 Hz: Moderately sharp — suspicious, may be higher-bitrate lossy • 1500-3000 Hz: Gradual rolloff — typical of genuine lossless • > 3000 Hz: Very gradual — natural rolloff from analog sources Significance: The strongest differentiator between genuine lossless and transcodes. Lossy encoders produce artificially steep lowpass filters.">
|
||||||
|
<div class="metric-label">Steepness</div>
|
||||||
|
<div class="metric-value" id="mSteepness">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card" title="Noise Floor — average magnitude in the top quarter of the spectrum (> 3/4 Nyquist), in dB relative to peak. How to read: • < -90 dB: Excellent — extremely clean, high dynamic range • -80 to -90 dB: Very good — typical of quality lossless • -70 to -80 dB: Moderate — some quantization noise present • -60 to -70 dB: Elevated — suggests lossy compression or analog hiss • > -60 dB: High — almost certainly lossy compressed Significance: Lossy codecs raise the noise floor through quantization. High noise floor + sharp cutoff = strong transcode indicator.">
|
||||||
|
<div class="metric-label">Noise Floor</div>
|
||||||
|
<div class="metric-value" id="mNoise">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card" title="Spectral Roughness (CoV) — coefficient of variation (σ/μ) of spectral magnitudes in the 60-95% region below cutoff. Measures how uneven/scalloped the spectrum is. How to read: • > 0.40: Strong spectral scalloping — clear lossy encoding signature • 0.30-0.40: Moderate scalloping — suspicious, may be high-bitrate lossy • 0.15-0.30: Mild unevenness — needs band-ratio correlation • < 0.15: Smooth spectrum — typical of genuine lossless Significance: Lossy codecs create a comb-filter pattern via frequency-bin quantization. High roughness = high probability of lossy encoding.">
|
||||||
|
<div class="metric-label">Roughness</div>
|
||||||
|
<div class="metric-value" id="mRoughness">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card" title="Band Ratio — ratio of average spectral energy in 16-20 kHz band to 12-16 kHz band. Detects unnatural high-frequency tilt. How to read: • < 0.5: Normal natural rolloff in top octave • 0.5-0.9: Typical for most recordings • 0.9-1.0: Flat HF — possible spectral reshaping from lossy encoding • > 1.0: Inverted tilt (more energy at 16-20 kHz than below) — strong transcode signature Significance: Used as secondary criterion when cutoff is high but roughness is ambiguous. Transcodes often show unnatural energy distribution in the top octaves.">
|
||||||
|
<div class="metric-label">Band Ratio</div>
|
||||||
|
<div class="metric-value" id="mBandRatio">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="analysis-info">
|
||||||
|
<span><strong>Windows:</strong> <span id="windowsCount">—</span></span>
|
||||||
|
<span><strong>FFT:</strong> <span id="fftSize">—</span></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="decisionSection" title="Verdict — the overall classification of the audio file based on spectral analysis. NATIVE: File matches its declared lossy format at a consistent bitrate (single encode). UPSCALED: Lossy file re-encoded to a higher bitrate than the original source. GENUINE: File appears to be native lossless (no transcode artifacts detected). TRANSCODE: Lossy source re-encoded to a lossless format (e.g. 128k MP3 → FLAC). The verdict is decided by: cutoff ratio, transition bandwidth (steepness), roughness (CoV), and band-ratio analysis against adaptive thresholds.">
|
||||||
|
<h2>Decision</h2>
|
||||||
|
<div id="decision" class="decision-text">—</div>
|
||||||
|
<div class="confidence-container" title="Validity Confidence — how strongly the evidence supports the verdict, from 0% (random chance) to 100% (certain). 85-100%: Very strong evidence — multiple criteria agree decisively. 70-84%: Strong evidence — most criteria support the verdict. 50-69%: Moderate evidence — some criteria triggered but not all. 30-49%: Weak evidence — marginal, borderline detection. 0-29%: Inconclusive — possible false positive, or audio is ambiguous. For lossy files: confidence is based on how far below the expected cutoff ratio the file sits. For lossless candidates: confidence blends cutoff ratio margin, steepness margin, and (above 80% Nyquist) secondary roughness + band-ratio criteria.">
|
||||||
|
<div class="confidence-label">Validity Confidence</div>
|
||||||
|
<div class="confidence-bar-bg">
|
||||||
|
<div id="confidenceBar" class="confidence-bar-fill" style="width:0%">0%</div>
|
||||||
|
</div>
|
||||||
|
<div id="confidenceLabel" class="confidence-label-small">—</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="factors" title="Factor Analysis — step-by-step breakdown of the detection criteria used to reach the verdict. ① PRIMARY criteria (applied to ALL files): Compares transition bandwidth (steepness) to an adaptive threshold based on cutoff ratio. If steepness is BELOW the threshold, the spectrum has an artificially sharp cutoff → transcode flagged. Files with cutoff_ratio ≥ 0.99 (full spectrum) bypass this check. ② SECONDARY criteria (applied when cutoff_ratio > 0.80): Checks spectral roughness (CoV) and band ratio for additional transcode signatures. High roughness (> 0.40) alone is sufficient to confirm. Moderate roughness needs band-ratio corroboration. Lossy files: checked only for cutoff ratio vs expected range for the bitrate. ✓ = criterion met (supports transcode/upscaled), ✗ = criterion not met (supports genuine/native).">
|
||||||
|
|
||||||
|
<section id="dataSection">
|
||||||
|
<h2>Analysis Data</h2>
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="dataTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Parameter</th><th>Value</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Based on <a href="https://codeberg.org/armin/tcd" target="_blank">tcd</a> — Psychoacoustic audio authenticity analysis</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/metadata.js"></script>
|
||||||
|
<script src="js/analyzer.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
431
js/analyzer.js
Normal file
431
js/analyzer.js
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
};
|
||||||
318
js/metadata.js
Normal file
318
js/metadata.js
Normal file
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
362
style.css
Normal file
362
style.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue