Run audio analysis and Whisper concurrently
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user