Run audio analysis and Whisper concurrently
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
}
|
&client,
|
||||||
};
|
&settings.whisper.url,
|
||||||
|
remuxed.path(),
|
||||||
|
timeout,
|
||||||
|
&user_whisper,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let text = match whisper::transcribe(
|
// Whisper is the gating outcome: without a transcript we don't
|
||||||
&client,
|
// write any meta, regardless of whether the analysis succeeded.
|
||||||
&settings.whisper.url,
|
let text = match whisper_result {
|
||||||
remuxed.path(),
|
|
||||||
timeout,
|
|
||||||
&user_whisper,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
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"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user