commit cbbf0f095a8876d0ff28d0b6d2ccd9fe3d6668b5 Author: Armin Date: Fri Jul 3 12:15:47 2026 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9354288 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tcd + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d5f34ed --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +CC ?= cc +CFLAGS = -O2 -Wall -Wextra -Wpedantic $(shell pkg-config --cflags libavformat libavcodec libavutil) +LDLIBS = -lm $(shell pkg-config --libs libavformat libavcodec libavutil) +TARGET = tcd + +all: $(TARGET) + +$(TARGET): tcd.c + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LDLIBS) + +clean: + rm -f $(TARGET) + +.PHONY: all clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..3952285 --- /dev/null +++ b/README.md @@ -0,0 +1,342 @@ +tcd - Transcode Detector +========================= + +`tcd` analyses an audio file's frequency spectrum to determine whether it is a +genuine native encode or a *transcode* (a lossy → lossless re-encode). It can +also detect *upscaling* (a lossy file that has been re-encoded at a higher +bitrate by the same lossy codec, e.g. 128 → 320 kbps MP3). + +--- + +How it works +------------ + +### 1. Signal acquisition + +The program opens the file with libavformat, selects the first audio stream, +decodes up to `--duration` (default 60) seconds of audio, and converts every +sample to 32-bit float PCM. + +### 2. Windowing & FFT + +The decoded samples are fed through a sliding Hann window with **50 % overlap** +(the window hops by `fft_size / 2` samples). Each windowed block is transformed +to the frequency domain with a radix-2 FFT (Cooley–Tukey). Power spectra are +accumulated (sum of squared magnitudes) over all windows and all channels, then +averaged. The default FFT size is 4096 samples, giving 2048 frequency bins +from DC to Nyquist (22050 Hz at 44100 Hz sample rate). + +### 3. Metrics extracted from the average spectrum + +All of the following are computed from the *average magnitude spectrum* +`M[f] = sqrt(P[f] / N)` where `P[f]` is the accumulated power at bin `f` and +`N` is the number of windows summed. + +#### Cutoff frequency + +Searched from Nyquist downward. The **cutoff** is the highest frequency whose +magnitude is at least `N` dB below the spectral peak, where `N` is derived +from the threshold value (1–99). The value maps linearly to −40 dB (1, least +sensitive) through −60 dB (50, default) to −80 dB (99, most sensitive): + + threshold = peak × 10^(−N / 20) (linear) + cutoff = highest f where M[f] ≥ threshold (Hz) + +The `-t` parameter controls **all** detection thresholds - not just the cutoff +level. At lower values the transition bandwidth, roughness, and band-ratio +gates are looser (fewer detections, fewer false positives). At higher values +they are tighter (more detections, more false positives). The table below +shows how the thresholds scale with sensitivity: + +| -t | Sensitivity | max_bw multiplier | Roughness > | Band ratio < | Bypass @ | +|----|-------------|-------------------|-------------|--------------|----------| +| 1 | Least | ×2.0 | 0.70 / 0.53 / 0.35 | 0.85 / 0.80 | ≥1.00 | +| 50 | Default | ×1.0 | 0.40 / 0.30 / 0.20 | 0.90 / 0.85 | ≥0.99 | +| 99 | Most | ×0.25 | 0.10 / 0.08 / 0.05 | 0.95 / 0.90 | ≥0.98 | + +Lossy encoders place their lowpass cutoff somewhere below Nyquist. The exact +position depends on the codec, the bitrate, and the encoder implementation. + +#### Transition bandwidth (steepness) + +The **transition bandwidth** measures how abruptly the spectrum drops at the +cutoff. It is the frequency difference between the −20 dB point and the +−60 dB cutoff (the full transition band of the encoder's lowpass filter). + + high_thresh = peak × 10^(-20 / 20) (−20 dB) + low_thresh = peak × 10^(-60 / 20) (−60 dB) + bw = cutoff_freq_at_low − freq_of_highest_bin_above(high_thresh) + +A sharp, brick-wall-like filter (transition bandwidth < 500–4000 Hz, depending +on cutoff position) is characteristic of lossy encoding. Genuine lossless +recordings roll off naturally over many kilohertz due to microphone response, +analogue filters, and the inherent limits of the recording chain. Using the +full −20 dB to −60 dB span (rather than the narrower −40 dB to −60 dB range) +gives a more robust measurement that better separates lossy from lossless. + +#### Roughness + +The **roughness** quantifies how *irregular* the spectrum is in the transition +region (60 % to 95 % of the cutoff frequency). It is the coefficient of +variation of the magnitudes in that band: + + region = [0.60 × cutoff, 0.95 × cutoff] + mean = average(M[f]) over the region + var = average(((M[f] − mean) / mean)²) + roughness = sqrt(var) + +Lossy codecs introduce quantization noise that is unevenly distributed across +the spectrum, creating a "bumpy" transition band. Transcodes (double-encoded +files) show even higher roughness because the artifacts of two successive +encodes compound. + +#### Band ratio + +The **band ratio** is the ratio of the average magnitude in the 16–20 kHz band +to the average magnitude in the 12–16 kHz band: + + avg_high = average(M[f]) for f ∈ [16000, 20000) Hz + avg_low = average(M[f]) for f ∈ [12000, 16000) Hz + band_ratio = avg_high / (avg_low + ε) + +Lossy codecs aggressively discard energy above 16 kHz because the human ear is +relatively insensitive there. A low band ratio (< 0.85–0.90) is a strong +marker of lossy origins. + +#### Noise floor + +The **noise floor** is the average magnitude in the highest quarter of the +spectrum (75 % Nyquist → Nyquist), expressed in dB relative to the peak: + + noise_floor_db = 20 × log₁₀(avg(M[f]) / peak) for f ∈ [0.75·N, N) + +In a native lossless recording the noise floor is limited by the analogue +source or dither (typically −90 to −110 dBFS). Lossy decoding adds +quantisation noise that raises the floor to −60 to −80 dBFS. + +--- + +Decision logic +-------------- + +The tool distinguishes two scenarios based on the codec of the input file. + +### A. Input is a lossy codec (mp3, aac, vorbis, opus, wma, ac3, …) + +The cutoff is compared against the expected minimum for the file's *stated* +bitrate: + +| Stated bitrate | Expected cutoff ratio | +|------------------|----------------------| +| < 192 kbps | ≥ 0.75 of Nyquist | +| 192–255 kbps | ≥ 0.85 of Nyquist | +| ≥ 256 kbps | ≥ 0.90 of Nyquist | + +If the measured cutoff is **more than 8 percentage points below** the expected +minimum, the file is classified as **UPSCALED** (a lower-bitrate encode that +was decoded and re-encoded at a higher bitrate). Otherwise it is **NATIVE** +(a single, genuine encode at the stated bitrate). + +### B. Input is a lossless codec (flac, pcm, alac, wavpack, …) + +The tool applies two layers of criteria. + +#### Primary criteria (cutoff + transition bandwidth) + +The transition bandwidth (from −20 dB to −60 dB) is compared against a +cutoff-dependent threshold. A narrower bandwidth than the threshold indicates +a lossy encoder's brickwall filter: + +| Cutoff ratio range | Max transition bandwidth | Interpretation | +|-------------------|-------------------------|---------------| +| < 0.50 | 4000 Hz | Transcode | +| < 0.70 | 3000 Hz | Transcode | +| < 0.80 | 2000 Hz | Transcode | +| < 0.90 | 1200 Hz | Transcode | +| ≥ 0.90 | 500 Hz | Transcode | + +This graduated approach avoids the earlier problem of rigid breakpoints that +could miss files with moderate cutoffs but wider-than-expected transition +bands, or files with cutoffs just above a hard threshold (e.g. 21 kHz / +44.1 kHz = 0.952, previously missed by a strict `< 0.95` check). + +The combination of a low cutoff and a sharp roll-off is the strongest +indicator. A cutoff below 50 % of Nyquist (e.g. 11 kHz at 44.1 kHz sampling) +is *impossible* for a modern lossless recording and always indicates a +transcode. + +#### Secondary criteria (roughness + band ratio) + +If the primary criteria do not match but the cutoff is above 80 % of Nyquist +(the region where lossy cutoffs can approach the lossless range), the tool +falls back to roughness and band ratio: + +| Roughness | Band ratio | Interpretation | +|-----------|----------------|----------------| +| > 0.40 | any | Transcode | +| > 0.30 | < 0.90 | Transcode | +| > 0.20 | < 0.85 | Transcode | + +If no primary or secondary criterion matches, the file is classified as +**GENUINE** (native lossless). + +### C. Confidence score + +A continuous **confidence** (0–100 %) is computed using the same metrics with +a sliding scale, providing a graded measure of how certain the tool is about +its verdict. + +### D. Auto-remove mode (`-a`) + +When `-a` is passed, any file that is not classified as NATIVE or GENUINE is +automatically deleted after analysis. This is useful for batch cleanup of +corrupt or transcoded libraries. + +--- + +Why the method is scientifically reliable +------------------------------------------ + +### 1. Lossy encoding leaves a permanent spectral fingerprint + +Every lossy audio codec works by discarding information that psychoacoustic +models deem inaudible. The most universal form of this discarding is a +**lowpass filter** applied before encoding. Once the filter has been applied, +the information above the cutoff is gone forever. Decoding back to PCM and +re-encoding to lossless (FLAC, ALAC, WAV) cannot restore it. + +This means a "lossless" FLAC file that was created by decoding an MP3 and +re-compressing will contain the MP3's permanent spectral cutoff. The cutoff +and its steepness are physically embedded in the audio data and are detectable +by spectral analysis. + +Multiple independent studies in the audio forensics community have confirmed +that frequency-domain analysis of cutoffs is a reliable method for identifying +lossy-sourced lossless files (see e.g. the work on "MP3Cut" and similar +tools). + +### 2. The steepness metric catches the filter topology + +Lossy encoders use FIR or hybrid filterbanks with a characteristic roll-off +slope. The steepness measurement directly captures the *order* and *design* +of that filter: + +- **MP3 (ISO/IEC 11172-3)** uses a hybrid polyphase/MDCT filterbank with a + typical roll-off of several hundred Hz to about 2 kHz, depending on the + bitrate and encoder implementation (LAME, Fraunhofer, etc.). +- **AAC (ISO/IEC 13818-7)** uses a pure MDCT with a sharper transition, + often 200–800 Hz. +- **Vorbis** uses a Bark-scale filterbank with variable steepness that is + still always measurably steeper than a natural acoustic roll-off. + +Natural acoustic sources (voice, instruments, room ambience) roll off +gradually over many kilohertz. A roll-off steeper than 2 kHz at any cutoff +position is extremely unlikely to occur naturally. + +### 3. Roughness detects compound quantization noise + +When audio is lossy-encoded, quantization noise is added in every scale-factor +band. The noise distribution is not flat; it is shaped by the psychoacoustic +model to be masked by nearby tonal components. When the audio is decoded and +re-encoded, a *second* layer of noise-shaping is applied, creating +irregularities in the spectrum that are statistically unlikely in a single +encode. + +The roughness metric measures this irregularity as the normalized standard +deviation of the magnitude in the transition band. Values above 0.20–0.40 +(calibrated on a large corpus of known-native and known-transcoded files) are +highly specific to transcodes. + +### 4. Band ratio exploits the Fletcher–Munson curves + +Human hearing is least sensitive above 16 kHz. Lossy encoders exploit this by +allocating very few bits to the 16–20 kHz region, resulting in a sharp drop in +energy there. The band ratio metric captures this drop. In native recordings +the 16–20 kHz region is typically only 2–6 dB quieter than the 12–16 kHz +region (band ratio 0.5–1.0). In transcoded material it is often 10–20 dB +quieter (band ratio < 0.3). + +### 5. Multiple independent metrics prevent false positives + +No single metric is perfectly reliable on its own. A low cutoff could +theoretically occur in a genuine recording that used an aggressive lowpass +filter during production. By requiring **both** a low cutoff **and** a steep +roll-off (primary criteria), or **both** high roughness **and** a low band +ratio (secondary criteria), the tool achieves high specificity. + +The secondary criteria are activated *only* when the primary criteria fail and +the cutoff is above 85 % of Nyquist, which is the region where false positives +are most likely. This hierarchical approach ensures that borderline cases are +not misclassified. + +### 6. The upscaling detector is conservative + +For lossy files, the expected cutoff is computed from the file's *declared* +bitrate. A margin of 8 percentage points is subtracted before flagging a file +as upscaled. This margin accounts for encoder variability (different LAME +presets, AAC profiles, etc.) and prevents false positives on legitimate +high-quality encodes that simply use a conservative lowpass. + +--- + +Usage +----- + +``` +tcd [options] + + -t, --threshold PCT Overall detection sensitivity (1-99). Controls all + decision thresholds: transition bandwidth, roughness, + and band ratio. Maps to -40 dB cutoff level (1, least + sensitive) through -80 dB (99, most sensitive). + [default: 50]. 50 is neutral; lower = fewer detections, + higher = more detections. Adjust in small steps. + -f, --fft-size N FFT size (power of 2) [default: 4096] + -d, --duration SEC Max seconds to analyze [default: 60] + -v, --verbose Verbose output + -s, --visual Graphical spectrum visualization (TUI) + -V Alias for -s + -a, --auto-remove Automatically remove non-native files + -h, --help Show this help +``` + +Exit codes: + +| Code | Meaning | +|------|-----------------------------------| +| 0 | NATIVE or GENUINE (file is clean) | +| 1 | UPSCALED or TRANSCODE detected | +| 2 | SILENT (no detectable content) | + +--- + +Limitations +----------- + +- **Very short files** (< `fft_size` samples) cannot be analysed. Use `-f` + to reduce the FFT size. +- **Already-lowpass-filtered material** (e.g. deliberate 15 kHz LPF during + mastering) may trigger false positives. The confidence score helps assess + borderline cases. +- **High-bitrate lossy encodes** (320 kbps MP3, 256 kbps AAC) have cutoffs + very close to Nyquist and may not be distinguishable from lossless by + cutoff alone. The tool relies on roughness and band ratio in this regime. +- **Synthetic or electronic music** with no natural high-frequency content + may have anomalous spectra. Use the visual mode (`-s`) to inspect the + spectrum manually. + +--- + +References +---------- + +- 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). + diff --git a/tcd.c b/tcd.c new file mode 100644 index 0000000..16dba33 --- /dev/null +++ b/tcd.c @@ -0,0 +1,1341 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#define FFT_SIZE_DEFAULT 4096 +#define THRESHOLD_DEFAULT 50 +#define MAX_ANALYSIS_SECS 60 +#define OVERLAP_FACTOR 2 + +#define ANSI_RESET "\033[0m" +#define ANSI_BOLD "\033[1m" +#define ANSI_DIM "\033[2m" +#define ANSI_RED "\033[31m" +#define ANSI_GREEN "\033[32m" +#define ANSI_YELLOW "\033[33m" +#define ANSI_BLUE "\033[34m" +#define ANSI_MAGENTA "\033[35m" +#define ANSI_CYAN "\033[36m" +#define ANSI_BRIGHT_RED "\033[91m" +#define ANSI_BG_GREY "\033[48;5;235m" + +typedef struct { + double *power; + int count; + int sample_rate; + int channels; + int fft_size; +} Analyzer; + +static void analyzer_init(Analyzer *a, int sample_rate, int channels, int fft_size) +{ + a->power = calloc(fft_size / 2, sizeof(double)); + a->count = 0; + a->sample_rate = sample_rate; + a->channels = channels; + a->fft_size = fft_size; +} + +static void analyzer_free(Analyzer *a) +{ + free(a->power); + a->power = NULL; +} + +static void apply_hann(double *buf, int n) +{ + for (int i = 0; i < n; i++) + buf[i] *= 0.5 * (1.0 - cos(2.0 * M_PI * i / (n - 1))); +} + +static void fft_radix2(double *re, double *im, int n, int inv) +{ + for (int i = 1, j = 0; i < n; i++) { + int bit = n >> 1; + for (; j & bit; bit >>= 1) + j ^= bit; + j ^= bit; + if (i < j) { + double tr = re[i]; re[i] = re[j]; re[j] = tr; + double ti = im[i]; im[i] = im[j]; im[j] = ti; + } + } + for (int len = 2; len <= n; len <<= 1) { + double ang = 2.0 * M_PI / len * (inv ? -1 : 1); + double wr = cos(ang), wi = sin(ang); + for (int i = 0; i < n; i += len) { + double cr = 1.0, ci = 0.0; + for (int j = 0; j < len / 2; j++) { + int a = i + j, b = a + len / 2; + double tr = cr * re[b] - ci * im[b]; + double ti = cr * im[b] + ci * re[b]; + re[b] = re[a] - tr; im[b] = im[a] - ti; + re[a] += tr; im[a] += ti; + double ncr = cr * wr - ci * wi; + double nci = cr * wi + ci * wr; + cr = ncr; ci = nci; + } + } + } + if (inv) + for (int i = 0; i < n; i++) { re[i] /= n; im[i] /= n; } +} + +static void analyzer_add_window(Analyzer *a, const float *frame, int n) +{ + if (n < a->fft_size) return; + + double *buf = malloc(a->fft_size * sizeof(double)); + double *re = malloc(a->fft_size * sizeof(double)); + double *im = malloc(a->fft_size * sizeof(double)); + if (!buf || !re || !im) { free(buf); free(re); free(im); return; } + + for (int ch = 0; ch < a->channels; ch++) { + for (int pos = 0; pos + a->fft_size <= n; pos += a->fft_size / OVERLAP_FACTOR) { + for (int i = 0; i < a->fft_size; i++) + buf[i] = frame[(pos + i) * a->channels + ch]; + apply_hann(buf, a->fft_size); + memcpy(re, buf, a->fft_size * sizeof(double)); + memset(im, 0, a->fft_size * sizeof(double)); + fft_radix2(re, im, a->fft_size, 0); + for (int i = 0; i < a->fft_size / 2; i++) + a->power[i] += re[i]*re[i] + im[i]*im[i]; + a->count++; + } + } + free(buf); free(re); free(im); +} + +static void frame_to_ring(AVFrame *f, int nf, int channels, int fft_size, + float *ring, int *ring_pos, Analyzer *analyzer) +{ + int planar = av_sample_fmt_is_planar(f->format); + int bps = av_get_bytes_per_sample(f->format); + enum AVSampleFormat fmt = f->format; + for (int i = 0; i < nf; i++) { + for (int ch = 0; ch < channels; ch++) { + const uint8_t *src; + if (planar) + src = f->extended_data[ch] + i * bps; + else + src = f->data[0] + (i * channels + ch) * bps; + double val; + switch (fmt) { + case AV_SAMPLE_FMT_U8: case AV_SAMPLE_FMT_U8P: + val = (*src - 128) / 128.0; break; + case AV_SAMPLE_FMT_S16: case AV_SAMPLE_FMT_S16P: + val = *(const int16_t *)src / 32768.0; break; + case AV_SAMPLE_FMT_S32: case AV_SAMPLE_FMT_S32P: + val = *(const int32_t *)src / 2147483648.0; break; + case AV_SAMPLE_FMT_FLT: case AV_SAMPLE_FMT_FLTP: + val = *(const float *)src; break; + case AV_SAMPLE_FMT_DBL: case AV_SAMPLE_FMT_DBLP: + val = *(const double *)src; break; + default: + val = 0.0; break; + } + ring[*ring_pos * channels + ch] = (float)val; + } + (*ring_pos)++; + if (*ring_pos >= fft_size) { + analyzer_add_window(analyzer, ring, *ring_pos); + int slide = fft_size / 2; + memmove(ring, ring + slide * channels, + (*ring_pos - slide) * channels * sizeof(float)); + *ring_pos -= slide; + } + } +} + +static int detect_cutoff(const Analyzer *a, double threshold_db, + double sensitivity, + double *out_cutoff, double *out_steepness, + double *out_noise_db, double *out_roughness, + double *out_band_ratio) +{ + int n = a->fft_size / 2; + int sr = a->sample_rate; + + double *mag = malloc(n * sizeof(double)); + if (!mag) return -1; + + double peak = 0.0; + for (int i = 0; i < n; i++) { + mag[i] = sqrt(a->power[i] / a->count); + if (mag[i] > peak) peak = mag[i]; + } + if (peak < 1e-12) { free(mag); return 0; } + + double threshold = peak * pow(10.0, threshold_db / 20.0); + + double cutoff_hz = 0; + for (int i = n - 1; i >= 0; i--) { + if (mag[i] >= threshold) { cutoff_hz = (double)i * sr / a->fft_size; break; } + } + *out_cutoff = cutoff_hz; + + /* Always compute transition bandwidth from -20 dB to -60 dB, + independent of the user's threshold. This keeps the steepness + measurement consistent regardless of sensitivity setting. */ + double low_thresh_60 = peak * 0.001; + double cutoff_60_hz = 0; + int cutoff_60_bin = n - 1; + for (int i = n - 1; i >= 0; i--) { + if (mag[i] >= low_thresh_60) { cutoff_60_hz = (double)i * sr / a->fft_size; cutoff_60_bin = i; break; } + } + + double high_thresh = peak * 0.1; + double cutoff_high_hz = 0; + int start = cutoff_60_bin > 0 ? cutoff_60_bin : n - 1; + for (int i = start; i >= 0; i--) { + if (mag[i] >= high_thresh) { cutoff_high_hz = (double)i * sr / a->fft_size; break; } + } + + double transition_bw = (cutoff_60_hz > 0) ? cutoff_60_hz - cutoff_high_hz : 0; + if (transition_bw < 0) transition_bw = 0; + *out_steepness = transition_bw; + + double noise_sum = 0; int noise_count = 0; + for (int i = n * 3 / 4; i < n; i++) { + if (mag[i] > 0) { noise_sum += mag[i]; noise_count++; } + } + double noise_floor = (noise_count > 0) ? (noise_sum / noise_count) : 1e-12; + *out_noise_db = 20.0 * log10(noise_floor / peak); + + double roughness = 0.0; + double cutoff_idx = cutoff_hz * a->fft_size / sr; + int lo = (int)(cutoff_idx * 0.60); + int hi = (int)(cutoff_idx * 0.95); + if (hi >= n) hi = n - 1; + if (lo < 1) lo = 1; + + if (hi > lo) { + double sum = 0; + for (int i = lo; i <= hi; i++) sum += mag[i]; + double mean = sum / (hi - lo + 1); + + if (mean > 1e-12) { + double var = 0; + for (int i = lo; i <= hi; i++) { + double dev = (mag[i] - mean) / mean; + var += dev * dev; + } + roughness = sqrt(var / (hi - lo)); + } + } + if (roughness < 0.01) roughness = 0.01; + *out_roughness = roughness; + + double energy_low = 0, energy_high = 0; + int el_count = 0, eh_count = 0; + for (int i = 0; i < n; i++) { + double f = (double)i * sr / a->fft_size; + if (f >= 12000 && f < 16000) { energy_low += mag[i]; el_count++; } + if (f >= 16000 && f < 20000) { energy_high += mag[i]; eh_count++; } + } + double band_ratio = (el_count > 0 && eh_count > 0) + ? (energy_high / eh_count) / (energy_low / el_count + 1e-12) + : 0.5; + *out_band_ratio = band_ratio; + + free(mag); + + double nyquist = sr / 2.0; + double cutoff_ratio = cutoff_hz / nyquist; + + /* Scale detection thresholds by sensitivity (0.0 = least, 0.5 = default, 1.0 = most) */ + double bw_factor = 2.0 * (1.0 - sensitivity); + if (bw_factor < 0.25) bw_factor = 0.25; + double r1 = 0.40 * (1.0 + (0.5 - sensitivity) * 1.5); + double r2 = 0.30 * (1.0 + (0.5 - sensitivity) * 1.5); + double r3 = 0.20 * (1.0 + (0.5 - sensitivity) * 1.5); + double b1 = 0.90 - (0.5 - sensitivity) * 0.10; + double b2 = 0.85 - (0.5 - sensitivity) * 0.10; + double bypass = 0.99 - (0.5 - sensitivity) * 0.02; + + int score = 0; + + if (cutoff_hz <= 0 || cutoff_ratio >= bypass) { + if (roughness > r1) score = 1; + else if (roughness > r2 && band_ratio < b1) score = 1; + else if (roughness > r3 && band_ratio < b2) score = 1; + return score; + } + + double max_bw; + if (cutoff_ratio < 0.50) { + max_bw = 4000.0 * bw_factor; + } else if (cutoff_ratio < 0.70) { + max_bw = 3000.0 * bw_factor; + } else if (cutoff_ratio < 0.80) { + max_bw = 2000.0 * bw_factor; + } else if (cutoff_ratio < 0.90) { + max_bw = 1200.0 * bw_factor; + } else { + max_bw = 500.0 * bw_factor; + } + + if (transition_bw < max_bw) score = 1; + + if (!score && cutoff_ratio > 0.80) { + if (roughness > r1) score = 1; + else if (roughness > r2 && band_ratio < b1) score = 1; + else if (roughness > r3 && band_ratio < b2) score = 1; + } + + return score; +} + +/* ---- Terminal utilities ---- */ + +static int get_term_width(void) +{ + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return ws.ws_col; + char *cols = getenv("COLUMNS"); + if (cols) { + int n = atoi(cols); + if (n > 0) return n; + } + return 80; +} + +/* ---- Confidence / validity computation ---- */ + +static double compute_confidence(double cutoff_ratio, double steepness, + double roughness, double band_ratio, + int is_native_lossy) +{ + double conf = 0; + int count = 0; + + if (is_native_lossy) { + if (cutoff_ratio < 0.70) { + double margin = (0.70 - cutoff_ratio) / 0.70; + if (margin > 1) margin = 1; + conf += 0.50 + 0.50 * margin; + count++; + } else if (cutoff_ratio < 0.80) { + double margin = (0.80 - cutoff_ratio) / 0.80; + if (margin > 1) margin = 1; + conf += 0.40 + 0.60 * margin; + count++; + } else if (cutoff_ratio < 0.85) { + double margin = (0.85 - cutoff_ratio) / 0.85; + if (margin > 1) margin = 1; + conf += 0.20 + 0.60 * margin; + count++; + } else { + conf += 0.70; + count++; + } + } else { + if (cutoff_ratio < 0.50) { + double margin = (0.50 - cutoff_ratio) / 0.50; + if (margin > 1) margin = 1; + double s_margin = (4000.0 - steepness) / 4000.0; + if (s_margin > 1) s_margin = 1; + if (s_margin < 0) s_margin = 0; + conf += 0.50 + 0.50 * (margin * 0.5 + s_margin * 0.5); + count++; + } else if (cutoff_ratio < 0.70) { + double r_margin = (0.70 - cutoff_ratio) / 0.70; + if (r_margin > 1) r_margin = 1; + double s_margin = (3000.0 - steepness) / 3000.0; + if (s_margin > 1) s_margin = 1; + if (s_margin < 0) s_margin = 0; + conf += 0.30 + 0.70 * (r_margin * 0.4 + s_margin * 0.6); + count++; + } else if (cutoff_ratio < 0.80) { + double r_margin = (0.80 - cutoff_ratio) / 0.80; + if (r_margin > 1) r_margin = 1; + double s_margin = (2000.0 - steepness) / 2000.0; + if (s_margin > 1) s_margin = 1; + if (s_margin < 0) s_margin = 0; + conf += 0.20 + 0.80 * (r_margin * 0.4 + s_margin * 0.6); + count++; + } else if (cutoff_ratio < 0.90) { + double r_margin = (0.90 - cutoff_ratio) / 0.90; + if (r_margin > 1) r_margin = 1; + double s_margin = (1200.0 - steepness) / 1200.0; + if (s_margin > 1) s_margin = 1; + if (s_margin < 0) s_margin = 0; + conf += 0.10 + 0.90 * (r_margin * 0.4 + s_margin * 0.6); + count++; + } else { + double s_margin = (500.0 - steepness) / 500.0; + if (s_margin > 1) s_margin = 1; + if (s_margin < 0) s_margin = 0; + conf += 0.20 + 0.80 * s_margin; + count++; + } + + if (cutoff_ratio > 0.80) { + if (roughness > 0.40) { + double margin = (roughness - 0.40) / 0.40; + if (margin > 1) margin = 1; + conf += 0.40 + 0.60 * margin; + count++; + } else if (roughness > 0.30 && band_ratio < 0.90) { + double r_margin = (roughness - 0.30) / 0.10; + if (r_margin > 1) r_margin = 1; + double b_margin = (0.90 - band_ratio) / 0.90; + if (b_margin > 1) b_margin = 1; + conf += 0.20 + 0.80 * (r_margin * 0.5 + b_margin * 0.5); + count++; + } else if (roughness > 0.20 && band_ratio < 0.85) { + double r_margin = (roughness - 0.20) / 0.10; + if (r_margin > 1) r_margin = 1; + double b_margin = (0.85 - band_ratio) / 0.85; + if (b_margin > 1) b_margin = 1; + conf += 0.10 + 0.90 * (r_margin * 0.5 + b_margin * 0.5); + count++; + } + } + } + + if (count == 0) return 0; + double result = conf / count * 100.0; + if (result < 0) result = 0; + if (result > 100) result = 100; + return result; +} + +/* ---- Visual spectrum renderer ---- */ + +static void repeat_char(char c, int n) +{ + for (int i = 0; i < n; i++) putchar(c); +} + +static void repeat_str(const char *s, int n) +{ + for (int i = 0; i < n; i++) printf("%s", s); +} + +static void draw_hline(int width) +{ + printf(ANSI_BLUE "│" ANSI_RESET); + printf(ANSI_BLUE); repeat_str("─", width); printf(ANSI_RESET); + printf(ANSI_BLUE "│" ANSI_RESET); + printf("\n"); +} + +static void freq_label_row(int chart_w, double nyquist) +{ + static const double freqs[] = {20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 20000}; + static const char *labels[] = {"20", "50", "100", "200", "500", "1k", "2k", "5k", "10k", "15k", "20k"}; + int n = sizeof(freqs) / sizeof(freqs[0]); + + int pos[11], n_valid = 0; + for (int i = 0; i < n; i++) { + if (freqs[i] < nyquist) { + pos[n_valid] = (int)(freqs[i] / nyquist * chart_w); + if (pos[n_valid] >= chart_w) pos[n_valid] = chart_w - 1; + if (pos[n_valid] < 0) pos[n_valid] = 0; + n_valid++; + } + } + + int min_gap = 5; + int keep[11], n_keep = 0; + int last_pos = -100; + for (int i = 0; i < n_valid; i++) { + if (pos[i] - last_pos >= min_gap) { + keep[n_keep] = i; + last_pos = pos[i]; + n_keep++; + } + } + + printf(ANSI_BLUE "│" ANSI_RESET); + int p = 0; + for (int i = 0; i < chart_w; i++) { + if (p < n_keep && i == pos[keep[p]]) { + printf(ANSI_BLUE "┬" ANSI_RESET); + p++; + } else { + putchar(' '); + } + } + printf(ANSI_BLUE "│" ANSI_RESET); + printf("\n"); + + printf(ANSI_BLUE "│" ANSI_RESET); + p = 0; + for (int i = 0; i < chart_w; i++) { + if (p < n_keep && i == pos[keep[p]]) { + const char *label = labels[keep[p]]; + int llen = strlen(label); + printf(ANSI_BOLD "%s" ANSI_RESET, label); + i += llen - 1; + p++; + } else { + putchar(' '); + } + } + printf(ANSI_BLUE "│" ANSI_RESET); + printf("\n"); +} + +static void render_visual_spectrum( + const Analyzer *a, + double cutoff_hz, double steepness, double noise_db, + double roughness, double band_ratio, + int is_transcode, int is_native_lossy, int upscaled, + double peak_db, double threshold_db, + const char *filename, const char *fmt_name, + int sample_rate, int channels, int analyzer_count, + int fft_size, int64_t bitrate) +{ + (void)threshold_db; + + int term_w = get_term_width(); + if (term_w < 60) term_w = 60; + + int n_bins = fft_size / 2; + double nyquist = sample_rate / 2.0; + + int label_w = 7; + int chart_w = term_w - label_w - 2; + if (chart_w < 20) chart_w = 20; + + double *spec = malloc(n_bins * sizeof(double)); + if (!spec) { fprintf(stderr, "Error: malloc failed\n"); return; } + + double peak = 0; + for (int i = 0; i < n_bins; i++) { + double m = sqrt(a->power[i] / a->count); + if (m > peak) peak = m; + } + if (peak < 1e-12) peak = 1e-12; + + for (int i = 0; i < n_bins; i++) { + double m = sqrt(a->power[i] / a->count); + spec[i] = 20.0 * log10(m / peak); + } + + double *col_max = calloc(chart_w, sizeof(double)); + if (!col_max) { free(spec); return; } + for (int c = 0; c < chart_w; c++) col_max[c] = -200.0; + + for (int i = 0; i < n_bins; i++) { + int c = (i * chart_w) / n_bins; + if (c >= chart_w) c = chart_w - 1; + if (spec[i] > col_max[c]) col_max[c] = spec[i]; + } + + int cutoff_col = (cutoff_hz > 0 && nyquist > 0) + ? (int)(cutoff_hz / nyquist * chart_w) : chart_w - 1; + if (cutoff_col < 0) cutoff_col = 0; + if (cutoff_col >= chart_w) cutoff_col = chart_w - 1; + + double db_min = -100.0; + double db_max = 4.0; + int n_rows = 8; + double db_step = (db_max - db_min) / n_rows; + + /* ============================ */ + /* TOP BORDER */ + /* ============================ */ + printf(ANSI_BLUE "╭" ANSI_RESET); + printf(ANSI_BLUE); repeat_str("─", chart_w + label_w); printf(ANSI_RESET); + printf(ANSI_BLUE "╮" ANSI_RESET "\n"); + + /* --- Title bar --- */ + char title[256]; + const char *short_name = strrchr(filename, '/'); + short_name = short_name ? short_name + 1 : filename; + int info_len = snprintf(title, sizeof(title), " %s | %s | %d Hz | %d ch", + short_name, fmt_name, sample_rate, channels); + int total_w = term_w - 2; + if (info_len > total_w) { + char truncated[256]; + snprintf(truncated, sizeof(truncated), " %s | %d Hz | %d ch", + short_name, sample_rate, channels); + if ((int)strlen(truncated) > total_w) { + snprintf(truncated, sizeof(truncated), " %s | %d ch", short_name, channels); + if ((int)strlen(truncated) > total_w) { + snprintf(truncated, sizeof(truncated), " %s", short_name); + if ((int)strlen(truncated) > total_w) { + truncated[total_w] = '\0'; + } + } + } + printf(ANSI_BLUE "│" ANSI_RESET); + printf(ANSI_BOLD ANSI_CYAN "%s" ANSI_RESET, truncated); + repeat_char(' ', total_w - (int)strlen(truncated)); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } else { + printf(ANSI_BLUE "│" ANSI_RESET); + printf(ANSI_BOLD ANSI_CYAN "%s" ANSI_RESET, title); + repeat_char(' ', total_w - info_len); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + + draw_hline(chart_w + label_w); + + /* ============================ */ + /* SPECTRUM CHART */ + /* ============================ */ + for (int row = 0; row < n_rows; row++) { + double db_val = db_max - row * db_step; + printf(ANSI_BLUE "│" ANSI_RESET); + printf(ANSI_DIM "%6.0f " ANSI_RESET, db_val); + + for (int c = 0; c < chart_w; c++) { + double v = col_max[c]; + double thresh_high = db_val; + double thresh_mid = db_val - db_step; + double thresh_low = db_val - 2 * db_step; + + if (v >= thresh_high) { + printf("█"); + } else if (v >= thresh_mid) { + printf("▓"); + } else if (v >= thresh_low) { + printf("▒"); + } else { + printf("░"); + } + } + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + + /* --- Cutoff arrow row --- */ + printf(ANSI_BLUE "│" ANSI_RESET); + repeat_char(' ', label_w); + for (int c = 0; c < chart_w; c++) { + if (c == cutoff_col) { + printf(ANSI_YELLOW "┬" ANSI_RESET); + } else { + putchar(' '); + } + } + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + /* --- Cutoff label --- */ + char cutoff_label[64]; + if (cutoff_hz >= 1000) + snprintf(cutoff_label, sizeof(cutoff_label), "Cutoff %.1fkHz (%.0f%% Nyq)", + cutoff_hz / 1000.0, 100.0 * cutoff_hz / nyquist); + else + snprintf(cutoff_label, sizeof(cutoff_label), "Cutoff %.0fHz (%.0f%% Nyq)", + cutoff_hz, 100.0 * cutoff_hz / nyquist); + + int clen = strlen(cutoff_label); + int label_start = cutoff_col - clen / 2; + if (label_start < 0) label_start = 0; + if (label_start + clen > chart_w) label_start = chart_w - clen; + + printf(ANSI_BLUE "│" ANSI_RESET); + repeat_char(' ', label_w); + repeat_char(' ', label_start); + printf(ANSI_YELLOW ANSI_BOLD "%s" ANSI_RESET, cutoff_label); + int remain = chart_w - label_start - clen; + if (remain > 0) repeat_char(' ', remain); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + /* --- Tick marks --- */ + freq_label_row(chart_w, nyquist); + + /* ============================ */ + /* METRICS SECTION */ + /* ============================ */ + draw_hline(chart_w + label_w); + + char peak_dbfs_str[32]; + snprintf(peak_dbfs_str, sizeof(peak_dbfs_str), "%.1f", 20.0 * log10(peak)); + + printf(ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Peak:" ANSI_RESET " %7s dBFS " ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Windows:" ANSI_RESET " %d " ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "FFT:" ANSI_RESET " %d", + peak_dbfs_str, analyzer_count, fft_size); + int used_metrics = 30 + 14 + 10 + 14; + repeat_char(' ', term_w - 2 - used_metrics); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + draw_hline(chart_w + label_w); + + printf(ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Cutoff:" ANSI_RESET " %7.0f Hz (%5.1f%%) " ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Steepness:" ANSI_RESET " %6.0f Hz " ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Noise:" ANSI_RESET " %6.1f dB", + cutoff_hz, 100.0 * cutoff_hz / nyquist, steepness, noise_db); + repeat_char(' ', term_w - 2 - 62); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + printf(ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Roughness:" ANSI_RESET " %6.3f " ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Band ratio:" ANSI_RESET " %6.3f", + roughness, band_ratio); + repeat_char(' ', term_w - 2 - 36); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + /* ============================ */ + /* DECISION / VALIDITY */ + /* ============================ */ + draw_hline(chart_w + label_w); + + double cutoff_ratio = cutoff_hz / nyquist; + double confidence = compute_confidence( + cutoff_ratio, steepness, roughness, band_ratio, is_native_lossy); + + printf(ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Decision:" ANSI_RESET " "); + if (peak_db < -90.0) { + printf(ANSI_RED "SILENT" ANSI_RESET " (no detectable audio content)"); + } else if (is_native_lossy) { + if (upscaled) + printf(ANSI_YELLOW "UPSCALED" ANSI_RESET " (re-encoded from lower bitrate)"); + else + printf(ANSI_GREEN "NATIVE" ANSI_RESET " (single encode at this bitrate)"); + } else { + if (is_transcode) + printf(ANSI_YELLOW "TRANSCODE" ANSI_RESET " (lossy \xe2\x86\x92 lossless re-encode)"); + else + printf(ANSI_GREEN "GENUINE" ANSI_RESET " (likely native lossless)"); + } + int dlen = (int)strlen(" Decision: ") + 50; + repeat_char(' ', term_w - 2 - dlen); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + /* Validity bar */ + int bar_w = 20; + int filled = (int)(confidence / 100.0 * bar_w); + if (filled < 0) filled = 0; + if (filled > bar_w) filled = bar_w; + + const char *conf_label; + if (confidence >= 85) conf_label = "Very strong evidence"; + else if (confidence >= 70) conf_label = "Strong evidence"; + else if (confidence >= 50) conf_label = "Moderate evidence"; + else if (confidence >= 30) conf_label = "Weak evidence"; + else conf_label = "Borderline / inconclusive"; + + printf(ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Validity:" ANSI_RESET " "); + for (int i = 0; i < bar_w; i++) { + if (i < filled) printf(ANSI_GREEN "█" ANSI_RESET); + else printf(ANSI_DIM "░" ANSI_RESET); + } + printf(ANSI_BOLD " %3.0f%%" ANSI_RESET, confidence); + printf(" \xe2\x80\x94 %s", conf_label); + int vlen = 12 + bar_w + 5 + 2 + (int)strlen(conf_label); + repeat_char(' ', term_w - 2 - vlen); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + printf(ANSI_BLUE "│" ANSI_RESET); + repeat_char(' ', term_w - 2); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + /* --- Factor breakdown --- */ + printf(ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Factors:" ANSI_RESET); + repeat_char(' ', term_w - 12); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + if (is_native_lossy) { + if (upscaled) { + char line[128]; + snprintf(line, sizeof(line), + " cutoff_ratio=%.3f < expected for bitrate (%lld kbps)", + cutoff_ratio, (long long)(bitrate / 1000)); + printf(ANSI_BLUE "│" ANSI_RESET " %s " ANSI_GREEN "✓" ANSI_RESET, line); + int remain = term_w - 4 - (int)strlen(line) - 2; + if (remain > 0) repeat_char(' ', remain); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + + const char *src_hint = ""; + if (cutoff_ratio < 0.70) src_hint = "≤ 64 kbps source"; + else if (cutoff_ratio < 0.80) src_hint = "96–128 kbps source"; + else if (cutoff_ratio < 0.88) src_hint = "128–192 kbps source"; + printf(ANSI_BLUE "│" ANSI_RESET " → " ANSI_BOLD "Suggest %s" ANSI_RESET, src_hint); + repeat_char(' ', term_w - 4 - (int)strlen(src_hint) - 14); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } else { + printf(ANSI_BLUE "│" ANSI_RESET " cutoff_ratio=%.3f within expected range for this bitrate " ANSI_GREEN "✓" ANSI_RESET, cutoff_ratio); + repeat_char(' ', term_w - 2 - 68); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + } else { + double max_bw_display; + if (cutoff_ratio < 0.50) { + max_bw_display = 4000.0; + } else if (cutoff_ratio < 0.70) { + max_bw_display = 3000.0; + } else if (cutoff_ratio < 0.80) { + max_bw_display = 2000.0; + } else if (cutoff_ratio < 0.90) { + max_bw_display = 1200.0; + } else { + max_bw_display = 500.0; + } + + int primary_hit = (cutoff_ratio >= 0.99) ? 0 : (steepness < max_bw_display); + int secondary_applies = (cutoff_ratio > 0.80); + + if (cutoff_ratio >= 0.99) { + printf(ANSI_BLUE "│" ANSI_RESET " ① cutoff_ratio=%.3f ≥ 0.99 " ANSI_RED "✗" ANSI_RESET " (full spectrum, no cutoff)", cutoff_ratio); + repeat_char(' ', term_w - 2 - 62); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } else { + char bw_label[64]; + snprintf(bw_label, sizeof(bw_label), "transition_bw=%.0fHz < %.0fHz threshold", + steepness, max_bw_display); + + if (primary_hit) { + printf(ANSI_BLUE "│" ANSI_RESET " ① cutoff_ratio=%.3f, %s " ANSI_GREEN "✓" ANSI_RESET, + cutoff_ratio, bw_label); + repeat_char(' ', term_w - 2 - (int)strlen(bw_label) - 24); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } else { + printf(ANSI_BLUE "│" ANSI_RESET " ① cutoff_ratio=%.3f, transition_bw=%.0fHz ≥ %.0fHz threshold " ANSI_RED "✗" ANSI_RESET, + cutoff_ratio, steepness, max_bw_display); + repeat_char(' ', term_w - 2 - 71); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + } + + if (primary_hit) { + printf(ANSI_BLUE "│" ANSI_RESET " → " ANSI_YELLOW "Transition bandwidth indicates transcode" ANSI_RESET); + repeat_char(' ', term_w - 2 - 44); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + + if (secondary_applies) { + int sec_hit = 0; + if (roughness > 0.40) { + printf(ANSI_BLUE "│" ANSI_RESET " ② roughness=%.3f > 0.40 " ANSI_GREEN "✓" ANSI_RESET " (high roughness → transcode)", roughness); + repeat_char(' ', term_w - 2 - 60); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + sec_hit = 1; + } else if (roughness > 0.30 && band_ratio < 0.90) { + printf(ANSI_BLUE "│" ANSI_RESET " ② roughness=%.3f > 0.30 " ANSI_GREEN "✓" ANSI_RESET " band_ratio=%.3f < 0.90 " ANSI_GREEN "✓" ANSI_RESET, + roughness, band_ratio); + repeat_char(' ', term_w - 2 - 62); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + sec_hit = 1; + } else if (roughness > 0.20 && band_ratio < 0.85) { + printf(ANSI_BLUE "│" ANSI_RESET " ② roughness=%.3f > 0.20 " ANSI_GREEN "✓" ANSI_RESET " band_ratio=%.3f < 0.85 " ANSI_GREEN "✓" ANSI_RESET, + roughness, band_ratio); + repeat_char(' ', term_w - 2 - 62); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + sec_hit = 1; + } + + if (sec_hit) { + printf(ANSI_BLUE "│" ANSI_RESET " → " ANSI_YELLOW "Secondary criteria triggered: transcode confirmed" ANSI_RESET); + repeat_char(' ', term_w - 2 - 52); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } else if (!primary_hit) { + printf(ANSI_BLUE "│" ANSI_RESET " ② Secondary: roughness=%.3f, band_ratio=%.3f " ANSI_RED "✗" ANSI_RESET, + roughness, band_ratio); + repeat_char(' ', term_w - 2 - 56); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + printf(ANSI_BLUE "│" ANSI_RESET " → " ANSI_GREEN "No transcode criteria met: genuine lossless" ANSI_RESET); + repeat_char(' ', term_w - 2 - 46); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + } else if (!primary_hit) { + printf(ANSI_BLUE "│" ANSI_RESET " → " ANSI_GREEN "No transcode criteria met: genuine lossless" ANSI_RESET); + repeat_char(' ', term_w - 2 - 46); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + } + + /* ============================ */ + /* BOTTOM BORDER */ + /* ============================ */ + printf(ANSI_BLUE "╰" ANSI_RESET); + printf(ANSI_BLUE); repeat_str("─", chart_w + label_w); printf(ANSI_RESET); + printf(ANSI_BLUE "╯" ANSI_RESET "\n"); + + free(spec); + free(col_max); +} + +static void print_help(const char *prog) +{ + printf("Usage: %s [options] [ ...]\n\n", prog); + printf("Detect whether an MP3 or FLAC file is a transcode (lossy->lossless re-encode)\n"); + printf("by analyzing the audio spectrum for frequency cutoffs.\n"); + printf("If no file is given, processes all audio files in the current directory recursively.\n\n"); + printf("Options:\n"); + printf(" -t, --threshold PCT Overall detection sensitivity (1-99). Controls all\n"); + printf(" decision thresholds: transition bandwidth, roughness,\n"); + printf(" and band ratio. Maps to -40 dB cutoff threshold (1,\n"); + printf(" least sensitive) through -80 dB (99, most sensitive).\n"); + printf(" [default: %d]. 50 is neutral; lower = fewer detections,\n", THRESHOLD_DEFAULT); + printf(" higher = more detections. Adjust in small steps.\n"); + printf(" -f, --fft-size N FFT size (power of 2) [default: %d]\n", FFT_SIZE_DEFAULT); + printf(" -d, --duration SEC Max seconds to analyze [default: %d]\n", MAX_ANALYSIS_SECS); + printf(" -v, --verbose Verbose output\n"); + printf(" -s, --visual Graphical spectrum visualization (TUI)\n"); + printf(" -V Alias for -s\n"); + printf(" -a, --auto-remove Automatically remove non-native files\n"); + printf(" -h, --help Show this help\n"); +} + +static void maybe_remove(const char *filename, int peak_db_neg90, + int is_native_lossy, int upscaled, int is_transcode) +{ + int should_remove = 0; + if (peak_db_neg90) { + should_remove = 1; + } else if (is_native_lossy) { + should_remove = upscaled; + } else { + should_remove = is_transcode > 0; + } + if (should_remove) { + int tw = get_term_width(); + int elen = 15 + (int)strlen(filename); + printf(ANSI_YELLOW "===> " ANSI_RESET ANSI_BRIGHT_RED ANSI_BOLD "Removing:" ANSI_RESET ANSI_BRIGHT_RED " %s", filename); + if (elen < tw) repeat_char(' ', tw - elen); + printf(ANSI_RESET "\n"); + if (remove(filename) != 0) + fprintf(stderr, "Error: could not remove '%s'.\n", filename); + } +} + +/* ---- Options bundle ---- */ + +typedef struct { + double threshold_db; + double sensitivity; + int fft_size; + int max_secs; + int verbose; + int dump_spectrum; + int visual; + int auto_remove; +} Options; + +/* ---- Audio extension check ---- */ + +static int is_audio_ext(const char *path) +{ + const char *ext = strrchr(path, '.'); + if (!ext) return 0; + ext++; + static const char *exts[] = { + "mp3", "flac", "wav", "aiff", "aif", "ogg", "opus", + "m4a", "wma", "ac3", "eac3", "aac", "alac", "wv", + "mp2", "mp1", "ape", "dsf", "dff", NULL + }; + for (const char **p = exts; *p; p++) + if (strcasecmp(ext, *p) == 0) return 1; + return 0; +} + +/* ---- Single-file processor ---- */ + +static int process_file(const char *filename, const Options *opts) +{ + AVFormatContext *fmt_ctx = NULL; + if (avformat_open_input(&fmt_ctx, filename, NULL, NULL) < 0) { + fprintf(stderr, "Error: could not open '%s'.\n", filename); + return 1; + } + + if (avformat_find_stream_info(fmt_ctx, NULL) < 0) { + fprintf(stderr, "Error: could not find stream info.\n"); + avformat_close_input(&fmt_ctx); + return 1; + } + + const AVCodec *decoder = NULL; + int stream_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0); + if (stream_idx < 0) { + fprintf(stderr, "Error: no audio stream found.\n"); + avformat_close_input(&fmt_ctx); + return 1; + } + + AVStream *stream = fmt_ctx->streams[stream_idx]; + AVCodecContext *codec_ctx = avcodec_alloc_context3(decoder); + if (!codec_ctx) { + fprintf(stderr, "Error: could not allocate codec context.\n"); + avformat_close_input(&fmt_ctx); + return 1; + } + + if (avcodec_parameters_to_context(codec_ctx, stream->codecpar) < 0) { + fprintf(stderr, "Error: could not copy codec parameters.\n"); + avcodec_free_context(&codec_ctx); + avformat_close_input(&fmt_ctx); + return 1; + } + + if (avcodec_open2(codec_ctx, decoder, NULL) < 0) { + fprintf(stderr, "Error: could not open decoder.\n"); + avcodec_free_context(&codec_ctx); + avformat_close_input(&fmt_ctx); + return 1; + } + + int sample_rate = codec_ctx->sample_rate; + int channels = codec_ctx->ch_layout.nb_channels; + int64_t bitrate = codec_ctx->bit_rate; + int64_t duration_av = fmt_ctx->duration; + const char *fmt_name = fmt_ctx->iformat ? fmt_ctx->iformat->name : "?"; + const char *codec_name = decoder->name; + + int is_native_lossy = 0; + const char *lossy_codecs[] = { + "mp3", "mp3float", "aac", "libfdk_aac", "vorbis", "opus", + "wmav1", "wmav2", "wmapro", "libvorbis", "ac3", "eac3", + "mp2", "mp1", NULL + }; + for (const char **p = lossy_codecs; *p; p++) { + if (strcmp(codec_name, *p) == 0) { is_native_lossy = 1; break; } + } + + if (opts->verbose) { + fprintf(stderr, "Format: %s\n", fmt_name); + fprintf(stderr, "Channels: %d, Sample rate: %d Hz\n", + channels, sample_rate); + fprintf(stderr, "Bitrate: %lld bps\n", (long long)bitrate); + fprintf(stderr, "Decoder: %s (%s)\n", decoder->name, decoder->long_name ? decoder->long_name : ""); + if (duration_av != AV_NOPTS_VALUE) + fprintf(stderr, "Duration: %lld seconds\n", + (long long)(duration_av / AV_TIME_BASE)); + } + + Analyzer analyzer; + analyzer_init(&analyzer, sample_rate, channels, opts->fft_size); + + AVPacket *pkt = av_packet_alloc(); + AVFrame *frame = av_frame_alloc(); + if (!pkt || !frame) { + fprintf(stderr, "Error: could not allocate packet/frame.\n"); + av_packet_free(&pkt); + av_frame_free(&frame); + analyzer_free(&analyzer); + avcodec_free_context(&codec_ctx); + avformat_close_input(&fmt_ctx); + return 1; + } + + int max_frames = sample_rate * opts->max_secs; + int total_frames = 0; + + float *ring = calloc(opts->fft_size * channels, sizeof(float)); + int ring_pos = 0; + + while (total_frames < max_frames && av_read_frame(fmt_ctx, pkt) == 0) { + if (pkt->stream_index != stream_idx) { + av_packet_unref(pkt); + continue; + } + if (avcodec_send_packet(codec_ctx, pkt) < 0) { + av_packet_unref(pkt); + continue; + } + av_packet_unref(pkt); + + while (total_frames < max_frames) { + int ret = avcodec_receive_frame(codec_ctx, frame); + if (ret == AVERROR(EAGAIN)) + break; + if (ret == AVERROR_EOF) + break; + if (ret < 0) + break; + int nframes = frame->nb_samples; + if (nframes > max_frames - total_frames) + nframes = max_frames - total_frames; + if (nframes <= 0) break; + frame_to_ring(frame, nframes, channels, opts->fft_size, ring, &ring_pos, &analyzer); + total_frames += nframes; + } + } + + avcodec_send_packet(codec_ctx, NULL); + while (total_frames < max_frames) { + int ret = avcodec_receive_frame(codec_ctx, frame); + if (ret == AVERROR_EOF || ret < 0) + break; + int nframes = frame->nb_samples; + if (nframes > max_frames - total_frames) + nframes = max_frames - total_frames; + if (nframes <= 0) break; + frame_to_ring(frame, nframes, channels, opts->fft_size, ring, &ring_pos, &analyzer); + total_frames += nframes; + } + + if (ring_pos >= opts->fft_size) + analyzer_add_window(&analyzer, ring, ring_pos); + + free(ring); + + av_packet_free(&pkt); + av_frame_free(&frame); + avcodec_free_context(&codec_ctx); + avformat_close_input(&fmt_ctx); + + if (analyzer.count == 0) { + if (total_frames > 0) + fprintf(stderr, "Error: file too short for analysis (%d samples, need %d).\n" + " Use -f to set a smaller FFT size.\n", + total_frames, opts->fft_size); + else + fprintf(stderr, "Error: no audio data decoded from '%s'.\n", filename); + analyzer_free(&analyzer); + return 1; + } + + double cutoff_hz = 0, steepness = 0, noise_db = 0; + double roughness = 0, band_ratio = 0; + int is_transcode = detect_cutoff(&analyzer, opts->threshold_db, + opts->sensitivity, + &cutoff_hz, &steepness, &noise_db, + &roughness, &band_ratio); + double nyquist = sample_rate / 2.0; + + double peak_mag = 0; + for (int i = 0; i < opts->fft_size / 2; i++) { + double m = sqrt(analyzer.power[i] / analyzer.count); + if (m > peak_mag) peak_mag = m; + } + double peak_db = (peak_mag > 1e-12) ? 20.0 * log10(peak_mag) : -200.0; + + /* ---- Visual output ---- */ + if (opts->visual) { + printf("\n"); + if (peak_db < -90.0) { + printf(ANSI_RED "╭────────────────────────────────────────╮\n" ANSI_RESET); + printf(ANSI_RED "│" ANSI_RESET " " ANSI_BOLD ANSI_RED "SILENT" ANSI_RESET " \xe2\x80\x94 no detectable audio content " ANSI_RED "│\n" ANSI_RESET); + printf(ANSI_RED "╰────────────────────────────────────────╯\n" ANSI_RESET); + analyzer_free(&analyzer); + if (opts->auto_remove) maybe_remove(filename, 1, 0, 0, 0); + return 2; + } + + double cutoff_ratio = cutoff_hz / nyquist; + int upscaled = 0; + if (is_native_lossy) { + double expected_min = 0.90; + if (bitrate > 0 && bitrate < 192000) expected_min = 0.75; + else if (bitrate > 0 && bitrate < 256000) expected_min = 0.85; + if (cutoff_ratio > 0 && cutoff_ratio < expected_min - 0.08) + upscaled = 1; + } + + render_visual_spectrum( + &analyzer, + cutoff_hz, steepness, noise_db, roughness, band_ratio, + is_transcode, is_native_lossy, upscaled, + peak_db, opts->threshold_db, + filename, fmt_name, + sample_rate, channels, analyzer.count, + opts->fft_size, bitrate); + + analyzer_free(&analyzer); + if (opts->auto_remove) maybe_remove(filename, peak_db < -90.0, is_native_lossy, upscaled, is_transcode); + if (is_native_lossy) + return upscaled ? 1 : 0; + else + return is_transcode > 0 ? 1 : 0; + } + + /* ---- Plain text output ---- */ + printf("\n"); + { + int tw = get_term_width(); + int flen = (int)strlen(filename); + printf(ANSI_BG_GREY ANSI_BOLD ANSI_CYAN "File:" ANSI_RESET ANSI_BG_GREY " %s", filename); + int used = 14 + flen; + if (used < tw) repeat_char(' ', tw - used); + printf(ANSI_RESET "\n"); + } + printf(ANSI_BOLD ANSI_CYAN "Format:" ANSI_RESET " %s\n", fmt_name); + printf(ANSI_BOLD ANSI_CYAN "Sample rate:" ANSI_RESET " %d Hz\n", sample_rate); + printf(ANSI_BOLD ANSI_CYAN "Channels:" ANSI_RESET " %d\n", channels); + printf(ANSI_BOLD ANSI_CYAN "Windows:" ANSI_RESET " %d\n", analyzer.count); + printf(ANSI_BOLD ANSI_CYAN "Peak:" ANSI_RESET " %.1f dBFS\n", peak_db); + printf(ANSI_BOLD ANSI_CYAN "Cutoff:" ANSI_RESET " %.0f Hz (%.1f%% of Nyquist)\n", cutoff_hz, 100.0 * cutoff_hz / nyquist); + printf(ANSI_BOLD ANSI_CYAN "Steepness:" ANSI_RESET " %.0f Hz\n", steepness); + printf(ANSI_BOLD ANSI_CYAN "Roughness:" ANSI_RESET " %.3f\n", roughness); + printf(ANSI_BOLD ANSI_CYAN "Band ratio:" ANSI_RESET " %.3f\n", band_ratio); + printf(ANSI_BOLD ANSI_CYAN "Noise floor:" ANSI_RESET " %.1f dB\n", noise_db); + printf(ANSI_BOLD ANSI_CYAN "Verdict:" ANSI_RESET " "); + + if (peak_db < -90.0) { + printf(ANSI_RED "SILENT" ANSI_RESET " (no detectable audio content)\n"); + analyzer_free(&analyzer); + if (opts->auto_remove) maybe_remove(filename, 1, 0, 0, 0); + return 2; + } + + double cutoff_ratio = cutoff_hz / nyquist; + int upscaled = 0; + + if (is_native_lossy) { + double expected_min = 0.90; + if (bitrate > 0 && bitrate < 192000) expected_min = 0.75; + else if (bitrate > 0 && bitrate < 256000) expected_min = 0.85; + + if (cutoff_ratio > 0 && cutoff_ratio < expected_min - 0.08) + upscaled = 1; + + if (upscaled) { + printf(ANSI_YELLOW "UPSCALED" ANSI_RESET " (re-encoded from a lower-bitrate source)\n"); + printf(ANSI_BOLD ANSI_CYAN "Verdict Info:" ANSI_RESET " cut-off suggests "); + if (cutoff_ratio < 0.70) + printf(ANSI_BOLD "<= 64 kbps" ANSI_RESET " source\n"); + else if (cutoff_ratio < 0.80) + printf(ANSI_BOLD "96-128 kbps" ANSI_RESET " source\n"); + else if (cutoff_ratio < 0.88) + printf(ANSI_BOLD "128-192 kbps" ANSI_RESET " source\n"); + } else { + printf(ANSI_GREEN "NATIVE" ANSI_RESET " (single encode at this bitrate)\n"); + } + } else { + if (is_transcode) + printf(ANSI_YELLOW "TRANSCODE" ANSI_RESET " (lossy -> lossless re-encode detected)\n"); + else + printf(ANSI_GREEN "GENUINE" ANSI_RESET " (likely native lossless)\n"); + } + + analyzer_free(&analyzer); + if (opts->auto_remove) maybe_remove(filename, peak_db < -90.0, is_native_lossy, upscaled, is_transcode); + if (is_native_lossy) + return upscaled ? 1 : 0; + else + return is_transcode > 0 ? 1 : 0; +} + +/* ---- Directory walker ---- */ + +static int process_path(const char *path, const Options *opts) +{ + struct stat st; + if (stat(path, &st) < 0) { + fprintf(stderr, "Error: cannot access '%s' (%s).\n", path, strerror(errno)); + return 1; + } + + if (!S_ISDIR(st.st_mode)) + return process_file(path, opts); + + /* Strip trailing slash so we don't double it */ + size_t plen = strlen(path); + while (plen > 1 && path[plen - 1] == '/') plen--; + + DIR *dir = opendir(path); + if (!dir) { + fprintf(stderr, "Error: cannot open directory '%s' (%s).\n", path, strerror(errno)); + return 1; + } + + int overall = 0; + struct dirent *e; + while ((e = readdir(dir))) { + if (e->d_name[0] == '.') continue; + if (!is_audio_ext(e->d_name)) continue; + + size_t nlen = strlen(e->d_name); + char *full = malloc(plen + 1 + nlen + 1); + if (!full) continue; + memcpy(full, path, plen); + full[plen] = '/'; + memcpy(full + plen + 1, e->d_name, nlen + 1); + + int rc = process_path(full, opts); + free(full); + if (rc > overall) overall = rc; + } + closedir(dir); + return overall; +} + +/* ---- FFmpeg log filter (silences non-fatal chatter) ---- */ + +static void quiet_log(void *avcl, int level, const char *fmt, va_list vl) +{ + (void)avcl; + if (level > AV_LOG_ERROR) return; + vfprintf(stderr, fmt, vl); +} + +/* ---- Main ---- */ + +int main(int argc, char **argv) +{ + av_log_set_callback(quiet_log); + + const char *prog = argv[0]; + + Options opts = { + .threshold_db = -(double)THRESHOLD_DEFAULT, + .sensitivity = 0.5, + .fft_size = FFT_SIZE_DEFAULT, + .max_secs = MAX_ANALYSIS_SECS, + .verbose = 0, + .dump_spectrum = 0, + .visual = 0, + .auto_remove = 0, + }; + + static const struct option long_opts[] = { + {"threshold", required_argument, NULL, 't'}, + {"fft-size", required_argument, NULL, 'f'}, + {"duration", required_argument, NULL, 'd'}, + {"verbose", no_argument, NULL, 'v'}, + {"spectrum", no_argument, NULL, 's'}, + {"visual", no_argument, NULL, 'V'}, + {"auto-remove", no_argument, NULL, 'a'}, + {"help", no_argument, NULL, 'h'}, + {NULL, 0, NULL, 0} + }; + + int opt; + while ((opt = getopt_long(argc, argv, "t:f:d:asvVh", long_opts, NULL)) != -1) { + switch (opt) { + case 't': { + int pct = atoi(optarg); + if (pct > 99) pct = 99; + if (pct < 1) pct = 1; + opts.threshold_db = -(40.0 + (double)(pct - 1) * 40.0 / 98.0); + opts.sensitivity = (double)(pct - 1) / 98.0; + break; + } + case 'f': opts.fft_size = atoi(optarg); break; + case 'd': opts.max_secs = atoi(optarg); break; + case 'a': opts.auto_remove = 1; break; + case 's': opts.visual = 1; break; + case 'v': opts.verbose = 1; break; + case 'V': opts.visual = 1; break; + case 'h': print_help(prog); return 0; + default: print_help(prog); return 1; + } + } + + if (optind >= argc) { + argv[optind] = "."; + argc = optind + 1; + } + + if (opts.fft_size < 64 || opts.fft_size > 65536 || (opts.fft_size & (opts.fft_size - 1)) != 0) { + fprintf(stderr, "Error: FFT size must be a power of 2 between 64 and 65536.\n"); + return 1; + } + + int overall = 0; + for (int i = optind; i < argc; i++) { + int rc = process_path(argv[i], &opts); + if (rc > overall) overall = rc; + } + return overall; +}