mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
Merge pull request #589 with lossless replacement safeguards
Keep automatic Hi-Res replacement off by default. Require exact full-duration PCM equivalence across every channel before and after processing. Retain the original through publication and history persistence, restore it on failure or cancellation, and bound WAV format parsing. Validated with 30 Rust tests, 46 Dart tests, clean analyzer/Clippy checks, Android ARM64 and iOS builds, and iOS simulator startup.
This commit is contained in:
commit
b7d4ceb7e1
36 files changed
+3690
-12
No files matched your search
@@ -16,6 +16,7 @@ unicode-normalization.workspace = true
|
||||
unicode-general-category.workspace = true
|
||||
chrono.workspace = true
|
||||
rustix.workspace = true
|
||||
claxon.workspace = true
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tz-rs.workspace = true
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! Evidence that separates a fake Hi-Res file from a genuine master that was
|
||||
//! merely low-pass filtered. Above ~22 kHz the two are the same signal, so no
|
||||
//! spectral rule can tell them apart everywhere; these tests answer instead
|
||||
//! how the sampled segment may have been made. They cannot prove that a
|
||||
//! separate LOSSLESS copy preserves the whole file.
|
||||
//!
|
||||
//! - Integer-ratio upsampling artifacts (sample-and-hold, linear
|
||||
//! interpolation) and spectral imaging are exact fingerprints: "certain".
|
||||
//! - A brickwall right at a CD/DAT Nyquist (22.05 / 24 kHz) means a
|
||||
//! 44.1/48 kHz chain. With an in-band noise floor no lower than 16-bit
|
||||
//! quantization noise, a CD-derived source is "likely". This remains a
|
||||
//! heuristic and does not authorize automatic replacement.
|
||||
//! - Anything else that fails the cutoff test may be a genuine master:
|
||||
//! "suspect", never replaced automatically.
|
||||
|
||||
use super::fft::Radix2Fft;
|
||||
|
||||
pub const CONFIDENCE_CERTAIN: &str = "certain";
|
||||
pub const CONFIDENCE_LIKELY: &str = "likely";
|
||||
pub const CONFIDENCE_SUSPECT: &str = "suspect";
|
||||
|
||||
pub const ARTIFACT_SAMPLE_HOLD: &str = "sample_hold";
|
||||
pub const ARTIFACT_INTERPOLATION: &str = "linear_interpolation";
|
||||
pub const ARTIFACT_IMAGING: &str = "imaging";
|
||||
|
||||
pub const FLOOR_BELOW_16BIT: &str = "below_16bit";
|
||||
pub const FLOOR_AT_16BIT: &str = "at_16bit";
|
||||
pub const FLOOR_MASKED: &str = "masked";
|
||||
|
||||
/// Standard-definition rates a fake is made from; their Nyquist is where a
|
||||
/// resampler's anti-imaging filter leaves its cliff.
|
||||
const SOURCE_RATES: [u32; 2] = [44_100, 48_000];
|
||||
|
||||
/// Band the in-band noise floor is measured over. Noise-shaped dither lowers
|
||||
/// the floor in the mid-band only by raising it above ~15 kHz, so averaging
|
||||
/// over the whole band keeps a shaped 16-bit floor at or above the flat one.
|
||||
const FLOOR_BAND_LOW_HZ: f64 = 500.0;
|
||||
const FLOOR_BAND_HIGH_HZ: f64 = 20_000.0;
|
||||
/// Fraction of fully-inside STFT frames, quietest first, the floor is read in.
|
||||
const QUIET_FRAME_FRACTION: f64 = 0.1;
|
||||
/// Floor against flat 16-bit quantization noise: below this there is
|
||||
/// resolution a 16-bit copy would lose; above FLOOR_MASKED_DB the quietest
|
||||
/// frames still carry music and the floor cannot be read.
|
||||
const FLOOR_BELOW_16BIT_DB: f64 = -6.0;
|
||||
const FLOOR_MASKED_DB: f64 = 20.0;
|
||||
/// Brickwall: flat passband before the source Nyquist, a cliff after it.
|
||||
const BRICKWALL_MAX_PASSBAND_DROP_DB: f64 = 20.0;
|
||||
const BRICKWALL_MIN_CLIFF_DB: f64 = 40.0;
|
||||
/// Within this, the passband counts as still flat at the edge.
|
||||
const BRICKWALL_FLAT_PASSBAND_DB: f64 = 6.0;
|
||||
/// Imaging: the band above the source Nyquist mirrors the one below.
|
||||
const IMAGING_MIN_CORRELATION: f64 = 0.9;
|
||||
/// Integer-upsampling artifacts: the signal must move at enough original
|
||||
/// samples, and (almost) never between them.
|
||||
const ARTIFACT_MIN_MOVING_ANCHORS: usize = 1000;
|
||||
const ARTIFACT_MAX_VIOLATION_RATE: f64 = 0.001;
|
||||
/// Music bandwidth: 1 kHz bands from MUSIC_BAND_START_HZ up are music while
|
||||
/// their level swings with it across frames (p95-p5 at least
|
||||
/// MUSIC_MIN_SPREAD_DB). Steady noise, such as the ultrasonic hump a DSD or
|
||||
/// tape transfer carries up to Nyquist, averages out to a few dB.
|
||||
const MUSIC_BAND_START_HZ: f64 = 16_000.0;
|
||||
const MUSIC_BAND_WIDTH_HZ: f64 = 1_000.0;
|
||||
const MUSIC_MIN_SPREAD_DB: f64 = 10.0;
|
||||
/// Active content this far past the music bandwidth is reported as steady
|
||||
/// noise rather than content.
|
||||
pub const ULTRASONIC_NOISE_MARGIN_HZ: f64 = 8_000.0;
|
||||
/// Standard rate families; a file's useful rate is looked up in its own.
|
||||
const RATE_FAMILIES: [[u32; 4]; 2] = [
|
||||
[44_100, 88_200, 176_400, 352_800],
|
||||
[48_000, 96_000, 192_000, 384_000],
|
||||
];
|
||||
|
||||
/// The averaged spectrum plus what the quiet frames and the ultrasonic bands
|
||||
/// say, gathered in the same STFT pass.
|
||||
pub struct StftStats {
|
||||
pub avg_magnitude: Vec<f64>,
|
||||
/// Variance-equivalent (white noise) floor of the quietest frames in
|
||||
/// full-scale units, or NaN when no frame qualified.
|
||||
pub quiet_floor_var: f64,
|
||||
/// p95-p5 spread across frames of each 1 kHz band's level, from 16 kHz
|
||||
/// up, in order; see [`music_cutoff`].
|
||||
pub music_band_spreads: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Matches `np.abs(librosa.stft(y, n_fft)).mean(axis=1)` for the averaged
|
||||
/// spectrum: periodic Hann window, hop n_fft/4, frames centred by
|
||||
/// zero-padding n_fft/2 at both ends.
|
||||
pub fn analyze_stft(
|
||||
y: &[f32],
|
||||
n_fft: usize,
|
||||
sample_rate: u32,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<StftStats, String> {
|
||||
let hop = n_fft / 4;
|
||||
let half = n_fft / 2;
|
||||
let bins = half + 1;
|
||||
let mut stats = StftStats {
|
||||
avg_magnitude: vec![0.0; bins],
|
||||
quiet_floor_var: f64::NAN,
|
||||
music_band_spreads: Vec::new(),
|
||||
};
|
||||
let padded_len = y.len() + 2 * half;
|
||||
if padded_len < n_fft {
|
||||
return Ok(stats);
|
||||
}
|
||||
let frame_count = 1 + (padded_len - n_fft) / hop;
|
||||
|
||||
let window: Vec<f64> = (0..n_fft)
|
||||
.map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n_fft as f64).cos())
|
||||
.collect();
|
||||
let window_power: f64 = window.iter().map(|w| w * w).sum();
|
||||
let bin_hz = f64::from(sample_rate) / n_fft as f64;
|
||||
let band_low = (FLOOR_BAND_LOW_HZ / bin_hz).ceil() as usize;
|
||||
let band_high = ((FLOOR_BAND_HIGH_HZ / bin_hz) as usize).min(half);
|
||||
|
||||
// Per-frame level of each 1 kHz band above 16 kHz, for the music
|
||||
// bandwidth. Only above a CD's Nyquist does the question arise.
|
||||
let mut music_bands: Vec<(usize, usize)> = Vec::new();
|
||||
let nyquist = f64::from(sample_rate) / 2.0;
|
||||
if nyquist > f64::from(SOURCE_RATES[1]) / 2.0 {
|
||||
let mut lo = MUSIC_BAND_START_HZ;
|
||||
while lo + MUSIC_BAND_WIDTH_HZ <= nyquist {
|
||||
let first = (lo / bin_hz).ceil() as usize;
|
||||
let last = (((lo + MUSIC_BAND_WIDTH_HZ) / bin_hz).ceil() as usize).min(half + 1);
|
||||
if last > first {
|
||||
music_bands.push((first, last));
|
||||
}
|
||||
lo += MUSIC_BAND_WIDTH_HZ;
|
||||
}
|
||||
}
|
||||
let mut music_levels: Vec<Vec<f64>> = vec![Vec::new(); music_bands.len()];
|
||||
|
||||
let fft = Radix2Fft::new(n_fft);
|
||||
let mut re = vec![0.0; n_fft];
|
||||
let mut im = vec![0.0; n_fft];
|
||||
let mut band_power: Vec<f64> = Vec::with_capacity(band_high.saturating_sub(band_low) + 1);
|
||||
// (mean, median) band power of each fully-inside, non-silent frame.
|
||||
let mut frames: Vec<(f64, f64)> = Vec::new();
|
||||
|
||||
for f in 0..frame_count {
|
||||
if f.is_multiple_of(256) {
|
||||
check()?;
|
||||
}
|
||||
// Index into y of the frame's first sample.
|
||||
let start = (f * hop) as isize - half as isize;
|
||||
for (i, (r, w)) in re.iter_mut().zip(&window).enumerate() {
|
||||
let j = start + i as isize;
|
||||
*r = if j >= 0 && (j as usize) < y.len() {
|
||||
f64::from(y[j as usize]) * w
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
im.fill(0.0);
|
||||
fft.transform(&mut re, &mut im);
|
||||
for (k, avg) in stats.avg_magnitude.iter_mut().enumerate() {
|
||||
*avg += re[k].hypot(im[k]);
|
||||
}
|
||||
|
||||
// Frames that overlap the zero padding would read as quiet for the
|
||||
// wrong reason; only frames fully inside the signal are candidates.
|
||||
if start < 0 || start as usize + n_fft > y.len() || band_high <= band_low {
|
||||
continue;
|
||||
}
|
||||
band_power.clear();
|
||||
let mut sum = 0.0;
|
||||
for k in band_low..=band_high {
|
||||
let p = re[k] * re[k] + im[k] * im[k];
|
||||
band_power.push(p);
|
||||
sum += p;
|
||||
}
|
||||
if sum == 0.0 {
|
||||
continue; // digital silence carries no floor to measure
|
||||
}
|
||||
let mean = sum / band_power.len() as f64;
|
||||
frames.push((mean, median_in_place(&mut band_power)));
|
||||
for (levels, &(first, last)) in music_levels.iter_mut().zip(&music_bands) {
|
||||
let power: f64 = (first..last)
|
||||
.map(|k| re[k] * re[k] + im[k] * im[k])
|
||||
.sum::<f64>()
|
||||
/ (last - first) as f64;
|
||||
levels.push(10.0 * power.max(1e-30).log10());
|
||||
}
|
||||
}
|
||||
for avg in &mut stats.avg_magnitude {
|
||||
*avg /= frame_count as f64;
|
||||
}
|
||||
|
||||
if !frames.is_empty() {
|
||||
frames.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let count = ((frames.len() as f64 * QUIET_FRAME_FRACTION) as usize).max(1);
|
||||
// |X|^2 of white noise is exponential with mean sigma^2 * sum(w^2), so
|
||||
// its median is ln 2 times that. The median ignores tonal peaks.
|
||||
let mut estimates: Vec<f64> = frames[..count]
|
||||
.iter()
|
||||
.map(|&(_, median)| median / std::f64::consts::LN_2 / window_power)
|
||||
.collect();
|
||||
stats.quiet_floor_var = median_in_place(&mut estimates);
|
||||
}
|
||||
for mut levels in music_levels {
|
||||
if levels.len() < 2 {
|
||||
break;
|
||||
}
|
||||
levels.sort_by(f64::total_cmp);
|
||||
stats
|
||||
.music_band_spreads
|
||||
.push(percentile_sorted(&levels, 0.95) - percentile_sorted(&levels, 0.05));
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Compares the measured floor with flat 16-bit quantization noise: LSB^2/12
|
||||
/// per channel, divided by the channel count for the mono downmix of
|
||||
/// independent channels, which is the lowest it can be.
|
||||
pub fn classify_noise_floor(floor_var: f64, channels: u32) -> (&'static str, f64) {
|
||||
if floor_var.is_nan() || floor_var <= 0.0 {
|
||||
return ("", 0.0);
|
||||
}
|
||||
let lsb = 2f64.powi(-15);
|
||||
let reference = lsb * lsb / 12.0 / f64::from(channels.max(1));
|
||||
let vs_16bit_db = 10.0 * (floor_var / reference).log10();
|
||||
let class = if vs_16bit_db < FLOOR_BELOW_16BIT_DB {
|
||||
FLOOR_BELOW_16BIT
|
||||
} else if vs_16bit_db > FLOOR_MASKED_DB {
|
||||
FLOOR_MASKED
|
||||
} else {
|
||||
FLOOR_AT_16BIT
|
||||
};
|
||||
(class, vs_16bit_db)
|
||||
}
|
||||
|
||||
/// Converts the averaged magnitude spectrum to dB below its peak.
|
||||
pub fn spectrum_db(avg: &[f64]) -> Vec<f64> {
|
||||
let peak = avg.iter().copied().fold(0.0, f64::max).max(1e-30);
|
||||
avg.iter()
|
||||
.map(|&v| 20.0 * (v.max(1e-30) / peak).log10())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The bins between `low_hz` and `high_hz` inclusive.
|
||||
fn spectrum_band(
|
||||
spec_db: &[f64],
|
||||
sample_rate: u32,
|
||||
n_fft: usize,
|
||||
low_hz: f64,
|
||||
high_hz: f64,
|
||||
) -> Vec<f64> {
|
||||
let bin_hz = f64::from(sample_rate) / n_fft as f64;
|
||||
let lo = (low_hz / bin_hz).ceil().max(0.0) as usize;
|
||||
let hi = ((high_hz / bin_hz) as usize).min(spec_db.len().saturating_sub(1));
|
||||
if hi < lo {
|
||||
return Vec::new();
|
||||
}
|
||||
spec_db[lo..=hi].to_vec()
|
||||
}
|
||||
|
||||
/// The source Nyquist (22050 or 24000 Hz) at which the spectrum stays flat
|
||||
/// right up to the edge and then falls off a cliff: the shape a resampler's
|
||||
/// anti-imaging filter leaves. A mastering low-pass rolls off gradually and is
|
||||
/// already well down before the edge. 0 if neither.
|
||||
///
|
||||
/// A 48 kHz source passes the test at 22.05 kHz too (its cliff lies beyond
|
||||
/// that edge as well), so the highest edge the passband still reaches flat
|
||||
/// wins; a 44.1 kHz source is already sloping into its transition band there.
|
||||
pub fn detect_brickwall(
|
||||
spec_db: &[f64],
|
||||
sample_rate: u32,
|
||||
n_fft: usize,
|
||||
noise_floor_db: f64,
|
||||
) -> f64 {
|
||||
let mut best = 0.0;
|
||||
let mut best_drop = f64::INFINITY;
|
||||
for rate in SOURCE_RATES {
|
||||
let edge = f64::from(rate) / 2.0;
|
||||
if edge + 5000.0 > f64::from(sample_rate) / 2.0 {
|
||||
continue;
|
||||
}
|
||||
let mut passband = spectrum_band(spec_db, sample_rate, n_fft, edge - 8000.0, edge - 5000.0);
|
||||
let mut below = spectrum_band(spec_db, sample_rate, n_fft, edge - 2500.0, edge - 500.0);
|
||||
let mut above = spectrum_band(spec_db, sample_rate, n_fft, edge + 2500.0, edge + 5000.0);
|
||||
if passband.is_empty() || below.is_empty() || above.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let below_level = median_in_place(&mut below);
|
||||
let drop = median_in_place(&mut passband) - below_level;
|
||||
let cliff = below_level - median_in_place(&mut above);
|
||||
if below_level <= noise_floor_db
|
||||
|| drop > BRICKWALL_MAX_PASSBAND_DROP_DB
|
||||
|| cliff < BRICKWALL_MIN_CLIFF_DB
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let flat = drop <= BRICKWALL_FLAT_PASSBAND_DB;
|
||||
let best_flat = best_drop <= BRICKWALL_FLAT_PASSBAND_DB;
|
||||
if best == 0.0
|
||||
|| (flat && (!best_flat || edge > best))
|
||||
|| (!flat && !best_flat && drop < best_drop)
|
||||
{
|
||||
best = edge;
|
||||
best_drop = drop;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Whether the band above a source Nyquist mirrors the band below it, which
|
||||
/// is what upsampling without (or with a poor) anti-imaging filter leaves.
|
||||
/// Genuine content keeps falling with frequency, so its mirror correlation is
|
||||
/// near zero or negative.
|
||||
pub fn detect_imaging(
|
||||
spec_db: &[f64],
|
||||
sample_rate: u32,
|
||||
n_fft: usize,
|
||||
noise_floor_db: f64,
|
||||
) -> bool {
|
||||
let bin_hz = f64::from(sample_rate) / n_fft as f64;
|
||||
for rate in SOURCE_RATES {
|
||||
let rate = f64::from(rate);
|
||||
let low_hz = rate / 2.0 + 500.0;
|
||||
let high_hz = (rate - 500.0).min(f64::from(sample_rate) / 2.0 - 500.0);
|
||||
if high_hz - low_hz < 2000.0 {
|
||||
continue;
|
||||
}
|
||||
let mut image = Vec::new();
|
||||
let mut mirror = Vec::new();
|
||||
let mut k = (low_hz / bin_hz).ceil() as usize;
|
||||
while k as f64 * bin_hz <= high_hz {
|
||||
// Rounded half away from zero, as Go's math.Round does.
|
||||
let m = ((rate - k as f64 * bin_hz) / bin_hz).round();
|
||||
if k < spec_db.len() && m >= 0.0 && (m as usize) < spec_db.len() {
|
||||
image.push(spec_db[k]);
|
||||
mirror.push(spec_db[m as usize]);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
if image.len() < 16 || image.iter().sum::<f64>() / image.len() as f64 <= noise_floor_db {
|
||||
continue; // no content above the edge: nothing was mirrored
|
||||
}
|
||||
if pearson(&image, &mirror) >= IMAGING_MIN_CORRELATION {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The exact fingerprints of upsampling by an integer ratio from 44.1/48 kHz:
|
||||
/// every sample repeated (sample-and-hold) or the in-between samples on a
|
||||
/// straight line (linear interpolation). `samples` is one channel,
|
||||
/// right-justified; `or_bits` is the OR over the whole window.
|
||||
pub fn detect_integer_upsampling(samples: &[i32], or_bits: u32, sample_rate: u32) -> &'static str {
|
||||
if or_bits == 0 || samples.len() < 4 {
|
||||
return "";
|
||||
}
|
||||
// Measure in units of the bits actually used, so a padded source's
|
||||
// rounding stays within a couple of its own LSBs.
|
||||
let unused = or_bits.trailing_zeros();
|
||||
for rate in SOURCE_RATES {
|
||||
if !sample_rate.is_multiple_of(rate) {
|
||||
continue;
|
||||
}
|
||||
let ratio = (sample_rate / rate) as usize;
|
||||
if !(2..=8).contains(&ratio) {
|
||||
continue;
|
||||
}
|
||||
for phase in 0..ratio {
|
||||
let artifact = integer_upsampling_at_phase(samples, unused, ratio, phase);
|
||||
if !artifact.is_empty() {
|
||||
return artifact;
|
||||
}
|
||||
}
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn integer_upsampling_at_phase(
|
||||
samples: &[i32],
|
||||
unused: u32,
|
||||
ratio: usize,
|
||||
phase: usize,
|
||||
) -> &'static str {
|
||||
let at = |i: usize| i64::from(samples[i] >> unused);
|
||||
let (mut inner, mut hold_violations, mut hold_moving) = (0usize, 0usize, 0usize);
|
||||
let (mut line_violations, mut line_moving) = (0usize, 0usize);
|
||||
for i in 1..samples.len() - 1 {
|
||||
let d1 = at(i) - at(i - 1);
|
||||
let d2 = (at(i - 1) - 2 * at(i) + at(i + 1)).abs();
|
||||
if !(i + ratio - phase).is_multiple_of(ratio) {
|
||||
// Between two original samples.
|
||||
inner += 1;
|
||||
hold_violations += usize::from(d1 != 0);
|
||||
line_violations += usize::from(d2 > 2);
|
||||
} else {
|
||||
// An original sample.
|
||||
hold_moving += usize::from(d1 != 0);
|
||||
line_moving += usize::from(d2 > 2);
|
||||
}
|
||||
}
|
||||
let max_violations = inner as f64 * ARTIFACT_MAX_VIOLATION_RATE;
|
||||
if hold_moving >= ARTIFACT_MIN_MOVING_ANCHORS && hold_violations as f64 <= max_violations {
|
||||
return ARTIFACT_SAMPLE_HOLD;
|
||||
}
|
||||
if line_moving >= ARTIFACT_MIN_MOVING_ANCHORS && line_violations as f64 <= max_violations {
|
||||
return ARTIFACT_INTERPOLATION;
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
/// Upper edge of the last contiguous 1 kHz band, from 16 kHz up, that both
|
||||
/// carries active content (its level in the averaged spectrum above
|
||||
/// `noise_floor_db`) and moves with the music. The level test keeps a
|
||||
/// resampler's leakage, which swings with the music too but sits far below
|
||||
/// it, from counting. 0 when even the first band fails.
|
||||
pub fn music_cutoff(
|
||||
spreads: &[f64],
|
||||
spec_db: &[f64],
|
||||
sample_rate: u32,
|
||||
n_fft: usize,
|
||||
noise_floor_db: f64,
|
||||
) -> f64 {
|
||||
let mut cutoff = 0.0;
|
||||
for (index, &spread) in spreads.iter().enumerate() {
|
||||
let lo = MUSIC_BAND_START_HZ + index as f64 * MUSIC_BAND_WIDTH_HZ;
|
||||
let band = spectrum_band(spec_db, sample_rate, n_fft, lo, lo + MUSIC_BAND_WIDTH_HZ);
|
||||
if band.is_empty() || spread < MUSIC_MIN_SPREAD_DB {
|
||||
break;
|
||||
}
|
||||
if band.iter().sum::<f64>() / band.len() as f64 <= noise_floor_db {
|
||||
break;
|
||||
}
|
||||
cutoff = lo + MUSIC_BAND_WIDTH_HZ;
|
||||
}
|
||||
cutoff
|
||||
}
|
||||
|
||||
/// The lowest standard rate in the declared rate's family whose Nyquist
|
||||
/// still holds `music_cutoff_hz`; the declared rate itself when none below it
|
||||
/// does, or when the rate is not a standard one.
|
||||
pub fn useful_sample_rate(declared: u32, music_cutoff_hz: f64) -> u32 {
|
||||
for family in RATE_FAMILIES {
|
||||
if !declared.is_multiple_of(family[0]) {
|
||||
continue;
|
||||
}
|
||||
for rate in family {
|
||||
if rate >= declared {
|
||||
break;
|
||||
}
|
||||
if f64::from(rate) / 2.0 >= music_cutoff_hz {
|
||||
return rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
declared
|
||||
}
|
||||
|
||||
fn pearson(a: &[f64], b: &[f64]) -> f64 {
|
||||
let n = a.len() as f64;
|
||||
let mean_a = a.iter().sum::<f64>() / n;
|
||||
let mean_b = b.iter().sum::<f64>() / n;
|
||||
let (mut cov, mut var_a, mut var_b) = (0.0, 0.0, 0.0);
|
||||
for (&x, &y) in a.iter().zip(b) {
|
||||
let (da, db) = (x - mean_a, y - mean_b);
|
||||
cov += da * db;
|
||||
var_a += da * da;
|
||||
var_b += db * db;
|
||||
}
|
||||
if var_a == 0.0 || var_b == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
cov / (var_a * var_b).sqrt()
|
||||
}
|
||||
|
||||
/// Sorts `values` and returns their median.
|
||||
pub(super) fn median_in_place(values: &mut [f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
values.sort_by(f64::total_cmp);
|
||||
let mid = values.len() / 2;
|
||||
if values.len() % 2 == 1 {
|
||||
values[mid]
|
||||
} else {
|
||||
(values[mid - 1] + values[mid]) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
/// numpy's default (linear) percentile of sorted values.
|
||||
fn percentile_sorted(sorted: &[f64], q: f64) -> f64 {
|
||||
let pos = q * (sorted.len() - 1) as f64;
|
||||
let lo = pos.floor() as usize;
|
||||
let hi = (lo + 1).min(sorted.len() - 1);
|
||||
sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo as f64)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! In-place iterative Cooley-Tukey FFT with precomputed twiddles and
|
||||
//! bit-reversal permutation, reused across all STFT frames. The same
|
||||
//! algorithm as SpotiFLAC-Module-Version's checker, so both agree bin for bin.
|
||||
|
||||
pub struct Radix2Fft {
|
||||
n: usize,
|
||||
twiddle_re: Vec<f64>,
|
||||
twiddle_im: Vec<f64>,
|
||||
reversed: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Radix2Fft {
|
||||
/// `n` must be a power of two.
|
||||
pub fn new(n: usize) -> Self {
|
||||
let angle = |k: usize| -2.0 * std::f64::consts::PI * k as f64 / n as f64;
|
||||
let shift = usize::BITS - n.trailing_zeros();
|
||||
Self {
|
||||
n,
|
||||
twiddle_re: (0..n / 2).map(|k| angle(k).cos()).collect(),
|
||||
twiddle_im: (0..n / 2).map(|k| angle(k).sin()).collect(),
|
||||
reversed: (0..n)
|
||||
.map(|i| i.reverse_bits().checked_shr(shift).unwrap_or(0))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transform(&self, re: &mut [f64], im: &mut [f64]) {
|
||||
for (i, &j) in self.reversed.iter().enumerate() {
|
||||
if i < j {
|
||||
re.swap(i, j);
|
||||
im.swap(i, j);
|
||||
}
|
||||
}
|
||||
let mut size = 2;
|
||||
while size <= self.n {
|
||||
let half = size / 2;
|
||||
let step = self.n / size;
|
||||
for start in (0..self.n).step_by(size) {
|
||||
for k in 0..half {
|
||||
let (w_re, w_im) = (self.twiddle_re[k * step], self.twiddle_im[k * step]);
|
||||
let (a, b) = (start + k, start + k + half);
|
||||
let t_re = w_re * re[b] - w_im * im[b];
|
||||
let t_im = w_re * im[b] + w_im * re[b];
|
||||
re[b] = re[a] - t_re;
|
||||
im[b] = im[a] - t_im;
|
||||
re[a] += t_re;
|
||||
im[a] += t_im;
|
||||
}
|
||||
}
|
||||
size <<= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
//! Hi-Res authenticity check, in lockstep with SpotiFLAC-Module-Version's
|
||||
//! `core/hires_check.py`: same thresholds, same verdicts.
|
||||
//!
|
||||
//! A file can claim Hi-Res along two independent axes, and each is checked on
|
||||
//! its own terms:
|
||||
//!
|
||||
//! - Sample rate: it declares e.g. 96 kHz but its spectral content stops at,
|
||||
//! or just above, the ~22.05 kHz Nyquist of a 44.1 kHz source, the
|
||||
//! fingerprint of upsampling. Measuring it is a heuristic.
|
||||
//! - Bit depth: it declares 24-bit but only 16 of those bits ever carry data,
|
||||
//! the low 8 being zero in every sample: a CD master padded out. Unlike the
|
||||
//! spectral test this one is exact, and it is the only test that can judge
|
||||
//! a 24-bit/44.1 kHz file.
|
||||
//!
|
||||
//! The cutoff alone cannot tell an upsampled CD from a genuine master that
|
||||
//! was low-pass filtered in mastering, so every `fake_hires` verdict is graded
|
||||
//! ("certain", "likely", "suspect") by the evidence in `evidence`. These
|
||||
//! confidence levels describe the sampled evidence, not permission to discard
|
||||
//! audio. Replacement additionally requires a full, exact PCM comparison.
|
||||
//!
|
||||
//! Only FLAC and PCM WAV are decoded. Anything else is
|
||||
//! [`HiResCheckError::Unsupported`], which callers treat as "check skipped".
|
||||
|
||||
mod evidence;
|
||||
mod fft;
|
||||
mod replacement;
|
||||
pub use replacement::replacement_preserves_audio;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use evidence::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read, Seek, SeekFrom};
|
||||
|
||||
pub const VERDICT_FAKE: &str = "fake_hires";
|
||||
pub const VERDICT_STANDARD: &str = "standard_definition";
|
||||
pub const VERDICT_GENUINE: &str = "genuine_hires";
|
||||
pub const VERDICT_INCONCLUSIVE: &str = "inconclusive";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum HiResCheckError {
|
||||
/// A container this checker cannot decode; never a reason to treat the
|
||||
/// file itself as broken.
|
||||
Unsupported,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HiResCheckError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unsupported => f.write_str("hi-res check: unsupported audio format"),
|
||||
Self::Failed(message) => write!(f, "hi-res check: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for HiResCheckError {
|
||||
fn from(message: String) -> Self {
|
||||
Self::Failed(message)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HiResCheckOptions {
|
||||
/// Length of the segment analysed from the middle of the track.
|
||||
pub sample_seconds: i64,
|
||||
/// dB relative to the segment's spectral peak above which a bin is
|
||||
/// considered active content rather than noise.
|
||||
pub noise_floor_db: f64,
|
||||
/// Sample rate above which a file claims Hi-Res.
|
||||
pub hires_sample_rate_threshold: i64,
|
||||
/// Minimum active-content cutoff a genuine Hi-Res file must reach. Sits
|
||||
/// well above 22.05 kHz on purpose: a resampler upsampling from CD leaves
|
||||
/// a transition-band tail a couple of kHz wide (a measured 44.1 -> 176.4
|
||||
/// kHz upsample reached ~24.7 kHz).
|
||||
pub hires_cutoff_threshold_hz: f64,
|
||||
/// STFT window size; must be a power of two. Shrunk for short segments.
|
||||
pub n_fft: i64,
|
||||
}
|
||||
|
||||
impl Default for HiResCheckOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sample_seconds: 30,
|
||||
noise_floor_db: -80.0,
|
||||
hires_sample_rate_threshold: 48_000,
|
||||
hires_cutoff_threshold_hz: 28_000.0,
|
||||
n_fft: 4096,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct HiResCheckResult {
|
||||
pub file_path: String,
|
||||
pub declared_sample_rate: u32,
|
||||
pub total_duration_s: f64,
|
||||
pub analyzed_duration_s: f64,
|
||||
pub cutoff_frequency_hz: f64,
|
||||
pub noise_floor_db: f64,
|
||||
pub verdict: String,
|
||||
/// Bits per sample the container declares; 0 when the format has no
|
||||
/// fixed-point depth to declare (float PCM).
|
||||
pub declared_bit_depth: u32,
|
||||
/// Bits per sample that actually carry data; 0 when not measured.
|
||||
pub effective_bit_depth: u32,
|
||||
/// Why the file was flagged, in one clause; empty when it was not.
|
||||
pub reason: String,
|
||||
/// How sure a fake_hires verdict is: "certain" (exact fingerprint),
|
||||
/// "likely" (a resampler's cliff over a 16-bit floor) or "suspect" (may be
|
||||
/// a genuine low-pass filtered master). Empty for other verdicts.
|
||||
pub confidence: String,
|
||||
/// Exact upsampling fingerprint found, if any: "sample_hold",
|
||||
/// "linear_interpolation" or "imaging".
|
||||
pub upsampling_artifact: String,
|
||||
/// Source Nyquist (22050 / 24000) with a resampler-style cliff; 0 if none.
|
||||
pub brickwall_hz: f64,
|
||||
/// In-band floor of the quietest frames against flat 16-bit quantization
|
||||
/// noise: "below_16bit", "at_16bit", "masked" (music never quiet enough),
|
||||
/// or empty when not measured.
|
||||
pub noise_floor_class: String,
|
||||
pub noise_floor_vs_16bit_db: f64,
|
||||
/// Highest frequency whose level still moves with the music, for a file
|
||||
/// claiming Hi-Res by rate; 0 when not measured. Informational: the
|
||||
/// verdict rests on the tests above.
|
||||
pub music_cutoff_hz: f64,
|
||||
/// True when the active content past `music_cutoff_hz` is steady noise,
|
||||
/// like the ultrasonic hump of a DSD or analog tape transfer, so
|
||||
/// `cutoff_frequency_hz` marks where that noise ends rather than the music.
|
||||
pub ultrasonic_noise_only: bool,
|
||||
/// With `ultrasonic_noise_only`: the lowest standard rate of the same
|
||||
/// family that holds all the music (e.g. 88200 for a 176.4 kHz file whose
|
||||
/// music stops at 34 kHz). Otherwise the declared rate.
|
||||
pub useful_sample_rate: u32,
|
||||
}
|
||||
|
||||
impl HiResCheckResult {
|
||||
/// True for any "fake hi-res" verdict, whatever its confidence.
|
||||
pub fn is_suspicious(&self) -> bool {
|
||||
self.verdict == VERDICT_FAKE
|
||||
}
|
||||
|
||||
/// A conservative candidate filter, not proof that a replacement is safe.
|
||||
/// Both depth and rate must fit CD quality; spectral heuristics cannot
|
||||
/// establish that. The caller must still compare every decoded sample.
|
||||
pub fn redownload_safe(&self) -> bool {
|
||||
self.is_suspicious()
|
||||
&& self.confidence == CONFIDENCE_CERTAIN
|
||||
&& self.declared_bit_depth > 0
|
||||
&& self.effective_bit_depth > 0
|
||||
&& self.effective_bit_depth <= 16
|
||||
&& (self.declared_sample_rate <= 44_100
|
||||
|| (self.upsampling_artifact == ARTIFACT_SAMPLE_HOLD
|
||||
&& self.declared_sample_rate.is_multiple_of(44_100)))
|
||||
}
|
||||
|
||||
/// True when the declared depth is bits the file never uses.
|
||||
pub fn padded_bit_depth(&self) -> bool {
|
||||
self.declared_bit_depth > 16
|
||||
&& self.effective_bit_depth > 0
|
||||
&& self.effective_bit_depth <= 16
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded segment: the mono signal for the spectrum and the OR of every
|
||||
/// raw sample for the bit-depth test, gathered in one pass.
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
mono: Vec<f32>,
|
||||
/// OR of all raw integer samples, right-justified at the declared depth.
|
||||
or_bits: u32,
|
||||
/// First channel's raw integer samples, for the upsampling-artifact
|
||||
/// tests; empty for float PCM.
|
||||
first_channel: Vec<i32>,
|
||||
}
|
||||
|
||||
/// Analyses one file and returns a populated result. Only a short segment from
|
||||
/// the middle of the track is decoded, never the whole file.
|
||||
pub fn check_file(
|
||||
mut file: File,
|
||||
file_path: &str,
|
||||
options: &HiResCheckOptions,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<HiResCheckResult, HiResCheckError> {
|
||||
if options.sample_seconds <= 0 {
|
||||
return Err("sample_seconds must be positive".to_string().into());
|
||||
}
|
||||
if options.n_fft <= 0 || (options.n_fft & (options.n_fft - 1)) != 0 {
|
||||
return Err("n_fft must be a positive power of two".to_string().into());
|
||||
}
|
||||
let size = file.metadata().map_err(|e| e.to_string())?.len();
|
||||
if size == 0 {
|
||||
return Err(format!("file is empty: {file_path}").into());
|
||||
}
|
||||
check()?;
|
||||
|
||||
let mut source = Source::open(&mut file)?;
|
||||
let sr = source.sample_rate();
|
||||
if sr == 0 {
|
||||
return Err("invalid declared sample rate 0".to_string().into());
|
||||
}
|
||||
let total_duration = source.total_frames() as f64 / f64::from(sr);
|
||||
if total_duration <= 0.0 {
|
||||
return Err("file reports no duration, likely corrupt"
|
||||
.to_string()
|
||||
.into());
|
||||
}
|
||||
|
||||
let analyzed_duration = (options.sample_seconds as f64).min(total_duration);
|
||||
let offset = ((total_duration - analyzed_duration) / 2.0).max(0.0);
|
||||
let start_frame = (offset * f64::from(sr)) as u64;
|
||||
let window_frames = (analyzed_duration * f64::from(sr)) as u64;
|
||||
|
||||
let window = source
|
||||
.read_window(start_frame, window_frames, check)
|
||||
.map_err(|e| HiResCheckError::Failed(format!("could not decode audio: {e}")))?;
|
||||
let y = &window.mono;
|
||||
if y.is_empty() {
|
||||
return Err("decoded audio segment is empty".to_string().into());
|
||||
}
|
||||
|
||||
let mut result = HiResCheckResult {
|
||||
file_path: file_path.to_string(),
|
||||
declared_sample_rate: sr,
|
||||
total_duration_s: total_duration,
|
||||
analyzed_duration_s: analyzed_duration,
|
||||
noise_floor_db: options.noise_floor_db,
|
||||
verdict: VERDICT_INCONCLUSIVE.into(),
|
||||
..HiResCheckResult::default()
|
||||
};
|
||||
|
||||
// A silent segment makes spectral analysis meaningless rather than wrong.
|
||||
if !y.iter().any(|v| f64::from(v.abs()) > 1e-9) {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Shrink n_fft for very short segments so the window does not end up
|
||||
// measuring its own zero padding.
|
||||
let mut n_fft = options.n_fft as usize;
|
||||
while n_fft > 256 && n_fft > y.len() * 2 {
|
||||
n_fft /= 2;
|
||||
}
|
||||
|
||||
let stats = analyze_stft(y, n_fft, sr, check)?;
|
||||
if !stats.avg_magnitude.iter().any(|&v| v > 0.0) {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Deliberately unclamped: flooring at the noise floor itself would make
|
||||
// every floored bin count as active at any lower threshold.
|
||||
let spec_db = spectrum_db(&stats.avg_magnitude);
|
||||
let cutoff = spec_db
|
||||
.iter()
|
||||
.rposition(|&db| db > options.noise_floor_db)
|
||||
.map_or(0.0, |k| k as f64 * f64::from(sr) / n_fft as f64);
|
||||
result.cutoff_frequency_hz = cutoff;
|
||||
|
||||
let declared_bits = source.declared_bits();
|
||||
let mut effective_bits = 0;
|
||||
if declared_bits > 0 && window.or_bits != 0 {
|
||||
// Digital silence carries no bits at all; leaving it at 0 keeps it
|
||||
// out of the padded-depth test.
|
||||
effective_bits = declared_bits.saturating_sub(window.or_bits.trailing_zeros());
|
||||
}
|
||||
result.declared_bit_depth = declared_bits;
|
||||
result.effective_bit_depth = effective_bits;
|
||||
|
||||
// A file can claim Hi-Res by rate, by depth, or both, and each claim is
|
||||
// answered by the test that can actually judge it.
|
||||
let claims_by_rate = i64::from(sr) > options.hires_sample_rate_threshold;
|
||||
let claims_by_depth = declared_bits > 16;
|
||||
result.useful_sample_rate = sr;
|
||||
if claims_by_rate {
|
||||
result.music_cutoff_hz = music_cutoff(
|
||||
&stats.music_band_spreads,
|
||||
&spec_db,
|
||||
sr,
|
||||
n_fft,
|
||||
options.noise_floor_db,
|
||||
);
|
||||
if result.music_cutoff_hz > 0.0
|
||||
&& cutoff - result.music_cutoff_hz >= ULTRASONIC_NOISE_MARGIN_HZ
|
||||
{
|
||||
result.ultrasonic_noise_only = true;
|
||||
result.useful_sample_rate = useful_sample_rate(sr, result.music_cutoff_hz);
|
||||
}
|
||||
// A resampler's cliff at 22.05/24 kHz betrays a 44.1/48 kHz chain
|
||||
// even when a weak stopband leaves a flat plateau above it that reads
|
||||
// as "content" to the cutoff test (ffmpeg's default resampler does).
|
||||
result.brickwall_hz = detect_brickwall(&spec_db, sr, n_fft, options.noise_floor_db);
|
||||
}
|
||||
let cutoff_is_low = claims_by_rate && cutoff < options.hires_cutoff_threshold_hz;
|
||||
let rate_is_fake = cutoff_is_low || result.brickwall_hz > 0.0;
|
||||
let depth_is_fake = claims_by_depth && effective_bits > 0 && effective_bits <= 16;
|
||||
|
||||
// Exact fingerprints of a conversion. Imaging puts content back above
|
||||
// 22 kHz, so such a file can pass the cutoff test and still be a fake.
|
||||
let mut artifact = "";
|
||||
if claims_by_rate {
|
||||
artifact = detect_integer_upsampling(&window.first_channel, window.or_bits, sr);
|
||||
if artifact.is_empty() && detect_imaging(&spec_db, sr, n_fft, options.noise_floor_db) {
|
||||
artifact = ARTIFACT_IMAGING;
|
||||
}
|
||||
}
|
||||
result.upsampling_artifact = artifact.into();
|
||||
if rate_is_fake {
|
||||
let (class, vs_16bit_db) =
|
||||
classify_noise_floor(stats.quiet_floor_var, source.channel_count());
|
||||
result.noise_floor_class = class.into();
|
||||
result.noise_floor_vs_16bit_db = vs_16bit_db;
|
||||
}
|
||||
|
||||
result.verdict = if !claims_by_rate && !claims_by_depth {
|
||||
VERDICT_STANDARD
|
||||
} else if rate_is_fake || depth_is_fake || !artifact.is_empty() {
|
||||
VERDICT_FAKE
|
||||
} else {
|
||||
VERDICT_GENUINE
|
||||
}
|
||||
.into();
|
||||
|
||||
if result.verdict == VERDICT_FAKE {
|
||||
result.confidence = if depth_is_fake || !artifact.is_empty() {
|
||||
CONFIDENCE_CERTAIN
|
||||
} else if result.brickwall_hz > 0.0 && result.noise_floor_class == FLOOR_AT_16BIT {
|
||||
CONFIDENCE_LIKELY
|
||||
} else {
|
||||
CONFIDENCE_SUSPECT
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
let mut findings: Vec<String> = Vec::new();
|
||||
match artifact {
|
||||
ARTIFACT_SAMPLE_HOLD => {
|
||||
findings.push("every sample is repeated (sample-and-hold upsampling)".into());
|
||||
}
|
||||
ARTIFACT_INTERPOLATION => {
|
||||
findings.push("in-between samples are linearly interpolated".into());
|
||||
}
|
||||
ARTIFACT_IMAGING => {
|
||||
findings.push("content above the source Nyquist mirrors the audible band".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cutoff_is_low {
|
||||
findings.push(format!(
|
||||
"declares {sr} Hz but content stops at ~{cutoff:.0} Hz"
|
||||
));
|
||||
} else if rate_is_fake {
|
||||
findings.push(format!(
|
||||
"declares {sr} Hz but the spectrum falls off a cliff at {:.0} Hz",
|
||||
result.brickwall_hz
|
||||
));
|
||||
}
|
||||
if depth_is_fake {
|
||||
findings.push(format!(
|
||||
"declares {declared_bits}-bit but only {effective_bits} bits carry data"
|
||||
));
|
||||
}
|
||||
if result.confidence == CONFIDENCE_SUSPECT {
|
||||
findings.push("may be a genuine master low-pass filtered in mastering".into());
|
||||
}
|
||||
result.reason = findings.join("; ");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The containers the checker decodes, sniffed from their magic bytes rather
|
||||
/// than the extension so Android descriptor paths without a suffix work too.
|
||||
enum Source<'a> {
|
||||
Flac(Box<claxon::FlacReader<BufReader<&'a mut File>>>),
|
||||
Wav(WavSource<'a>),
|
||||
}
|
||||
|
||||
impl<'a> Source<'a> {
|
||||
fn open(file: &'a mut File) -> Result<Self, HiResCheckError> {
|
||||
let mut head = [0u8; 12];
|
||||
let mut read = 0;
|
||||
while read < head.len() {
|
||||
match file.read(&mut head[read..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => read += n,
|
||||
Err(e) => return Err(e.to_string().into()),
|
||||
}
|
||||
}
|
||||
file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?;
|
||||
if read >= 4 && &head[..4] == b"fLaC" {
|
||||
return claxon::FlacReader::new(BufReader::new(file))
|
||||
.map(|reader| Source::Flac(Box::new(reader)))
|
||||
.map_err(|e| HiResCheckError::Failed(format!("could not read audio header: {e}")));
|
||||
}
|
||||
if read >= 12 && &head[..4] == b"RIFF" && &head[8..12] == b"WAVE" {
|
||||
return WavSource::open(file).map(Source::Wav);
|
||||
}
|
||||
Err(HiResCheckError::Unsupported)
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> u32 {
|
||||
match self {
|
||||
Source::Flac(reader) => reader.streaminfo().sample_rate,
|
||||
Source::Wav(wav) => wav.sample_rate,
|
||||
}
|
||||
}
|
||||
|
||||
fn total_frames(&self) -> u64 {
|
||||
match self {
|
||||
Source::Flac(reader) => reader.streaminfo().samples.unwrap_or(0),
|
||||
Source::Wav(wav) => wav.data_size / wav.frame_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 for formats without a fixed-point depth.
|
||||
fn declared_bits(&self) -> u32 {
|
||||
match self {
|
||||
Source::Flac(reader) => reader.streaminfo().bits_per_sample,
|
||||
Source::Wav(wav) if wav.is_float => 0,
|
||||
Source::Wav(wav) => wav.container_bits,
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_count(&self) -> u32 {
|
||||
match self {
|
||||
Source::Flac(reader) => reader.streaminfo().channels,
|
||||
Source::Wav(wav) => wav.channels,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
match self {
|
||||
Source::Flac(reader) => read_flac_window(reader, start_frame, frames, check),
|
||||
Source::Wav(wav) => wav.read_window(start_frame, frames, check),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes [start_frame, start_frame + frames). claxon hands back samples
|
||||
/// right-justified with wasted bits already shifted back in and inter-channel
|
||||
/// decorrelation undone, so each value is the stored sample. It cannot seek,
|
||||
/// so the frames before the window are decoded and skipped.
|
||||
fn read_flac_window(
|
||||
reader: &mut claxon::FlacReader<BufReader<&mut File>>,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
let bits = reader.streaminfo().bits_per_sample;
|
||||
let scale = 1.0 / 2f64.powi(bits as i32 - 1);
|
||||
let capacity = usize::try_from(frames).unwrap_or(0);
|
||||
let mut out = Window {
|
||||
mono: Vec::with_capacity(capacity),
|
||||
first_channel: Vec::with_capacity(capacity),
|
||||
..Window::default()
|
||||
};
|
||||
let end = start_frame + frames;
|
||||
let mut blocks = reader.blocks();
|
||||
let mut buffer = Vec::new();
|
||||
let mut decoded = 0u64;
|
||||
while (out.mono.len() as u64) < frames {
|
||||
if decoded.is_multiple_of(64) {
|
||||
check()?;
|
||||
}
|
||||
decoded += 1;
|
||||
let block = match blocks.read_next_or_eof(buffer) {
|
||||
Ok(Some(block)) => block,
|
||||
Ok(None) => break,
|
||||
// A truncated tail still leaves a usable window.
|
||||
Err(_) if !out.mono.is_empty() => break,
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let first = block.time();
|
||||
let n = u64::from(block.duration());
|
||||
if first + n > start_frame {
|
||||
let channels = block.channels();
|
||||
let from = start_frame.saturating_sub(first);
|
||||
let to = n.min(end - first);
|
||||
for i in from..to {
|
||||
let i = i as usize;
|
||||
let mut sum = 0.0;
|
||||
for ch in 0..channels {
|
||||
let v = block.channel(ch)[i];
|
||||
out.or_bits |= v as u32;
|
||||
sum += f64::from(v);
|
||||
}
|
||||
out.first_channel.push(block.channel(0)[i]);
|
||||
out.mono.push((sum / f64::from(channels) * scale) as f32);
|
||||
}
|
||||
}
|
||||
buffer = block.into_buffer();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const WAV_FORMAT_PCM: u16 = 1;
|
||||
const WAV_FORMAT_FLOAT: u16 = 3;
|
||||
const WAV_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
|
||||
struct WavSource<'a> {
|
||||
file: &'a mut File,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
/// Bits per sample as stored.
|
||||
container_bits: u32,
|
||||
is_float: bool,
|
||||
data_offset: u64,
|
||||
data_size: u64,
|
||||
}
|
||||
|
||||
impl<'a> WavSource<'a> {
|
||||
fn open(file: &'a mut File) -> Result<Self, HiResCheckError> {
|
||||
let file_size = file.metadata().map_err(|e| e.to_string())?.len();
|
||||
file.seek(SeekFrom::Start(12)).map_err(|e| e.to_string())?;
|
||||
let (mut sample_rate, mut channels, mut container_bits) = (0u32, 0u32, 0u32);
|
||||
let mut format = 0u16;
|
||||
let (mut data_offset, mut data_size) = (0u64, 0u64);
|
||||
let mut header = [0u8; 8];
|
||||
while data_offset == 0 {
|
||||
if file.read_exact(&mut header).is_err() {
|
||||
break;
|
||||
}
|
||||
let size = u64::from(u32::from_le_bytes([
|
||||
header[4], header[5], header[6], header[7],
|
||||
]));
|
||||
let pad = size & 1;
|
||||
let chunk_start = file.stream_position().map_err(|e| e.to_string())?;
|
||||
let chunk_end = chunk_start + size;
|
||||
// Only the data chunk may use a streaming-size placeholder.
|
||||
if &header[..4] != b"data" && chunk_end + pad > file_size {
|
||||
return Err("truncated WAV chunk".to_string().into());
|
||||
}
|
||||
match &header[..4] {
|
||||
b"fmt " => {
|
||||
// The format prefix is at most 40 bytes. Never allocate
|
||||
// from an untrusted 32-bit chunk length.
|
||||
let mut chunk = [0u8; 40];
|
||||
let prefix_len = size.min(chunk.len() as u64) as usize;
|
||||
if prefix_len < 16 || file.read_exact(&mut chunk[..prefix_len]).is_err() {
|
||||
return Err("truncated WAV fmt chunk".to_string().into());
|
||||
}
|
||||
format = u16::from_le_bytes([chunk[0], chunk[1]]);
|
||||
channels = u32::from(u16::from_le_bytes([chunk[2], chunk[3]]));
|
||||
sample_rate = u32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]);
|
||||
container_bits = u32::from(u16::from_le_bytes([chunk[14], chunk[15]]));
|
||||
if format == WAV_FORMAT_EXTENSIBLE {
|
||||
if prefix_len < 40 || u16::from_le_bytes([chunk[16], chunk[17]]) < 22 {
|
||||
return Err("truncated extensible WAV fmt chunk".to_string().into());
|
||||
}
|
||||
// SubFormat GUID's leading format tag.
|
||||
format = u16::from_le_bytes([chunk[24], chunk[25]]);
|
||||
}
|
||||
file.seek(SeekFrom::Start(chunk_end + pad))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
b"data" => {
|
||||
data_offset = file.stream_position().map_err(|e| e.to_string())?;
|
||||
// Streamed WAVs often carry a placeholder data size.
|
||||
if size != u64::from(u32::MAX) && chunk_end > file_size {
|
||||
return Err("truncated WAV data chunk".to_string().into());
|
||||
}
|
||||
data_size = size.min(file_size.saturating_sub(data_offset));
|
||||
}
|
||||
_ => {
|
||||
file.seek(SeekFrom::Start(chunk_end + pad))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data_offset == 0 || channels == 0 || sample_rate == 0 {
|
||||
return Err("WAV has no usable fmt/data chunk".to_string().into());
|
||||
}
|
||||
let is_float = match (format, container_bits) {
|
||||
(WAV_FORMAT_PCM, 8 | 16 | 24 | 32) => false,
|
||||
(WAV_FORMAT_FLOAT, 32 | 64) => true,
|
||||
_ => return Err(HiResCheckError::Unsupported),
|
||||
};
|
||||
Ok(Self {
|
||||
file,
|
||||
sample_rate,
|
||||
channels,
|
||||
container_bits,
|
||||
is_float,
|
||||
data_offset,
|
||||
data_size,
|
||||
})
|
||||
}
|
||||
|
||||
fn frame_bytes(&self) -> u64 {
|
||||
u64::from(self.channels * self.container_bits / 8)
|
||||
}
|
||||
|
||||
fn read_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
let total = self.data_size / self.frame_bytes();
|
||||
let frames = frames.min(total.saturating_sub(start_frame));
|
||||
let mut out = Window::default();
|
||||
if frames == 0 {
|
||||
return Ok(out);
|
||||
}
|
||||
let frame_bytes = self.frame_bytes() as usize;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(
|
||||
self.data_offset + start_frame * frame_bytes as u64,
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let capacity = usize::try_from(frames).unwrap_or(0);
|
||||
out.mono.reserve(capacity);
|
||||
if !self.is_float {
|
||||
out.first_channel.reserve(capacity);
|
||||
}
|
||||
let bytes_per_sample = (self.container_bits / 8) as usize;
|
||||
let scale = 1.0 / 2f64.powi(self.container_bits as i32 - 1);
|
||||
let mut reader = BufReader::with_capacity(1 << 16, &mut *self.file);
|
||||
let mut frame = vec![0u8; frame_bytes];
|
||||
|
||||
for index in 0..frames {
|
||||
if index.is_multiple_of(65_536) {
|
||||
check()?;
|
||||
}
|
||||
if reader.read_exact(&mut frame).is_err() {
|
||||
break;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for (c, b) in frame.chunks_exact(bytes_per_sample).enumerate() {
|
||||
if self.is_float {
|
||||
sum += if self.container_bits == 32 {
|
||||
f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
} else {
|
||||
f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
|
||||
};
|
||||
continue;
|
||||
}
|
||||
let v: i32 = match self.container_bits {
|
||||
8 => i32::from(b[0]) - 128, // 8-bit WAV is unsigned
|
||||
16 => i32::from(i16::from_le_bytes([b[0], b[1]])),
|
||||
24 => i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8,
|
||||
_ => i32::from_le_bytes([b[0], b[1], b[2], b[3]]),
|
||||
};
|
||||
out.or_bits |= v as u32;
|
||||
if c == 0 {
|
||||
out.first_channel.push(v);
|
||||
}
|
||||
sum += f64::from(v) * scale;
|
||||
}
|
||||
out.mono.push((sum / f64::from(self.channels)) as f32);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::{HiResCheckError, Source};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
/// Checks all channels and the entire duration, without a spectral heuristic
|
||||
/// or floating-point tolerance. Only padding and exact sample repetition can
|
||||
/// be removed. A different master, dither, resampling or truncated file fails
|
||||
/// closed. Memory is bounded by one decoded block from each file.
|
||||
pub fn replacement_preserves_audio(
|
||||
mut original: File,
|
||||
mut replacement: File,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<bool, HiResCheckError> {
|
||||
check()?;
|
||||
let original = Source::open(&mut original)?;
|
||||
let replacement = Source::open(&mut replacement)?;
|
||||
let rate = replacement.sample_rate();
|
||||
if rate == 0
|
||||
|| original.sample_rate() < rate
|
||||
|| !original.sample_rate().is_multiple_of(rate)
|
||||
|| original.channel_count() != replacement.channel_count()
|
||||
|| original.channel_count() == 0
|
||||
|| !(1..=32).contains(&original.declared_bits())
|
||||
|| !(1..=32).contains(&replacement.declared_bits())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let ratio = u64::from(original.sample_rate() / rate);
|
||||
let frames = replacement.total_frames();
|
||||
if frames == 0 || frames.checked_mul(ratio) != Some(original.total_frames()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut original = PcmStream::new(original)?;
|
||||
let mut replacement = PcmStream::new(replacement)?;
|
||||
for index in 0..frames {
|
||||
if index.is_multiple_of(4096) {
|
||||
check()?;
|
||||
}
|
||||
let Some(expected) = replacement.next_frame()? else {
|
||||
return Ok(false);
|
||||
};
|
||||
for _ in 0..ratio {
|
||||
if original.next_frame()? != Some(expected) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(original.next_frame()?.is_none() && replacement.next_frame()?.is_none())
|
||||
}
|
||||
|
||||
struct PcmStream<'a> {
|
||||
source: Source<'a>,
|
||||
samples: Vec<i32>,
|
||||
buffer: Vec<i32>,
|
||||
cursor: usize,
|
||||
frames_read: u64,
|
||||
channels: usize,
|
||||
shift: u32,
|
||||
}
|
||||
|
||||
impl<'a> PcmStream<'a> {
|
||||
fn new(mut source: Source<'a>) -> Result<Self, HiResCheckError> {
|
||||
if let Source::Wav(wav) = &mut source {
|
||||
if !wav.data_size.is_multiple_of(wav.frame_bytes()) {
|
||||
return Err("partial WAV audio frame".to_string().into());
|
||||
}
|
||||
wav.file
|
||||
.seek(SeekFrom::Start(wav.data_offset))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Self {
|
||||
channels: source.channel_count() as usize,
|
||||
shift: 32 - source.declared_bits(),
|
||||
source,
|
||||
samples: Vec::new(),
|
||||
buffer: Vec::new(),
|
||||
cursor: 0,
|
||||
frames_read: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Result<Option<&[i32]>, HiResCheckError> {
|
||||
if self.cursor == self.samples.len() {
|
||||
self.samples.clear();
|
||||
self.cursor = 0;
|
||||
match &mut self.source {
|
||||
Source::Flac(reader) => {
|
||||
let block = reader
|
||||
.blocks()
|
||||
.read_next_or_eof(std::mem::take(&mut self.buffer))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(block) = block else { return Ok(None) };
|
||||
if block.channels() as usize != self.channels {
|
||||
return Err("inconsistent FLAC channel count".to_string().into());
|
||||
}
|
||||
// Decode in stream order. Claxon's time() multiplies a
|
||||
// fixed-block frame number by the current block's size,
|
||||
// so it is inaccurate for a shorter final block.
|
||||
for frame in 0..block.duration() as usize {
|
||||
for channel in 0..self.channels {
|
||||
self.samples
|
||||
.push(block.channel(channel as u32)[frame] << self.shift);
|
||||
}
|
||||
}
|
||||
self.frames_read += u64::from(block.duration());
|
||||
self.buffer = block.into_buffer();
|
||||
}
|
||||
Source::Wav(wav) => {
|
||||
let remaining = wav.data_size / wav.frame_bytes() - self.frames_read;
|
||||
if remaining == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let frames = remaining.min(4096).min(65_536 / self.channels as u64);
|
||||
let bytes_per_sample = (wav.container_bits / 8) as usize;
|
||||
let mut bytes = vec![0u8; (frames * wav.frame_bytes()) as usize];
|
||||
wav.file.read_exact(&mut bytes).map_err(|e| e.to_string())?;
|
||||
for sample in bytes.chunks_exact(bytes_per_sample) {
|
||||
let value = match bytes_per_sample {
|
||||
1 => i32::from(sample[0]) - 128,
|
||||
2 => i32::from(i16::from_le_bytes([sample[0], sample[1]])),
|
||||
3 => i32::from_le_bytes([0, sample[0], sample[1], sample[2]]) >> 8,
|
||||
_ => i32::from_le_bytes(sample.try_into().expect("32-bit PCM")),
|
||||
};
|
||||
self.samples.push(value << self.shift);
|
||||
}
|
||||
self.frames_read += frames;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.samples.is_empty() {
|
||||
return Err("empty audio block".to_string().into());
|
||||
}
|
||||
let frame = &self.samples[self.cursor..self.cursor + self.channels];
|
||||
self.cursor += self.channels;
|
||||
Ok(Some(frame))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
//! Port of SpotiFLAC-Module-Version's tests/test_hires_check.py. Signals are
|
||||
//! synthesised rather than fixtured: a "fake Hi-Res" file is exactly a
|
||||
//! band-limited 44.1 kHz signal carried at a higher rate, which a few lines
|
||||
//! build more clearly than any committed binary could describe.
|
||||
|
||||
use super::fft::Radix2Fft;
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const HIRES_SR: u32 = 176_400;
|
||||
const CD_SR: u32 = 44_100;
|
||||
/// A power of two so the frequency-domain construction can use the checker's
|
||||
/// own FFT; ~3 s at 176.4 kHz.
|
||||
const HIRES_FRAMES: usize = 1 << 19;
|
||||
/// ~3 s at 44.1 kHz; x4 is HIRES_FRAMES.
|
||||
const CD_SOURCE_FRAMES: usize = HIRES_FRAMES / 4;
|
||||
|
||||
/// SplitMix64: deterministic noise without a dependency.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Uniform in [0, 1).
|
||||
fn uniform(&mut self) -> f64 {
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Standard normal via Box-Muller.
|
||||
fn normal(&mut self) -> f64 {
|
||||
let u1 = self.uniform().max(1e-300);
|
||||
let u2 = self.uniform();
|
||||
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
|
||||
}
|
||||
}
|
||||
|
||||
fn full_band_noise(n: usize, seed: u64) -> Vec<f64> {
|
||||
let mut rng = Rng(seed);
|
||||
(0..n).map(|_| rng.normal() * 0.2).collect()
|
||||
}
|
||||
|
||||
/// Applies `gain(freq)` to `y`'s spectrum; `y.len()` must be a power of two.
|
||||
fn shape_spectrum(y: &[f64], sample_rate: u32, gain: impl Fn(f64) -> f64) -> Vec<f64> {
|
||||
let n = y.len();
|
||||
let mut re = y.to_vec();
|
||||
let mut im = vec![0.0; n];
|
||||
let fft = Radix2Fft::new(n);
|
||||
fft.transform(&mut re, &mut im);
|
||||
for k in 0..=n / 2 {
|
||||
let g = gain(k as f64 * f64::from(sample_rate) / n as f64);
|
||||
re[k] *= g;
|
||||
im[k] *= g;
|
||||
if k > 0 && k < n / 2 {
|
||||
re[n - k] = re[k];
|
||||
im[n - k] = -im[k];
|
||||
}
|
||||
}
|
||||
// Inverse FFT via conjugation.
|
||||
im.iter_mut().for_each(|v| *v = -*v);
|
||||
fft.transform(&mut re, &mut im);
|
||||
re.iter().map(|v| v / n as f64).collect()
|
||||
}
|
||||
|
||||
/// CD-bandwidth noise in a 176.4 kHz container. Everything above 22.05 kHz is
|
||||
/// zeroed, then a raised-cosine taper runs up to ~24.5 kHz: the
|
||||
/// transition-band tail a real resampler leaves behind.
|
||||
fn upsampled_from_cd() -> Vec<f64> {
|
||||
let (cd_nyquist, taper_end) = (f64::from(CD_SR) / 2.0, 24_500.0);
|
||||
shape_spectrum(&full_band_noise(HIRES_FRAMES, 0), HIRES_SR, |freq| {
|
||||
if freq >= taper_end {
|
||||
0.0
|
||||
} else if freq >= cd_nyquist {
|
||||
let ramp = (freq - cd_nyquist) / (taper_end - cd_nyquist);
|
||||
0.5 * (1.0 + (std::f64::consts::PI * ramp).cos()) * 1e-3
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Band-limited interpolation by an integer ratio: nothing at all above the
|
||||
/// source Nyquist.
|
||||
fn ideal_upsample(x: &[f64], ratio: usize) -> Vec<f64> {
|
||||
let (n, m) = (x.len(), x.len() * ratio);
|
||||
let mut re = x.to_vec();
|
||||
let mut im = vec![0.0; n];
|
||||
Radix2Fft::new(n).transform(&mut re, &mut im);
|
||||
let (mut out_re, mut out_im) = (vec![0.0; m], vec![0.0; m]);
|
||||
for k in 0..n / 2 {
|
||||
out_re[k] = re[k];
|
||||
out_im[k] = im[k];
|
||||
if k > 0 {
|
||||
out_re[m - k] = re[n - k];
|
||||
out_im[m - k] = im[n - k];
|
||||
}
|
||||
}
|
||||
out_re[n / 2] = re[n / 2] / 2.0;
|
||||
out_re[m - n / 2] = re[n / 2] / 2.0;
|
||||
out_im.iter_mut().for_each(|v| *v = -*v);
|
||||
Radix2Fft::new(m).transform(&mut out_re, &mut out_im);
|
||||
out_re.iter().map(|v| v / n as f64).collect()
|
||||
}
|
||||
|
||||
/// Loud noise, then a half holding only `quiet_noise`: the gaps real music
|
||||
/// leaves, where a noise floor shows through.
|
||||
fn cd_source_with_quiet_half(quiet_noise: f64) -> Vec<f64> {
|
||||
let mut rng = Rng(3);
|
||||
(0..CD_SOURCE_FRAMES)
|
||||
.map(|i| {
|
||||
rng.normal()
|
||||
* if i < CD_SOURCE_FRAMES / 2 {
|
||||
0.2
|
||||
} else {
|
||||
quiet_noise
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What a CD master is: TPDF-dithered 16-bit, as floats.
|
||||
fn dither_to_16_bit(y: &[f64]) -> Vec<f64> {
|
||||
let mut rng = Rng(4);
|
||||
y.iter()
|
||||
.map(|v| {
|
||||
let dither = (rng.uniform() - rng.uniform()) / 32768.0;
|
||||
((v + dither) * 32768.0).round() / 32768.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Maps [-1, 1) floats to integers of the given depth.
|
||||
fn quantize(y: &[f64], bits: u32) -> Vec<i32> {
|
||||
let full = 2f64.powi(bits as i32 - 1);
|
||||
y.iter()
|
||||
.map(|v| (v * full).round().clamp(-full, full - 1.0) as i32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Noise occupying exactly `used_bits`, stored right-justified at
|
||||
/// `container_bits`: a 16-bit master padded into 24 bits has its low 8 bits
|
||||
/// zero.
|
||||
fn pcm_using_bits(used_bits: u32, container_bits: u32, n: usize, seed: u64) -> Vec<i32> {
|
||||
let mut rng = Rng(seed);
|
||||
let span = 1i64 << used_bits;
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
let v = (rng.next_u64() % span as u64) as i64 - span / 2;
|
||||
(v << (container_bits - used_bits)) as i32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fade(signal: &mut [f64]) {
|
||||
const FADE: usize = 2048;
|
||||
let n = signal.len();
|
||||
for i in 0..FADE {
|
||||
let ramp = 0.5 - 0.5 * (std::f64::consts::PI * i as f64 / FADE as f64).cos();
|
||||
signal[i] *= ramp;
|
||||
signal[n - 1 - i] *= ramp;
|
||||
}
|
||||
}
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"spotiflac-hires-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos())
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("temp dir");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn file(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct BitWriter {
|
||||
bytes: Vec<u8>,
|
||||
bit: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
bytes: Vec::new(),
|
||||
bit: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&mut self, value: u64, width: u32) {
|
||||
for shift in (0..width).rev() {
|
||||
if self.bit == 0 {
|
||||
self.bytes.push(0);
|
||||
}
|
||||
let last = self.bytes.len() - 1;
|
||||
self.bytes[last] |= (((value >> shift) & 1) as u8) << (7 - self.bit);
|
||||
self.bit = (self.bit + 1) % 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn crc8(data: &[u8]) -> u8 {
|
||||
data.iter().fold(0u8, |mut crc, &byte| {
|
||||
crc ^= byte;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 {
|
||||
(crc << 1) ^ 0x07
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
crc
|
||||
})
|
||||
}
|
||||
|
||||
fn crc16(data: &[u8]) -> u16 {
|
||||
data.iter().fold(0u16, |mut crc, &byte| {
|
||||
crc ^= u16::from(byte) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 {
|
||||
(crc << 1) ^ 0x8005
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
crc
|
||||
})
|
||||
}
|
||||
|
||||
/// FLAC's UTF-8-like coding of the frame number.
|
||||
fn utf8_number(value: u64) -> Vec<u8> {
|
||||
if value < 0x80 {
|
||||
return vec![value as u8];
|
||||
}
|
||||
let mut continuation = Vec::new();
|
||||
let mut rest = value;
|
||||
let mut payload_bits = 6; // bits left for the leading byte's payload
|
||||
while rest >= 1 << payload_bits {
|
||||
continuation.push(0x80 | (rest & 0x3F) as u8);
|
||||
rest >>= 6;
|
||||
payload_bits -= 1;
|
||||
}
|
||||
let count = continuation.len() + 1;
|
||||
let lead = (0xFFu16 << (8 - count)) as u8 | rest as u8;
|
||||
std::iter::once(lead)
|
||||
.chain(continuation.into_iter().rev())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Encodes `samples` (identical on every channel) as FLAC with verbatim
|
||||
/// subframes: no compression, but every CRC valid, so any decoder reads it.
|
||||
fn write_flac(path: &Path, samples: &[i32], sample_rate: u32, bits: u32, channels: u32) {
|
||||
const BLOCK: usize = 4096;
|
||||
let mut out = b"fLaC".to_vec();
|
||||
let mut info = BitWriter::new();
|
||||
info.put(BLOCK as u64, 16);
|
||||
info.put(BLOCK as u64, 16);
|
||||
info.put(0, 24);
|
||||
info.put(0, 24);
|
||||
info.put(u64::from(sample_rate), 20);
|
||||
info.put(u64::from(channels - 1), 3);
|
||||
info.put(u64::from(bits - 1), 5);
|
||||
info.put(samples.len() as u64, 36);
|
||||
info.put(0, 64);
|
||||
info.put(0, 64);
|
||||
out.extend_from_slice(&[0x80, 0, 0, 34]); // last metadata block, STREAMINFO
|
||||
out.extend_from_slice(&info.bytes);
|
||||
|
||||
for (number, block) in samples.chunks(BLOCK).enumerate() {
|
||||
// Fixed blocking; 16-bit block size; sample rate from STREAMINFO;
|
||||
// independent channels; the depth spelled out, as encoders do.
|
||||
let depth_code = match bits {
|
||||
8 => 0b001,
|
||||
12 => 0b010,
|
||||
16 => 0b100,
|
||||
20 => 0b101,
|
||||
_ => 0b110, // 24
|
||||
};
|
||||
let mut frame = vec![
|
||||
0xFF,
|
||||
0xF8,
|
||||
0x70,
|
||||
(((channels - 1) << 4) | (depth_code << 1)) as u8,
|
||||
];
|
||||
frame.extend(utf8_number(number as u64));
|
||||
frame.extend_from_slice(&((block.len() - 1) as u16).to_be_bytes());
|
||||
frame.push(crc8(&frame));
|
||||
let mut body = BitWriter::new();
|
||||
for _ in 0..channels {
|
||||
body.put(0b0000_0010, 8); // verbatim, no wasted bits
|
||||
for &sample in block {
|
||||
body.put(u64::from(sample as u32) & ((1u64 << bits) - 1), bits);
|
||||
}
|
||||
}
|
||||
frame.extend(body.bytes);
|
||||
let crc = crc16(&frame);
|
||||
frame.extend_from_slice(&crc.to_be_bytes());
|
||||
out.extend(frame);
|
||||
}
|
||||
std::fs::write(path, out).expect("write flac");
|
||||
}
|
||||
|
||||
fn write_wav(path: &Path, samples: &[i32], sample_rate: u32, bits: u32) {
|
||||
let bytes_per = (bits / 8) as usize;
|
||||
let data: Vec<u8> = samples
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes()[..bytes_per].to_vec())
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(b"WAVEfmt ");
|
||||
out.extend_from_slice(&16u32.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
out.extend_from_slice(&(sample_rate * bytes_per as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(bytes_per as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(bits as u16).to_le_bytes());
|
||||
out.extend_from_slice(b"data");
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend(data);
|
||||
std::fs::write(path, out).expect("write wav");
|
||||
}
|
||||
|
||||
fn run_check(
|
||||
path: &Path,
|
||||
options: &HiResCheckOptions,
|
||||
) -> Result<HiResCheckResult, HiResCheckError> {
|
||||
let file = File::open(path).expect("open fixture");
|
||||
check_file(file, &path.to_string_lossy(), options, &|| Ok(()))
|
||||
}
|
||||
|
||||
fn check(path: &Path) -> HiResCheckResult {
|
||||
run_check(path, &HiResCheckOptions::default()).expect("check")
|
||||
}
|
||||
|
||||
fn flac(dir: &TempDir, name: &str, samples: &[i32], sample_rate: u32, bits: u32) -> PathBuf {
|
||||
let path = dir.file(name);
|
||||
write_flac(&path, samples, sample_rate, bits, 1);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radix2_fft_matches_a_direct_dft() {
|
||||
const N: usize = 64;
|
||||
let mut rng = Rng(2);
|
||||
let input: Vec<f64> = (0..N).map(|_| rng.uniform() - 0.5).collect();
|
||||
let (mut re, mut im) = (input.clone(), vec![0.0; N]);
|
||||
Radix2Fft::new(N).transform(&mut re, &mut im);
|
||||
for k in 0..N {
|
||||
let (mut want_re, mut want_im) = (0.0, 0.0);
|
||||
for (j, v) in input.iter().enumerate() {
|
||||
let angle = -2.0 * std::f64::consts::PI * (k * j) as f64 / N as f64;
|
||||
want_re += v * angle.cos();
|
||||
want_im += v * angle.sin();
|
||||
}
|
||||
assert!((re[k] - want_re).hypot(im[k] - want_im) < 1e-9, "bin {k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_frame_numbers_use_the_utf8_like_coding() {
|
||||
assert_eq!(utf8_number(0x7F), vec![0x7F]);
|
||||
assert_eq!(utf8_number(0x80), vec![0xC2, 0x80]);
|
||||
assert_eq!(utf8_number(0x7FF), vec![0xDF, 0xBF]);
|
||||
assert_eq!(utf8_number(0x800), vec![0xE0, 0xA0, 0x80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_hires_is_not_flagged() {
|
||||
let dir = TempDir::new("genuine");
|
||||
let samples = quantize(&full_band_noise(HIRES_FRAMES, 0), 24);
|
||||
let r = check(&flac(&dir, "genuine.flac", &samples, HIRES_SR, 24));
|
||||
assert_eq!(r.verdict, VERDICT_GENUINE);
|
||||
assert_eq!(r.upsampling_artifact, "");
|
||||
assert_eq!(r.brickwall_hz, 0.0);
|
||||
assert_eq!(r.confidence, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upsampled_cd_signal_that_is_never_quiet_is_only_suspect() {
|
||||
let dir = TempDir::new("upsampled");
|
||||
let samples = quantize(&upsampled_from_cd(), 24);
|
||||
let r = check(&flac(&dir, "fake.flac", &samples, HIRES_SR, 24));
|
||||
assert_eq!(r.verdict, VERDICT_FAKE);
|
||||
assert!(r.is_suspicious());
|
||||
assert!(
|
||||
r.cutoff_frequency_hz > 22_000.0 && r.cutoff_frequency_hz < 28_000.0,
|
||||
"{}",
|
||||
r.cutoff_frequency_hz
|
||||
);
|
||||
assert!(r.reason.contains("content stops"), "{}", r.reason);
|
||||
// Loud from start to end: the floor cannot be read, so a genuine master
|
||||
// filtered at 22 kHz would look the same. Flagged, never replaced.
|
||||
assert_eq!(r.brickwall_hz, 22_050.0);
|
||||
assert_eq!(r.noise_floor_class, FLOOR_MASKED);
|
||||
assert_eq!(r.confidence, CONFIDENCE_SUSPECT);
|
||||
assert!(!r.redownload_safe());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cd_file_claims_nothing() {
|
||||
let dir = TempDir::new("cd");
|
||||
let samples = quantize(&full_band_noise(CD_SR as usize * 3, 0), 16);
|
||||
let r = check(&flac(&dir, "cd.flac", &samples, CD_SR, 16));
|
||||
assert_eq!(r.verdict, VERDICT_STANDARD);
|
||||
assert!(!r.is_suspicious());
|
||||
}
|
||||
|
||||
/// 24-bit/44.1 kHz claims Hi-Res by depth alone, which no spectral test can
|
||||
/// judge: what gives a padded CD master away is its always-zero low 8 bits.
|
||||
#[test]
|
||||
fn a_padded_24_bit_cd_master_is_certain() {
|
||||
let dir = TempDir::new("padded");
|
||||
let samples = pcm_using_bits(16, 24, CD_SR as usize * 3, 1);
|
||||
let r = check(&flac(&dir, "padded.flac", &samples, CD_SR, 24));
|
||||
assert_eq!(r.verdict, VERDICT_FAKE);
|
||||
assert_eq!((r.declared_bit_depth, r.effective_bit_depth), (24, 16));
|
||||
assert!(r.padded_bit_depth());
|
||||
assert!(
|
||||
r.reason.contains("24-bit") && r.reason.contains("16 bits"),
|
||||
"{}",
|
||||
r.reason
|
||||
);
|
||||
// The spectral half made no finding, so it must not appear in the reason.
|
||||
assert!(!r.reason.contains("content stops"), "{}", r.reason);
|
||||
assert_eq!(r.confidence, CONFIDENCE_CERTAIN);
|
||||
assert!(r.redownload_safe());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_24_bit_cd_rate_file_is_not_flagged() {
|
||||
let dir = TempDir::new("real24");
|
||||
let samples = pcm_using_bits(24, 24, CD_SR as usize * 3, 1);
|
||||
let r = check(&flac(&dir, "real24.flac", &samples, CD_SR, 24));
|
||||
assert_eq!(r.verdict, VERDICT_GENUINE);
|
||||
assert_eq!((r.declared_bit_depth, r.effective_bit_depth), (24, 24));
|
||||
assert!(!r.padded_bit_depth());
|
||||
}
|
||||
|
||||
/// A 16-bit container declares no Hi-Res depth, so the spectral verdict is the
|
||||
/// whole answer.
|
||||
#[test]
|
||||
fn sixteen_bit_makes_no_depth_claim() {
|
||||
let dir = TempDir::new("hires16");
|
||||
let samples = quantize(&full_band_noise(HIRES_FRAMES, 0), 16);
|
||||
let r = check(&flac(&dir, "hires16.flac", &samples, HIRES_SR, 16));
|
||||
assert_eq!(r.declared_bit_depth, 16);
|
||||
assert!(!r.padded_bit_depth());
|
||||
assert_eq!(r.verdict, VERDICT_GENUINE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_is_inconclusive() {
|
||||
let dir = TempDir::new("silent");
|
||||
let r = check(&flac(
|
||||
&dir,
|
||||
"silent.flac",
|
||||
&vec![0; HIRES_FRAMES],
|
||||
HIRES_SR,
|
||||
24,
|
||||
));
|
||||
assert_eq!(r.verdict, VERDICT_INCONCLUSIVE);
|
||||
}
|
||||
|
||||
/// A floor below -80 dB must still measure the spectrum rather than report
|
||||
/// the full Nyquist frequency as content for every file.
|
||||
#[test]
|
||||
fn a_lower_noise_floor_still_measures() {
|
||||
let dir = TempDir::new("floor");
|
||||
let samples = quantize(&upsampled_from_cd(), 24);
|
||||
let path = flac(&dir, "fake.flac", &samples, HIRES_SR, 24);
|
||||
let options = HiResCheckOptions {
|
||||
noise_floor_db: -90.0,
|
||||
..HiResCheckOptions::default()
|
||||
};
|
||||
let r = run_check(&path, &options).expect("check");
|
||||
assert!(
|
||||
r.cutoff_frequency_hz < f64::from(HIRES_SR) / 2.0,
|
||||
"{}",
|
||||
r.cutoff_frequency_hz
|
||||
);
|
||||
}
|
||||
|
||||
/// Real-world FLACs are stereo and longer than the 30 s window, which must be
|
||||
/// taken from the middle.
|
||||
#[test]
|
||||
fn a_long_stereo_flac_is_read_from_the_middle() {
|
||||
let dir = TempDir::new("stereo");
|
||||
let path = dir.file("stereo.flac");
|
||||
let samples = pcm_using_bits(16, 24, CD_SR as usize * 70, 1);
|
||||
write_flac(&path, &samples, CD_SR, 24, 2);
|
||||
let r = check(&path);
|
||||
assert_eq!(r.analyzed_duration_s, 30.0);
|
||||
assert_eq!(r.verdict, VERDICT_FAKE);
|
||||
assert_eq!(r.effective_bit_depth, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_is_checked_too() {
|
||||
let dir = TempDir::new("wav");
|
||||
let padded = dir.file("padded.wav");
|
||||
write_wav(
|
||||
&padded,
|
||||
&pcm_using_bits(16, 24, CD_SR as usize * 3, 1),
|
||||
CD_SR,
|
||||
24,
|
||||
);
|
||||
let r = check(&padded);
|
||||
assert_eq!(
|
||||
(r.verdict.as_str(), r.effective_bit_depth),
|
||||
(VERDICT_FAKE, 16)
|
||||
);
|
||||
|
||||
let fake = dir.file("fake.wav");
|
||||
write_wav(&fake, &quantize(&upsampled_from_cd(), 24), HIRES_SR, 24);
|
||||
let r = check(&fake);
|
||||
assert_eq!(
|
||||
(r.verdict.as_str(), r.effective_bit_depth),
|
||||
(VERDICT_FAKE, 24)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_unsupported_formats() {
|
||||
let dir = TempDir::new("errors");
|
||||
let empty = dir.file("empty.flac");
|
||||
std::fs::write(&empty, b"").expect("write");
|
||||
let mp3 = dir.file("song.mp3");
|
||||
std::fs::write(&mp3, b"ID3\x04\x00\x00\x00\x00\x00\x00junk").expect("write");
|
||||
let corrupt = dir.file("corrupt.flac");
|
||||
std::fs::write(&corrupt, b"fLaC\x00\x00").expect("write");
|
||||
let options = HiResCheckOptions::default();
|
||||
|
||||
assert!(matches!(
|
||||
run_check(&empty, &options),
|
||||
Err(HiResCheckError::Failed(_))
|
||||
));
|
||||
assert_eq!(run_check(&mp3, &options), Err(HiResCheckError::Unsupported));
|
||||
assert!(matches!(
|
||||
run_check(&corrupt, &options),
|
||||
Err(HiResCheckError::Failed(_))
|
||||
));
|
||||
let bad = HiResCheckOptions {
|
||||
n_fft: 1000,
|
||||
..HiResCheckOptions::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
run_check(&mp3, &bad),
|
||||
Err(HiResCheckError::Failed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_check_stops() {
|
||||
let dir = TempDir::new("cancel");
|
||||
let samples = quantize(&full_band_noise(HIRES_FRAMES, 0), 24);
|
||||
let path = flac(&dir, "genuine.flac", &samples, HIRES_SR, 24);
|
||||
let file = File::open(&path).expect("open");
|
||||
let result = check_file(file, "x", &HiResCheckOptions::default(), &|| {
|
||||
Err("cancelled".into())
|
||||
});
|
||||
assert_eq!(result, Err(HiResCheckError::Failed("cancelled".into())));
|
||||
}
|
||||
|
||||
/// A dithered 16-bit CD master upsampled cleanly: the cliff sits at 22.05 kHz
|
||||
/// and the quiet half shows 16-bit dither noise. This supports a likely
|
||||
/// verdict, but cannot prove that a separately downloaded copy is equivalent.
|
||||
#[test]
|
||||
fn an_upsampled_16_bit_master_is_likely() {
|
||||
let dir = TempDir::new("likely");
|
||||
let signal = ideal_upsample(&dither_to_16_bit(&cd_source_with_quiet_half(0.0)), 4);
|
||||
let r = check(&flac(
|
||||
&dir,
|
||||
"cd16.flac",
|
||||
&quantize(&signal, 24),
|
||||
HIRES_SR,
|
||||
24,
|
||||
));
|
||||
assert_eq!(r.verdict, VERDICT_FAKE);
|
||||
assert_eq!(r.brickwall_hz, 22_050.0);
|
||||
assert_eq!(
|
||||
r.noise_floor_class, FLOOR_AT_16BIT,
|
||||
"{} dB",
|
||||
r.noise_floor_vs_16bit_db
|
||||
);
|
||||
assert_eq!(r.confidence, CONFIDENCE_LIKELY);
|
||||
assert!(
|
||||
!r.redownload_safe(),
|
||||
"Spectral evidence cannot authorize replacement"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same cliff, but the quiet half carries detail far below 16-bit noise:
|
||||
/// a 24-bit master made at 44.1 kHz. LOSSLESS would lose that depth.
|
||||
#[test]
|
||||
fn a_24_bit_master_made_at_44k_is_only_suspect() {
|
||||
let dir = TempDir::new("master24");
|
||||
let signal = ideal_upsample(&cd_source_with_quiet_half(1e-6), 4);
|
||||
let r = check(&flac(
|
||||
&dir,
|
||||
"master24.flac",
|
||||
&quantize(&signal, 24),
|
||||
HIRES_SR,
|
||||
24,
|
||||
));
|
||||
assert_eq!(r.verdict, VERDICT_FAKE);
|
||||
assert_eq!(
|
||||
r.noise_floor_class, FLOOR_BELOW_16BIT,
|
||||
"{} dB",
|
||||
r.noise_floor_vs_16bit_db
|
||||
);
|
||||
assert_eq!(r.confidence, CONFIDENCE_SUSPECT);
|
||||
assert!(!r.redownload_safe());
|
||||
assert!(r.reason.contains("genuine master"), "{}", r.reason);
|
||||
}
|
||||
|
||||
/// A gradual mastering roll-off that still ends below 28 kHz: no resampler
|
||||
/// cliff, so at most a suspect. Faded in and out: an abrupt start inside the
|
||||
/// analyzed window is a step, whose broadband splatter reads as content.
|
||||
#[test]
|
||||
fn a_gradual_mastering_roll_off_is_only_suspect() {
|
||||
let dir = TempDir::new("lpf");
|
||||
let mut signal = shape_spectrum(&full_band_noise(HIRES_FRAMES, 0), HIRES_SR, |freq| {
|
||||
let gain_db = if freq > 16_000.0 {
|
||||
-10.0 * (freq - 16_000.0) / 1000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
10f64.powf(gain_db / 20.0)
|
||||
});
|
||||
fade(&mut signal);
|
||||
let r = check(&flac(
|
||||
&dir,
|
||||
"lpf.flac",
|
||||
&quantize(&signal, 24),
|
||||
HIRES_SR,
|
||||
24,
|
||||
));
|
||||
assert_eq!(r.verdict, VERDICT_FAKE);
|
||||
assert_eq!(r.brickwall_hz, 0.0);
|
||||
assert_eq!(r.confidence, CONFIDENCE_SUSPECT);
|
||||
}
|
||||
|
||||
/// 24-bit source samples (so the depth test passes) taken to 176.4 kHz by the
|
||||
/// cheap upsamplers whose output is an exact, checkable pattern.
|
||||
#[test]
|
||||
fn integer_upsampling_artifacts_are_certain() {
|
||||
let source = pcm_using_bits(24, 24, CD_SOURCE_FRAMES + 1, 1);
|
||||
let (mut hold, mut line, mut zero_stuffed) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for pair in source.windows(2) {
|
||||
let (a, b) = (f64::from(pair[0]), f64::from(pair[1]));
|
||||
for j in 0..4 {
|
||||
hold.push(pair[0]);
|
||||
line.push((a + (b - a) * f64::from(j) / 4.0).round() as i32);
|
||||
zero_stuffed.push(if j == 0 { pair[0] / 2 } else { 0 });
|
||||
}
|
||||
}
|
||||
let dir = TempDir::new("artifacts");
|
||||
for (name, samples, artifact) in [
|
||||
("hold", hold, ARTIFACT_SAMPLE_HOLD),
|
||||
("linear", line, ARTIFACT_INTERPOLATION),
|
||||
("zero", zero_stuffed, ARTIFACT_IMAGING),
|
||||
] {
|
||||
let r = check(&flac(&dir, &format!("{name}.flac"), &samples, HIRES_SR, 24));
|
||||
assert_eq!(r.upsampling_artifact, artifact, "{name}");
|
||||
assert_eq!(r.verdict, VERDICT_FAKE, "{name}");
|
||||
assert_eq!(r.confidence, CONFIDENCE_CERTAIN, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A DSD or analog-tape transfer: music that stops around 30 kHz, and a steady
|
||||
/// ultrasonic noise hump running on to ~80 kHz. The active-content cutoff
|
||||
/// marks the end of the hump; the music bandwidth must not, and the useful
|
||||
/// rate is the 88.2 kHz that holds all the music.
|
||||
#[test]
|
||||
fn music_is_told_apart_from_ultrasonic_noise() {
|
||||
let band = |seed, low: f64, high: f64| {
|
||||
shape_spectrum(&full_band_noise(HIRES_FRAMES, seed), HIRES_SR, |freq| {
|
||||
if (low..=high).contains(&freq) {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
};
|
||||
let (music, hump) = (band(5, 20.0, 30_000.0), band(6, 40_000.0, 80_000.0));
|
||||
let mut signal: Vec<f64> = music
|
||||
.iter()
|
||||
.zip(&hump)
|
||||
.enumerate()
|
||||
.map(|(i, (m, h))| {
|
||||
// A 3 Hz swell swings the music ~20 dB; the hump never moves.
|
||||
let phase = 2.0 * std::f64::consts::PI * 3.0 * i as f64 / f64::from(HIRES_SR);
|
||||
m * (0.55 + 0.45 * phase.cos()) + 0.02 * h
|
||||
})
|
||||
.collect();
|
||||
fade(&mut signal);
|
||||
let dir = TempDir::new("dsd");
|
||||
let r = check(&flac(
|
||||
&dir,
|
||||
"dsd.flac",
|
||||
&quantize(&signal, 24),
|
||||
HIRES_SR,
|
||||
24,
|
||||
));
|
||||
assert_eq!(r.verdict, VERDICT_GENUINE);
|
||||
assert!(
|
||||
r.cutoff_frequency_hz > 70_000.0,
|
||||
"{}",
|
||||
r.cutoff_frequency_hz
|
||||
);
|
||||
assert!(
|
||||
(28_000.0..=33_000.0).contains(&r.music_cutoff_hz),
|
||||
"{}",
|
||||
r.music_cutoff_hz
|
||||
);
|
||||
assert!(r.ultrasonic_noise_only);
|
||||
assert_eq!(r.useful_sample_rate, 88_200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_depth_does_not_make_high_rate_content_safe_to_replace_with_cd() {
|
||||
let dir = TempDir::new("review-high-rate");
|
||||
let samples: Vec<i32> = quantize(&full_band_noise(HIRES_FRAMES, 17), 16)
|
||||
.into_iter()
|
||||
.map(|sample| sample << 8)
|
||||
.collect();
|
||||
let result = check(&flac(&dir, "high-rate-padded.flac", &samples, HIRES_SR, 24));
|
||||
assert_eq!(result.effective_bit_depth, 16);
|
||||
assert!(result.cutoff_frequency_hz > 30_000.0);
|
||||
assert!(
|
||||
!result.redownload_safe(),
|
||||
"Full-rate content would be lost: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_upsampling_does_not_make_real_24_bit_depth_safe_to_replace_with_cd() {
|
||||
let dir = TempDir::new("review-full-depth");
|
||||
let source = pcm_using_bits(24, 24, CD_SOURCE_FRAMES, 1);
|
||||
let samples: Vec<i32> = source.into_iter().flat_map(|sample| [sample; 4]).collect();
|
||||
let result = check(&flac(
|
||||
&dir,
|
||||
"full-depth-upsampled.flac",
|
||||
&samples,
|
||||
HIRES_SR,
|
||||
24,
|
||||
));
|
||||
assert_eq!(result.effective_bit_depth, 24);
|
||||
assert_eq!(result.upsampling_artifact, ARTIFACT_SAMPLE_HOLD);
|
||||
assert!(
|
||||
!result.redownload_safe(),
|
||||
"Real 24-bit depth would be lost: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn preserves(original: &Path, replacement: &Path) -> bool {
|
||||
replacement_preserves_audio(
|
||||
File::open(original).expect("original"),
|
||||
File::open(replacement).expect("replacement"),
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_verifies_padding_and_sample_repetition_across_every_frame() {
|
||||
let dir = TempDir::new("replacement-pcm");
|
||||
let source = pcm_using_bits(16, 16, 20_001, 8);
|
||||
let replacement = flac(&dir, "cd.flac", &source, CD_SR, 16);
|
||||
for ratio in [1, 2, 4] {
|
||||
let padded: Vec<i32> = source
|
||||
.iter()
|
||||
.flat_map(|v| std::iter::repeat_n(v << 8, ratio))
|
||||
.collect();
|
||||
let original = flac(&dir, "original.flac", &padded, CD_SR * ratio as u32, 24);
|
||||
assert!(preserves(&original, &replacement));
|
||||
let wav = dir.file("original.wav");
|
||||
write_wav(&wav, &padded, CD_SR * ratio as u32, 24);
|
||||
assert!(preserves(&wav, &replacement));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_rejects_real_precision_outside_the_sampled_window() {
|
||||
let dir = TempDir::new("replacement-tail");
|
||||
let source = pcm_using_bits(16, 16, 20_001, 9);
|
||||
let replacement = flac(&dir, "cd.flac", &source, CD_SR, 16);
|
||||
let padded: Vec<i32> = source.iter().map(|v| v << 8).collect();
|
||||
for index in [0, 10_000, 20_000] {
|
||||
let mut original = padded.clone();
|
||||
original[index] += 1;
|
||||
let original = flac(&dir, "original.flac", &original, CD_SR, 24);
|
||||
assert!(!preserves(&original, &replacement), "frame {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_rejects_changed_master_rate_duration_and_truncation() {
|
||||
let dir = TempDir::new("replacement-invalid");
|
||||
let source = pcm_using_bits(16, 16, 20_001, 10);
|
||||
let padded: Vec<i32> = source.iter().map(|v| v << 8).collect();
|
||||
let original = flac(&dir, "original.flac", &padded, CD_SR, 24);
|
||||
let other = pcm_using_bits(16, 16, source.len(), 11);
|
||||
for (samples, rate) in [
|
||||
(&other[..], CD_SR),
|
||||
(&source[..], 48_000),
|
||||
(&source[..20_000], CD_SR),
|
||||
] {
|
||||
let replacement = flac(&dir, "other.flac", samples, rate, 16);
|
||||
assert!(!preserves(&original, &replacement));
|
||||
}
|
||||
let replacement = flac(&dir, "truncated.flac", &source, CD_SR, 16);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&replacement)
|
||||
.expect("open");
|
||||
file.set_len(file.metadata().expect("stat").len() - 3)
|
||||
.expect("truncate");
|
||||
assert!(!preserves(&original, &replacement));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_checks_every_channel() {
|
||||
let dir = TempDir::new("replacement-stereo");
|
||||
let source = pcm_using_bits(16, 16, 20_001, 12);
|
||||
let padded: Vec<i32> = source.iter().map(|v| v << 8).collect();
|
||||
let original = dir.file("stereo.flac");
|
||||
write_flac(&original, &padded, CD_SR, 24, 2);
|
||||
let stereo = dir.file("stereo.wav");
|
||||
let interleaved: Vec<i32> = source.iter().flat_map(|&v| [v, v]).collect();
|
||||
write_wav(&stereo, &interleaved, CD_SR, 16);
|
||||
let mut bytes = std::fs::read(&stereo).expect("wav");
|
||||
bytes[22..24].copy_from_slice(&2u16.to_le_bytes());
|
||||
bytes[28..32].copy_from_slice(&(CD_SR * 4).to_le_bytes());
|
||||
bytes[32..34].copy_from_slice(&4u16.to_le_bytes());
|
||||
std::fs::write(&stereo, &bytes).expect("stereo header");
|
||||
assert!(preserves(&original, &stereo));
|
||||
// Only the last frame's right channel changes.
|
||||
let last = bytes.len() - 2;
|
||||
bytes[last] ^= 1;
|
||||
std::fs::write(&stereo, &bytes).expect("different right channel");
|
||||
assert!(!preserves(&original, &stereo));
|
||||
let mono = flac(&dir, "mono.flac", &source, CD_SR, 16);
|
||||
assert!(!preserves(&original, &mono));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_comparison_is_cancellable() {
|
||||
let dir = TempDir::new("replacement-cancel");
|
||||
let original = flac(&dir, "original.flac", &vec![256; 20_000], CD_SR, 24);
|
||||
let replacement = flac(&dir, "cd.flac", &vec![1; 20_000], CD_SR, 16);
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let result = replacement_preserves_audio(
|
||||
File::open(original).expect("original"),
|
||||
File::open(replacement).expect("replacement"),
|
||||
&|| {
|
||||
calls.set(calls.get() + 1);
|
||||
if calls.get() > 2 {
|
||||
Err("cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(matches!(result, Err(HiResCheckError::Failed(message)) if message == "cancelled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_rejects_oversized_and_truncated_chunks_without_allocating_them() {
|
||||
let dir = TempDir::new("wav-malformed");
|
||||
let path = dir.file("bad.wav");
|
||||
for id in [b"fmt ", b"JUNK"] {
|
||||
let mut bytes = b"RIFF\xff\xff\xff\xffWAVE".to_vec();
|
||||
bytes.extend_from_slice(id);
|
||||
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
bytes.extend_from_slice(&[0; 40]);
|
||||
std::fs::write(&path, bytes).expect("malformed WAV");
|
||||
assert!(run_check(&path, &HiResCheckOptions::default()).is_err());
|
||||
}
|
||||
write_wav(&path, &[0; 100], CD_SR, 16);
|
||||
let mut bytes = std::fs::read(&path).expect("wav");
|
||||
bytes.truncate(bytes.len() - 2);
|
||||
std::fs::write(&path, bytes).expect("truncated data");
|
||||
assert!(run_check(&path, &HiResCheckOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_skips_extra_format_bytes_and_odd_chunk_padding() {
|
||||
let dir = TempDir::new("wav-extra-fmt");
|
||||
let path = dir.file("extended.wav");
|
||||
write_wav(&path, &pcm_using_bits(16, 24, 20_000, 13), CD_SR, 24);
|
||||
let mut bytes = std::fs::read(&path).expect("wav");
|
||||
bytes[16..20].copy_from_slice(&41u32.to_le_bytes());
|
||||
bytes.splice(36..36, [0; 26]); // 25 extra format bytes plus one pad byte
|
||||
let len = bytes.len() as u32 - 8;
|
||||
bytes[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
std::fs::write(&path, bytes).expect("extra fmt bytes");
|
||||
assert!(check(&path).padded_bit_depth());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Bounded, seek-based implementation of Go's FLAC/MP4 quality probe.
|
||||
|
||||
mod audio;
|
||||
pub mod hires;
|
||||
pub(crate) mod mp4;
|
||||
|
||||
pub(crate) use audio::{mp3_quality, ogg_quality, riff_quality};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use crate::cancellation::RequestLease;
|
||||
use crate::tags::{check_lease, open_audio_file};
|
||||
use spotiflac_core::media::hires::{self, HiResCheckOptions};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||
#[uniffi(flat_error)]
|
||||
pub enum HiResCheckError {
|
||||
#[error("{message}")]
|
||||
Failed { message: String },
|
||||
}
|
||||
|
||||
impl From<String> for HiResCheckError {
|
||||
fn from(message: String) -> Self {
|
||||
Self::Failed { message }
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the fake Hi-Res check on one local FLAC or WAV file; `options_json`
|
||||
/// may be empty or override any `HiResCheckOptions` field. A format the
|
||||
/// checker cannot decode is not an error: it returns `{"supported": false}`
|
||||
/// so the UI can say "not checkable" rather than report a broken file.
|
||||
/// CPU-bound for a second or two: call it off the main thread.
|
||||
/// With `verify_replacement_path`, bypasses sampled analysis and compares the
|
||||
/// entire decoded PCM stream, returning only `replacement_equivalent`.
|
||||
#[uniffi::export]
|
||||
pub fn check_hires_authenticity(
|
||||
path: String,
|
||||
options_json: String,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, HiResCheckError> {
|
||||
let check = || check_lease(lease.as_deref());
|
||||
check()?;
|
||||
let request: serde_json::Value = if options_json.trim().is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(&options_json)
|
||||
.map_err(|error| format!("invalid hi-res check options: {error}"))?
|
||||
};
|
||||
if let Some(replacement_path) = request.get("verify_replacement_path") {
|
||||
let replacement_path = replacement_path
|
||||
.as_str()
|
||||
.filter(|path| !path.trim().is_empty())
|
||||
.ok_or("invalid replacement path".to_string())?;
|
||||
let equivalent = hires::replacement_preserves_audio(
|
||||
open_audio_file(&path)?,
|
||||
open_audio_file(replacement_path)?,
|
||||
&check,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
return Ok(serde_json::json!({"replacement_equivalent": equivalent}).to_string());
|
||||
}
|
||||
let options: HiResCheckOptions = if options_json.trim().is_empty() {
|
||||
HiResCheckOptions::default()
|
||||
} else {
|
||||
serde_json::from_str(&options_json)
|
||||
.map_err(|error| format!("invalid hi-res check options: {error}"))?
|
||||
};
|
||||
let file = open_audio_file(&path)?;
|
||||
let result = match hires::check_file(file, &path, &options, &check) {
|
||||
Ok(result) => result,
|
||||
Err(hires::HiResCheckError::Unsupported) => {
|
||||
return Ok(serde_json::json!({"supported": false, "file_path": path}).to_string());
|
||||
}
|
||||
Err(error) => return Err(error.to_string().into()),
|
||||
};
|
||||
let mut value = serde_json::to_value(&result).map_err(|error| error.to_string())?;
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.insert("supported".into(), true.into());
|
||||
object.insert("is_suspicious".into(), result.is_suspicious().into());
|
||||
object.insert("padded_bit_depth".into(), result.padded_bit_depth().into());
|
||||
object.insert("redownload_safe".into(), result.redownload_safe().into());
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacement_verification_is_exposed_and_fails_closed() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("spotiflac-hires-verify-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let original = dir.join("original.wav");
|
||||
let replacement = dir.join("replacement.wav");
|
||||
let wav = |bits: u16, sample: i32| {
|
||||
let bytes = u32::from(bits / 8);
|
||||
let mut data = b"RIFF".to_vec();
|
||||
data.extend_from_slice(&(36 + bytes).to_le_bytes());
|
||||
data.extend_from_slice(b"WAVEfmt ");
|
||||
data.extend_from_slice(&16u32.to_le_bytes());
|
||||
data.extend_from_slice(&1u16.to_le_bytes());
|
||||
data.extend_from_slice(&1u16.to_le_bytes());
|
||||
data.extend_from_slice(&44_100u32.to_le_bytes());
|
||||
data.extend_from_slice(&(44_100 * bytes).to_le_bytes());
|
||||
data.extend_from_slice(&(bytes as u16).to_le_bytes());
|
||||
data.extend_from_slice(&bits.to_le_bytes());
|
||||
data.extend_from_slice(b"data");
|
||||
data.extend_from_slice(&bytes.to_le_bytes());
|
||||
data.extend_from_slice(&sample.to_le_bytes()[..bytes as usize]);
|
||||
data
|
||||
};
|
||||
std::fs::write(&original, wav(24, 256)).expect("original");
|
||||
for (sample, expected) in [(1, true), (2, false)] {
|
||||
std::fs::write(&replacement, wav(16, sample)).expect("replacement");
|
||||
let raw = check_hires_authenticity(
|
||||
original.to_string_lossy().into_owned(),
|
||||
serde_json::json!({"verify_replacement_path": replacement}).to_string(),
|
||||
None,
|
||||
)
|
||||
.expect("verify");
|
||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("json");
|
||||
assert_eq!(value["replacement_equivalent"], expected);
|
||||
assert!(value.get("redownload_safe").is_none());
|
||||
}
|
||||
assert!(
|
||||
check_hires_authenticity(
|
||||
original.to_string_lossy().into_owned(),
|
||||
"{\"verify_replacement_path\":123}".into(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_formats_are_not_errors_and_bad_options_are() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("spotiflac-hires-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let mp3 = dir.join("song.mp3");
|
||||
std::fs::write(&mp3, b"ID3\x04\x00\x00\x00\x00\x00\x00junk").expect("write");
|
||||
let path = mp3.to_string_lossy().to_string();
|
||||
|
||||
let raw = check_hires_authenticity(path.clone(), String::new(), None).expect("check");
|
||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("json");
|
||||
assert_eq!(value["supported"], false);
|
||||
|
||||
assert!(check_hires_authenticity(path, "{bad".into(), None).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod cancellation;
|
||||
mod extensions;
|
||||
mod ffmpeg;
|
||||
mod filename;
|
||||
mod hires;
|
||||
mod index;
|
||||
mod logging;
|
||||
mod lyrics;
|
||||
|
||||
@@ -339,7 +339,7 @@ pub fn read_file_metadata(
|
||||
serde_json::to_string(&metadata).map_err(|error| error.to_string().into())
|
||||
}
|
||||
|
||||
fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> {
|
||||
pub(crate) fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> {
|
||||
lease.map_or(Ok(()), |lease| {
|
||||
lease
|
||||
.inner
|
||||
@@ -348,7 +348,7 @@ fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn open_audio_file(path: &str) -> Result<File, String> {
|
||||
pub(crate) fn open_audio_file(path: &str) -> Result<File, String> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
|
||||
Reference in new issue
Block a user