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,
};
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;
+59 -25
View File
@@ -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
}
};
let text = match whisper::transcribe(
// 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,
)
.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"
);