diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index b0d3bd8..c5586c7 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -118,38 +118,19 @@ pub async fn run( } }; - // Translate the analysis outcome into the two `RecordingMeta` - // fields. Silent files (volumedetect → -inf dB) keep a valid - // duration but skip loudness — `serde_json` refuses non-finite - // floats, and persisting `+Inf` gain would loop the worker - // forever (write fails → no sidecar → idempotency gate stays - // open → retry hits the same trap). - let (duration_seconds, loudness) = match analysis_result { - Ok(a) if a.mean_db.is_finite() && a.max_db.is_finite() => { - let gain_db = loudness::compute_gain_db(a.mean_db, a.max_db); - ( - Some(a.duration_seconds), - Some(Loudness { - mean_db: a.mean_db, - max_db: a.max_db, - gain_db, - }), - ) - } - Ok(a) => { - // Silent (or zero-sample) audio: duration is still - // valid, but there is no level to amplify against. - (Some(a.duration_seconds), None) - } - Err(e) => { - warn!( - audio = %audio_path.display(), - error = %e, - "audio analysis failed — meta will record null duration and loudness" - ); - (None, None) - } - }; + // Log analysis failures here (so the helper stays pure and + // unit-testable) before mapping the result onto the meta + // fields. The mapping rules — finiteness gate, silent-file + // handling, error propagation — live in + // `analysis_to_meta_fields` with their own tests. + if let Err(ref e) = analysis_result { + warn!( + audio = %audio_path.display(), + error = %e, + "audio analysis failed — meta will record null duration and loudness" + ); + } + let (duration_seconds, loudness) = analysis_to_meta_fields(analysis_result); // Normalize terminology against the curated vocab before // persisting. Downstream consumers (oneliner, analyze) read @@ -222,6 +203,41 @@ pub async fn run( warn!("Transcription worker stopped (channel closed)"); } +/// Map an [`ffmpeg::analyze_audio`] outcome onto the two +/// [`RecordingMeta`] fields it can populate. +/// +/// Pure: no logging, no I/O, no Tokio. The caller is expected to log +/// failures (so the test fixture for this fn does not have to fight +/// the global tracing subscriber). +/// +/// - `Ok(a)` with both `mean_db` and `max_db` finite → both fields +/// populated, `gain_db` from [`loudness::compute_gain_db`]. +/// - `Ok(a)` with either non-finite (silent / zero-sample input) → +/// duration kept, loudness dropped: `serde_json` refuses non-finite +/// floats and persisting `+Inf` gain would loop the worker. +/// - `Err(_)` → both fields `None`. ffmpeg failed; the recording will +/// show up in the UI without duration or replay-gain, but the +/// transcript still gets persisted. +fn analysis_to_meta_fields( + analysis_result: Result, +) -> (Option, Option) { + match analysis_result { + Ok(a) if a.mean_db.is_finite() && a.max_db.is_finite() => { + let gain_db = loudness::compute_gain_db(a.mean_db, a.max_db); + ( + Some(a.duration_seconds), + Some(Loudness { + mean_db: a.mean_db, + max_db: a.max_db, + gain_db, + }), + ) + } + Ok(a) => (Some(a.duration_seconds), None), + Err(_) => (None, None), + } +} + /// Atomically rename a failed recording from `.m4a` to `.m4a.failed` /// so the recovery scan no longer picks it up. The UI still surfaces these /// files (with a "failed" flag) so the user can listen to the audio and @@ -592,4 +608,70 @@ mod tests { let dir = tempdir().unwrap(); assert!(!has_pending_recordings(dir.path()).await); } + + /// Successful analysis with finite mean and max — both meta fields + /// are populated and `gain_db` matches the loudness formula + /// (mean=-22, max=-15 → target-limited at +6 dB; same case as + /// `loudness::tests::target_limits_gain`). + #[test] + fn analysis_to_meta_fields_finite_populates_both() { + let analysis = ffmpeg::AudioAnalysis { + duration_seconds: 30, + mean_db: -22.0, + max_db: -15.0, + }; + let (duration, loudness) = analysis_to_meta_fields(Ok(analysis)); + assert_eq!(duration, Some(30)); + assert_eq!( + loudness, + Some(Loudness { + mean_db: -22.0, + max_db: -15.0, + gain_db: 6.0, + }) + ); + } + + /// Silent file: volumedetect returns `-inf dB` for both. Duration + /// is still valid (the decode pass measured it), but loudness must + /// be `None` — `serde_json` would refuse to serialize an + /// `Infinity` and the worker would loop forever on retry. + #[test] + fn analysis_to_meta_fields_silent_drops_loudness() { + let analysis = ffmpeg::AudioAnalysis { + duration_seconds: 5, + mean_db: f64::NEG_INFINITY, + max_db: f64::NEG_INFINITY, + }; + let (duration, loudness) = analysis_to_meta_fields(Ok(analysis)); + assert_eq!(duration, Some(5)); + assert_eq!(loudness, None); + } + + /// Mixed case: only one of the two values is non-finite. The gate + /// is `&&` so a single non-finite value still drops loudness — we + /// never feed a partially-defined measurement into the gain + /// formula. + #[test] + fn analysis_to_meta_fields_partial_inf_drops_loudness() { + let analysis = ffmpeg::AudioAnalysis { + duration_seconds: 10, + mean_db: -27.3, + max_db: f64::NEG_INFINITY, + }; + let (duration, loudness) = analysis_to_meta_fields(Ok(analysis)); + assert_eq!(duration, Some(10)); + assert_eq!(loudness, None); + } + + /// ffmpeg failed: both fields are `None`, the transcript still + /// gets persisted by the caller. The worker logs the error before + /// calling this helper, so the helper itself stays log-free. + #[test] + fn analysis_to_meta_fields_err_yields_none_none() { + let err = ffmpeg::FfmpegError::Parse("synthetic test error".into()); + let (duration, loudness) = analysis_to_meta_fields(Err(err)); + assert_eq!(duration, None); + assert_eq!(loudness, None); + } }