Run audio analysis and Whisper concurrently

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