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:
+114
-32
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user