From 8e19a9058af03da888c337a7667a23ab54f37008 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 17:29:18 +0200 Subject: [PATCH 1/7] feat(quality): measure a lossless file's real quality from its audio FFmpeg decodes each lossless file in the background and the desktop's wall search, ported from waveflow-core, finds where its highs stop; the effective bit depth and a 256-band spectrum come from the same pass. Verdicts are SQL over track_quality, reusing the core's own macros. Five routes serve them, a spectrogram is drawn on demand, and Needs attention lists probable fake lossless files (RFC-016). Signed-off-by: InstaZDLL --- Cargo.lock | 1 + Cargo.toml | 6 + .../20261025000000_track_quality.sql | 35 + src/api/mod.rs | 19 + src/api/quality.rs | 155 +++ src/config.rs | 15 + src/lib.rs | 21 + src/main.rs | 3 + src/media.rs | 37 +- src/quality/bandwidth.rs | 403 ++++++++ src/quality/mod.rs | 418 ++++++++ src/scanner.rs | 30 +- src/services/attention.rs | 20 +- src/services/mod.rs | 9 + src/services/quality.rs | 744 +++++++++++++ tests/quality.rs | 973 ++++++++++++++++++ 16 files changed, 2878 insertions(+), 11 deletions(-) create mode 100644 migrations-v2/20261025000000_track_quality.sql create mode 100644 src/api/quality.rs create mode 100644 src/quality/bandwidth.rs create mode 100644 src/quality/mod.rs create mode 100644 src/services/quality.rs create mode 100644 tests/quality.rs diff --git a/Cargo.lock b/Cargo.lock index 70136239..459fbdbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3659,6 +3659,7 @@ dependencies = [ "md-5", "percent-encoding", "rand 0.10.3", + "realfft", "reqwest", "roxmltree", "rust-embed", diff --git a/Cargo.toml b/Cargo.toml index 41ff6ec1..8f2466ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ md-5 = "0.11" # percent escapes out of playlist entries and a transitive dependency is not a # promise. percent-encoding = "2" +realfft = "3" rand = "0.10" # The feature set is `waveflow-core`'s, deliberately, minus `blocking` which # nothing here needs. That crate already pulls reqwest over rustls into this @@ -89,6 +90,7 @@ waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "102df003 [dev-dependencies] http-body-util = "0.1" +realfft = "3" tempfile = "3" tokio-tungstenite = "0.30" @@ -172,3 +174,7 @@ path = "tests/enrichment.rs" [[test]] name = "credits" path = "tests/credits.rs" + +[[test]] +name = "quality" +path = "tests/quality.rs" diff --git a/migrations-v2/20261025000000_track_quality.sql b/migrations-v2/20261025000000_track_quality.sql new file mode 100644 index 00000000..15794f9c --- /dev/null +++ b/migrations-v2/20261025000000_track_quality.sql @@ -0,0 +1,35 @@ +-- The real quality of a file, measured from its audio (RFC-016). +-- +-- One measurement per track. It describes the content whose `full_hash` it +-- carries, taken by the analyser generation `version`, through the FFmpeg +-- build whose `ffmpeg -version` output hashes to `decoder`: a row whose hash +-- is no longer the track's is about a file that has gone, and a row from +-- another generation or another FFmpeg is about samples the server would not +-- produce today. Either way the track reads as `pending` until it is measured +-- again. +-- +-- No verdict is stored. The verdict is SQL over these numbers and the +-- track's own columns, computed where it is read, so a change to its rules is +-- a code change and never a re-analysis. +CREATE TABLE track_quality ( + track_id TEXT PRIMARY KEY NOT NULL REFERENCES track(id) ON DELETE CASCADE, + full_hash TEXT NOT NULL CHECK (length(full_hash) > 0), + version INTEGER NOT NULL CHECK (version > 0), + decoder TEXT NOT NULL CHECK (length(decoder) = 64), + outcome TEXT NOT NULL CHECK (outcome IN ('measured', 'silent', 'unreadable')), + -- Where the wall stands, in hertz; NULL when the spectrum has none. + cutoff_hz REAL, + -- How far the level falls across the steepest candidate, wall or not. + wall_db REAL, + -- The bits the samples use; NULL for floating point, or all zero. + effective_bits INTEGER CHECK (effective_bits BETWEEN 1 AND 32), + -- The averaged spectrum: 256 bands from 0 Hz to Nyquist, one byte each, + -- in half-decibels under the loudest band. + spectrum BLOB CHECK (spectrum IS NULL OR length(spectrum) = 256), + -- Why a file was unreadable. Never a message: FFmpeg's own words can carry + -- the path. + failure TEXT CHECK (failure IN ('decode', 'timeout')), + analyzed_at INTEGER NOT NULL, + CHECK ((outcome = 'measured') = (spectrum IS NOT NULL)), + CHECK ((outcome = 'unreadable') = (failure IS NOT NULL)) +) STRICT; diff --git a/src/api/mod.rs b/src/api/mod.rs index 91c4f77a..3f9cbaa8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -35,6 +35,7 @@ mod oauth; mod playback; mod playlists; mod probes; +mod quality; mod radio; mod scrobbling; mod setup; @@ -60,6 +61,7 @@ pub use oauth::*; pub use playback::*; pub use playlists::*; pub use probes::*; +pub use quality::*; pub use radio::*; pub use scrobbling::*; pub use setup::*; @@ -156,6 +158,23 @@ pub fn router(state: AppState) -> Router { "/api/v2/libraries/{library_id}/credit-decisions/{decision_id}", delete(delete_credit_decision), ) + .route( + "/api/v2/libraries/{library_id}/quality", + get(quality_summary), + ) + .route( + "/api/v2/libraries/{library_id}/quality/tracks", + get(quality_tracks), + ) + .route( + "/api/v2/libraries/{library_id}/quality/analysis", + post(request_quality_analysis), + ) + .route("/api/v2/tracks/{track_id}/quality", get(track_quality)) + .route( + "/api/v2/tracks/{track_id}/spectrogram", + get(track_spectrogram), + ) .route("/api/v2/libraries/{library_id}/events", get(library_events)) .route( "/api/v2/libraries/{library_id}/events/ack", diff --git a/src/api/quality.rs b/src/api/quality.rs new file mode 100644 index 00000000..50cccbf4 --- /dev/null +++ b/src/api/quality.rs @@ -0,0 +1,155 @@ +//! The real quality of a file (RFC-016). +//! +//! Handlers orchestrate HTTP only; the rules live in `DomainServices`. Any +//! member of a library reads its quality; asking for an analysis again is for +//! its owners and managers, and everyone else gets the same `404` a library +//! they are not in would. + +use super::*; + +#[derive(Debug, Deserialize)] +pub struct QualityTracksQuery { + pub verdict: Option, + pub codec: Option, + pub offset: Option, + pub limit: Option, +} + +/// A library's counts per verdict, and how far the analysis has got. +#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/quality", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 200, body = crate::services::QualitySummary), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn quality_summary( + State(state): State, + Path(library_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .quality_summary(user.id, library_id) + .await + .map(Json) + .map_err(service_error) +} + +/// One page of a library's files with their verdicts, worst first. `422` for +/// a verdict the server does not know. +#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/quality/tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("verdict" = Option, Query), ("codec" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::QualityTrack]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn quality_tracks( + State(state): State, + Path(library_id): Path, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let verdict = match query.verdict.as_deref().filter(|value| !value.is_empty()) { + Some(value) => { + Some(crate::services::QualityVerdict::parse(value).ok_or(ApiError::Validation)?) + } + None => None, + }; + let codec = query.codec.as_deref().filter(|value| !value.is_empty()); + let page = + crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?; + state + .services + .quality_tracks(user.id, library_id, verdict, codec, page) + .await + .map(Json) + .map_err(service_error) +} + +/// Deletes the library's measurements and queues its files. Owners and +/// managers only. +#[utoipa::path(post, path = "/api/v2/libraries/{library_id}/quality/analysis", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 202), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn request_quality_analysis( + State(state): State, + Path(library_id): Path, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .request_quality_analysis(user.id, library_id) + .await + .map(|()| StatusCode::ACCEPTED) + .map_err(service_error) +} + +/// One track's verdict, the numbers behind it, and its averaged spectrum. +#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/quality", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::TrackQualityDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn track_quality( + State(state): State, + Path(track_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .track_quality(user.id, track_id) + .await + .map(Json) + .map_err(service_error) +} + +/// The track's spectrogram, drawn by FFmpeg on demand: WebP, or JPEG when +/// FFmpeg has no libwebp. The browser may keep it but asks again before every +/// use; the answer is `304` only once the caller's access has been checked. +/// `429` with `Retry-After` while the caller, or the server, is already drawing +/// as many as it may. +#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/spectrogram", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, description = "The picture", content_type = "image/webp"), (status = 304), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 429, body = ErrorResponse), (status = 503, body = ErrorResponse)))] +pub async fn track_spectrogram( + State(state): State, + Path(track_id): Path, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers, Access::Read).await?; + let if_none_match = headers + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()); + let answer = match state + .services + .track_spectrogram(user.id, track_id, if_none_match) + .await + { + Ok(answer) => answer, + Err(crate::services::ServiceError::Busy) => { + let mut response = ApiError::TooManyRequests.into_response(); + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from_static("2")); + return Ok(response); + } + Err(error) => return Err(service_error(error)), + }; + let cache = HeaderValue::from_static("private, no-cache"); + Ok(match answer { + crate::services::Spectrogram::NotModified { etag } => ( + StatusCode::NOT_MODIFIED, + [ + (header::CACHE_CONTROL, cache), + ( + header::ETAG, + HeaderValue::from_str(&etag).map_err(|_| ApiError::Unavailable)?, + ), + ], + ) + .into_response(), + crate::services::Spectrogram::Picture { + etag, + bytes, + content_type, + } => ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, HeaderValue::from_static(content_type)), + (header::CACHE_CONTROL, cache), + ( + header::ETAG, + HeaderValue::from_str(&etag).map_err(|_| ApiError::Unavailable)?, + ), + ], + bytes, + ) + .into_response(), + }) +} diff --git a/src/config.rs b/src/config.rs index 84554712..10018789 100644 --- a/src/config.rs +++ b/src/config.rs @@ -334,6 +334,12 @@ pub struct Config { pub transcode_cache_max_bytes: u64, pub transcode_global_limit: usize, pub transcode_per_user_limit: usize, + /// How many lossless files the quality analysis (RFC-016) measures at once, + /// each one FFmpeg process on one thread. `WAVEFLOW_QUALITY_PARALLELISM`, `1` + /// by default, so the analysis costs one core at most whatever the size of + /// the library; `0` turns it off, and every lossless file then stays + /// `pending`. + pub quality_parallelism: usize, /// What the server will accept when a library opts in to receiving files. /// /// `WAVEFLOW_UPLOAD_MAX_FILE_BYTES`, `WAVEFLOW_UPLOAD_LIBRARY_QUOTA_BYTES`, @@ -471,6 +477,7 @@ impl std::fmt::Debug for Config { .field("transcode_cache_dir", &self.transcode_cache_dir) .field("transcode_cache_max_bytes", &self.transcode_cache_max_bytes) .field("transcode_global_limit", &self.transcode_global_limit) + .field("quality_parallelism", &self.quality_parallelism) .field("transcode_per_user_limit", &self.transcode_per_user_limit) .field("uploads", &self.uploads) .field("library_event_retention", &self.library_event_retention) @@ -542,6 +549,12 @@ impl Config { let transcode_global_limit = parse_positive_env("WAVEFLOW_TRANSCODE_GLOBAL_LIMIT", 4usize)?; let transcode_per_user_limit = parse_positive_env("WAVEFLOW_TRANSCODE_PER_USER_LIMIT", 2usize)?; + // Zero is a setting here, not a mistake: it is how an operator turns the + // analysis off, so this is not `parse_positive_env`. + let quality_parallelism = std::env::var("WAVEFLOW_QUALITY_PARALLELISM") + .unwrap_or_else(|_| "1".to_owned()) + .parse::() + .map_err(|error| anyhow::anyhow!("invalid WAVEFLOW_QUALITY_PARALLELISM: {error}"))?; let uploads = UploadLimits { max_file_bytes: parse_positive_env("WAVEFLOW_UPLOAD_MAX_FILE_BYTES", 1_073_741_824i64)?, library_quota_bytes: parse_positive_env( @@ -741,6 +754,7 @@ impl Config { transcode_cache_max_bytes, transcode_global_limit, transcode_per_user_limit, + quality_parallelism, uploads, library_event_retention, canvas_dir, @@ -791,6 +805,7 @@ impl Config { transcode_cache_max_bytes: 128 * 1024 * 1024, transcode_global_limit: 2, transcode_per_user_limit: 1, + quality_parallelism: 1, // Small enough that a test can reach every bound without writing a // gigabyte, and shaped like production rather than unlimited: a // suite that never meets a limit never proves one exists. diff --git a/src/lib.rs b/src/lib.rs index cae190fb..f03534a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod outbound; pub mod pid; pub mod playlist_file; pub mod portrait; +pub mod quality; pub mod scanner; pub mod scrobblers; pub mod security; @@ -154,6 +155,11 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { api::list_credit_decisions, api::create_credit_decision, api::delete_credit_decision, + api::quality_summary, + api::quality_tracks, + api::request_quality_analysis, + api::track_quality, + api::track_spectrogram, api::get_track, api::update_track, api::get_track_lyrics, @@ -331,6 +337,12 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { services::CreditFragment, services::CreditDecisionKind, services::CreditDecisionRecord, + services::QualitySummary, + services::QualityVerdict, + services::QualityVerdictCount, + services::QualityTrack, + services::TrackQuality, + services::TrackQualityDetail, api::CreditDecisionRequest, services::FolderPatch, services::FolderRemoved, @@ -740,6 +752,15 @@ pub async fn initialize(config: &Config) -> anyhow::Result { &config.metadata, config.artwork_dir.clone(), )?); + // Before anything reads `track_quality`: a measurement another analyser or + // another FFmpeg made is deleted here, so every read can call a row current + // on its content hash alone (RFC-016 decision 5). + services + .use_quality_decoder(quality::Decoder { + digest: media.ffmpeg_digest().to_owned(), + webp: quality::probe_webp(&config.ffmpeg_path).await, + }) + .await?; Ok(AppState { db, auth, diff --git a/src/main.rs b/src/main.rs index 82ab6e9a..901105b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,9 @@ async fn serve(config: Config, state: waveflow_server::AppState) -> anyhow::Resu } } state.scanner.spawn_background(config.scan_interval); + // At boot, then whenever a scan finishes or a library is asked to be + // analysed again; one core at most by default, and off at zero (RFC-016). + state.services.spawn_quality_analyzer(); // Abandoned transfers hold the operator's disk, and nothing else would // reclaim it until somebody offered another file. state.services.spawn_upload_sweeper(); diff --git a/src/media.rs b/src/media.rs index 3e76d5aa..a603f51a 100644 --- a/src/media.rs +++ b/src/media.rs @@ -71,6 +71,9 @@ struct MediaInner { pending_fills: DashSet, active_transcodes: AtomicUsize, transcoding_available: bool, + /// SHA-256 of the whole `ffmpeg -version` output (RFC-016 decision 5): a + /// quality measurement is current only under the FFmpeg that made it. + ffmpeg_digest: String, } #[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] @@ -126,7 +129,7 @@ pub enum MediaError { impl MediaService { pub async fn initialize(config: &Config) -> anyhow::Result { - check_tool(&config.ffmpeg_path, "ffmpeg").await?; + let ffmpeg_version = check_tool(&config.ffmpeg_path, "ffmpeg").await?; check_tool(&config.ffprobe_path, "ffprobe").await?; tokio::fs::create_dir_all(&config.transcode_cache_dir) .await @@ -151,6 +154,7 @@ impl MediaService { pending_fills: DashSet::new(), active_transcodes: AtomicUsize::new(0), transcoding_available: true, + ffmpeg_digest: crate::quality::decoder_digest(&ffmpeg_version), }), }) } @@ -159,6 +163,11 @@ impl MediaService { &self.inner.ffprobe } + /// Which FFmpeg this process runs, as `track_quality.decoder` records it. + pub fn ffmpeg_digest(&self) -> &str { + &self.inner.ffmpeg_digest + } + pub fn active_transcodes(&self) -> usize { self.inner.active_transcodes.load(Ordering::Relaxed) } @@ -1454,26 +1463,35 @@ impl IntoResponse for MediaError { /// installation takes to say so. const TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(30); -async fn check_tool(path: &Path, name: &str) -> anyhow::Result<()> { +/// Runs ` -version` and answers what it printed. +/// +/// The output is kept rather than discarded because FFmpeg's identifies the +/// decoder a quality measurement was made through (RFC-016 decision 5). +async fn check_tool(path: &Path, name: &str) -> anyhow::Result> { let output = tokio::time::timeout( TOOL_CHECK_TIMEOUT, Command::new(path) .arg("-version") .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .status(), + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), ) .await .map_err(|_| anyhow::anyhow!("{name} check timed out: {}", path.display()))? .map_err(|error| anyhow::anyhow!("{name} is required at {}: {error}", path.display()))?; - if !output.success() { - anyhow::bail!("{name} check failed at {} with {output}", path.display()); + if !output.status.success() { + anyhow::bail!( + "{name} check failed at {} with {}", + path.display(), + output.status + ); } - Ok(()) + Ok(output.stdout) } -async fn secure_track_path(root: &Path, relative: &str) -> io::Result { +pub(crate) async fn secure_track_path(root: &Path, relative: &str) -> io::Result { let canonical_root = tokio::fs::canonicalize(root).await?; let relative = Path::new(relative); if relative.as_os_str().is_empty() @@ -1895,6 +1913,7 @@ mod tests { pending_fills: dashmap::DashSet::new(), active_transcodes: std::sync::atomic::AtomicUsize::new(0), transcoding_available: true, + ffmpeg_digest: String::new(), }; prune_cache(&inner).await; assert!(!old.exists()); diff --git a/src/quality/bandwidth.rs b/src/quality/bandwidth.rs new file mode 100644 index 00000000..f67be830 --- /dev/null +++ b/src/quality/bandwidth.rs @@ -0,0 +1,403 @@ +//! Where a file's high frequencies stop, and whether they stop like a +//! lossy encoder makes them stop. +//! +//! **A port of `waveflow_core::analysis::bandwidth`** at `BANDWIDTH_VERSION` +//! 1 (RFC-016 decision 2). The core keeps its meter `pub(crate)`, and the +//! server feeds it from FFmpeg rather than from symphonia, so the averaging and +//! the search are carried here unchanged; what is added is +//! [`BandwidthMeter::finish_with_spectrum`], which hands back the averaged +//! spectrum the meter already computed. Two tests hold the port to the +//! original: the public constants must be equal, and `tests/quality.rs` measures +//! the same signals through both and compares. +//! +//! Every lossy encoder low-passes before it encodes: LAME cuts a 128 kbps +//! MP3 around 16-17 kHz and a 320 kbps one around 20 kHz, AAC and Vorbis +//! do the same by bitrate, and Opus never codes past 20 kHz. Decoding the +//! result back into FLAC or WAV keeps the cut. A recording made lossless +//! has no such cut: its spectrum fades into the noise floor, or runs on +//! to the converter's own filter just under Nyquist. +//! +//! So the measure is the spectrum averaged over the whole file, at the +//! native rate and channel by channel (a mono sum cancels highs that sit +//! in opposition between the channels, and would draw a wall a genuine +//! file does not have), and the search is for a **wall**: a fall of tens of dB +//! within a few hundred hertz, with nothing coming back above it. A slow +//! fade, however low it ends, is not a wall — which is what keeps an old, +//! naturally dull recording from being taken for an MP3. + +use std::sync::Arc; + +use realfft::num_complex::Complex; +use realfft::{RealFftPlanner, RealToComplex}; + +/// Samples per FFT frame: 10.8 Hz per bin at 44.1 kHz, fine enough to +/// place a wall within a band of a few hundred hertz. +const FRAME: usize = 4096; +/// Frames under this RMS (dBFS) are left out: silence and fades would +/// pull the average towards the noise floor everywhere at once. +const SILENT_DBFS: f64 = -60.0; +/// The band the spectrum is smoothed over before the search, in hertz. +const SMOOTH_HZ: f64 = 100.0; +/// How far on each side of a candidate the levels are compared, in hertz, +/// and the gap left around the candidate itself. +const SPAN_HZ: f64 = 800.0; +const GAP_HZ: f64 = 150.0; +/// The lowest frequency a wall is looked for at. Below this a fall is +/// the music's own (a low-passed mix, a telephone sound), not a codec's. +const SEARCH_FROM_HZ: f64 = 11_000.0; +/// A fall at least this steep across the candidate is a wall. +/// +/// Set with the settings below on averaged spectra (`analysis_bench` +/// plus a sweep): 41 genuine WAVs and 40 full-band FLACs, 240 fakes made +/// from those FLACs through LAME, Opus, Vorbis and ffmpeg's AAC, and 600 +/// real MP3 and M4A files. At 20 dB no genuine file is flagged; at 15 one +/// WAV was. Dark sources (lo-fi, sparse acoustic) are what a higher +/// setting loses: their highs sit so near the floor that the encoder's +/// cut is a fall of 12-20 dB. +pub const WALL_DB: f64 = 20.0; +/// Above a wall nothing may come back to within this of the level +/// below it. Measured relative to the music, not to the floor: an MP3 +/// decoded at 48 kHz leaves faint tones near Nyquist, 60 dB under the +/// music yet well over the floor, and those are not content coming back. +const COMES_BACK_DB: f64 = 15.0; + +/// A wall at or under this, in a lossless file, is the mark of a lossy +/// source. LAME's highest setting cuts at 20-20.5 kHz and Opus at 20 kHz, +/// while a recording made lossless runs past 21 kHz or fades with no +/// wall at all. +pub const LOSSY_CUTOFF_HZ: f64 = 21_000.0; + +/// How far under Nyquist a wall must stand to count. A converter's own +/// anti-alias filter sits just under it — near 15 kHz for a 32 kHz file, +/// which the fixed threshold above would take for a 128 kbps MP3 — and a +/// 44.1 kHz file is not affected (21.05 kHz is past the threshold). +pub const NYQUIST_MARGIN_HZ: f64 = 1_000.0; + +/// The core's generation this port follows. A change to the core's detector +/// bumps its own `BANDWIDTH_VERSION`, and `the_port_follows_the_core` then +/// fails until this module has been brought level and this number with it; +/// following it is a reason to bump `quality::QUALITY_VERSION`. +pub const BANDWIDTH_VERSION: i64 = 1; + +/// What [`BandwidthMeter::finish`] measured. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bandwidth { + /// Where the wall stands, in hertz, or `None` when the spectrum has + /// none — it fades out, or runs to Nyquist. + pub cutoff_hz: Option, + /// How far the level falls across the steepest candidate, in dB. + /// Kept for tuning; the wall is what `cutoff_hz` reports. + pub wall_db: f64, + /// Half the sample rate: the most a file could hold. + pub nyquist_hz: f64, +} + +/// Streaming average power spectrum at the native rate, the channels' +/// powers added. +pub struct BandwidthMeter { + rate: f64, + fft: Arc>, + window: Vec, + /// One frame buffer per channel. + frames_in: Vec>, + filled: usize, + input: Vec, + spectrum: Vec>, + scratch: Vec>, + power: Vec, + frames: u64, +} + +impl BandwidthMeter { + pub fn new(rate: f64, channels: usize) -> Self { + let fft = RealFftPlanner::::new().plan_fft_forward(FRAME); + let spectrum = fft.make_output_vec(); + let scratch = fft.make_scratch_vec(); + let window = (0..FRAME) + .map(|i| { + let phase = 2.0 * std::f32::consts::PI * i as f32 / FRAME as f32; + 0.5 - 0.5 * phase.cos() + }) + .collect(); + Self { + rate, + power: vec![0.0; spectrum.len()], + fft, + window, + frames_in: vec![vec![0.0; FRAME]; channels.max(1)], + filled: 0, + input: vec![0.0; FRAME], + spectrum, + scratch, + frames: 0, + } + } + + /// One sample per channel, in channel order. Extra samples beyond the + /// channel count given to [`Self::new`] are ignored. + pub fn push_frame(&mut self, frame: &[f32]) { + for (buffer, &sample) in self.frames_in.iter_mut().zip(frame) { + buffer[self.filled] = sample; + } + self.filled += 1; + if self.filled == FRAME { + self.process(); + self.filled = 0; + } + } + + fn process(&mut self) { + let channels = self.frames_in.len() as f64; + let energy: f64 = self + .frames_in + .iter() + .flatten() + .map(|&s| f64::from(s) * f64::from(s)) + .sum(); + let rms_db = 10.0 * (energy / (FRAME as f64 * channels)).max(1e-20).log10(); + if rms_db < SILENT_DBFS { + return; + } + for channel in 0..self.frames_in.len() { + for ((dst, &src), &w) in self + .input + .iter_mut() + .zip(&self.frames_in[channel]) + .zip(&self.window) + { + *dst = src * w; + } + if self + .fft + .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch) + .is_err() + { + return; + } + for (acc, bin) in self.power.iter_mut().zip(&self.spectrum) { + *acc += f64::from(bin.norm_sqr()); + } + } + self.frames += 1; + } + + pub fn finish(self) -> Option { + self.finish_with_spectrum().map(|(bandwidth, _)| bandwidth) + } + + /// [`Self::finish`], and the averaged spectrum it searched: one level in + /// dB per FFT bin, from 0 Hz to Nyquist. `None` when every frame was + /// silent. + pub fn finish_with_spectrum(self) -> Option<(Bandwidth, Vec)> { + if self.frames == 0 { + return None; + } + let db: Vec = self + .power + .iter() + .map(|&p| 10.0 * (p / self.frames as f64).max(1e-20).log10()) + .collect(); + Some((find_wall(&db, self.rate), db)) + } +} + +/// The wall in an averaged spectrum (`db[k]` for bin `k` of a +/// [`FRAME`]-point FFT at `rate`), or none. +fn find_wall(db: &[f64], rate: f64) -> Bandwidth { + let nyquist = rate / 2.0; + let bin_hz = rate / FRAME as f64; + let to_bin = |hz: f64| ((hz / bin_hz).round() as usize).min(db.len() - 1); + + // Smoothed over SMOOTH_HZ, so one tonal peak or one notch is not a wall. + let half = ((SMOOTH_HZ / bin_hz / 2.0).round() as usize).max(1); + let mut prefix = Vec::with_capacity(db.len() + 1); + prefix.push(0.0); + for &v in db { + prefix.push(prefix.last().unwrap() + v); + } + let mean = |lo: usize, hi: usize| -> f64 { + let (lo, hi) = (lo.min(db.len()), hi.min(db.len())); + if hi <= lo { + return f64::NEG_INFINITY; + } + (prefix[hi] - prefix[lo]) / (hi - lo) as f64 + }; + let smooth: Vec = (0..db.len()) + .map(|k| mean(k.saturating_sub(half), k + half + 1)) + .collect(); + + let span = to_bin(SPAN_HZ); + let gap = to_bin(GAP_HZ).max(1); + let top = to_bin(nyquist - 100.0); + let first = to_bin(SEARCH_FROM_HZ); + let last = top.saturating_sub(gap + span / 2); + + let mut best: Option<(usize, f64)> = None; + for k in first..last { + let below = mean(k.saturating_sub(gap + span), k - gap); + let above = mean(k + gap, (k + gap + span).min(top)); + let drop = below - above; + // Spelled without `Option::is_none_or`: the MSRV is 1.80. + let better = match best { + Some((_, d)) => drop > d, + None => true, + }; + if better { + best = Some((k, drop)); + } + } + let Some((k, wall_db)) = best else { + return Bandwidth { + cutoff_hz: None, + wall_db: 0.0, + nyquist_hz: nyquist, + }; + }; + + // Above a wall the spectrum stays down to Nyquist: a fall that comes + // back is a notch or a band-limited instrument, not a codec's cut. + // Checked past the span the floor was averaged over, where the wall + // itself has finished falling. + let below = mean(k.saturating_sub(gap + span), k - gap); + let comes_back = smooth[(k + gap + span).min(top)..=top] + .iter() + .any(|&v| v > below - COMES_BACK_DB); + let cutoff_hz = (wall_db >= WALL_DB && !comes_back).then_some(k as f64 * bin_hz); + Bandwidth { + cutoff_hz, + wall_db, + nyquist_hz: nyquist, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: f64 = 44_100.0; + + /// Noise shaped by a gain per frequency: white noise through an FFT + /// mask, in frames, so the test controls the spectrum exactly. + fn shaped_noise(secs: f64, gain_db: impl Fn(f64) -> f64) -> Vec { + let mut planner = RealFftPlanner::::new(); + let forward = planner.plan_fft_forward(FRAME); + let inverse = planner.plan_fft_inverse(FRAME); + let mut seed = 0x1234_5678_u32; + let mut out = Vec::new(); + while (out.len() as f64) < secs * RATE { + let mut block: Vec = (0..FRAME) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + (seed as f32 / u32::MAX as f32) - 0.5 + }) + .collect(); + let mut spec = forward.make_output_vec(); + forward.process(&mut block, &mut spec).unwrap(); + for (k, bin) in spec.iter_mut().enumerate() { + let hz = k as f64 * RATE / FRAME as f64; + *bin *= 10f32.powf((gain_db(hz) / 20.0) as f32); + } + let mut back = inverse.make_output_vec(); + inverse.process(&mut spec, &mut back).unwrap(); + out.extend(back.iter().map(|v| v / FRAME as f32)); + } + out + } + + fn measure(signal: &[f32]) -> Bandwidth { + let mut meter = BandwidthMeter::new(RATE, 1); + for &s in signal { + meter.push_frame(&[s]); + } + meter.finish().expect("a spectrum") + } + + /// A music-like tilt: the level falls 3 dB per octave above 1 kHz. + fn tilt(hz: f64) -> f64 { + -3.0 * (hz.max(1000.0) / 1000.0).log2() + } + + #[test] + fn a_lossy_cut_is_found_where_it_stands() { + for cut in [16_000.0, 19_000.0, 20_000.0] { + let b = measure(&shaped_noise(20.0, |hz| { + tilt(hz) + if hz > cut { -60.0 } else { 0.0 } + })); + let found = b.cutoff_hz.expect("a wall"); + assert!((found - cut).abs() < 300.0, "{cut}: {b:?}"); + } + } + + #[test] + fn a_full_band_spectrum_has_no_wall() { + let b = measure(&shaped_noise(20.0, tilt)); + assert!(b.cutoff_hz.is_none(), "{b:?}"); + } + + /// An old, dull recording: the highs fade by 12 dB per octave from + /// 6 kHz. Low, but no wall — not a lossy file. + #[test] + fn a_gentle_fade_is_not_a_wall() { + let b = measure(&shaped_noise(20.0, |hz| { + tilt(hz) - 12.0 * (hz.max(6000.0) / 6000.0).log2() + })); + assert!(b.cutoff_hz.is_none(), "{b:?}"); + } + + #[test] + fn the_port_follows_the_core() { + use waveflow_core::analysis::bandwidth as core; + assert_eq!(BANDWIDTH_VERSION, core::BANDWIDTH_VERSION); + assert_eq!(WALL_DB, core::WALL_DB); + assert_eq!(LOSSY_CUTOFF_HZ, core::LOSSY_CUTOFF_HZ); + assert_eq!(NYQUIST_MARGIN_HZ, core::NYQUIST_MARGIN_HZ); + } + + #[test] + fn the_sql_threshold_is_the_constant() { + let sql = waveflow_core::probable_fake_lossless_sql!(); + assert!( + sql.contains(&format!( + "MIN({}, t.sample_rate / 2.0 - {})", + LOSSY_CUTOFF_HZ as i64, NYQUIST_MARGIN_HZ as i64 + )), + "{sql}" + ); + } + + #[test] + fn silence_measures_nothing() { + let mut meter = BandwidthMeter::new(RATE, 2); + for _ in 0..(5.0 * RATE) as usize { + meter.push_frame(&[0.0, 0.0]); + } + assert!(meter.finish().is_none()); + } + + /// Highs in opposition between the channels vanish from a mono sum, + /// which then shows a wall at 15 kHz; channel by channel they are + /// there, and a genuine file is not taken for a fake. + #[test] + fn highs_in_opposition_are_not_a_wall() { + let lows = shaped_noise(20.0, |hz| { + tilt(hz) + if hz > 15_000.0 { -80.0 } else { 0.0 } + }); + let highs = shaped_noise(20.0, |hz| { + tilt(hz) + if hz > 15_000.0 { 0.0 } else { -80.0 } + }); + let mut meter = BandwidthMeter::new(RATE, 2); + for (&l, &h) in lows.iter().zip(&highs) { + meter.push_frame(&[l + h, l - h]); + } + let b = meter.finish().expect("a spectrum"); + assert!(b.cutoff_hz.is_none(), "{b:?}"); + + let mono: Vec = lows.to_vec(); + assert!( + measure(&mono).cutoff_hz.is_some(), + "the sum alone has the wall" + ); + } +} diff --git a/src/quality/mod.rs b/src/quality/mod.rs new file mode 100644 index 00000000..f1a36acf --- /dev/null +++ b/src/quality/mod.rs @@ -0,0 +1,418 @@ +//! The real quality of a file, measured from its audio (RFC-016). +//! +//! What lives here touches no database: decoding a file through FFmpeg, the +//! desktop's wall search ([`bandwidth`]), the effective bit depth, the +//! averaged spectrum as it is stored, and the spectrogram drawn on demand. +//! Which files are measured, when, and what a measurement means to a reader +//! belong to `services::quality`. +//! +//! **FFmpeg decodes, out of process** (decision 2). It reads every lossless +//! format the scanner indexes, APE and WavPack included, which the server's +//! symphonia cannot; a file that hangs it costs a kill at the deadline rather +//! than a blocked thread; and it hands over integers, which the bit depth +//! needs. The rate and the channel count come from ffprobe, not from the track +//! row, so the meter is never fed at a rate the decoder did not produce, and +//! nothing is resampled: a resampler's filter would draw a wall of its own. + +pub mod bandwidth; + +use std::{ + path::Path, + process::Stdio, + time::{Duration, Instant}, +}; + +use sha2::{Digest, Sha256}; +use tokio::{io::AsyncReadExt, process::Command}; + +/// The server's own generation of what `track_quality` stores: the ported +/// wall search, the bit depth and the spectrum's bands. A change to any of +/// them bumps it, and every lossless file is measured again (decision 5). +pub const QUALITY_VERSION: i64 = 1; + +/// How many bands the stored spectrum has, from 0 Hz to Nyquist. +pub const SPECTRUM_BANDS: usize = 256; + +/// How long ffprobe gets to name a file's rate and channels. +const PROBE_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long a spectrogram may take to draw (decision 8). +pub const SPECTROGRAM_TIMEOUT: Duration = Duration::from_secs(30); + +/// The picture's parameters, which are part of its `ETag`: a change here is a +/// different picture of the same file. +pub const SPECTROGRAM_FILTER: &str = "[0:a:0]showspectrumpic=s=1024x512:legend=0:scale=log"; + +/// What one pass over a file found. +#[derive(Debug, Clone, PartialEq)] +pub enum Measurement { + /// The file decoded and something in it was louder than −60 dBFS. + Measured { + cutoff_hz: Option, + wall_db: f64, + effective_bits: Option, + spectrum: Vec, + }, + /// The file decoded and nothing in it was louder than −60 dBFS. + Silent { effective_bits: Option }, + /// FFmpeg could not read it, or ran past its deadline. + Unreadable(Failure), +} + +/// Why a file was unreadable. Never a message: FFmpeg's own words can carry +/// the path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Failure { + Decode, + Timeout, +} + +impl Failure { + pub fn as_str(self) -> &'static str { + match self { + Self::Decode => "decode", + Self::Timeout => "timeout", + } + } +} + +/// The FFmpeg this process runs, as far as a measurement depends on it. +#[derive(Debug, Clone)] +pub struct Decoder { + /// SHA-256, in hex, of the whole `ffmpeg -version` output: the release, + /// the configuration and the version of every library, as compiled and as + /// loaded. Two builds of one release with different decoders differ here, + /// and so does one binary over an upgraded shared `libavcodec`. + pub digest: String, + /// Whether FFmpeg can encode WebP; the spectrogram falls back to JPEG, + /// which every FFmpeg encodes, when it cannot. + pub webp: bool, +} + +/// SHA-256 of an `ffmpeg -version` output, as stored in `track_quality`. +pub fn decoder_digest(version_output: &[u8]) -> String { + hex::encode(Sha256::digest(version_output)) +} + +/// Asks FFmpeg which encoders it has, once at start. `ffmpeg -version` itself +/// is read by `media::check_tool`, which already runs it. +pub async fn probe_webp(ffmpeg: &Path) -> bool { + let output = tokio::time::timeout( + PROBE_TIMEOUT, + Command::new(ffmpeg) + .args(["-hide_banner", "-encoders"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await; + match output { + Ok(Ok(output)) if output.status.success() => String::from_utf8_lossy(&output.stdout) + .lines() + .any(|line| line.split_whitespace().nth(1) == Some("libwebp")), + _ => false, + } +} + +/// The first audio stream's rate, channel count and sample format. +struct Stream { + rate: f64, + channels: usize, + float: bool, +} + +async fn probe(ffprobe: &Path, path: &Path) -> Option { + let output = tokio::time::timeout( + PROBE_TIMEOUT, + Command::new(ffprobe) + .args([ + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=sample_rate,channels,sample_fmt", + "-of", + "default=noprint_wrappers=1", + ]) + .arg(path) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let field = |name: &str| { + text.lines() + .find_map(|line| line.strip_prefix(name)?.strip_prefix('=')) + .map(str::trim) + }; + let rate: f64 = field("sample_rate")?.parse().ok()?; + let channels: usize = field("channels")?.parse().ok()?; + let format = field("sample_fmt").unwrap_or(""); + if rate <= 0.0 || channels == 0 { + return None; + } + Some(Stream { + rate, + channels, + float: format.starts_with("flt") || format.starts_with("dbl"), + }) +} + +/// Measures one file: ffprobe for its stream, then FFmpeg decoding it to +/// signed 32-bit integers on a pipe, at its own rate, on one thread. +/// +/// The child is killed if this future is dropped or the deadline passes. The +/// FFT runs on a blocking thread fed by the pipe, so the async workers only +/// move bytes. +pub async fn measure( + ffprobe: &Path, + ffmpeg: &Path, + path: &Path, + deadline: Duration, +) -> Measurement { + let started = Instant::now(); + let Some(stream) = probe(ffprobe, path).await else { + return Measurement::Unreadable(Failure::Decode); + }; + let remaining = deadline.saturating_sub(started.elapsed()); + let child = Command::new(ffmpeg) + .args([ + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-threads", + "1", + "-i", + ]) + .arg(path) + .args([ + "-map", + "0:a:0", + "-f", + "s32le", + "-acodec", + "pcm_s32le", + "pipe:1", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn(); + let Ok(mut child) = child else { + return Measurement::Unreadable(Failure::Decode); + }; + let Some(mut stdout) = child.stdout.take() else { + return Measurement::Unreadable(Failure::Decode); + }; + + let (sender, receiver) = tokio::sync::mpsc::channel::>(8); + let channels = stream.channels; + let rate = stream.rate; + let meter = tokio::task::spawn_blocking(move || Accumulator::run(rate, channels, receiver)); + + let decode = async { + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let read = stdout.read(&mut buffer).await?; + if read == 0 { + break; + } + if sender.send(buffer[..read].to_vec()).await.is_err() { + break; + } + } + drop(sender); + child.wait().await + }; + let status = match tokio::time::timeout(remaining, decode).await { + Ok(Ok(status)) => status, + Ok(Err(_)) => return Measurement::Unreadable(Failure::Decode), + // The child is dropped with the future above and killed with it. + Err(_) => return Measurement::Unreadable(Failure::Timeout), + }; + let Ok(accumulated) = meter.await else { + return Measurement::Unreadable(Failure::Decode); + }; + if !status.success() || accumulated.samples == 0 { + return Measurement::Unreadable(Failure::Decode); + } + accumulated.into_measurement(stream.float) +} + +/// What the blocking side of the pipe gathered. +struct Accumulator { + meter: bandwidth::BandwidthMeter, + or_bits: u32, + samples: u64, +} + +impl Accumulator { + fn run(rate: f64, channels: usize, mut receiver: tokio::sync::mpsc::Receiver>) -> Self { + let mut this = Self { + meter: bandwidth::BandwidthMeter::new(rate, channels), + or_bits: 0, + samples: 0, + }; + let frame_bytes = 4 * channels; + let mut pending: Vec = Vec::new(); + let mut frame = vec![0f32; channels]; + while let Some(chunk) = receiver.blocking_recv() { + pending.extend_from_slice(&chunk); + let whole = pending.len() / frame_bytes * frame_bytes; + for bytes in pending[..whole].chunks_exact(frame_bytes) { + for (slot, sample) in frame.iter_mut().zip(bytes.as_chunks::<4>().0) { + let value = i32::from_le_bytes(*sample); + this.or_bits |= value as u32; + *slot = value as f32 / 2_147_483_648.0; + } + this.meter.push_frame(&frame); + this.samples += 1; + } + pending.drain(..whole); + } + this + } + + fn into_measurement(self, float: bool) -> Measurement { + let effective_bits = effective_bits(self.or_bits, float); + match self.meter.finish_with_spectrum() { + Some((wall, db)) => Measurement::Measured { + cutoff_hz: wall.cutoff_hz, + wall_db: wall.wall_db, + effective_bits, + spectrum: quantise_spectrum(&db), + }, + None => Measurement::Silent { effective_bits }, + } + } +} + +/// The bits the samples use, from the OR of every sample as a signed 32-bit +/// integer: everything above the lowest bit any sample sets. +/// +/// `None` for a floating-point source, whose bits do not mean depth, and for +/// a file whose every sample is zero. +pub fn effective_bits(or_bits: u32, float: bool) -> Option { + (!float && or_bits != 0).then(|| 32 - i64::from(or_bits.trailing_zeros())) +} + +/// The averaged spectrum as `track_quality` stores it: [`SPECTRUM_BANDS`] +/// bands from 0 Hz to Nyquist, each the mean power of its FFT bins, in +/// half-decibels under the loudest band, down to −127.5 dB. +pub fn quantise_spectrum(db: &[f64]) -> Vec { + let bins = db.len().max(1); + let bands: Vec = (0..SPECTRUM_BANDS) + .map(|band| { + let lo = band * bins / SPECTRUM_BANDS; + let hi = ((band + 1) * bins / SPECTRUM_BANDS).max(lo + 1).min(bins); + let power: f64 = db[lo..hi].iter().map(|v| 10f64.powf(v / 10.0)).sum(); + 10.0 * (power / (hi - lo) as f64).max(1e-30).log10() + }) + .collect(); + let loudest = bands.iter().copied().fold(f64::NEG_INFINITY, f64::max); + bands + .iter() + .map(|level| ((loudest - level) * 2.0).round().clamp(0.0, 255.0) as u8) + .collect() +} + +/// The stored spectrum back in dB under the loudest band. +pub fn spectrum_db(stored: &[u8]) -> Vec { + stored.iter().map(|&step| -f64::from(step) / 2.0).collect() +} + +/// A spectrogram, drawn by FFmpeg on demand (decision 8). +pub struct Picture { + pub bytes: Vec, + pub content_type: &'static str, +} + +/// Draws a file's spectrogram: WebP when FFmpeg has libwebp, JPEG otherwise. +/// `None` when FFmpeg fails or runs past [`SPECTROGRAM_TIMEOUT`]. +pub async fn spectrogram(ffmpeg: &Path, path: &Path, webp: bool) -> Option { + let mut command = Command::new(ffmpeg); + command + .args([ + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-threads", + "1", + "-i", + ]) + .arg(path) + .args(["-filter_complex", SPECTROGRAM_FILTER, "-frames:v", "1"]); + let content_type = if webp { + command.args(["-c:v", "libwebp", "-quality", "70", "-f", "webp", "pipe:1"]); + "image/webp" + } else { + command.args(["-c:v", "mjpeg", "-q:v", "4", "-f", "image2pipe", "pipe:1"]); + "image/jpeg" + }; + let output = tokio::time::timeout( + SPECTROGRAM_TIMEOUT, + command + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .ok()? + .ok()?; + (output.status.success() && !output.stdout.is_empty()).then_some(Picture { + bytes: output.stdout, + content_type, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_padded_sixteen_bit_file_uses_sixteen_bits() { + // A 16-bit sample decoded to s32 is shifted left by sixteen. + assert_eq!(effective_bits(0x7FFF_0000 | 0x0001_0000, false), Some(16)); + // A 24-bit one by eight, and a genuine one sets its lowest bit. + assert_eq!(effective_bits(0x0000_0100, false), Some(24)); + assert_eq!(effective_bits(0x0000_0001, false), Some(32)); + assert_eq!(effective_bits(0, false), None); + assert_eq!(effective_bits(0x0001_0000, true), None); + } + + #[test] + fn the_spectrum_is_stored_under_its_loudest_band() { + let mut db = vec![-20.0; 2049]; + for level in db.iter_mut().skip(1500) { + *level = -80.0; + } + let stored = quantise_spectrum(&db); + assert_eq!(stored.len(), SPECTRUM_BANDS); + assert_eq!(stored[0], 0); + assert_eq!(stored[SPECTRUM_BANDS - 1], 120); + assert_eq!(spectrum_db(&stored)[SPECTRUM_BANDS - 1], -60.0); + } + + #[test] + fn the_decoder_digest_reads_past_the_first_line() { + let one = b"ffmpeg version 8.0\nconfiguration: --enable-libwebp\nlibavcodec 62. 11.100 / 62. 11.100\n"; + let two = b"ffmpeg version 8.0\nconfiguration: --disable-libwebp\nlibavcodec 62. 11.100 / 62. 11.100\n"; + let three = b"ffmpeg version 8.0\nconfiguration: --enable-libwebp\nlibavcodec 62. 11.100 / 62. 12.100\n"; + assert_ne!(decoder_digest(one), decoder_digest(two)); + assert_ne!(decoder_digest(one), decoder_digest(three)); + assert_eq!(decoder_digest(one).len(), 64); + } +} diff --git a/src/scanner.rs b/src/scanner.rs index ac276593..1d64ea53 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -15,7 +15,7 @@ use lofty::{ file::TaggedFileExt, prelude::{Accessor, AudioFile, ItemKey}, }; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::{broadcast, watch, Mutex}; use uuid::Uuid; use walkdir::WalkDir; @@ -85,6 +85,19 @@ pub struct ScanManager { parallelism: usize, events: Arc>>, library_locks: Arc>>>, + /// How many scan jobs are running, of any library. The quality analysis + /// (RFC-016 decision 4) starts no file while this is above zero, and wakes + /// when it falls back to it. + in_flight: Arc>, +} + +/// One scan counted as running, for as long as this lives. +pub struct ScanInFlight(Arc>); + +impl Drop for ScanInFlight { + fn drop(&mut self) { + self.0.send_modify(|count| *count = count.saturating_sub(1)); + } } impl ScanManager { @@ -119,9 +132,22 @@ impl ScanManager { parallelism: parallelism.max(1), events: Arc::new(DashMap::new()), library_locks: Arc::new(DashMap::new()), + in_flight: Arc::new(watch::Sender::new(0)), } } + /// Counts a scan as running until the guard is dropped. Every job takes + /// one for its whole run, success or failure. + pub fn begin_scan(&self) -> ScanInFlight { + self.in_flight.send_modify(|count| *count += 1); + ScanInFlight(Arc::clone(&self.in_flight)) + } + + /// How many scans are running, and a way to wait for that to change. + pub fn scans_in_flight(&self) -> watch::Receiver { + self.in_flight.subscribe() + } + pub async fn trigger( &self, library: LibraryRecord, @@ -146,7 +172,9 @@ impl ScanManager { let (sender, _) = broadcast::channel(128); self.events.insert(scan_id, sender); let manager = self.clone(); + let running = self.begin_scan(); tokio::spawn(async move { + let _running = running; if let Err(error) = manager.run(scan_id, library).await { tracing::error!(%scan_id, error = %error, "library scan failed"); let message = error.to_string(); diff --git a/src/services/attention.rs b/src/services/attention.rs index f31279f6..6181a0f8 100644 --- a/src/services/attention.rs +++ b/src/services/attention.rs @@ -29,11 +29,14 @@ pub enum AttentionCategory { DuplicateTrackNumber, TrackNumberGap, ProbableDuplicate, + /// Lossless files whose highs stop at a lossy encoder's wall (RFC-016 + /// decision 9). + ProbableFakeLossless, } impl AttentionCategory { /// Display order, the desktop's. - pub const ALL: [Self; 11] = [ + pub const ALL: [Self; 12] = [ Self::SplitCredits, Self::MissingArtist, Self::MissingAlbum, @@ -45,6 +48,7 @@ impl AttentionCategory { Self::DuplicateTrackNumber, Self::TrackNumberGap, Self::ProbableDuplicate, + Self::ProbableFakeLossless, ]; /// `None` for anything that is not a track category, so a route can answer @@ -147,6 +151,19 @@ macro_rules! track_number_gap { }; } +// RFC-016's `lossy_source` verdict, as the desktop defines its category: a +// current measurement of a lossless file with a wall where a lossy encoder puts +// one, through the core's own definition. +macro_rules! probable_fake_lossless { + () => { + concat!( + "EXISTS (SELECT 1 FROM track_quality ta \n WHERE ta.track_id = t.id AND ta.full_hash = t.full_hash AND ", + waveflow_core::probable_fake_lossless_sql!(), + ")" + ) + }; +} + macro_rules! count_head { () => { "SELECT COUNT(*) FROM track t \ @@ -194,6 +211,7 @@ impl AttentionCategory { Self::CompilationNoAlbumArtist => category_sql!(compilation_no_album_artist), Self::DuplicateTrackNumber => category_sql!(duplicate_track_number), Self::TrackNumberGap => category_sql!(track_number_gap), + Self::ProbableFakeLossless => category_sql!(probable_fake_lossless), Self::SplitCredits | Self::ProbableDuplicate => return None, }) } diff --git a/src/services/mod.rs b/src/services/mod.rs index 642ceb9d..b87378ba 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1364,6 +1364,9 @@ pub struct DomainServices { /// [`ARTIST_IMAGE_SEARCH_DEADLINE`], except where a test shortens it /// rather than wait five minutes. image_search_deadline_ms: Arc, + /// The quality analysis (RFC-016): its bound, the FFmpeg it measures with, + /// and the spectrograms in flight. + quality: quality::QualityRuntime, } #[derive(Debug, thiserror::Error)] @@ -1424,6 +1427,7 @@ mod library_tree; mod lyrics_uploads; mod playback; mod playlists; +mod quality; mod radio; mod scan; mod scrobbling; @@ -1462,6 +1466,10 @@ pub use library_folders::{ }; pub use library_tree::{DiskFolder, DiskListing, DiskTrack}; pub use lyrics_uploads::{LyricsFile, MAX_UPLOAD_BYTES as MAX_LYRICS_UPLOAD_BYTES}; +pub use quality::{ + QualitySummary, QualityTrack, QualityVerdict, QualityVerdictCount, Spectrogram, TrackQuality, + TrackQualityDetail, +}; pub use scrobbling::{ ScrobbleDestinationName, ScrobbleDrain, ScrobbleEnvelope, ScrobbleLinkState, ScrobbleProvider, ScrobbleTarget, ScrobbleUnavailable, ScrobbleVerdict, UncertainScrobble, @@ -1516,6 +1524,7 @@ impl DomainServices { image_search_deadline_ms: Arc::new(std::sync::atomic::AtomicU64::new( u64::try_from(ARTIST_IMAGE_SEARCH_DEADLINE.as_millis()).unwrap_or(u64::MAX), )), + quality: quality::QualityRuntime::new(config.quality_parallelism), } } diff --git a/src/services/quality.rs b/src/services/quality.rs new file mode 100644 index 00000000..61ebedec --- /dev/null +++ b/src/services/quality.rs @@ -0,0 +1,744 @@ +//! The real quality of a file (RFC-016): which files are measured and when, +//! what a measurement means to a reader, and who may read it. +//! +//! **Never on the answering path** (decision 4). Every read answers from what +//! `track_quality` holds, and a file with nothing current there is `pending`. +//! The background pass measures the available lossless files with nothing +//! current, oldest first, at most `WAVEFLOW_QUALITY_PARALLELISM` at a time, and +//! starts no file while a scan runs. It wakes at boot, when a scan finishes, +//! and when an owner or manager asks for a library to be analysed again. +//! +//! **The verdict is computed where it is read** (decision 3), in one SQL +//! expression, [`quality_verdict_sql!`]. It reuses the core's +//! `lossless_track_sql!` and `probable_fake_lossless_sql!` verbatim, so the +//! server and the desktop cannot disagree about a fake lossless file. +//! +//! **A measurement is of a content, by a version, through a decoder** +//! (decision 5). Rows written by another `QUALITY_VERSION` or another FFmpeg +//! are deleted at boot, before anything reads them, which is what lets every +//! query here call a row current on its `full_hash` alone. + +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; + +use futures_util::{stream, StreamExt}; +use sha2::{Digest, Sha256}; +use tokio::sync::{Notify, Semaphore}; + +use super::credit_decisions::curated_library; +use super::*; +use crate::quality::{self, Measurement}; + +/// Whether the track aliased `t` is in a lossy format: the formats the +/// scanner names that throw something away. AAC with a bit depth is ALAC, +/// which the core counts as lossless. +macro_rules! lossy_track_sql { + () => { + "(t.codec IN ('MP3', 'Vorbis', 'Opus', 'Speex') \ + OR (t.codec = 'AAC' AND t.bit_depth IS NULL))" + }; +} + +/// Every current measurement of the track aliased `t`, aliased `ta`. A row +/// whose hash is not the track's describes a file that has gone. +macro_rules! quality_join { + () => { + " LEFT JOIN track_quality ta ON ta.track_id = t.id AND ta.full_hash = t.full_hash" + }; +} + +/// The verdict of the track aliased `t`, over [`quality_join!`]. The first +/// that applies, in the order of RFC-016 decision 3. +/// +/// `upsampled` stops at 30 kHz rather than at a 48 kHz source's 24 kHz +/// Nyquist: a resampler's filter does not stop dead there. Measured on +/// FFmpeg's own, the wall stands at 21.5 to 23.8 kHz with a steep filter and +/// at 25.7 kHz (from 44.1 kHz) or 28.0 kHz (from 48 kHz) with its default one. +macro_rules! quality_verdict_sql { + () => { + concat!( + "(CASE WHEN ", + lossy_track_sql!(), + " THEN 'lossy' WHEN NOT ", + waveflow_core::lossless_track_sql!(), + " THEN 'not_measured' \ + WHEN ta.track_id IS NULL THEN 'pending' \ + WHEN ta.outcome = 'unreadable' THEN 'unreadable' \ + WHEN ta.outcome = 'silent' THEN 'silent' \ + WHEN ", + waveflow_core::probable_fake_lossless_sql!(), + " THEN 'lossy_source' \ + WHEN t.sample_rate > 48000 AND ta.cutoff_hz IS NOT NULL \ + AND ta.cutoff_hz <= 30000 THEN 'upsampled' \ + WHEN ta.effective_bits IS NOT NULL AND t.bit_depth IS NOT NULL \ + AND ta.effective_bits < t.bit_depth THEN 'padded' \ + ELSE 'consistent' END)" + ) + }; +} + +/// Worst first: the order the page lists in (decision 10). +macro_rules! verdict_rank_sql { + ($column:literal) => { + concat!( + "(CASE ", + $column, + " WHEN 'lossy_source' THEN 0 WHEN 'upsampled' THEN 1 WHEN 'padded' THEN 2 \ + WHEN 'unreadable' THEN 3 WHEN 'silent' THEN 4 WHEN 'pending' THEN 5 \ + WHEN 'consistent' THEN 6 WHEN 'lossy' THEN 7 ELSE 8 END)" + ) + }; +} + +/// What the audio says about a file, against what its container claims. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum QualityVerdict { + /// A wall where a lossy encoder puts one: probably made from a lossy file. + LossySource, + /// Above 48 kHz, with a wall where a resampler from 44.1 or 48 kHz leaves + /// one. + Upsampled, + /// Fewer bits used than the container declares. + Padded, + /// FFmpeg could not read it, or ran past its deadline. + Unreadable, + /// Nothing louder than −60 dBFS. + Silent, + /// Lossless, with no measurement of this content yet. + Pending, + /// No sign of a lossy source. Not "genuine": an encoder that keeps the + /// full band leaves nothing to find. + Consistent, + /// A lossy format, which is not measured. + Lossy, + /// DSD, or a format neither list knows. + NotMeasured, +} + +impl QualityVerdict { + /// Worst first, as the page lists them. + pub const ALL: [Self; 9] = [ + Self::LossySource, + Self::Upsampled, + Self::Padded, + Self::Unreadable, + Self::Silent, + Self::Pending, + Self::Consistent, + Self::Lossy, + Self::NotMeasured, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::LossySource => "lossy_source", + Self::Upsampled => "upsampled", + Self::Padded => "padded", + Self::Unreadable => "unreadable", + Self::Silent => "silent", + Self::Pending => "pending", + Self::Consistent => "consistent", + Self::Lossy => "lossy", + Self::NotMeasured => "not_measured", + } + } + + pub fn parse(value: &str) -> Option { + Self::ALL + .into_iter() + .find(|verdict| verdict.as_str() == value) + } +} + +/// How many files of a library carry one verdict. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QualityVerdictCount { + pub verdict: QualityVerdict, + pub count: i64, +} + +/// A library's quality at a glance (decision 10's header). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QualitySummary { + /// Whether this server measures at all; `false` when the operator set + /// `WAVEFLOW_QUALITY_PARALLELISM=0`. + pub enabled: bool, + /// Available lossless files, the ones the analysis is about. + pub lossless: i64, + /// Of those, how many have a current measurement. + pub measured: i64, + /// Every verdict, worst first, zero counts included. + pub verdicts: Vec, +} + +/// One file's verdict and the numbers behind it. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TrackQuality { + pub verdict: QualityVerdict, + /// Where the wall stands, in hertz; `null` with no wall or no measurement. + pub cutoff_hz: Option, + /// How far the level falls across the steepest candidate, in dB. + pub wall_db: Option, + /// Half the sample rate: the most the file could hold. + pub nyquist_hz: Option, + /// How many bits the samples use; `null` for floating point. + pub effective_bits: Option, + pub analyzed_at: Option, +} + +/// A song item of the quality list, with its verdict. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QualityTrack { + #[serde(flatten)] + pub song: SongItem, + pub quality: TrackQuality, +} + +/// One track's quality, with the averaged spectrum it was judged on. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TrackQualityDetail { + #[serde(flatten)] + pub quality: TrackQuality, + /// 256 bands from 0 Hz to Nyquist, in dB under the loudest one; `null` + /// when nothing was measured or the file was silent. + pub spectrum: Option>, +} + +/// What the spectrogram route answers. +pub enum Spectrogram { + /// The caller's copy is the picture this file would draw. + NotModified { etag: String }, + Picture { + etag: String, + bytes: Vec, + content_type: &'static str, + }, +} + +/// The analysis's own state, shared by every clone of [`DomainServices`]. +#[derive(Clone)] +pub(super) struct QualityRuntime(Arc); + +struct QualityInner { + parallelism: usize, + /// Placed at boot by [`DomainServices::use_quality_decoder`]; nothing is + /// measured before it is. + decoder: RwLock>, + wake: Notify, + /// Spectrograms in flight across the server: the analysis's own bound plus + /// one, so a reader can have one drawn while the background pass runs. + spectrograms: Arc, + /// One spectrogram per account at a time. + spectrogram_users: dashmap::DashMap>, +} + +impl QualityRuntime { + pub(super) fn new(parallelism: usize) -> Self { + Self(Arc::new(QualityInner { + parallelism, + decoder: RwLock::new(None), + wake: Notify::new(), + spectrograms: Arc::new(Semaphore::new(parallelism + 1)), + spectrogram_users: dashmap::DashMap::new(), + })) + } + + fn decoder(&self) -> Option { + self.0 + .decoder + .read() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + } +} + +/// A file waiting for its measurement. +struct Pending { + track_id: String, + full_hash: String, + root_path: String, + relative_path: String, + duration_ms: i64, + created_at: i64, +} + +/// A file measured, waiting for its row. +struct Measured { + track_id: String, + full_hash: String, + measurement: Measurement, +} + +/// How many files the pass asks for at a time. +const PAGE: i64 = 64; + +/// How many rows one write carries. +const BATCH: usize = 16; + +impl DomainServices { + /// Places the FFmpeg this process measures with, and deletes every + /// measurement another generation or another FFmpeg made (decision 5). + /// Runs once at boot, before anything reads `track_quality`. + pub async fn use_quality_decoder( + &self, + decoder: quality::Decoder, + ) -> Result { + let removed = { + let _writer = self.db.writer_guard().await; + sqlx::query("DELETE FROM track_quality WHERE version <> ? OR decoder <> ?") + .bind(quality::QUALITY_VERSION) + .bind(&decoder.digest) + .execute(self.db.pool()) + .await? + .rows_affected() + }; + *self + .quality + .0 + .decoder + .write() + .unwrap_or_else(|poison| poison.into_inner()) = Some(decoder); + if removed > 0 { + tracing::info!( + removed, + "quality measurements from another analyser or FFmpeg will be taken again" + ); + } + Ok(removed) + } + + /// Starts the background pass, unless the operator turned it off. + pub fn spawn_quality_analyzer(&self) { + if self.quality.0.parallelism == 0 { + return; + } + let services = self.clone(); + tokio::spawn(async move { + let mut scans = services.scanner.scans_in_flight(); + loop { + if let Err(error) = services.analyze_pending_quality().await { + tracing::warn!(error = %error, "the quality analysis stopped early"); + } + tokio::select! { + () = services.quality.0.wake.notified() => {} + () = scan_finished(&mut scans) => {} + } + } + }); + } + + /// Measures every available lossless file with nothing current, then + /// returns how many it measured. The background pass is this in a loop; a + /// test calls it directly. + pub async fn analyze_pending_quality(&self) -> Result { + let parallelism = self.quality.0.parallelism; + let Some(decoder) = self.quality.decoder() else { + return Ok(0); + }; + if parallelism == 0 { + return Ok(0); + } + let mut measured = 0; + // Keyset rather than offset: rows written behind the cursor drop out + // of the query, and a file that keeps failing to be written cannot + // hold the pass in place. + let mut after: (i64, String) = (i64::MIN, String::new()); + loop { + let page = self.pending_quality(&after).await?; + let Some(last) = page.last() else { + break; + }; + after = (last.created_at, last.track_id.clone()); + let results: Vec>> = stream::iter(page) + .map(|pending| self.measure_one(pending)) + .buffer_unordered(parallelism) + .chunks(BATCH) + .collect() + .await; + for batch in results { + let batch: Vec = batch.into_iter().flatten().collect(); + measured += batch.len(); + self.write_measurements(&decoder, batch).await?; + } + } + Ok(measured) + } + + /// One page of the files with nothing current, oldest first. + async fn pending_quality(&self, after: &(i64, String)) -> Result, ServiceError> { + let rows = sqlx::query(concat!( + "SELECT t.id, t.full_hash, l.root_path, t.relative_path, t.duration_ms, t.created_at \ + FROM track t JOIN library l ON l.id = t.library_id", + quality_join!(), + " WHERE t.is_available = 1 AND ", + waveflow_core::lossless_track_sql!(), + " AND ta.track_id IS NULL AND (t.created_at, t.id) > (?, ?) \ + ORDER BY t.created_at, t.id LIMIT ?" + )) + .bind(after.0) + .bind(&after.1) + .bind(PAGE) + .fetch_all(self.db.pool()) + .await?; + rows.into_iter() + .map(|row| { + Ok(Pending { + track_id: row.try_get("id")?, + full_hash: row.try_get("full_hash")?, + root_path: row.try_get("root_path")?, + relative_path: row.try_get("relative_path")?, + duration_ms: row.try_get("duration_ms")?, + created_at: row.try_get("created_at")?, + }) + }) + .collect::>() + .map_err(ServiceError::from) + } + + /// Measures one file once no scan is running. `None` when the file cannot + /// be reached at all: a drive that is not mounted says nothing about the + /// file, so nothing is recorded and the next pass asks again. + async fn measure_one(&self, pending: Pending) -> Option { + let mut scans = self.scanner.scans_in_flight(); + let _ = scans.wait_for(|running| *running == 0).await; + let path = crate::media::secure_track_path( + std::path::Path::new(&pending.root_path), + &pending.relative_path, + ) + .await + .ok()?; + let deadline = Duration::from_secs(60) + + Duration::from_millis(u64::try_from(pending.duration_ms).unwrap_or(0)); + let measurement = + quality::measure(&self.ffprobe_path, &self.ffmpeg_path, &path, deadline).await; + Some(Measured { + track_id: pending.track_id, + full_hash: pending.full_hash, + measurement, + }) + } + + /// Writes one batch in one transaction, under the writer gate. A row is + /// written only while the track still holds the content it measured. + async fn write_measurements( + &self, + decoder: &quality::Decoder, + batch: Vec, + ) -> Result<(), ServiceError> { + if batch.is_empty() { + return Ok(()); + } + let now = now_ms(); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + for measured in batch { + let (outcome, cutoff_hz, wall_db, effective_bits, spectrum, failure) = + match measured.measurement { + Measurement::Measured { + cutoff_hz, + wall_db, + effective_bits, + spectrum, + } => ( + "measured", + cutoff_hz, + Some(wall_db), + effective_bits, + Some(spectrum), + None, + ), + Measurement::Silent { effective_bits } => { + ("silent", None, None, effective_bits, None, None) + } + Measurement::Unreadable(failure) => { + ("unreadable", None, None, None, None, Some(failure.as_str())) + } + }; + sqlx::query( + "INSERT INTO track_quality (track_id, full_hash, version, decoder, outcome, \ + cutoff_hz, wall_db, effective_bits, spectrum, failure, analyzed_at) \ + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? \ + WHERE EXISTS (SELECT 1 FROM track WHERE id = ? AND full_hash = ?) \ + ON CONFLICT (track_id) DO UPDATE SET full_hash = excluded.full_hash, \ + version = excluded.version, decoder = excluded.decoder, \ + outcome = excluded.outcome, cutoff_hz = excluded.cutoff_hz, \ + wall_db = excluded.wall_db, effective_bits = excluded.effective_bits, \ + spectrum = excluded.spectrum, failure = excluded.failure, \ + analyzed_at = excluded.analyzed_at", + ) + .bind(&measured.track_id) + .bind(&measured.full_hash) + .bind(quality::QUALITY_VERSION) + .bind(&decoder.digest) + .bind(outcome) + .bind(cutoff_hz) + .bind(wall_db) + .bind(effective_bits) + .bind(spectrum) + .bind(failure) + .bind(now) + .bind(&measured.track_id) + .bind(&measured.full_hash) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + /// A library's counts per verdict, and how far the analysis has got. Any + /// member may read it (decision 7). + pub async fn quality_summary( + &self, + user_id: Uuid, + library_id: Uuid, + ) -> Result { + self.require_member(user_id, library_id).await?; + let rows: Vec<(String, i64)> = sqlx::query_as(concat!( + "SELECT verdict, COUNT(*) FROM (SELECT ", + quality_verdict_sql!(), + " AS verdict FROM track t", + quality_join!(), + " WHERE t.library_id = ? AND t.is_available = 1) GROUP BY verdict" + )) + .bind(library_id.to_string()) + .fetch_all(self.db.pool()) + .await?; + let count = |verdict: QualityVerdict| { + rows.iter() + .find(|(name, _)| name == verdict.as_str()) + .map_or(0, |(_, count)| *count) + }; + let not_lossless = count(QualityVerdict::Lossy) + count(QualityVerdict::NotMeasured); + let total: i64 = rows.iter().map(|(_, count)| count).sum(); + let lossless = total - not_lossless; + Ok(QualitySummary { + enabled: self.quality.0.parallelism > 0, + lossless, + measured: lossless - count(QualityVerdict::Pending), + verdicts: QualityVerdict::ALL + .into_iter() + .map(|verdict| QualityVerdictCount { + verdict, + count: count(verdict), + }) + .collect(), + }) + } + + /// One page of a library's files with their verdicts, worst first, then + /// lowest cutoff first. Any member may read it. + pub async fn quality_tracks( + &self, + user_id: Uuid, + library_id: Uuid, + verdict: Option, + codec: Option<&str>, + page: BrowsePage, + ) -> Result, ServiceError> { + self.require_member(user_id, library_id).await?; + let verdict = verdict.map(QualityVerdict::as_str); + let rows = sqlx::query(concat!( + "SELECT * FROM (SELECT s.*, ", + quality_verdict_sql!(), + " AS q_verdict, ta.cutoff_hz AS q_cutoff_hz, ta.wall_db AS q_wall_db, \ + ta.effective_bits AS q_effective_bits, ta.analyzed_at AS q_analyzed_at \ + FROM (", + song_select!(), + " AND t.library_id = ?) AS s JOIN track t ON t.id = s.id", + quality_join!(), + ") AS q WHERE (? IS NULL OR q.q_verdict = ?) \ + AND (? IS NULL OR q.codec = ? COLLATE NOCASE) \ + ORDER BY ", + verdict_rank_sql!("q.q_verdict"), + ", q.q_cutoff_hz NULLS LAST, q.title COLLATE NOCASE, q.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(library_id.to_string()) + .bind(verdict) + .bind(verdict) + .bind(codec) + .bind(codec) + .bind(page.limit) + .bind(page.offset) + .fetch_all(self.db.pool()) + .await?; + let mut qualities = Vec::with_capacity(rows.len()); + let mut songs = Vec::with_capacity(rows.len()); + for row in rows { + qualities.push(quality_from_row(&row, "q_")?); + songs.push(song_from_row(row)?); + } + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(songs + .into_iter() + .zip(qualities) + .map(|(song, quality)| QualityTrack { song, quality }) + .collect()) + } + + /// One track's verdict, its numbers and its averaged spectrum. + pub async fn track_quality( + &self, + user_id: Uuid, + track_id: Uuid, + ) -> Result { + let row = sqlx::query(concat!( + "SELECT ", + quality_verdict_sql!(), + " AS q_verdict, ta.cutoff_hz AS q_cutoff_hz, ta.wall_db AS q_wall_db, \ + ta.effective_bits AS q_effective_bits, ta.analyzed_at AS q_analyzed_at, \ + ta.spectrum AS q_spectrum, t.sample_rate \ + FROM track t JOIN library_member m ON m.library_id = t.library_id", + quality_join!(), + " WHERE m.user_id = ? AND t.id = ? AND t.is_available = 1" + )) + .bind(user_id.to_string()) + .bind(track_id.to_string()) + .fetch_optional(self.db.pool()) + .await? + .ok_or(ServiceError::NotFound)?; + let spectrum: Option> = row.try_get("q_spectrum")?; + Ok(TrackQualityDetail { + quality: quality_from_row(&row, "q_")?, + spectrum: spectrum.as_deref().map(quality::spectrum_db), + }) + } + + /// Asks for a library to be measured again from scratch (decision 5). + /// Owners and managers only; anyone else gets the `404` of a foreign + /// library. + pub async fn request_quality_analysis( + &self, + user_id: Uuid, + library_id: Uuid, + ) -> Result<(), ServiceError> { + { + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + curated_library(&mut tx, user_id, library_id).await?; + sqlx::query( + "DELETE FROM track_quality WHERE track_id IN \ + (SELECT id FROM track WHERE library_id = ?)", + ) + .bind(library_id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + } + self.quality.0.wake.notify_one(); + Ok(()) + } + + /// A track's spectrogram, drawn on demand (decision 8). + /// + /// Access is checked first, on every request: before a `304`, so a + /// browser's copy cannot outlive a membership, and before the bound, so an + /// account that may not read the track gets `404` and never learns from a + /// `429` that something is there. + pub async fn track_spectrogram( + &self, + user_id: Uuid, + track_id: Uuid, + if_none_match: Option<&str>, + ) -> Result { + let (root_path, relative_path, full_hash): (String, String, String) = sqlx::query_as( + "SELECT l.root_path, t.relative_path, t.full_hash FROM track t \ + JOIN library l ON l.id = t.library_id \ + JOIN library_member m ON m.library_id = t.library_id \ + WHERE m.user_id = ? AND t.id = ? AND t.is_available = 1", + ) + .bind(user_id.to_string()) + .bind(track_id.to_string()) + .fetch_optional(self.db.pool()) + .await? + .ok_or(ServiceError::NotFound)?; + let webp = self.quality.decoder().is_some_and(|decoder| decoder.webp); + let etag = format!( + "\"{}\"", + &hex::encode(Sha256::digest(format!( + "{full_hash}|{}|{webp}", + quality::SPECTROGRAM_FILTER + )))[..32] + ); + if if_none_match.is_some_and(|value| { + value + .split(',') + .any(|candidate| candidate.trim().trim_start_matches("W/") == etag) + }) { + return Ok(Spectrogram::NotModified { etag }); + } + let per_user = self + .quality + .0 + .spectrogram_users + .entry(user_id) + .or_insert_with(|| Arc::new(Semaphore::new(1))) + .clone(); + let _mine = per_user + .try_acquire_owned() + .map_err(|_| ServiceError::Busy)?; + let _slot = Arc::clone(&self.quality.0.spectrograms) + .try_acquire_owned() + .map_err(|_| ServiceError::Busy)?; + let path = + crate::media::secure_track_path(std::path::Path::new(&root_path), &relative_path) + .await + .map_err(|_| ServiceError::Unavailable)?; + let picture = quality::spectrogram(&self.ffmpeg_path, &path, webp) + .await + .ok_or(ServiceError::Unavailable)?; + Ok(Spectrogram::Picture { + etag, + bytes: picture.bytes, + content_type: picture.content_type, + }) + } + + /// The caller is a member of the library, in any role. + async fn require_member(&self, user_id: Uuid, library_id: Uuid) -> Result<(), ServiceError> { + sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM library_member WHERE user_id = ? AND library_id = ?", + ) + .bind(user_id.to_string()) + .bind(library_id.to_string()) + .fetch_optional(self.db.pool()) + .await? + .map(|_| ()) + .ok_or(ServiceError::NotFound) + } +} + +/// Resolves when the number of running scans falls back to zero after having +/// changed; never, once the scanner is gone. +async fn scan_finished(scans: &mut tokio::sync::watch::Receiver) { + loop { + if scans.changed().await.is_err() { + std::future::pending::<()>().await; + } + if *scans.borrow_and_update() == 0 { + return; + } + } +} + +fn quality_from_row( + row: &sqlx::sqlite::SqliteRow, + prefix: &str, +) -> Result { + let verdict: String = row.try_get(format!("{prefix}verdict").as_str())?; + let sample_rate: Option = row.try_get("sample_rate")?; + Ok(TrackQuality { + verdict: QualityVerdict::parse(&verdict).ok_or_else(|| { + sqlx::Error::Decode(format!("unknown quality verdict {verdict:?}").into()) + })?, + cutoff_hz: row.try_get(format!("{prefix}cutoff_hz").as_str())?, + wall_db: row.try_get(format!("{prefix}wall_db").as_str())?, + nyquist_hz: sample_rate.map(|rate| rate as f64 / 2.0), + effective_bits: row.try_get(format!("{prefix}effective_bits").as_str())?, + analyzed_at: row.try_get(format!("{prefix}analyzed_at").as_str())?, + }) +} diff --git a/tests/quality.rs b/tests/quality.rs new file mode 100644 index 00000000..e728974a --- /dev/null +++ b/tests/quality.rs @@ -0,0 +1,973 @@ +//! The real quality of a file, measured from its audio (RFC-016). +//! +//! Every file here is made by FFmpeg from noise, so its spectrum is known: a +//! full band, the wall LAME leaves at 128 kbps, the filter of a 44.1 kHz +//! source resampled to 96 kHz, a 16-bit signal in a 24-bit container. The +//! scanner indexes them for real, and the analysis measures them through the +//! same FFmpeg the server runs. +//! +//! The accounts are rows written by hand and reach the routes through an API +//! token, as in `tests/credits.rs`: nothing here hands a literal to a hash. + +#[allow(dead_code)] +mod support; + +use std::path::{Path, PathBuf}; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use http_body_util::BodyExt; +use support::*; +use tower::ServiceExt; +use uuid::Uuid; +use waveflow_server::authentication::now_ms; +use waveflow_server::catalog::LibraryRecord; +use waveflow_server::database::{LibraryRole, LibraryVisibility}; +use waveflow_server::services::{AttentionCategory, BrowsePage, QualityVerdict}; +use waveflow_server::AppState; + +struct Shelf { + state: AppState, + config: waveflow_server::Config, + owner: Uuid, + library: LibraryRecord, + music: PathBuf, + _temp: tempfile::TempDir, +} + +async fn account(state: &AppState, name: &str) -> Uuid { + let id = Uuid::new_v4(); + let now = now_ms(); + sqlx::query( + "INSERT INTO account (id, username, password_hash, role, disabled, created_at, updated_at) \ + VALUES (?, ?, 'this-account-never-authenticates', 'admin', 0, ?, ?)", + ) + .bind(id.to_string()) + .bind(name) + .bind(now) + .bind(now) + .execute(state.db.pool()) + .await + .unwrap(); + id +} + +async fn shelf(name: &str) -> Shelf { + let (temp, config, state) = test_app().await; + let owner = account(&state, name).await; + let music = config.data_dir.join(name); + std::fs::create_dir_all(&music).unwrap(); + let root = std::fs::canonicalize(&music).unwrap(); + let id = state + .db + .create_library(owner, name, &root, LibraryVisibility::Private, now_ms()) + .await + .unwrap(); + Shelf { + state, + config, + owner, + library: LibraryRecord { + id, + name: name.into(), + root_path: root, + }, + music, + _temp: temp, + } +} + +/// Runs FFmpeg with these arguments, failing the test on any error. +fn ffmpeg(args: &[&str]) { + let output = std::process::Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y"]) + .args(args) + .output() + .expect("FFmpeg must be on PATH"); + assert!( + output.status.success(), + "ffmpeg {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// White noise: a full band, which no lossy encoder keeps. +fn noise(rate: u32, seconds: u32, seed: u32) -> String { + format!("anoisesrc=d={seconds}:c=white:a=0.3:r={rate}:s={seed}") +} + +/// A genuine 16-bit FLAC: the full band, and every bit it declares. +fn genuine(path: &Path, seed: u32) { + ffmpeg(&[ + "-f", + "lavfi", + "-i", + &noise(44_100, 6, seed), + "-ac", + "2", + "-sample_fmt", + "s16", + "-c:a", + "flac", + path.to_str().unwrap(), + ]); +} + +/// A FLAC made from a 128 kbps MP3 of the same noise. +fn from_mp3(path: &Path, scratch: &Path) { + let mp3 = scratch.join(format!( + "{}.mp3", + path.file_stem().unwrap().to_string_lossy() + )); + ffmpeg(&[ + "-f", + "lavfi", + "-i", + &noise(44_100, 6, 7), + "-ac", + "2", + "-c:a", + "libmp3lame", + "-b:a", + "128k", + mp3.to_str().unwrap(), + ]); + ffmpeg(&[ + "-i", + mp3.to_str().unwrap(), + "-sample_fmt", + "s16", + "-c:a", + "flac", + path.to_str().unwrap(), + ]); +} + +/// A 44.1 kHz recording resampled to 96 kHz and written at 24 bits: a hi-res +/// file holding nothing past what the CD held. +fn upsampled(path: &Path) { + ffmpeg(&[ + "-f", + "lavfi", + "-i", + &noise(44_100, 6, 11), + "-ac", + "2", + "-af", + "aresample=96000", + "-sample_fmt", + "s32", + "-bits_per_raw_sample", + "24", + "-c:a", + "flac", + path.to_str().unwrap(), + ]); +} + +/// Noise recorded at 96 kHz: a genuine hi-res file, full band to Nyquist. +fn hi_res(path: &Path) { + ffmpeg(&[ + "-f", + "lavfi", + "-i", + &noise(96_000, 6, 13), + "-ac", + "2", + "-sample_fmt", + "s32", + "-bits_per_raw_sample", + "24", + "-c:a", + "flac", + path.to_str().unwrap(), + ]); +} + +/// A 16-bit signal written into a 24-bit container: its low eight bits are +/// zero in every sample. +/// +/// Through a 16-bit WAV, in two runs: in one filter graph FFmpeg negotiates +/// the 16-bit step away and the noise keeps all 24 bits. +fn padded(path: &Path, scratch: &Path) { + let wav = scratch.join("padded-16.wav"); + ffmpeg(&[ + "-f", + "lavfi", + "-i", + &noise(44_100, 6, 17), + "-ac", + "2", + "-c:a", + "pcm_s16le", + wav.to_str().unwrap(), + ]); + ffmpeg(&[ + "-i", + wav.to_str().unwrap(), + "-sample_fmt", + "s32", + "-bits_per_raw_sample", + "24", + "-c:a", + "flac", + path.to_str().unwrap(), + ]); +} + +impl Shelf { + async fn scan(&self) { + run_scan(&self.state, self.owner, self.library.clone()).await; + } + + async fn analyze(&self) -> usize { + self.state.services.analyze_pending_quality().await.unwrap() + } + + async fn track(&self, relative: &str) -> Uuid { + let id: String = + sqlx::query_scalar("SELECT id FROM track WHERE library_id = ? AND relative_path = ?") + .bind(self.library.id.to_string()) + .bind(relative) + .fetch_one(self.state.db.pool()) + .await + .unwrap_or_else(|_| panic!("no track at {relative:?}")); + Uuid::parse_str(&id).unwrap() + } + + async fn verdict(&self, relative: &str) -> QualityVerdict { + let track = self.track(relative).await; + self.state + .services + .track_quality(self.owner, track) + .await + .unwrap() + .quality + .verdict + } + + async fn rows(&self) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM track_quality") + .fetch_one(self.state.db.pool()) + .await + .unwrap() + } + + /// A token for the account named `username`, minted by the owner. + async fn token(&self, username: &str) -> String { + self.state + .services + .create_api_token(self.owner, &[], username, "quality", &[]) + .await + .unwrap() + .1 + } + + async fn join(&self, user: Uuid, role: LibraryRole) { + self.state + .db + .add_library_member(self.owner, self.library.id, user, role, now_ms()) + .await + .unwrap(); + } +} + +async fn send( + router: &axum::Router, + token: &str, + request: Request, +) -> axum::response::Response { + let (mut parts, body) = request.into_parts(); + parts + .headers + .insert("authorization", format!("Bearer {token}").parse().unwrap()); + router + .clone() + .oneshot(Request::from_parts(parts, body)) + .await + .unwrap() +} + +fn get(uri: &str) -> Request { + Request::get(uri).body(Body::empty()).unwrap() +} + +#[tokio::test] +async fn each_claim_is_checked_against_the_audio() { + let shelf = shelf("claims").await; + let scratch = shelf.config.data_dir.join("scratch"); + std::fs::create_dir_all(&scratch).unwrap(); + genuine(&shelf.music.join("genuine.flac"), 3); + from_mp3(&shelf.music.join("from-mp3.flac"), &scratch); + upsampled(&shelf.music.join("upsampled.flac")); + hi_res(&shelf.music.join("hi-res.flac")); + padded(&shelf.music.join("padded.flac"), &scratch); + std::fs::copy(scratch.join("from-mp3.mp3"), shelf.music.join("lossy.mp3")).unwrap(); + shelf.scan().await; + + assert_eq!( + shelf.verdict("genuine.flac").await, + QualityVerdict::Pending, + "nothing is measured on the answering path" + ); + assert_eq!( + shelf.analyze().await, + 5, + "the five lossless files, not the MP3" + ); + + let detail = |relative: &'static str| { + let shelf = &shelf; + async move { + let track = shelf.track(relative).await; + shelf + .state + .services + .track_quality(shelf.owner, track) + .await + .unwrap() + } + }; + let fake = detail("from-mp3.flac").await; + assert_eq!(fake.quality.verdict, QualityVerdict::LossySource); + let cutoff = fake.quality.cutoff_hz.expect("a wall"); + assert!( + (15_000.0..18_000.0).contains(&cutoff), + "LAME's wall at 128 kbps, found at {cutoff}" + ); + assert_eq!(fake.spectrum.as_ref().map(Vec::len), Some(256)); + + let genuine = detail("genuine.flac").await; + assert_eq!(genuine.quality.verdict, QualityVerdict::Consistent); + assert_eq!(genuine.quality.cutoff_hz, None, "white noise has no wall"); + assert_eq!(genuine.quality.effective_bits, Some(16)); + + let resampled = detail("upsampled.flac").await; + assert_eq!( + resampled.quality.verdict, + QualityVerdict::Upsampled, + "{:?}", + resampled.quality + ); + assert_eq!( + detail("hi-res.flac").await.quality.verdict, + QualityVerdict::Consistent + ); + + let padded = detail("padded.flac").await; + assert_eq!(padded.quality.verdict, QualityVerdict::Padded); + assert_eq!(padded.quality.effective_bits, Some(16)); + + assert_eq!(shelf.verdict("lossy.mp3").await, QualityVerdict::Lossy); + assert_eq!( + shelf.analyze().await, + 0, + "a measured file is not measured again" + ); +} + +#[tokio::test] +async fn a_measurement_belongs_to_the_content_it_measured() { + let shelf = shelf("content").await; + genuine(&shelf.music.join("a.flac"), 3); + genuine(&shelf.music.join("b.flac"), 5); + shelf.scan().await; + assert_eq!(shelf.analyze().await, 2); + let a = shelf.track("a.flac").await; + + // Moved: the scanner relocates it by its hash, and the measurement holds. + std::fs::create_dir_all(shelf.music.join("moved")).unwrap(); + std::fs::rename( + shelf.music.join("a.flac"), + shelf.music.join("moved").join("a.flac"), + ) + .unwrap(); + shelf.scan().await; + assert_eq!(shelf.track("moved/a.flac").await, a, "the same track"); + assert_eq!( + shelf.verdict("moved/a.flac").await, + QualityVerdict::Consistent + ); + assert_eq!(shelf.analyze().await, 0, "a move re-measures nothing"); + + // Changed in place: the same track, another content, nothing current. + let b = shelf.track("b.flac").await; + genuine(&shelf.music.join("b.flac"), 9); + shelf.scan().await; + assert_eq!(shelf.track("b.flac").await, b, "the same track"); + assert_eq!(shelf.verdict("b.flac").await, QualityVerdict::Pending); + assert_eq!(shelf.analyze().await, 1); + assert_eq!(shelf.verdict("b.flac").await, QualityVerdict::Consistent); +} + +#[tokio::test] +async fn another_ffmpeg_or_another_analyser_measures_again() { + let shelf = shelf("decoder").await; + genuine(&shelf.music.join("a.flac"), 3); + genuine(&shelf.music.join("b.flac"), 5); + shelf.scan().await; + assert_eq!(shelf.analyze().await, 2); + let digest = shelf.state.media.ffmpeg_digest().to_owned(); + sqlx::query("UPDATE track_quality SET decoder = ? WHERE track_id = ?") + .bind("0".repeat(64)) + .bind(shelf.track("a.flac").await.to_string()) + .execute(shelf.state.db.pool()) + .await + .unwrap(); + sqlx::query("UPDATE track_quality SET version = version + 1 WHERE track_id = ?") + .bind(shelf.track("b.flac").await.to_string()) + .execute(shelf.state.db.pool()) + .await + .unwrap(); + + // What the boot does, with this process's own FFmpeg. + let removed = shelf + .state + .services + .use_quality_decoder(waveflow_server::quality::Decoder { + digest, + webp: false, + }) + .await + .unwrap(); + assert_eq!(removed, 2); + assert_eq!(shelf.verdict("a.flac").await, QualityVerdict::Pending); + assert_eq!(shelf.verdict("b.flac").await, QualityVerdict::Pending); + assert_eq!(shelf.analyze().await, 2); +} + +/// Where a FLAC file's first audio frame starts: past `fLaC` and every +/// metadata block, the last of which sets the top bit of its header. +fn flac_frames_start(bytes: &[u8]) -> usize { + assert_eq!(&bytes[..4], b"fLaC"); + let mut at = 4; + loop { + let last = bytes[at] & 0x80 != 0; + let length = u32::from_be_bytes([0, bytes[at + 1], bytes[at + 2], bytes[at + 3]]) as usize; + at += 4 + length; + if last { + return at; + } + } +} + +#[tokio::test] +async fn a_file_ffmpeg_cannot_read_is_recorded_once() { + let shelf = shelf("unreadable").await; + let whole = shelf.config.data_dir.join("whole.flac"); + genuine(&whole, 3); + // The metadata intact and every frame zeroed: the scanner reads the + // stream's description, and FFmpeg finds no frame to decode. + let mut bytes = std::fs::read(&whole).unwrap(); + let frames = flac_frames_start(&bytes); + bytes[frames..].fill(0); + std::fs::write(shelf.music.join("broken.flac"), &bytes).unwrap(); + shelf.scan().await; + + assert_eq!(shelf.analyze().await, 1); + assert_eq!( + shelf.verdict("broken.flac").await, + QualityVerdict::Unreadable + ); + assert_eq!( + shelf.analyze().await, + 0, + "not retried while it is the same file" + ); +} + +#[tokio::test] +async fn a_running_scan_holds_the_analysis_back() { + let shelf = shelf("yield").await; + genuine(&shelf.music.join("a.flac"), 3); + shelf.scan().await; + + let scan = shelf.state.scanner.begin_scan(); + let services = shelf.state.services.clone(); + let pass = tokio::spawn(async move { services.analyze_pending_quality().await.unwrap() }); + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + assert_eq!( + shelf.rows().await, + 0, + "nothing is measured while a scan runs" + ); + assert!(!pass.is_finished()); + + drop(scan); + assert_eq!(pass.await.unwrap(), 1); + assert_eq!(shelf.rows().await, 1); +} + +/// Shaped noise, as the desktop's own tests make it: white noise through an +/// FFT mask, so the spectrum is exactly what the test says. +fn shaped_noise(rate: f64, seconds: f64, seed: u32, gain_db: impl Fn(f64) -> f64) -> Vec { + use realfft::RealFftPlanner; + const FRAME: usize = 4096; + let mut planner = RealFftPlanner::::new(); + let forward = planner.plan_fft_forward(FRAME); + let inverse = planner.plan_fft_inverse(FRAME); + let mut state = seed; + let mut out = Vec::new(); + while (out.len() as f64) < seconds * rate { + let mut block: Vec = (0..FRAME) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state as f32 / u32::MAX as f32) - 0.5 + }) + .collect(); + let mut spectrum = forward.make_output_vec(); + forward.process(&mut block, &mut spectrum).unwrap(); + for (k, bin) in spectrum.iter_mut().enumerate() { + let hz = k as f64 * rate / FRAME as f64; + *bin *= 10f32.powf((gain_db(hz) / 20.0) as f32); + } + let mut back = inverse.make_output_vec(); + inverse.process(&mut spectrum, &mut back).unwrap(); + out.extend(back.iter().map(|value| value / FRAME as f32)); + } + out +} + +/// A 16-bit PCM WAV, so both decoders hand the meter the same integers. +fn write_wav(path: &Path, rate: u32, channels: &[Vec]) { + let frames = channels[0].len(); + let count = channels.len() as u16; + let data_len = (frames * channels.len() * 2) as u32; + let mut bytes = Vec::with_capacity(44 + data_len as usize); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&count.to_le_bytes()); + bytes.extend_from_slice(&rate.to_le_bytes()); + bytes.extend_from_slice(&(rate * u32::from(count) * 2).to_le_bytes()); + bytes.extend_from_slice(&(count * 2).to_le_bytes()); + bytes.extend_from_slice(&16u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + for frame in 0..frames { + for channel in channels { + let sample = (channel[frame] * 4.0).clamp(-1.0, 1.0); + bytes.extend_from_slice(&((sample * 32_767.0) as i16).to_le_bytes()); + } + } + std::fs::write(path, bytes).unwrap(); +} + +/// The server's pipeline and the desktop's `analyze_file` find the same wall, +/// or the same absence of one, on the desktop's own test signals (RFC-016 +/// decision 2). +#[tokio::test] +async fn the_port_finds_what_the_desktop_finds() { + let temp = tempfile::tempdir().unwrap(); + let rate = 44_100.0; + let tilt = |hz: f64| -3.0 * (hz.max(1000.0) / 1000.0).log2(); + let mut signals: Vec<(String, Vec>)> = Vec::new(); + for cut in [16_000.0, 19_000.0, 20_000.0] { + signals.push(( + format!("cut-{cut}"), + vec![shaped_noise(rate, 10.0, 1, |hz| { + tilt(hz) + if hz > cut { -60.0 } else { 0.0 } + })], + )); + } + signals.push(("full".into(), vec![shaped_noise(rate, 10.0, 2, tilt)])); + signals.push(( + "fade".into(), + vec![shaped_noise(rate, 10.0, 3, |hz| { + tilt(hz) - 12.0 * (hz.max(6000.0) / 6000.0).log2() + })], + )); + let lows = shaped_noise(rate, 10.0, 4, |hz| { + tilt(hz) + if hz > 15_000.0 { -80.0 } else { 0.0 } + }); + let highs = shaped_noise(rate, 10.0, 5, |hz| { + tilt(hz) + if hz > 15_000.0 { 0.0 } else { -80.0 } + }); + signals.push(( + "opposition".into(), + vec![ + lows.iter().zip(&highs).map(|(l, h)| l + h).collect(), + lows.iter().zip(&highs).map(|(l, h)| l - h).collect(), + ], + )); + + let bin = rate / 4096.0; + let mut walls = 0; + for (name, channels) in signals { + let path = temp.path().join(format!("{name}.wav")); + write_wav(&path, rate as u32, &channels); + let desktop = waveflow_core::analysis::analyze_file(&path) + .unwrap() + .bandwidth + .expect("the desktop measured it"); + let server = waveflow_server::quality::measure( + Path::new("ffprobe"), + Path::new("ffmpeg"), + &path, + std::time::Duration::from_secs(60), + ) + .await; + let waveflow_server::quality::Measurement::Measured { cutoff_hz, .. } = server else { + panic!("{name}: the server measured nothing: {server:?}"); + }; + match (desktop.cutoff_hz, cutoff_hz) { + (None, None) => {} + (Some(a), Some(b)) => { + walls += 1; + assert!((a - b).abs() <= bin, "{name}: {a} against {b}"); + } + other => panic!("{name}: the two disagree on the wall: {other:?}"), + } + } + assert_eq!(walls, 3, "the three cuts, and nothing else"); +} + +#[tokio::test] +async fn members_read_the_quality_and_only_curators_ask_for_it_again() { + let shelf = shelf("routes").await; + let scratch = shelf.config.data_dir.join("scratch"); + std::fs::create_dir_all(&scratch).unwrap(); + genuine(&shelf.music.join("genuine.flac"), 3); + from_mp3(&shelf.music.join("from-mp3.flac"), &scratch); + shelf.scan().await; + shelf.analyze().await; + let listener = account(&shelf.state, "listener").await; + account(&shelf.state, "stranger").await; + shelf.join(listener, LibraryRole::Listener).await; + let router = waveflow_server::app(&shelf.config, shelf.state.clone()); + let owning = shelf.token("routes").await; + let listening = shelf.token("listener").await; + let outside = shelf.token("stranger").await; + let library = shelf.library.id; + let fake = shelf.track("from-mp3.flac").await; + + let summary = json_body( + send( + &router, + &listening, + get(&format!("/api/v2/libraries/{library}/quality")), + ) + .await, + ) + .await; + assert_eq!(summary["enabled"], true); + assert_eq!(summary["lossless"], 2); + assert_eq!(summary["measured"], 2); + assert_eq!(summary["verdicts"][0]["verdict"], "lossy_source"); + assert_eq!(summary["verdicts"][0]["count"], 1); + + let list = json_body( + send( + &router, + &listening, + get(&format!("/api/v2/libraries/{library}/quality/tracks")), + ) + .await, + ) + .await; + let list = list.as_array().unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0]["id"], fake.to_string(), "the worst first"); + assert_eq!(list[0]["quality"]["verdict"], "lossy_source"); + assert_eq!(list[0]["quality"]["nyquist_hz"], 22_050.0); + assert_eq!(list[1]["quality"]["verdict"], "consistent"); + + let filtered = json_body( + send( + &router, + &owning, + get(&format!( + "/api/v2/libraries/{library}/quality/tracks?verdict=consistent" + )), + ) + .await, + ) + .await; + assert_eq!(filtered.as_array().unwrap().len(), 1); + assert_eq!( + send( + &router, + &owning, + get(&format!( + "/api/v2/libraries/{library}/quality/tracks?verdict=fine" + )), + ) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + + let detail = json_body( + send( + &router, + &listening, + get(&format!("/api/v2/tracks/{fake}/quality")), + ) + .await, + ) + .await; + assert_eq!(detail["verdict"], "lossy_source"); + assert_eq!(detail["spectrum"].as_array().unwrap().len(), 256); + + let analysis = || { + Request::post(format!("/api/v2/libraries/{library}/quality/analysis")) + .body(Body::empty()) + .unwrap() + }; + assert_eq!( + send(&router, &listening, analysis()).await.status(), + StatusCode::NOT_FOUND, + "a listener cannot spend the operator's CPU" + ); + assert_eq!(shelf.rows().await, 2); + assert_eq!( + send(&router, &owning, analysis()).await.status(), + StatusCode::ACCEPTED + ); + assert_eq!( + shelf.rows().await, + 0, + "the library's measurements are dropped" + ); + assert_eq!(shelf.analyze().await, 2); + + for uri in [ + format!("/api/v2/libraries/{library}/quality"), + format!("/api/v2/libraries/{library}/quality/tracks"), + format!("/api/v2/tracks/{fake}/quality"), + format!("/api/v2/tracks/{fake}/spectrogram"), + ] { + assert_eq!( + send(&router, &outside, get(&uri)).await.status(), + StatusCode::NOT_FOUND, + "a stranger is told nothing at {uri}" + ); + } + assert_eq!( + send(&router, &outside, analysis()).await.status(), + StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn the_spectrogram_is_revalidated_by_whoever_asks() { + let shelf = shelf("spectrogram").await; + genuine(&shelf.music.join("a.flac"), 3); + shelf.scan().await; + let listener = account(&shelf.state, "listener").await; + shelf.join(listener, LibraryRole::Listener).await; + let router = waveflow_server::app(&shelf.config, shelf.state.clone()); + let listening = shelf.token("listener").await; + let track = shelf.track("a.flac").await; + let uri = format!("/api/v2/tracks/{track}/spectrogram"); + + let response = send(&router, &listening, get(&uri)).await; + assert_eq!(response.status(), StatusCode::OK); + let content_type = response.headers()[header::CONTENT_TYPE] + .to_str() + .unwrap() + .to_owned(); + assert!( + content_type == "image/webp" || content_type == "image/jpeg", + "{content_type}" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-cache", + "kept, but asked about before every use" + ); + let etag = response.headers()[header::ETAG] + .to_str() + .unwrap() + .to_owned(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let magic: &[u8] = if content_type == "image/webp" { + b"RIFF" + } else { + &[0xFF, 0xD8] + }; + assert!(bytes.starts_with(magic), "a picture of the type it names"); + + let revalidate = || { + Request::get(&uri) + .header(header::IF_NONE_MATCH, &etag) + .body(Body::empty()) + .unwrap() + }; + assert_eq!( + send(&router, &listening, revalidate()).await.status(), + StatusCode::NOT_MODIFIED + ); + + shelf + .state + .db + .remove_library_member(shelf.owner, shelf.library.id, listener, now_ms()) + .await + .unwrap(); + assert_eq!( + send(&router, &listening, revalidate()).await.status(), + StatusCode::NOT_FOUND, + "a browser's copy does not outlive the membership" + ); +} + +#[tokio::test] +async fn one_spectrogram_per_account_at_a_time() { + let shelf = shelf("bound").await; + // Long enough that the first is still being drawn when the second asks. + ffmpeg(&[ + "-f", + "lavfi", + "-i", + &noise(44_100, 240, 3), + "-ac", + "2", + "-sample_fmt", + "s16", + "-c:a", + "flac", + shelf.music.join("long.flac").to_str().unwrap(), + ]); + shelf.scan().await; + let router = waveflow_server::app(&shelf.config, shelf.state.clone()); + let token = shelf.token("bound").await; + let uri = format!( + "/api/v2/tracks/{}/spectrogram", + shelf.track("long.flac").await + ); + + let (first, second) = tokio::join!( + send(&router, &token, get(&uri)), + send(&router, &token, get(&uri)) + ); + let mut statuses = [first.status(), second.status()]; + statuses.sort(); + assert_eq!(statuses, [StatusCode::OK, StatusCode::TOO_MANY_REQUESTS]); + let busy = if first.status() == StatusCode::TOO_MANY_REQUESTS { + first + } else { + second + }; + assert!(busy.headers().contains_key(header::RETRY_AFTER)); +} + +#[tokio::test] +async fn needs_attention_lists_the_fake_lossless_files() { + let shelf = shelf("attention").await; + let scratch = shelf.config.data_dir.join("scratch"); + std::fs::create_dir_all(&scratch).unwrap(); + genuine(&shelf.music.join("genuine.flac"), 3); + from_mp3(&shelf.music.join("from-mp3.flac"), &scratch); + padded(&shelf.music.join("padded.flac"), &scratch); + shelf.scan().await; + + let count = || async { + shelf + .state + .services + .attention_summary(shelf.owner, shelf.library.id) + .await + .unwrap() + .into_iter() + .find(|item| item.category == AttentionCategory::ProbableFakeLossless) + .unwrap() + .count + }; + assert_eq!(count().await, 0, "nothing before it is measured"); + shelf.analyze().await; + assert_eq!(count().await, 1, "the fake, and not the padded file"); + let tracks = shelf + .state + .services + .attention_tracks( + shelf.owner, + shelf.library.id, + AttentionCategory::ProbableFakeLossless, + BrowsePage::default(), + ) + .await + .unwrap(); + assert_eq!(tracks.len(), 1); + assert_eq!(tracks[0].song.id, shelf.track("from-mp3.flac").await); +} + +/// With the analysis off, the server draws one spectrogram at a time; a +/// stranger who asks while that one is drawn is told nothing exists, not that +/// the server is busy with it. +#[tokio::test] +async fn a_stranger_is_told_nothing_even_at_the_bound() { + let temp = tempfile::tempdir().unwrap(); + let mut config = waveflow_server::Config::for_data_dir(temp.path().join("data")); + config.quality_parallelism = 0; + let state = waveflow_server::initialize(&config).await.unwrap(); + let owner = account(&state, "off").await; + let music = config.data_dir.join("off"); + std::fs::create_dir_all(&music).unwrap(); + let root = std::fs::canonicalize(&music).unwrap(); + let id = state + .db + .create_library(owner, "off", &root, LibraryVisibility::Private, now_ms()) + .await + .unwrap(); + let shelf = Shelf { + state, + config, + owner, + library: LibraryRecord { + id, + name: "off".into(), + root_path: root, + }, + music, + _temp: temp, + }; + ffmpeg(&[ + "-f", + "lavfi", + "-i", + &noise(44_100, 240, 3), + "-ac", + "2", + "-sample_fmt", + "s16", + "-c:a", + "flac", + shelf.music.join("long.flac").to_str().unwrap(), + ]); + shelf.scan().await; + account(&shelf.state, "stranger").await; + let router = waveflow_server::app(&shelf.config, shelf.state.clone()); + let owning = shelf.token("off").await; + let outside = shelf.token("stranger").await; + let track = shelf.track("long.flac").await; + let uri = format!("/api/v2/tracks/{track}/spectrogram"); + + assert_eq!(shelf.analyze().await, 0, "the operator turned it off"); + let summary = json_body( + send( + &router, + &owning, + get(&format!("/api/v2/libraries/{}/quality", shelf.library.id)), + ) + .await, + ) + .await; + assert_eq!(summary["enabled"], false); + assert_eq!(summary["measured"], 0); + + let (drawn, refused) = tokio::join!(send(&router, &owning, get(&uri)), async { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + send(&router, &outside, get(&uri)).await + }); + assert_eq!(drawn.status(), StatusCode::OK); + assert_eq!( + refused.status(), + StatusCode::NOT_FOUND, + "access is checked before the bound" + ); +} From 4f45b2b617d97cd9062d32f6aa98474e79169b1e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 17:29:18 +0200 Subject: [PATCH 2/7] feat(webapp): add a quality page and a real quality section in track properties Every member sees the library's files by what their audio says, worst first, with filters by verdict and format; owners and managers can ask for the whole library to be analysed again. A track's properties show the verdict, the numbers behind it, the averaged spectrum and, on request, the spectrogram (RFC-016). Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 215 ++++++++++++- webapp/src/api/attention.ts | 3 +- webapp/src/api/index.ts | 16 + webapp/src/api/quality.ts | 110 +++++++ webapp/src/api/queries.ts | 52 +++ webapp/src/components/icons.tsx | 14 +- webapp/src/components/quality-section.tsx | 277 ++++++++++++++++ webapp/src/components/track-properties.tsx | 2 + webapp/src/lib/quality.test.ts | 44 +++ webapp/src/lib/quality.ts | 70 ++++ webapp/src/main.tsx | 24 ++ webapp/src/pages/attention-page.tsx | 4 + webapp/src/pages/quality-page.tsx | 358 +++++++++++++++++++++ webapp/src/state/i18n.tsx | 128 ++++++++ webapp/src/styles/styles.css | 127 ++++++++ 15 files changed, 1439 insertions(+), 5 deletions(-) create mode 100644 webapp/src/api/quality.ts create mode 100644 webapp/src/components/quality-section.tsx create mode 100644 webapp/src/lib/quality.test.ts create mode 100644 webapp/src/lib/quality.ts create mode 100644 webapp/src/pages/quality-page.tsx diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index f0813de0..ed263b70 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -3432,10 +3432,11 @@ test("opens the rest of the client from the mobile bar", async ({ const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); - // The thirteen the bar cannot hold — including the three this account is + // The fourteen the bar cannot hold — including the three this account is // entitled to as an administrator and owner of a library that takes files. // `Radio` joined them with #217, `Folders` with RFC-012's filed folders and - // `Needs attention` with RFC-015; this count is what said so: a new entry in + // `Needs attention` with RFC-015 and `Quality` with RFC-016; this count is + // what said so: a new entry in // the sidebar reaches the sheet, and the sheet is pinned. for (const name of [ "Artists", @@ -3443,6 +3444,7 @@ test("opens the rest of the client from the mobile bar", async ({ "Genres", "Folders", "Needs attention", + "Quality", "Settings", "Random", "Recently played", @@ -3456,7 +3458,7 @@ test("opens the rest of the client from the mobile bar", async ({ } // Counted as well as named: a sheet that also offered something nobody asked // for would satisfy every line above. - await expect(sheet.getByRole("link")).toHaveCount(13); + await expect(sheet.getByRole("link")).toHaveCount(14); await expectNoWcagViolations(page); @@ -3495,6 +3497,8 @@ test("offers nothing in the sheet that the account may not reach", async ({ await expect(sheet.getByRole("link", { name: "Shares" })).toBeVisible(); await expect(sheet.getByRole("link", { name: "Admin" })).toHaveCount(0); await expect(sheet.getByRole("link", { name: "Upload" })).toHaveCount(0); + // Every member reads the quality of the files (RFC-016). + await expect(sheet.getByRole("link", { name: "Quality" })).toBeVisible(); // Nothing on it is a listener's to decide (RFC-015). await expect( sheet.getByRole("link", { name: "Needs attention" }), @@ -6182,3 +6186,208 @@ for (const theme of [ await expectNoWcagViolations(page); }); } + +/** + * The quality page (RFC-016), as the server answers it: the counts per + * verdict, the files worst first, one file's measurement with its averaged + * spectrum, and a spectrogram drawn when asked. Held here so a test can watch + * what the page asks for and writes. + */ +async function serveQuality(page: Page) { + const fake = { + ...song(31, "Made From An MP3", 0, false), + bit_depth: 24, + bitrate: 812, + quality: { + verdict: "lossy_source", + cutoff_hz: 16_390, + wall_db: 41.2, + nyquist_hz: 22_050, + effective_bits: 24, + analyzed_at: 1_700_000_000_000, + }, + }; + const genuine = { + ...song(32, "Straight From The Desk", 0, false), + quality: { + verdict: "consistent", + cutoff_hz: null, + wall_db: 4.1, + nyquist_hz: 22_050, + effective_bits: 16, + analyzed_at: 1_700_000_000_000, + }, + }; + const state = { + asked: [] as string[], + writes: [] as string[], + spectrograms: 0, + }; + const base = "**/api/v2/libraries/library-1"; + await page.route(`${base}/quality`, (route) => + route.fulfill({ + json: { + enabled: true, + lossless: 3, + measured: 2, + verdicts: [ + { verdict: "lossy_source", count: 1 }, + { verdict: "upsampled", count: 0 }, + { verdict: "padded", count: 0 }, + { verdict: "unreadable", count: 0 }, + { verdict: "silent", count: 0 }, + { verdict: "pending", count: 1 }, + { verdict: "consistent", count: 1 }, + { verdict: "lossy", count: 4 }, + { verdict: "not_measured", count: 0 }, + ], + }, + }), + ); + await page.route(`${base}/quality/tracks?*`, (route) => { + const url = new URL(route.request().url()); + state.asked.push(url.search); + const verdict = url.searchParams.get("verdict"); + const all = [fake, genuine]; + return route.fulfill({ + json: verdict + ? all.filter((item) => item.quality.verdict === verdict) + : all, + }); + }); + await page.route(`${base}/quality/analysis`, async (route) => { + state.writes.push(route.request().method()); + await route.fulfill({ status: 202, body: "" }); + }); + await page.route("**/api/v2/tracks/song-31/quality", (route) => + route.fulfill({ + json: { + ...fake.quality, + // Level to 16 kHz, then a wall: what LAME leaves at 128 kbps. + spectrum: Array.from({ length: 256 }, (_, band) => + band < 190 ? -20 - band / 20 : -95, + ), + }, + }), + ); + await page.route("**/api/v2/tracks/song-31", (route) => + route.fulfill({ + json: { + ...fake, + relative_path: "Fakes/Made From An MP3.flac", + full_hash: "b".repeat(64), + created_at: 1_700_000_000_000, + }, + }), + ); + // No correction: the catch-all would answer with a track, which the panel + // would read as one. + await page.route("**/api/v2/tracks/song-31/overrides", (route) => + route.fulfill({ status: 404, json: { error: "not found" } }), + ); + await page.route("**/api/v2/tracks/song-31/spectrogram", async (route) => { + state.spectrograms += 1; + await route.fulfill({ + status: 200, + headers: { + "content-type": "image/png", + "cache-control": "private, no-cache", + }, + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z4m8AAAAASUVORK5CYII=", + "base64", + ), + }); + }); + return state; +} + +test("lists a library's files by what their audio says, worst first", async ({ + page, +}) => { + const state = await serveQuality(page); + await page.goto("/quality"); + await expect(page.getByRole("heading", { name: "Quality" })).toBeVisible(); + await expect(page.getByText("2 of 3 lossless files analysed")).toBeVisible(); + + const verdicts = page.getByRole("list", { name: "Verdicts" }); + await expect( + verdicts.getByRole("link", { name: /Probably from a lossy file/ }), + ).toContainText("1"); + const rows = page.locator(".quality-table tbody tr"); + await expect(rows).toHaveCount(2); + await expect(rows.first()).toContainText("Made From An MP3"); + await expect(rows.first()).toContainText("16.4 kHz"); + await expect(rows.first()).toContainText("Probably from a lossy file"); + await expect(rows.nth(1)).toContainText("Full band"); + await expect(rows.nth(1)).toContainText("No sign of a lossy source"); + await expectNoWcagViolations(page); + + // A verdict filters through the server, and is written in the address. + await verdicts + .getByRole("link", { name: /Probably from a lossy file/ }) + .click(); + await expect(page).toHaveURL(/verdict=lossy_source/); + await expect(rows).toHaveCount(1); + expect( + state.asked.some((search) => search.includes("verdict=lossy_source")), + ).toBe(true); + + // A file opens on its real quality: the verdict, the numbers behind it, the + // spectrum it was judged on, and no spectrogram until somebody asks. + await page + .getByRole("button", { name: /^Made From An MP3/ }) + .click(); + const dialog = page.getByRole("dialog"); + await expect( + dialog.getByRole("heading", { name: "Real quality" }), + ).toBeVisible(); + await expect( + dialog.getByRole("img", { + name: "Averaged spectrum: the highs stop at 16.4 kHz.", + }), + ).toBeVisible(); + expect(state.spectrograms).toBe(0); + await dialog.getByRole("button", { name: "Show the spectrogram" }).click(); + await expect( + dialog.getByRole("img", { name: /Spectrogram of Made From An MP3/ }), + ).toBeVisible(); + expect(state.spectrograms).toBe(1); + await expectNoWcagViolations(page); +}); + +test("asks for the whole library to be analysed again on the second click", async ({ + page, +}) => { + const state = await serveQuality(page); + await page.goto("/quality"); + await page.getByRole("button", { name: "Analyse again" }).click(); + expect(state.writes).toEqual([]); + await page + .getByRole("button", { name: "Confirm: analyse the whole library again" }) + .click(); + await expect( + page.getByText("The library will be analysed again in the background."), + ).toBeVisible(); + expect(state.writes).toEqual(["POST"]); +}); + +test("shows a listener the quality, and nothing to ask for", async ({ + page, +}) => { + libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }]; + await serveQuality(page); + await page.goto("/quality"); + await expect(page.locator(".quality-table tbody tr")).toHaveCount(2); + await expect( + page.getByRole("button", { name: "Analyse again" }), + ).toHaveCount(0); +}); + +test("keeps the quality page readable in the light", async ({ page }) => { + await serveQuality(page); + await chooseLightTheme(page); + await page.goto("/quality"); + await expect(page.locator(".quality-table tbody tr")).toHaveCount(2); + await expectNoWcagViolations(page); +}); diff --git a/webapp/src/api/attention.ts b/webapp/src/api/attention.ts index faab2aff..c4d01781 100644 --- a/webapp/src/api/attention.ts +++ b/webapp/src/api/attention.ts @@ -17,7 +17,8 @@ export type AttentionCategory = | "compilation_no_album_artist" | "duplicate_track_number" | "track_number_gap" - | "probable_duplicate"; + | "probable_duplicate" + | "probable_fake_lossless"; /** A track category: every category but the credits. */ export type TrackCategory = Exclude; diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index 9647149b..1e0f0445 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -144,6 +144,22 @@ export { importPlaylist, listPlaylists, } from "./playlists"; +export type { + QualitySummary, + QualityTrack, + QualityVerdict, + TrackQuality, + TrackQualityDetail, +} from "./quality"; +export { + getQualitySummary, + getTrackQuality, + listQualityTracks, + QUALITY_TRACKS_PAGE, + QUALITY_VERDICTS, + requestQualityAnalysis, + spectrogramUrl, +} from "./quality"; export type { RadioStation, RadioStationDraft } from "./radio"; export { createRadioStation, diff --git a/webapp/src/api/quality.ts b/webapp/src/api/quality.ts new file mode 100644 index 00000000..d5e95a9f --- /dev/null +++ b/webapp/src/api/quality.ts @@ -0,0 +1,110 @@ +import type { Song } from "./catalog"; +import { call, request } from "./client"; + +/** + * The real quality of a file, measured from its audio (RFC-016): what the + * audio says against what the container claims. Worst first, which is the + * order the server lists and counts them in. + */ +export type QualityVerdict = + | "lossy_source" + | "upsampled" + | "padded" + | "unreadable" + | "silent" + | "pending" + | "consistent" + | "lossy" + | "not_measured"; + +export const QUALITY_VERDICTS: QualityVerdict[] = [ + "lossy_source", + "upsampled", + "padded", + "unreadable", + "silent", + "pending", + "consistent", + "lossy", + "not_measured", +]; + +export type QualitySummary = { + /** `false` when the operator turned the analysis off. */ + enabled: boolean; + /** Available lossless files: the ones the analysis is about. */ + lossless: number; + /** Of those, how many have a current measurement. */ + measured: number; + verdicts: Array<{ verdict: QualityVerdict; count: number }>; +}; + +/** One file's verdict, and the numbers behind it. */ +export type TrackQuality = { + verdict: QualityVerdict; + /** Where the highs stop, in hertz; `null` with no wall. */ + cutoff_hz: number | null; + wall_db: number | null; + nyquist_hz: number | null; + /** How many bits the samples use; `null` for floating point. */ + effective_bits: number | null; + analyzed_at: number | null; +}; + +export type QualityTrack = Song & { quality: TrackQuality }; + +/** One track's quality, with the averaged spectrum it was judged on. */ +export type TrackQualityDetail = TrackQuality & { + /** 256 bands from 0 Hz to Nyquist, in dB under the loudest one. */ + spectrum: number[] | null; +}; + +/** How many files one page of the list carries. */ +export const QUALITY_TRACKS_PAGE = 200; + +export const getQualitySummary = (libraryId: string) => + call(`/api/v2/libraries/${libraryId}/quality`); + +export const listQualityTracks = ( + libraryId: string, + filter: { verdict?: QualityVerdict; codec?: string }, + offset: number, + limit = QUALITY_TRACKS_PAGE, + signal?: AbortSignal, +) => { + const query = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + }); + if (filter.verdict) query.set("verdict", filter.verdict); + if (filter.codec) query.set("codec", filter.codec); + return call( + `/api/v2/libraries/${libraryId}/quality/tracks?${query}`, + { signal }, + ); +}; + +/** + * Drops the library's measurements and queues its files again. Owners and + * managers only; anyone else is answered the `404` of a foreign library. + */ +export const requestQualityAnalysis = (libraryId: string) => + call(`/api/v2/libraries/${libraryId}/quality/analysis`, { + method: "POST", + }); + +export const getTrackQuality = (trackId: string) => + call(`/api/v2/tracks/${trackId}/quality`); + +/** + * The track's spectrogram, drawn on demand, as an object URL for ``. + * + * Fetched with the bearer token like artwork, since `` cannot send + * one. The caller revokes the URL when it is done with it. A `429` means a + * picture is already being drawn for this account, and surfaces as the + * `ApiError` it is. + */ +export async function spectrogramUrl(trackId: string): Promise { + const response = await request(`/api/v2/tracks/${trackId}/spectrogram`); + return URL.createObjectURL(await response.blob()); +} diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries.ts index 4271c758..06930fb2 100644 --- a/webapp/src/api/queries.ts +++ b/webapp/src/api/queries.ts @@ -11,10 +11,12 @@ import { getArtist, getAttentionSummary, getLyrics, + getQualitySummary, getTrack, getTrackCredits, getTrackOverrides, getTrackProperties, + getTrackQuality, listAlbums, listApiTokens, listArtistAlbums, @@ -34,6 +36,7 @@ import { listNowPlaying, listOwnApiTokens, listPlaylists, + listQualityTracks, listRadioStations, listRandomSongs, listScrobbleDestinations, @@ -42,6 +45,8 @@ import { listSongs, listUncertainScrobbles, listUsers, + QUALITY_TRACKS_PAGE, + type QualityVerdict, readOwnSubsonicCredential, SONG_PAGE, type SongSort, @@ -266,6 +271,53 @@ export const attentionTracksQuery = ( : lastParam + ATTENTION_TRACKS_PAGE, }); +/** + * The quality page (RFC-016): keyed under `quality` and the library, so one + * invalidation after a request for a new analysis refreshes the whole page. + * While the analysis runs, the summary is asked again every ten seconds by the + * page itself; the list follows when the counts move. + */ +export const qualitySummaryQuery = (libraryId: string) => + queryOptions({ + queryKey: ["quality", libraryId, "summary"], + queryFn: () => getQualitySummary(libraryId), + }); + +/** One filter of the list, a page at a time. A short page is the end. */ +export const qualityTracksQuery = ( + libraryId: string, + filter: { verdict?: QualityVerdict; codec?: string }, +) => + infiniteQueryOptions({ + queryKey: [ + "quality", + libraryId, + "tracks", + filter.verdict ?? "", + filter.codec ?? "", + ], + queryFn: ({ pageParam, signal }) => + listQualityTracks( + libraryId, + filter, + pageParam, + QUALITY_TRACKS_PAGE, + signal, + ), + initialPageParam: 0, + getNextPageParam: (last, _pages, lastParam) => + last.length < QUALITY_TRACKS_PAGE + ? undefined + : lastParam + QUALITY_TRACKS_PAGE, + }); + +/** One track's verdict and spectrum, for its properties. */ +export const trackQualityQuery = (trackId: string) => + queryOptions({ + queryKey: ["track-quality", trackId], + queryFn: () => getTrackQuality(trackId), + }); + export const filedTreeQuery = (libraryId: string) => queryOptions({ queryKey: ["filed-tree", libraryId], diff --git a/webapp/src/components/icons.tsx b/webapp/src/components/icons.tsx index 1eea898c..dcc0f385 100644 --- a/webapp/src/components/icons.tsx +++ b/webapp/src/components/icons.tsx @@ -33,9 +33,21 @@ export type IconName = | "download" | "folder" | "back" - | "inbox"; + | "inbox" + | "quality"; const paths: Record = { + // Bars that fall away to the right and stop: a spectrum, and where its highs + // end. + quality: ( + <> + + + + + + + ), // A tray: what has arrived and is waiting to be put away. inbox: ( <> diff --git a/webapp/src/components/quality-section.tsx b/webapp/src/components/quality-section.tsx new file mode 100644 index 00000000..532c4867 --- /dev/null +++ b/webapp/src/components/quality-section.tsx @@ -0,0 +1,277 @@ +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useEffect, useState } from "react"; + +import { + ApiError, + type QualityVerdict, + type Song, + spectrogramUrl, +} from "../api"; +import { trackQualityQuery } from "../api/queries"; +import { + frequencyX, + spectrumGridlines, + spectrumPath, + verdictTone, +} from "../lib/quality"; +import { type TranslationKey, useI18n } from "../state/i18n"; + +/** The chart's box, in SVG units. */ +const WIDTH = 480; +const HEIGHT = 140; + +/** + * "Real quality" in a track's properties (RFC-016 decision 10): what the + * audio says against what the container claims, the numbers behind the + * verdict, the averaged spectrum the verdict was judged on, and a + * spectrogram drawn when somebody asks for one. + * + * Under the file's own facts and never in their place: the meter above grades + * the container, which is what it says it does, and this says what the audio + * does. Neither pretends to be the other. + */ +export function QualitySection({ song }: { song: Song }) { + const { t, locale } = useI18n(); + const { data } = useQuery(trackQualityQuery(song.id)); + // A verdict this client does not know is left unsaid rather than printed as + // a key: the type above is a claim about the server, not a guarantee. + if (!data || !Object.hasOwn(VERDICT_LABEL, data.verdict)) return null; + const kilohertz = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); + const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); + const measured = + data.verdict !== "pending" && + data.verdict !== "lossy" && + data.verdict !== "not_measured" && + data.verdict !== "unreadable"; + + const rows: Array<{ label: string; value: ReactNode }> = [ + { + label: t("quality.verdictLabel"), + value: ( + + {t(VERDICT_LABEL[data.verdict])} + + ), + }, + { + label: t("quality.highsStop"), + value: !measured + ? null + : data.cutoff_hz === null + ? t("quality.fullBand") + : t("quality.khz", { + value: kilohertz.format(data.cutoff_hz / 1000), + }), + }, + { + label: t("quality.wall"), + value: + measured && data.cutoff_hz !== null && data.wall_db !== null + ? t("quality.db", { value: number.format(data.wall_db) }) + : null, + }, + { + label: t("quality.bitsUsed"), + value: + data.effective_bits === null + ? null + : typeof song.bit_depth === "number" + ? t("quality.bitsOf", { + used: data.effective_bits, + declared: song.bit_depth, + }) + : String(data.effective_bits), + }, + { + label: t("quality.analysed"), + value: + data.analyzed_at === null + ? null + : new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format( + data.analyzed_at, + ), + }, + ]; + const shown = rows.filter((row) => row.value !== null); + + return ( +
+

{t("quality.section")}

+

+ {t(VERDICT_EXPLAIN[data.verdict])} +

+
+ {shown.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ {data.spectrum && data.nyquist_hz ? ( + + ) : null} + +
+ ); +} + +function SpectrumChart({ + spectrum, + nyquistHz, + cutoffHz, + label, +}: { + spectrum: number[]; + nyquistHz: number; + cutoffHz: number | null; + label: string; +}) { + const { locale } = useI18n(); + const kilohertz = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); + return ( +
+ + {spectrumGridlines(nyquistHz).map((hz) => { + const x = frequencyX(hz, nyquistHz, WIDTH); + return ( + + + + {kilohertz.format(hz / 1000)} + + + ); + })} + + {cutoffHz === null ? null : ( + + )} + +
{label}
+
+ ); +} + +/** + * Drawn on demand and never before: a picture costs the server a decode, so + * nobody pays for one they did not ask for. The object URL is revoked when the + * section goes. + */ +function Spectrogram({ song }: { song: Song }) { + const { t } = useI18n(); + const [state, setState] = useState< + | { kind: "idle" } + | { kind: "drawing" } + | { kind: "shown"; url: string } + | { kind: "failed"; busy: boolean } + >({ kind: "idle" }); + const shownUrl = state.kind === "shown" ? state.url : null; + useEffect( + () => () => { + if (shownUrl) URL.revokeObjectURL(shownUrl); + }, + [shownUrl], + ); + + async function draw() { + setState({ kind: "drawing" }); + try { + setState({ kind: "shown", url: await spectrogramUrl(song.id) }); + } catch (error) { + setState({ + kind: "failed", + busy: error instanceof ApiError && error.status === 429, + }); + } + } + + if (state.kind === "shown") { + return ( +
+ {t("quality.spectrogramAlt", +
+ ); + } + return ( +
+ + {state.kind === "failed" ? ( +

+ {t( + state.busy + ? "quality.spectrogramBusy" + : "quality.spectrogramFailed", + )} +

+ ) : null} +
+ ); +} + +/** What each verdict is called, on the page and in the properties. */ +export const VERDICT_LABEL: Record = { + lossy_source: "quality.verdict.lossy_source", + upsampled: "quality.verdict.upsampled", + padded: "quality.verdict.padded", + unreadable: "quality.verdict.unreadable", + silent: "quality.verdict.silent", + pending: "quality.verdict.pending", + consistent: "quality.verdict.consistent", + lossy: "quality.verdict.lossy", + not_measured: "quality.verdict.not_measured", +}; + +/** One sentence on what each verdict means, under the section's title. */ +const VERDICT_EXPLAIN: Record = { + lossy_source: "quality.explain.lossy_source", + upsampled: "quality.explain.upsampled", + padded: "quality.explain.padded", + unreadable: "quality.explain.unreadable", + silent: "quality.explain.silent", + pending: "quality.explain.pending", + consistent: "quality.explain.consistent", + lossy: "quality.explain.lossy", + not_measured: "quality.explain.not_measured", +}; diff --git a/webapp/src/components/track-properties.tsx b/webapp/src/components/track-properties.tsx index 5c532d4f..e1bb4f1f 100644 --- a/webapp/src/components/track-properties.tsx +++ b/webapp/src/components/track-properties.tsx @@ -7,6 +7,7 @@ import { useI18n } from "../state/i18n"; import { Artwork } from "./artwork"; import { Icon } from "./icons"; import { LyricsSection } from "./lyrics-section"; +import { QualitySection } from "./quality-section"; /** One row of a list, left out entirely when it has nothing to say. */ type Row = { label: string; value: ReactNode; corrected?: string | null }; @@ -259,6 +260,7 @@ export function TrackProperties({
+ ); diff --git a/webapp/src/lib/quality.test.ts b/webapp/src/lib/quality.test.ts new file mode 100644 index 00000000..daf74a3a --- /dev/null +++ b/webapp/src/lib/quality.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + frequencyX, + SPECTRUM_FLOOR_DB, + spectrumGridlines, + spectrumPath, + verdictTone, +} from "./quality"; + +describe("the averaged spectrum", () => { + it("runs from the loudest band at the top to the floor at the bottom", () => { + expect(spectrumPath([0, SPECTRUM_FLOOR_DB], 100, 50)).toBe("M0 0 L100 50"); + }); + + it("does not draw below the floor or above the top", () => { + expect(spectrumPath([5, -500], 10, 10)).toBe("M0 0 L10 10"); + }); + + it("draws nothing for nothing", () => { + expect(spectrumPath([], 100, 50)).toBe(""); + }); + + it("places a wall by its share of Nyquist", () => { + expect(frequencyX(11_025, 22_050, 200)).toBe(100); + expect(frequencyX(30_000, 22_050, 200)).toBe(200); + }); + + it("offers only the gridlines a file could reach", () => { + expect(spectrumGridlines(22_050)).toEqual([16_000, 19_000, 20_000, 22_000]); + expect(spectrumGridlines(20_000)).toEqual([16_000, 19_000]); + expect(spectrumGridlines(16_000)).toEqual([]); + }); +}); + +describe("a verdict's tone", () => { + it("draws an accusation apart from a state", () => { + expect(verdictTone("lossy_source")).toBe("finding"); + expect(verdictTone("upsampled")).toBe("finding"); + expect(verdictTone("padded")).toBe("mild"); + expect(verdictTone("pending")).toBe("neutral"); + expect(verdictTone("consistent")).toBe("neutral"); + }); +}); diff --git a/webapp/src/lib/quality.ts b/webapp/src/lib/quality.ts new file mode 100644 index 00000000..4317b0b7 --- /dev/null +++ b/webapp/src/lib/quality.ts @@ -0,0 +1,70 @@ +import type { QualityVerdict } from "../api"; + +/** + * The pure half of the quality page and of a track's "Real quality" section + * (RFC-016): the reading of a verdict, and the averaged spectrum drawn as a + * line. Kept apart from the components so a test can hold the drawing still. + */ + +/** + * What a verdict accuses a file of. A finding is drawn as a warning; the + * rest are states the analysis is in, or a verdict about nothing. + */ +export function verdictTone( + verdict: QualityVerdict, +): "finding" | "mild" | "neutral" { + switch (verdict) { + case "lossy_source": + case "upsampled": + return "finding"; + case "padded": + case "unreadable": + return "mild"; + default: + return "neutral"; + } +} + +/** + * The frequencies a reader compares a wall against, as RFC-016 decision 8 + * names them: where LAME cuts at 128, 192 and 320 kbps, and where a CD's own + * filter stands. Only those under the file's Nyquist. + */ +export function spectrumGridlines(nyquistHz: number): number[] { + return [16_000, 19_000, 20_000, 22_000].filter((hz) => hz < nyquistHz); +} + +/** The level the chart reaches down to, in dB under the loudest band. */ +export const SPECTRUM_FLOOR_DB = -120; + +/** + * The averaged spectrum as an SVG path in a `width` × `height` box: 0 Hz at + * the left, Nyquist at the right, the loudest band at the top and + * {@link SPECTRUM_FLOOR_DB} at the bottom. Empty for an empty spectrum. + */ +export function spectrumPath( + spectrum: number[], + width: number, + height: number, +): string { + if (spectrum.length === 0) return ""; + const step = spectrum.length > 1 ? width / (spectrum.length - 1) : 0; + return spectrum + .map((db, index) => { + const level = Math.min(0, Math.max(SPECTRUM_FLOOR_DB, db)); + const x = index * step; + const y = (level / SPECTRUM_FLOOR_DB) * height; + return `${index === 0 ? "M" : "L"}${round(x)} ${round(y)}`; + }) + .join(" "); +} + +/** Where a frequency stands across the chart, from 0 to `width`. */ +export function frequencyX(hz: number, nyquistHz: number, width: number) { + if (nyquistHz <= 0) return 0; + return round(Math.min(1, Math.max(0, hz / nyquistHz)) * width); +} + +function round(value: number): number { + return Math.round(value * 10) / 10; +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index ebb47705..0a4d6685 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -67,6 +67,7 @@ import { LoginPage } from "./pages/login-page"; import { NotFoundPage } from "./pages/not-found-page"; import { PlayingPage } from "./pages/playing-page"; import { PlaylistsPage } from "./pages/playlists-page"; +import { isQualityVerdict, QualityPage } from "./pages/quality-page"; import { QueuePage } from "./pages/queue-page"; import { RadioPage } from "./pages/radio-page"; import { RandomPage } from "./pages/random-page"; @@ -119,6 +120,7 @@ const navigation: Array<{ | "/songs" | "/folders" | "/attention" + | "/quality" | "/genres" | "/search" | "/favourites" @@ -167,6 +169,12 @@ const navigation: Array<{ curate: true, group: "library", }, + { + to: "/quality", + labelKey: "nav.quality", + icon: "quality", + group: "library", + }, { to: "/search", labelKey: "nav.search", @@ -312,6 +320,7 @@ async function prepareTab( case "/folders": // Its lists are asked for once the page knows the active library. case "/attention": + case "/quality": break; // A fresh draw on every ask, held for no time at all: a draw made here // is thrown away before the page mounts, and the page then draws again. @@ -904,6 +913,20 @@ const attentionRoute = createRoute({ }, }); +// The verdict filter is a search parameter, so a filtered list can be linked +// to, as the lists of "Needs attention" can. +const qualityRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/quality", + validateSearch: (search: Record) => ({ + verdict: isQualityVerdict(search.verdict) ? search.verdict : undefined, + }), + component: function QualityRoute() { + const { verdict } = qualityRoute.useSearch(); + return ; + }, +}); + const uploadRoute = createRoute({ getParentRoute: () => authedRoute, path: "/upload", @@ -934,6 +957,7 @@ const routeTree = rootRoute.addChildren([ songsRoute, foldersRoute, attentionRoute, + qualityRoute, artistsRoute, artistRoute, genresRoute, diff --git a/webapp/src/pages/attention-page.tsx b/webapp/src/pages/attention-page.tsx index b3707919..9e0b300d 100644 --- a/webapp/src/pages/attention-page.tsx +++ b/webapp/src/pages/attention-page.tsx @@ -43,6 +43,10 @@ const CATEGORIES: Array<{ key: AttentionCategory; label: TranslationKey }> = [ { key: "duplicate_track_number", label: "attention.duplicate_track_number" }, { key: "track_number_gap", label: "attention.track_number_gap" }, { key: "probable_duplicate", label: "attention.probable_duplicate" }, + { + key: "probable_fake_lossless", + label: "attention.probable_fake_lossless", + }, ]; /** How long a Split waits for the second click, as on the desktop. */ diff --git a/webapp/src/pages/quality-page.tsx b/webapp/src/pages/quality-page.tsx new file mode 100644 index 00000000..97110ab9 --- /dev/null +++ b/webapp/src/pages/quality-page.tsx @@ -0,0 +1,358 @@ +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + QUALITY_VERDICTS, + type QualityTrack, + type QualityVerdict, + requestQualityAnalysis, +} from "../api"; +import { qualitySummaryQuery, qualityTracksQuery } from "../api/queries"; +import { containerName, kilohertz } from "../components/file-quality"; +import { Loading, PageHeader } from "../components/page-shell"; +import { VERDICT_LABEL } from "../components/quality-section"; +import { TrackProperties } from "../components/track-properties"; +import { verdictTone } from "../lib/quality"; +import { useI18n } from "../state/i18n"; +import { mayCorrectTracks, useLibraryScope } from "../state/library-scope"; + +/** How long "Analyse again" waits for the second click, as Split does. */ +const CONFIRM_MS = 4000; + +/** How often the counts are asked again while files wait for analysis. */ +const REFRESH_MS = 10_000; + +/** + * The formats a reader filters by, as the scanner names them. ALAC is named + * `AAC` by the scanner, so it is filtered with AAC. + */ +const FORMATS = [ + "FLAC", + "WAV", + "AIFF", + "APE", + "WavPack", + "AAC", + "MP3", + "Vorbis", + "Opus", +]; + +export function isQualityVerdict(value: unknown): value is QualityVerdict { + return QUALITY_VERDICTS.includes(value as QualityVerdict); +} + +/** + * The real quality of a library's files (RFC-016 decision 10): measured from + * the audio, not from what the container claims. Every member sees it, since + * every member already sees the container's own facts; asking for the whole + * library to be analysed again is for its owners and managers. + * + * Worst first, so the probable fakes open the page. The verdict is not + * repeated on the other lists of the client, for the desktop's reason: a + * dull recording taken for an MP3 would carry the accusation on every row. + */ +export function QualityPage({ + verdict, +}: { + verdict: QualityVerdict | undefined; +}) { + const { t, locale } = useI18n(); + const { active, ready } = useLibraryScope(); + const libraryId = active?.id ?? ""; + const [codec, setCodec] = useState(""); + const summary = useQuery({ + ...qualitySummaryQuery(libraryId), + enabled: active !== null, + refetchInterval: (query) => { + const data = query.state.data; + return data?.enabled && data.measured < data.lossless + ? REFRESH_MS + : false; + }, + }); + const client = useQueryClient(); + // The list follows the counts: a file measured in the background moves. Only + // when the count moves, not on its first answer, which the list has just + // been asked alongside. + const measured = summary.data?.measured; + const lastMeasured = useRef(undefined); + useEffect(() => { + const previous = lastMeasured.current; + lastMeasured.current = measured; + if (previous !== undefined && measured !== previous) { + void client.invalidateQueries({ + queryKey: ["quality", libraryId, "tracks"], + }); + } + }, [measured, libraryId, client]); + + const count = (key: QualityVerdict) => + summary.data?.verdicts.find((entry) => entry.verdict === key)?.count ?? 0; + const number = new Intl.NumberFormat(locale); + const detail = !summary.data + ? undefined + : !summary.data.enabled + ? t("quality.disabled") + : summary.data.lossless === 0 + ? t("quality.noLossless") + : t("quality.progress", { + measured: number.format(summary.data.measured), + lossless: number.format(summary.data.lossless), + }); + + const header = ( + + {active && mayCorrectTracks(active) ? ( + + ) : null} + + ); + + if (ready && !active) { + return ( +
+ {header} +

{t("folders.noLibrary")}

+
+ ); + } + + return ( +
+ {header} +

{t("quality.help")}

+ {!summary.data ? ( + + ) : ( + <> +
    +
  • + + {t("quality.all")} + +
  • + {QUALITY_VERDICTS.map((key) => ( +
  • + + {t(VERDICT_LABEL[key])} + + {number.format(count(key))} + + +
  • + ))} +
+ + + + )} +
+ ); +} + +function QualityList({ + libraryId, + verdict, + codec, +}: { + libraryId: string; + verdict: QualityVerdict | undefined; + codec: string | undefined; +}) { + const { t, locale } = useI18n(); + const tracks = useInfiniteQuery( + qualityTracksQuery(libraryId, { verdict, codec }), + ); + const songs = useMemo(() => tracks.data?.pages.flat() ?? [], [tracks.data]); + const [opened, setOpened] = useState(null); + const returnTo = useRef(null); + if (!tracks.data) return ; + if (!songs.length) return

{t("quality.empty")}

; + const kilo = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); + const facts = (song: QualityTrack) => + [ + containerName(song), + typeof song.bit_depth === "number" ? `${song.bit_depth} bit` : null, + typeof song.sample_rate === "number" + ? `${kilohertz(song.sample_rate, locale)} kHz` + : null, + typeof song.bitrate === "number" ? `${song.bitrate} kbps` : null, + ] + .filter((fact): fact is string => fact !== null) + .join(" · "); + const highs = (song: QualityTrack) => { + const { verdict: found, cutoff_hz } = song.quality; + if (cutoff_hz !== null) { + return t("quality.khz", { value: kilo.format(cutoff_hz / 1000) }); + } + return found === "consistent" || found === "padded" || found === "upsampled" + ? t("quality.fullBand") + : "—"; + }; + + return ( + <> +
+ + + + + + + + + + + {songs.map((song) => ( + + + + + + + ))} + +
{t("quality.column.track")}{t("quality.column.container")}{t("quality.column.highs")}{t("quality.column.verdict")}
+ + {facts(song)}{highs(song)} + + {t(VERDICT_LABEL[song.quality.verdict])} + +
+
+ {tracks.hasNextPage ? ( +
+ +
+ ) : null} + {opened ? ( + { + setOpened(null); + returnTo.current?.focus(); + }} + /> + ) : null} + + ); +} + +/** + * Drops the library's measurements and queues every file again. The first + * click arms it and the second, within four seconds, confirms, as a split + * does on "Needs attention": the whole library goes back through FFmpeg. + */ +function AnalyseAgain({ libraryId }: { libraryId: string }) { + const { t } = useI18n(); + const client = useQueryClient(); + const [armed, setArmed] = useState(false); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState<{ error: boolean; text: string } | null>( + null, + ); + useEffect(() => { + if (!armed) return; + const timer = window.setTimeout(() => setArmed(false), CONFIRM_MS); + return () => window.clearTimeout(timer); + }, [armed]); + + async function confirm() { + setBusy(true); + try { + await requestQualityAnalysis(libraryId); + setNotice({ error: false, text: t("quality.requested") }); + await client.invalidateQueries({ queryKey: ["quality", libraryId] }); + await client.invalidateQueries({ queryKey: ["track-quality"] }); + await client.invalidateQueries({ queryKey: ["attention", libraryId] }); + } catch { + setNotice({ error: true, text: t("quality.requestFailed") }); + } finally { + setBusy(false); + setArmed(false); + } + } + + return ( +
+ + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 9c9b3d0d..8fbc7fc9 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -444,6 +444,7 @@ const en = { "nav.upload": "Upload", "nav.folders": "Folders", "nav.attention": "Needs attention", + "nav.quality": "Quality", "attention.lists": "Lists", "attention.total": { one: "{count} thing to look at", @@ -489,6 +490,68 @@ const en = { "attention.duplicate_track_number": "Duplicate track numbers", "attention.track_number_gap": "Gaps in track numbers", "attention.probable_duplicate": "Probable duplicates", + "attention.probable_fake_lossless": "Probable fake lossless", + "quality.help": + "Measured from the audio rather than read from the container: where the highs stop, and how many bits the samples use. A lossless file whose highs stop around 16 kHz was a 128 kbps MP3 before it was a FLAC. “No sign of a lossy source” is not proof: an encoder that keeps the full band leaves nothing to find.", + "quality.progress": "{measured} of {lossless} lossless files analysed", + "quality.disabled": "The analysis is turned off on this server.", + "quality.noLossless": "No lossless file in this library.", + "quality.filters": "Verdicts", + "quality.all": "All files", + "quality.format": "Format", + "quality.allFormats": "All formats", + "quality.empty": "No file with this verdict.", + "quality.column.track": "Track", + "quality.column.container": "What the file says", + "quality.column.highs": "Highs stop at", + "quality.column.verdict": "What the audio says", + "quality.fullBand": "Full band", + "quality.khz": "{value} kHz", + "quality.db": "{value} dB", + "quality.analyseAgain": "Analyse again", + "quality.confirmAnalyse": "Confirm: analyse the whole library again", + "quality.requested": "The library will be analysed again in the background.", + "quality.requestFailed": "That did not work. Try again.", + "quality.section": "Real quality", + "quality.verdictLabel": "Verdict", + "quality.highsStop": "Highs stop at", + "quality.wall": "Fall at that point", + "quality.bitsUsed": "Bits used", + "quality.bitsOf": "{used} of {declared}", + "quality.analysed": "Analysed", + "quality.chart": "Averaged spectrum: the highs stop at {where}.", + "quality.chartFullBand": "Averaged spectrum: the highs run on with no wall.", + "quality.showSpectrogram": "Show the spectrogram", + "quality.drawing": "Drawing…", + "quality.spectrogramAlt": "Spectrogram of {title}, frequency against time", + "quality.spectrogramBusy": + "A spectrogram is already being drawn for you. Try again in a moment.", + "quality.spectrogramFailed": "The spectrogram could not be drawn.", + "quality.verdict.lossy_source": "Probably from a lossy file", + "quality.verdict.upsampled": "Probably upsampled", + "quality.verdict.padded": "Padded bit depth", + "quality.verdict.unreadable": "Could not be read", + "quality.verdict.silent": "Silent", + "quality.verdict.pending": "Waiting for analysis", + "quality.verdict.consistent": "No sign of a lossy source", + "quality.verdict.lossy": "Lossy", + "quality.verdict.not_measured": "Not measured", + "quality.explain.lossy_source": + "The highs stop at a wall where a lossy encoder cuts: this file was probably decoded from an MP3, AAC or Opus file and saved as lossless.", + "quality.explain.upsampled": + "The rate is above 48 kHz, but the highs stop where a CD or a 48 kHz recording ends: it was probably resampled from one.", + "quality.explain.padded": + "The container declares more bits than the samples use: a 16-bit recording stored at 24 bits, for instance.", + "quality.explain.unreadable": + "FFmpeg could not decode this file. It will be tried again when the file changes.", + "quality.explain.silent": "Nothing in this file is loud enough to measure.", + "quality.explain.pending": + "This file has not been analysed yet. The analysis runs in the background.", + "quality.explain.consistent": + "Nothing in the audio contradicts the container. That is not proof: an encoder that keeps the full band leaves nothing to find.", + "quality.explain.lossy": + "A lossy file says what it is; its quality is its encoder and its bitrate. It is not analysed.", + "quality.explain.not_measured": "This format is not analysed.", "artistSplit.offer": "Split into {count} artists", "artistSplit.confirm": "Confirm: split into {count}", "artistSplit.done": "Split into {parts}.", @@ -1209,6 +1272,7 @@ const fr: Record = { "nav.upload": "Téléverser", "nav.folders": "Dossiers", "nav.attention": "À corriger", + "nav.quality": "Qualité", "attention.lists": "Listes", "attention.total": { one: "{count} point à vérifier", @@ -1253,6 +1317,70 @@ const fr: Record = { "attention.duplicate_track_number": "Numéros de piste en double", "attention.track_number_gap": "Trous dans la numérotation", "attention.probable_duplicate": "Doublons probables", + "attention.probable_fake_lossless": "Faux lossless probables", + "quality.help": + "Mesuré dans le son plutôt que lu dans le conteneur : où s’arrêtent les aigus, et combien de bits les échantillons utilisent. Un fichier sans perte dont les aigus s’arrêtent vers 16 kHz était un MP3 à 128 kb/s avant d’être un FLAC. « Aucun signe de source avec perte » n’est pas une preuve : un encodeur qui garde toute la bande ne laisse rien à trouver.", + "quality.progress": "{measured} fichiers sans perte analysés sur {lossless}", + "quality.disabled": "L’analyse est désactivée sur ce serveur.", + "quality.noLossless": "Aucun fichier sans perte dans cette bibliothèque.", + "quality.filters": "Verdicts", + "quality.all": "Tous les fichiers", + "quality.format": "Format", + "quality.allFormats": "Tous les formats", + "quality.empty": "Aucun fichier avec ce verdict.", + "quality.column.track": "Morceau", + "quality.column.container": "Ce que dit le fichier", + "quality.column.highs": "Les aigus s’arrêtent à", + "quality.column.verdict": "Ce que dit le son", + "quality.fullBand": "Bande complète", + "quality.khz": "{value} kHz", + "quality.db": "{value} dB", + "quality.analyseAgain": "Analyser à nouveau", + "quality.confirmAnalyse": "Confirmer : réanalyser toute la bibliothèque", + "quality.requested": "La bibliothèque sera réanalysée en arrière-plan.", + "quality.requestFailed": "Ça n’a pas marché. Réessayez.", + "quality.section": "Qualité réelle", + "quality.verdictLabel": "Verdict", + "quality.highsStop": "Les aigus s’arrêtent à", + "quality.wall": "Chute à cet endroit", + "quality.bitsUsed": "Bits utilisés", + "quality.bitsOf": "{used} sur {declared}", + "quality.analysed": "Analysé le", + "quality.chart": "Spectre moyen : les aigus s’arrêtent à {where}.", + "quality.chartFullBand": "Spectre moyen : les aigus continuent sans mur.", + "quality.showSpectrogram": "Afficher le spectrogramme", + "quality.drawing": "Dessin en cours…", + "quality.spectrogramAlt": + "Spectrogramme de {title}, fréquence en fonction du temps", + "quality.spectrogramBusy": + "Un spectrogramme est déjà en cours de dessin pour vous. Réessayez dans un instant.", + "quality.spectrogramFailed": "Le spectrogramme n’a pas pu être dessiné.", + "quality.verdict.lossy_source": "Probablement issu d’un fichier avec perte", + "quality.verdict.upsampled": "Probablement suréchantillonné", + "quality.verdict.padded": "Profondeur gonflée", + "quality.verdict.unreadable": "Illisible", + "quality.verdict.silent": "Silencieux", + "quality.verdict.pending": "En attente d’analyse", + "quality.verdict.consistent": "Aucun signe de source avec perte", + "quality.verdict.lossy": "Avec perte", + "quality.verdict.not_measured": "Non mesuré", + "quality.explain.lossy_source": + "Les aigus s’arrêtent net là où coupe un encodeur avec perte : ce fichier a probablement été décodé depuis un MP3, un AAC ou un Opus, puis enregistré sans perte.", + "quality.explain.upsampled": + "La fréquence dépasse 48 kHz, mais les aigus s’arrêtent là où finit un CD ou un enregistrement à 48 kHz : il a probablement été rééchantillonné depuis l’un d’eux.", + "quality.explain.padded": + "Le conteneur annonce plus de bits que les échantillons n’en utilisent : un enregistrement 16 bits rangé en 24 bits, par exemple.", + "quality.explain.unreadable": + "FFmpeg n’a pas pu décoder ce fichier. Il sera réessayé quand le fichier changera.", + "quality.explain.silent": + "Rien dans ce fichier n’est assez fort pour être mesuré.", + "quality.explain.pending": + "Ce fichier n’a pas encore été analysé. L’analyse tourne en arrière-plan.", + "quality.explain.consistent": + "Rien dans le son ne contredit le conteneur. Ce n’est pas une preuve : un encodeur qui garde toute la bande ne laisse rien à trouver.", + "quality.explain.lossy": + "Un fichier avec perte dit ce qu’il est ; sa qualité est celle de son encodeur et de son débit. Il n’est pas analysé.", + "quality.explain.not_measured": "Ce format n’est pas analysé.", "artistSplit.offer": "Scinder en {count} artistes", "artistSplit.confirm": "Confirmer : scinder en {count}", "artistSplit.done": "Scindé en {parts}.", diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index a27d1b94..7e5d0eb0 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -3451,3 +3451,130 @@ button.danger { justify-content: flex-start; } } + +/* The quality page (RFC-016). The verdicts read as the lists of "Needs + attention" do, one card per verdict in a wrapping grid, an empty one + greyed; the verdict itself is a pill whose colour says how much it accuses + the file of, and the words say the rest. */ +.quality-help { + max-width: 62ch; + margin: 0 0 var(--space-4); + font-size: var(--text-sm); +} + +.quality-verdicts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-2); + margin: 0 0 var(--space-4); + padding: 0; + list-style: none; +} + +.quality-format { + display: inline-flex; + align-items: center; + gap: var(--space-2); + margin: 0 0 var(--space-4); + font-size: var(--text-sm); +} + +.quality-verdict { + display: inline-block; + padding: 0.1rem var(--space-2); + border: 1px solid var(--line-strong); + border-radius: 999px; + font-size: var(--text-xs); + white-space: nowrap; +} + +.quality-verdict.finding { + border-color: var(--danger); + color: var(--danger); +} + +.quality-verdict.mild { + border-color: var(--warn); + color: var(--warn); +} + +.quality-verdict.neutral { + color: var(--muted); +} + +.quality-highs { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.quality-analyse { + display: grid; + justify-items: end; + gap: var(--space-1); +} + +.quality-analyse-notice { + margin: 0; + font-size: var(--text-sm); +} + +.quality-explain { + margin: 0 0 var(--space-3); + font-size: var(--text-sm); +} + +.quality-spectrum { + margin: var(--space-3) 0; +} + +.quality-spectrum svg { + display: block; + width: 100%; + height: 9rem; + overflow: visible; +} + +.quality-spectrum figcaption { + margin-top: var(--space-1); + font-size: var(--text-xs); +} + +.quality-curve { + fill: none; + stroke: var(--accent); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.quality-wall { + stroke: var(--danger); + stroke-width: 1.5; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; +} + +.quality-gridline line { + stroke: var(--line-strong); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.quality-gridline text { + fill: var(--muted); + font-size: 10px; +} + +.quality-spectrogram { + margin: var(--space-3) 0 0; +} + +.quality-spectrogram img { + display: block; + width: 100%; + height: auto; + border-radius: 0.5rem; +} + +.quality-spectrogram-action { + margin-top: var(--space-3); +} From aef0a6183866799c489691b4b48a17a66a7fc55a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 17:29:18 +0200 Subject: [PATCH 3/7] docs: RFC-016 sets the upsampled threshold from measurement, and CLAUDE.md names the analysis Signed-off-by: InstaZDLL --- CLAUDE.md | 3 +- docs/rfcs/RFC-016-audio-quality-analysis.md | 35 +++++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8bea50c5..1d0be490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ bun run build # webapp first, then cargo — the order Tests need no service container: `test_app()` builds a `Config` over a `TempDir` and the suite drives the same router `main` serves. FFmpeg and ffprobe must be on `PATH`. -The integration suite is nineteen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `credits`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says. +The integration suite is twenty targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `credits`, `quality`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says. `cargo build` works without a client build — `build.rs` embeds a placeholder page when `webapp/dist` is absent. A real `vite build` overwrites it. @@ -66,6 +66,7 @@ The integration suite is nineteen targets under `tests/`, one per subject — `a - **Native clients use Authorization Code + PKCE** (`src/oauth.rs`). S256 only; redirect targets restricted to loopback, https or a reverse-domain private scheme. A code is spent by its first presentation whatever the outcome. - **The web client is a router fallback** (`src/webui.rs`). Anything not claimed by an API route is a built asset or a client-side route. Paths under `api/`, `rest/`, `share/` and the probes are excluded, so a mistyped API call stays a JSON 404 instead of returning HTML. - **Audio files are read-only.** The scanner and tag operations never rewrite them. +- **The real quality of a file is measured, never read** ([RFC-016](docs/rfcs/RFC-016-audio-quality-analysis.md)). `src/quality/` decodes a lossless file through FFmpeg and runs the desktop's wall search on it, ported from `waveflow-core` and held to it by a test; `src/services/quality.rs` decides which files are measured and what a measurement means. The analysis runs in the background, `WAVEFLOW_QUALITY_PARALLELISM` files at a time (one by default, zero turns it off), and starts no file while a scan runs. A row in `track_quality` is current only for the content, the analyser version and the FFmpeg build that made it; boot deletes the others, so a read can call a row current on its `full_hash` alone. The verdict is SQL computed where it is read, never a column. - **The Subsonic contract is frozen** for v2.0-beta — see the "Frozen Subsonic v2.0-beta contract" section of RFC-002 and [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md). Changing observable wire behaviour there risks the clients already validated against it. - **Logging.** `tracing` + `RUST_LOG`; `WAVEFLOW_LOG_FORMAT=json` for prod. Traces record `uri.path()` only, with share tokens and stream tickets redacted. Never log headers, query strings, tokens or passwords. diff --git a/docs/rfcs/RFC-016-audio-quality-analysis.md b/docs/rfcs/RFC-016-audio-quality-analysis.md index fddbad78..394d33ff 100644 --- a/docs/rfcs/RFC-016-audio-quality-analysis.md +++ b/docs/rfcs/RFC-016-audio-quality-analysis.md @@ -6,6 +6,9 @@ RFC). The *Status* field above never flips in this project; this line is the one that says what is running. - **Date**: 2026-10-24 +- **Revised**: 2026-10-25, with the implementation. `upsampled` stops at + 30 kHz rather than 24, as measured on FFmpeg's own resampler (decision 3), + and decision 3 says which upsampled files it cannot see. - **Authors**: the WaveFlow project - **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md) (FFmpeg, read-only audio, the path guard, tenancy in the queries), @@ -265,21 +268,35 @@ In order, the first that applies: | `unreadable` | FFmpeg failed or ran past its deadline on this content | "Could not be read" | | `silent` | no frame above −60 dBFS | "Silent" | | `lossy_source` | `probable_fake_lossless_sql!`: a wall at or under `MIN(21000, sample_rate / 2 − 1000)` | "Probably from a lossy file", with the cutoff | -| `upsampled` | `sample_rate > 48000` and a wall at or under 24 kHz | "Probably upsampled", with the cutoff | +| `upsampled` | `sample_rate > 48000` and a wall at or under 30 kHz | "Probably upsampled", with the cutoff | | `padded` | `effective_bits < bit_depth` | "Padded to N bits", with the real depth | | `consistent` | none of the above | "No sign of a lossy source" | - **`lossy_source` before `upsampled`.** A 96 kHz file with a wall at 16 kHz came from an MP3 first; that it was upsampled afterwards is the lesser - news. Between 21 and 24 kHz the two cannot be confused: no lossy encoder - cuts there, and a CD converter's filter does. -- **`upsampled` is new**, and its threshold is not the desktop's tuning. 24 kHz - is the Nyquist of a 48 kHz source, so a file above 48 kHz whose highs stop - at or under it holds nothing a 48 kHz file could not. The wall test is the + news. Above 21 kHz the two cannot be confused: no lossy encoder cuts + there, and a resampler's filter does. +- **`upsampled` is new**, and its threshold is not the desktop's tuning. The + first draft put it at 24 kHz, the Nyquist of a 48 kHz source, on the + reasoning that a file above 48 kHz whose highs stop there holds nothing a + 48 kHz file could not. Measured, a resampler's filter does not stop dead + at the source's Nyquist. On white noise through FFmpeg's own resampler, + the wall stands at 21.5 to 23.8 kHz with a steep filter + (`filter_size=64:cutoff=0.91`), and at 25.7 kHz from 44.1 kHz and 28.0 kHz + from 48 kHz with its default one, whose transition runs past the source's + Nyquist. The threshold is 30 kHz, which holds all of them. A genuine + 96 kHz recording of the same noise has no wall at all. The wall test is the same as for `lossy_source`, which is what protects a genuine hi-res - recording that simply has little above 24 kHz: it fades, it does not hit a - wall. The implementation measures it on upsampled fixtures before it ships - (step 2's tests), and the page says "probably". + recording that simply has little up high: it fades, it does not hit a + wall. And the page says "probably". +- **What `upsampled` cannot see.** The same three minutes of real music + through FFmpeg's default resampler show no wall at all: that filter lets + through images of the music's own highs, mirrored above the source's + Nyquist, and they come back within 15 dB of the level under the wall, + which the desktop's rule reads, rightly, as content. Such a file is + `consistent`. A steeper resampler leaves the wall, at 21.5 kHz on the + same music, and is found. As with encoders that do not low-pass, no + measure of where the highs stop finds what does not stop. - **`padded` is the mildest**, and comes after the other two for that reason: a 16-bit file in a 24-bit container is still a lossless CD. It is reported because it is a claim the container makes and the audio contradicts, and because it is how From b88c8f88a3b85acd1ae723d155174557c067239e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 17:34:31 +0200 Subject: [PATCH 4/7] fix(quality): cap a measurement's deadline whatever the file claims its duration is Signed-off-by: InstaZDLL --- docs/rfcs/RFC-016-audio-quality-analysis.md | 4 +++- src/services/quality.rs | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/RFC-016-audio-quality-analysis.md b/docs/rfcs/RFC-016-audio-quality-analysis.md index 394d33ff..62380cf2 100644 --- a/docs/rfcs/RFC-016-audio-quality-analysis.md +++ b/docs/rfcs/RFC-016-audio-quality-analysis.md @@ -341,7 +341,9 @@ a library's backlog, and the analysis is never urgent. **How long one file may take.** A deadline of 60 seconds plus the track's own duration, which asks the decoder for real time where it ran 650 times faster -here, so only a stuck process meets it. After it, the process is killed +here, so only a stuck process meets it, and never more than 30 minutes: the +duration is what the file's header claims, and a crafted file claiming ten +hours that hangs FFmpeg must not hold a slot for ten hours. After it, the process is killed (`kill_on_drop`) and the file recorded `unreadable` for this content. A file that cannot be read is not retried on every wake, which would loop forever on one bad file; it is retried when its content changes or the detector's diff --git a/src/services/quality.rs b/src/services/quality.rs index 61ebedec..aad92760 100644 --- a/src/services/quality.rs +++ b/src/services/quality.rs @@ -278,6 +278,14 @@ const PAGE: i64 = 64; /// How many rows one write carries. const BATCH: usize = 16; +/// The longest one file may take, whatever it says about itself. The deadline +/// is a minute plus the track's duration, and the duration is read from the +/// file's own header: without this ceiling, a file claiming ten hours that +/// hangs FFmpeg would hold a slot of the analysis for ten hours. Thirty minutes +/// is a three-hour file decoded at six times real time, a hundred times slower +/// than measured here. +const MAX_DEADLINE: Duration = Duration::from_secs(30 * 60); + impl DomainServices { /// Places the FFmpeg this process measures with, and deletes every /// measurement another generation or another FFmpeg made (decision 5). @@ -410,8 +418,9 @@ impl DomainServices { ) .await .ok()?; - let deadline = Duration::from_secs(60) - + Duration::from_millis(u64::try_from(pending.duration_ms).unwrap_or(0)); + let deadline = (Duration::from_secs(60) + + Duration::from_millis(u64::try_from(pending.duration_ms).unwrap_or(0))) + .min(MAX_DEADLINE); let measurement = quality::measure(&self.ffprobe_path, &self.ffmpeg_path, &path, deadline).await; Some(Measured { From 888e6c223d5d2c157937da9a2f734c717e297fbb Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 17:40:42 +0200 Subject: [PATCH 5/7] docs: RFC-016 names #319 in its Implemented by line Signed-off-by: InstaZDLL --- docs/rfcs/RFC-016-audio-quality-analysis.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/RFC-016-audio-quality-analysis.md b/docs/rfcs/RFC-016-audio-quality-analysis.md index 62380cf2..ae7de8c0 100644 --- a/docs/rfcs/RFC-016-audio-quality-analysis.md +++ b/docs/rfcs/RFC-016-audio-quality-analysis.md @@ -1,10 +1,13 @@ # RFC-016 — The real quality of a file, measured from its audio - **Status**: Proposed -- **Implemented by**: in progress, in the order of "The order of the work" - below: [#317](https://github.com/InstaZDLL/waveflow-server/pull/317) (this - RFC). The *Status* field above never flips in this - project; this line is the one that says what is running. +- **Implemented by**: [#317](https://github.com/InstaZDLL/waveflow-server/pull/317) + (this RFC) and [#319](https://github.com/InstaZDLL/waveflow-server/pull/319) + (steps 2 to 4 together: the migration, the ported detector and the FFmpeg + pipe, the background analyser, the verdicts, the five routes, the "Needs + attention" category, the Quality page and the track properties' "Real + quality" section). The *Status* field above never flips in this project; + this line is the one that says what is running. - **Date**: 2026-10-24 - **Revised**: 2026-10-25, with the implementation. `upsampled` stops at 30 kHz rather than 24, as measured on FFmpeg's own resampler (decision 3), From 6ac94384fb7f8490c7a841898ed4c850a959f539 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 18:01:17 +0200 Subject: [PATCH 6/7] fix(quality): write each batch as it completes, keep the spectrogram under the request timeout, and say nothing of a silent file's highs Signed-off-by: InstaZDLL --- docs/rfcs/RFC-016-audio-quality-analysis.md | 4 +- src/quality/mod.rs | 45 ++++++++++++++++++--- src/services/mod.rs | 5 ++- src/services/quality.rs | 37 ++++++++++++----- webapp/src/components/quality-section.tsx | 22 +++++----- webapp/src/lib/quality.test.ts | 22 ++++++++++ webapp/src/lib/quality.ts | 19 +++++++++ webapp/src/pages/quality-page.tsx | 12 +++--- 8 files changed, 128 insertions(+), 38 deletions(-) diff --git a/docs/rfcs/RFC-016-audio-quality-analysis.md b/docs/rfcs/RFC-016-audio-quality-analysis.md index ae7de8c0..64fe8a17 100644 --- a/docs/rfcs/RFC-016-audio-quality-analysis.md +++ b/docs/rfcs/RFC-016-audio-quality-analysis.md @@ -533,7 +533,9 @@ by `WAVEFLOW_QUALITY_PARALLELISM` plus one, so a reader can still have one drawn while the background analysis runs; beyond that the route answers `429` with `Retry-After`. Access is checked before the bound, so an account that may not read the track gets `404` and never learns from a `429` that -something is there. It has a 30-second deadline. Unlike the analysis, +something is there. It has a 20-second deadline, and less when the +request timeout leaves less, so the route answers its own `503` before the +router's timeout answers for it. Unlike the analysis, it is on the answering path, because a picture someone clicked for is the answer; that is why it is bounded and never computed for anyone who did not ask. It is drawn for any file, lossy ones included, because a reader may want diff --git a/src/quality/mod.rs b/src/quality/mod.rs index f1a36acf..8cf8ad81 100644 --- a/src/quality/mod.rs +++ b/src/quality/mod.rs @@ -36,8 +36,22 @@ pub const SPECTRUM_BANDS: usize = 256; /// How long ffprobe gets to name a file's rate and channels. const PROBE_TIMEOUT: Duration = Duration::from_secs(30); -/// How long a spectrogram may take to draw (decision 8). -pub const SPECTROGRAM_TIMEOUT: Duration = Duration::from_secs(30); +/// How long a spectrogram may take to draw (decision 8), at most. The route +/// uses less when the request timeout leaves less: see +/// [`spectrogram_deadline`]. +pub const SPECTROGRAM_TIMEOUT: Duration = Duration::from_secs(20); + +/// How long a spectrogram may take under a request timeout of `request`. +/// +/// The router's own timeout answers first otherwise, and the caller is told +/// whatever that layer says rather than the `503` the route documents. Five +/// seconds are left for the access check, the path guard and the answer, and +/// one second is the least any picture is given. +pub fn spectrogram_deadline(request: Duration) -> Duration { + request + .saturating_sub(Duration::from_secs(5)) + .clamp(Duration::from_secs(1), SPECTROGRAM_TIMEOUT) +} /// The picture's parameters, which are part of its `ETag`: a change here is a /// different picture of the same file. @@ -339,8 +353,13 @@ pub struct Picture { } /// Draws a file's spectrogram: WebP when FFmpeg has libwebp, JPEG otherwise. -/// `None` when FFmpeg fails or runs past [`SPECTROGRAM_TIMEOUT`]. -pub async fn spectrogram(ffmpeg: &Path, path: &Path, webp: bool) -> Option { +/// `None` when FFmpeg fails or runs past `deadline`. +pub async fn spectrogram( + ffmpeg: &Path, + path: &Path, + webp: bool, + deadline: Duration, +) -> Option { let mut command = Command::new(ffmpeg); command .args([ @@ -362,7 +381,7 @@ pub async fn spectrogram(ffmpeg: &Path, path: &Path, webp: bool) -> Option); struct QualityInner { parallelism: usize, + /// How long a spectrogram may take, kept under the request timeout. + spectrogram_deadline: Duration, /// Placed at boot by [`DomainServices::use_quality_decoder`]; nothing is /// measured before it is. decoder: RwLock>, @@ -236,9 +238,10 @@ struct QualityInner { } impl QualityRuntime { - pub(super) fn new(parallelism: usize) -> Self { + pub(super) fn new(parallelism: usize, request_timeout: Duration) -> Self { Self(Arc::new(QualityInner { parallelism, + spectrogram_deadline: quality::spectrogram_deadline(request_timeout), decoder: RwLock::new(None), wake: Notify::new(), spectrograms: Arc::new(Semaphore::new(parallelism + 1)), @@ -360,16 +363,23 @@ impl DomainServices { break; }; after = (last.created_at, last.track_id.clone()); - let results: Vec>> = stream::iter(page) + // Each batch is written as soon as it is complete, so a restart + // loses at most one batch of work, and a batch that cannot be + // written costs that batch and not the pass: its files are still + // pending and the next pass asks for them again. + let mut batches = stream::iter(page) .map(|pending| self.measure_one(pending)) .buffer_unordered(parallelism) - .chunks(BATCH) - .collect() - .await; - for batch in results { + .chunks(BATCH); + while let Some(batch) = batches.next().await { let batch: Vec = batch.into_iter().flatten().collect(); - measured += batch.len(); - self.write_measurements(&decoder, batch).await?; + let count = batch.len(); + match self.write_measurements(&decoder, batch).await { + Ok(()) => measured += count, + Err(error) => { + tracing::warn!(error = %error, "a batch of quality measurements was not written"); + } + } } } Ok(measured) @@ -697,9 +707,14 @@ impl DomainServices { crate::media::secure_track_path(std::path::Path::new(&root_path), &relative_path) .await .map_err(|_| ServiceError::Unavailable)?; - let picture = quality::spectrogram(&self.ffmpeg_path, &path, webp) - .await - .ok_or(ServiceError::Unavailable)?; + let picture = quality::spectrogram( + &self.ffmpeg_path, + &path, + webp, + self.quality.0.spectrogram_deadline, + ) + .await + .ok_or(ServiceError::Unavailable)?; Ok(Spectrogram::Picture { etag, bytes: picture.bytes, diff --git a/webapp/src/components/quality-section.tsx b/webapp/src/components/quality-section.tsx index 532c4867..bbee8da1 100644 --- a/webapp/src/components/quality-section.tsx +++ b/webapp/src/components/quality-section.tsx @@ -10,6 +10,7 @@ import { import { trackQualityQuery } from "../api/queries"; import { frequencyX, + highsStop, spectrumGridlines, spectrumPath, verdictTone, @@ -38,11 +39,7 @@ export function QualitySection({ song }: { song: Song }) { if (!data || !Object.hasOwn(VERDICT_LABEL, data.verdict)) return null; const kilohertz = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); - const measured = - data.verdict !== "pending" && - data.verdict !== "lossy" && - data.verdict !== "not_measured" && - data.verdict !== "unreadable"; + const highs = highsStop(data.verdict, data.cutoff_hz); const rows: Array<{ label: string; value: ReactNode }> = [ { @@ -58,18 +55,17 @@ export function QualitySection({ song }: { song: Song }) { }, { label: t("quality.highsStop"), - value: !measured - ? null - : data.cutoff_hz === null - ? t("quality.fullBand") - : t("quality.khz", { - value: kilohertz.format(data.cutoff_hz / 1000), - }), + value: + highs === null + ? null + : highs.kind === "fullBand" + ? t("quality.fullBand") + : t("quality.khz", { value: kilohertz.format(highs.hz / 1000) }), }, { label: t("quality.wall"), value: - measured && data.cutoff_hz !== null && data.wall_db !== null + highs?.kind === "at" && data.wall_db !== null ? t("quality.db", { value: number.format(data.wall_db) }) : null, }, diff --git a/webapp/src/lib/quality.test.ts b/webapp/src/lib/quality.test.ts index daf74a3a..9f6aac07 100644 --- a/webapp/src/lib/quality.test.ts +++ b/webapp/src/lib/quality.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { frequencyX, + highsStop, SPECTRUM_FLOOR_DB, spectrumGridlines, spectrumPath, @@ -33,6 +34,27 @@ describe("the averaged spectrum", () => { }); }); +describe("where the highs stop", () => { + it("says a frequency when there is a wall", () => { + expect(highsStop("lossy_source", 16_400)).toEqual({ + kind: "at", + hz: 16_400, + }); + }); + + it("says the band is full only of a file that was measured", () => { + expect(highsStop("consistent", null)).toEqual({ kind: "fullBand" }); + expect(highsStop("padded", null)).toEqual({ kind: "fullBand" }); + }); + + it("says nothing of a silent file, nor of one not measured", () => { + expect(highsStop("silent", null)).toBeNull(); + expect(highsStop("pending", null)).toBeNull(); + expect(highsStop("unreadable", null)).toBeNull(); + expect(highsStop("lossy", null)).toBeNull(); + }); +}); + describe("a verdict's tone", () => { it("draws an accusation apart from a state", () => { expect(verdictTone("lossy_source")).toBe("finding"); diff --git a/webapp/src/lib/quality.ts b/webapp/src/lib/quality.ts index 4317b0b7..b02df1e8 100644 --- a/webapp/src/lib/quality.ts +++ b/webapp/src/lib/quality.ts @@ -34,6 +34,25 @@ export function spectrumGridlines(nyquistHz: number): number[] { return [16_000, 19_000, 20_000, 22_000].filter((hz) => hz < nyquistHz); } +/** + * Where a file's highs stop, as the page and the properties say it: at a + * frequency, across the whole band, or not at all. Only a measurement of + * sound says the band is full: a silent file has no spectrum, and a file not + * yet measured, unreadable or lossy has no measurement. + */ +export function highsStop( + verdict: QualityVerdict, + cutoffHz: number | null, +): { kind: "at"; hz: number } | { kind: "fullBand" } | null { + if (cutoffHz !== null) return { kind: "at", hz: cutoffHz }; + return verdict === "consistent" || + verdict === "padded" || + verdict === "upsampled" || + verdict === "lossy_source" + ? { kind: "fullBand" } + : null; +} + /** The level the chart reaches down to, in dB under the loudest band. */ export const SPECTRUM_FLOOR_DB = -120; diff --git a/webapp/src/pages/quality-page.tsx b/webapp/src/pages/quality-page.tsx index 97110ab9..1402fbf0 100644 --- a/webapp/src/pages/quality-page.tsx +++ b/webapp/src/pages/quality-page.tsx @@ -17,7 +17,7 @@ import { containerName, kilohertz } from "../components/file-quality"; import { Loading, PageHeader } from "../components/page-shell"; import { VERDICT_LABEL } from "../components/quality-section"; import { TrackProperties } from "../components/track-properties"; -import { verdictTone } from "../lib/quality"; +import { highsStop, verdictTone } from "../lib/quality"; import { useI18n } from "../state/i18n"; import { mayCorrectTracks, useLibraryScope } from "../state/library-scope"; @@ -218,13 +218,11 @@ function QualityList({ .filter((fact): fact is string => fact !== null) .join(" · "); const highs = (song: QualityTrack) => { - const { verdict: found, cutoff_hz } = song.quality; - if (cutoff_hz !== null) { - return t("quality.khz", { value: kilo.format(cutoff_hz / 1000) }); - } - return found === "consistent" || found === "padded" || found === "upsampled" + const found = highsStop(song.quality.verdict, song.quality.cutoff_hz); + if (found === null) return "—"; + return found.kind === "fullBand" ? t("quality.fullBand") - : "—"; + : t("quality.khz", { value: kilo.format(found.hz / 1000) }); }; return ( From a87eb4246b8eaa6b26ffb534626f37d3a321619b Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 18:12:43 +0200 Subject: [PATCH 7/7] fix(quality): keep the spectrogram strictly under any request timeout Signed-off-by: InstaZDLL --- src/quality/mod.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/quality/mod.rs b/src/quality/mod.rs index 8cf8ad81..07a71881 100644 --- a/src/quality/mod.rs +++ b/src/quality/mod.rs @@ -44,13 +44,15 @@ pub const SPECTROGRAM_TIMEOUT: Duration = Duration::from_secs(20); /// How long a spectrogram may take under a request timeout of `request`. /// /// The router's own timeout answers first otherwise, and the caller is told -/// whatever that layer says rather than the `503` the route documents. Five -/// seconds are left for the access check, the path guard and the answer, and -/// one second is the least any picture is given. +/// whatever that layer says rather than the `503` the route documents. The +/// access check, the path guard and the answer keep five seconds, or half the +/// request timeout when that is shorter, so the picture always gives up +/// strictly before the request does, whatever an operator sets. A request +/// timeout too short to draw anything then costs the spectrogram and nothing +/// else: it is not a reason to refuse a setting every other route lives with. pub fn spectrogram_deadline(request: Duration) -> Duration { - request - .saturating_sub(Duration::from_secs(5)) - .clamp(Duration::from_secs(1), SPECTROGRAM_TIMEOUT) + let margin = Duration::from_secs(5).min(request / 2); + request.saturating_sub(margin).min(SPECTROGRAM_TIMEOUT) } /// The picture's parameters, which are part of its `ETag`: a change here is a @@ -439,6 +441,12 @@ mod tests { spectrogram_deadline(Duration::from_secs(2)), Duration::from_secs(1) ); + // The shortest setting the configuration accepts still leaves the + // route time to answer for itself. + assert_eq!( + spectrogram_deadline(Duration::from_secs(1)), + Duration::from_millis(500) + ); } #[test]