Extract analysis mapping to pure function

This commit extracts the logic for mapping audio analysis results into
the `RecordingMeta` fields into a new, pure function
`analysis_to_meta_fields`. This improves testability by separating the
core mapping logic from logging and other side effects.

The previous implementation directly handled logging and conditional
logic within the `run` function. The new function encapsulates these
mapping rules, including handling finite values, silent audio, and error
propagation. The caller, `run`, is now responsible for logging any
analysis failures before calling the helper. Unit tests have been added
to verify the behavior of `analysis_to_meta_fields` in various
scenarios.
This commit is contained in:
2026-04-27 16:23:25 +02:00
parent 4a7f84d455
commit 710b60bb16
+114 -32
View File
@@ -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<ffmpeg::AudioAnalysis, ffmpeg::FfmpegError>,
) -> (Option<u32>, Option<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) => (Some(a.duration_seconds), None),
Err(_) => (None, None),
}
}
/// Atomically rename a failed recording from `<ts>.m4a` to `<ts>.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);
}
}