From 4a7f84d455076714d2246211d1cc32ea50e4bf3b Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 27 Apr 2026 16:18:45 +0200 Subject: [PATCH] Run audio analysis and Whisper concurrently --- doctate-common/src/lib.rs | 2 +- server/src/transcribe/worker.rs | 92 ++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/doctate-common/src/lib.rs b/doctate-common/src/lib.rs index 0d30587..75525a6 100644 --- a/doctate-common/src/lib.rs +++ b/doctate-common/src/lib.rs @@ -13,6 +13,6 @@ pub use constants::{ UPLOAD_PATH, }; pub use oneliners::{ONELINER_FILENAME, ONELINERS_PATH, OnelinerEntry, OnelinersResponse}; -pub use recordings::{RECORDING_META_SUFFIX, RecordingMeta, Transcript, TranscriptState}; +pub use recordings::{Loudness, RECORDING_META_SUFFIX, RecordingMeta, Transcript, TranscriptState}; pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem}; pub use url::join_url; diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 96e587b..b0d3bd8 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Duration; use doctate_common::oneliners::OnelinerState; -use doctate_common::{RecordingMeta, Transcript, TranscriptState}; +use doctate_common::{Loudness, RecordingMeta, Transcript, TranscriptState}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tracing::{error, info, warn}; @@ -12,6 +12,7 @@ use super::{TranscribeReceiver, ffmpeg, ollama, whisper}; use crate::config::{Config, WhisperUserSettings}; use crate::events::{self, CaseEventKind, EventSender}; use crate::gazetteer::Gazetteer; +use crate::loudness; use crate::oneliner_locks::OnelinerLocks; use crate::paths; use crate::settings::Settings; @@ -69,34 +70,33 @@ pub async fn run( } }; - // Probe the container duration on the remuxed copy (its `moov` - // atom sits at the start, so ffprobe returns without seeking - // to EOF). We carry the value in memory and persist it - // together with the transcript at the end of the pipeline. - // Non-fatal: a probe failure leaves `duration_seconds = None` - // and the recordings scan falls back to a read-only ffprobe - // for the UI render. - let duration_seconds = match ffmpeg::probe_duration_seconds(remuxed.path()).await { - Ok(s) => Some(s), - Err(e) => { - warn!( - audio = %audio_path.display(), - error = %e, - "ffprobe duration failed — meta will record null duration" - ); - None - } - }; + // Run audio analysis (duration + volumedetect) and Whisper + // concurrently against the remuxed tempfile. The volumedetect + // pass is decode-bound on a CPU core (~100-500 ms for typical + // dictation lengths); Whisper dominates the wallclock with a + // GPU/network round-trip in seconds, so the analysis cost + // disappears behind the join. + // + // `tokio::join!` (not `try_join!`) intentionally awaits both + // futures even on error: a failed analysis must not throw + // away a successful transcript, and a failed transcript must + // drop any analysis values — without a meta sidecar the + // page-load heal re-enqueues, and that retry must run the + // analysis fresh. + let (analysis_result, whisper_result) = tokio::join!( + ffmpeg::analyze_audio(remuxed.path()), + whisper::transcribe( + &client, + &settings.whisper.url, + remuxed.path(), + timeout, + &user_whisper, + ), + ); - let text = match whisper::transcribe( - &client, - &settings.whisper.url, - remuxed.path(), - timeout, - &user_whisper, - ) - .await - { + // Whisper is the gating outcome: without a transcript we don't + // write any meta, regardless of whether the analysis succeeded. + let text = match whisper_result { Ok(t) => t, Err(e) => { error!(audio = %audio_path.display(), error = %e, "whisper call failed"); @@ -118,6 +118,39 @@ 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) + } + }; + // Normalize terminology against the curated vocab before // persisting. Downstream consumers (oneliner, analyze) read // the canonical path. @@ -137,7 +170,7 @@ pub async fn run( let meta = RecordingMeta { transcript, duration_seconds, - loudness: None, + loudness, }; if let Err(e) = paths::write_recording_meta_atomic(&audio_path, &meta).await { error!(meta = %meta_path.display(), error = %e, "writing recording meta failed"); @@ -148,6 +181,7 @@ pub async fn run( audio = %audio_path.display(), bytes = bytes_logged, duration = ?duration_seconds, + gain_db = ?meta.loudness.as_ref().map(|l| l.gain_db), "Recording meta written" );