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:
+108
-26
@@ -118,38 +118,19 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Translate the analysis outcome into the two `RecordingMeta`
|
// Log analysis failures here (so the helper stays pure and
|
||||||
// fields. Silent files (volumedetect → -inf dB) keep a valid
|
// unit-testable) before mapping the result onto the meta
|
||||||
// duration but skip loudness — `serde_json` refuses non-finite
|
// fields. The mapping rules — finiteness gate, silent-file
|
||||||
// floats, and persisting `+Inf` gain would loop the worker
|
// handling, error propagation — live in
|
||||||
// forever (write fails → no sidecar → idempotency gate stays
|
// `analysis_to_meta_fields` with their own tests.
|
||||||
// open → retry hits the same trap).
|
if let Err(ref e) = analysis_result {
|
||||||
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!(
|
warn!(
|
||||||
audio = %audio_path.display(),
|
audio = %audio_path.display(),
|
||||||
error = %e,
|
error = %e,
|
||||||
"audio analysis failed — meta will record null duration and loudness"
|
"audio analysis failed — meta will record null duration and loudness"
|
||||||
);
|
);
|
||||||
(None, None)
|
|
||||||
}
|
}
|
||||||
};
|
let (duration_seconds, loudness) = analysis_to_meta_fields(analysis_result);
|
||||||
|
|
||||||
// Normalize terminology against the curated vocab before
|
// Normalize terminology against the curated vocab before
|
||||||
// persisting. Downstream consumers (oneliner, analyze) read
|
// persisting. Downstream consumers (oneliner, analyze) read
|
||||||
@@ -222,6 +203,41 @@ pub async fn run(
|
|||||||
warn!("Transcription worker stopped (channel closed)");
|
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`
|
/// 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
|
/// 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
|
/// files (with a "failed" flag) so the user can listen to the audio and
|
||||||
@@ -592,4 +608,70 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
assert!(!has_pending_recordings(dir.path()).await);
|
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