710b60bb16
This commit extracts the logic for mapping audio analysis results into the `RecordingMeta` fields into a new, pure function `analysis_to_meta_fields`. This improves testability by separating the core mapping logic from logging and other side effects. The previous implementation directly handled logging and conditional logic within the `run` function. The new function encapsulates these mapping rules, including handling finite values, silent audio, and error propagation. The caller, `run`, is now responsible for logging any analysis failures before calling the helper. Unit tests have been added to verify the behavior of `analysis_to_meta_fields` in various scenarios.
678 lines
26 KiB
Rust
678 lines
26 KiB
Rust
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use doctate_common::oneliners::OnelinerState;
|
|
use doctate_common::{Loudness, RecordingMeta, Transcript, TranscriptState};
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use tracing::{error, info, warn};
|
|
|
|
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;
|
|
use crate::{BusyGuard, WorkerBusy};
|
|
|
|
const ONELINER_TIMEOUT: Duration = Duration::from_secs(60);
|
|
|
|
/// Consume transcription jobs sequentially. Whisper holds the GPU, so parallelism
|
|
/// here would only cause contention. One job in flight at a time.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn run(
|
|
mut rx: TranscribeReceiver,
|
|
config: Arc<Config>,
|
|
settings: Arc<Settings>,
|
|
client: reqwest::Client,
|
|
worker_busy: WorkerBusy,
|
|
vocab: Arc<Gazetteer>,
|
|
events_tx: EventSender,
|
|
oneliner_locks: OnelinerLocks,
|
|
) {
|
|
info!("Transcription worker started");
|
|
let timeout = Duration::from_secs(settings.whisper.timeout_seconds);
|
|
|
|
while let Some(job) = rx.recv().await {
|
|
let _guard = BusyGuard::new(worker_busy.clone());
|
|
let audio_path = job.audio_path;
|
|
let meta_path = paths::recording_meta_path(&audio_path);
|
|
|
|
// Idempotency gate: presence of `<stem>.json` means the
|
|
// transcriber already finished this recording (single atomic
|
|
// write at the end of the pipeline). Skip the job, freeing
|
|
// Whisper for the next one.
|
|
if meta_path.exists() {
|
|
continue;
|
|
}
|
|
|
|
// Look up per-user Whisper settings live from the shared config so that
|
|
// edits to users.toml (on next restart) reach in-flight jobs naturally.
|
|
// Unknown slug → empty settings (service falls back to language=de).
|
|
let user_whisper: WhisperUserSettings = config
|
|
.users
|
|
.iter()
|
|
.find(|u| u.slug == job.user_slug)
|
|
.map(|u| u.whisper.clone())
|
|
.unwrap_or_default();
|
|
|
|
info!(audio = %audio_path.display(), user = %job.user_slug, "Transcribing");
|
|
|
|
let remuxed = match ffmpeg::remux_faststart(&audio_path).await {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
error!(audio = %audio_path.display(), error = %e, "ffmpeg remux failed");
|
|
mark_failed(&audio_path, &events_tx, &job.user_slug).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// 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,
|
|
),
|
|
);
|
|
|
|
// 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");
|
|
if e.is_transient() {
|
|
// Leave the recording as plain `.m4a` (no `.failed` suffix)
|
|
// so the page-load heal (`enqueue_pending_for_user`) picks
|
|
// it up on the next refresh. The natural retry trigger is
|
|
// a human navigating the UI — no scheduler, no sidecar.
|
|
info!(
|
|
audio = %audio_path.display(),
|
|
error = %e,
|
|
reason = "transient",
|
|
"recording left as .m4a, page-load heal will re-enqueue"
|
|
);
|
|
} else {
|
|
mark_failed(&audio_path, &events_tx, &job.user_slug).await;
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Log analysis failures here (so the helper stays pure and
|
|
// unit-testable) before mapping the result onto the meta
|
|
// fields. The mapping rules — finiteness gate, silent-file
|
|
// handling, error propagation — live in
|
|
// `analysis_to_meta_fields` with their own tests.
|
|
if let Err(ref e) = analysis_result {
|
|
warn!(
|
|
audio = %audio_path.display(),
|
|
error = %e,
|
|
"audio analysis failed — meta will record null duration and loudness"
|
|
);
|
|
}
|
|
let (duration_seconds, loudness) = analysis_to_meta_fields(analysis_result);
|
|
|
|
// Normalize terminology against the curated vocab before
|
|
// persisting. Downstream consumers (oneliner, analyze) read
|
|
// the canonical path.
|
|
let text = vocab.replace(&text);
|
|
|
|
// Single atomic write: assemble the recording metadata
|
|
// (transcript outcome + duration) in memory, then materialise
|
|
// it on disk in one rename. Either readers see the previous
|
|
// state (no sidecar = pending) or the full new sidecar — never
|
|
// a half-written one.
|
|
let bytes_logged = text.len();
|
|
let transcript = if text.trim().is_empty() {
|
|
Transcript::Silent
|
|
} else {
|
|
Transcript::Content { text }
|
|
};
|
|
let meta = RecordingMeta {
|
|
transcript,
|
|
duration_seconds,
|
|
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");
|
|
continue;
|
|
}
|
|
|
|
info!(
|
|
audio = %audio_path.display(),
|
|
bytes = bytes_logged,
|
|
duration = ?duration_seconds,
|
|
gain_db = ?meta.loudness.as_ref().map(|l| l.gain_db),
|
|
"Recording meta written"
|
|
);
|
|
|
|
// Notify the live-update bus so subscribed browsers can refresh the
|
|
// recordings view. case_id = parent dir of the audio file.
|
|
if let Some(case_dir) = audio_path.parent() {
|
|
events::emit(
|
|
&events_tx,
|
|
&job.user_slug,
|
|
events::case_id_of(case_dir),
|
|
CaseEventKind::TranscriptReady,
|
|
);
|
|
}
|
|
|
|
// Regenerate the oneliner, but only after the *last* pending
|
|
// recording in the batch — otherwise we'd burn LLM calls on
|
|
// incomplete inputs that the next job would redo. Later
|
|
// recordings still override earlier ones (symmetric to the
|
|
// analyze-LLM "Spätere Aufnahmen haben Vorrang" rule). Silent /
|
|
// empty cases are no-ops; LLM errors leave any existing
|
|
// oneliner untouched.
|
|
if let Some(case_dir) = audio_path.parent()
|
|
&& !has_pending_recordings(case_dir).await
|
|
{
|
|
update_oneliner(
|
|
case_dir,
|
|
&job.user_slug,
|
|
&client,
|
|
&settings,
|
|
&vocab,
|
|
&events_tx,
|
|
&oneliner_locks,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
warn!("Transcription worker stopped (channel closed)");
|
|
}
|
|
|
|
/// Map an [`ffmpeg::analyze_audio`] outcome onto the two
|
|
/// [`RecordingMeta`] fields it can populate.
|
|
///
|
|
/// Pure: no logging, no I/O, no Tokio. The caller is expected to log
|
|
/// failures (so the test fixture for this fn does not have to fight
|
|
/// the global tracing subscriber).
|
|
///
|
|
/// - `Ok(a)` with both `mean_db` and `max_db` finite → both fields
|
|
/// populated, `gain_db` from [`loudness::compute_gain_db`].
|
|
/// - `Ok(a)` with either non-finite (silent / zero-sample input) →
|
|
/// duration kept, loudness dropped: `serde_json` refuses non-finite
|
|
/// floats and persisting `+Inf` gain would loop the worker.
|
|
/// - `Err(_)` → both fields `None`. ffmpeg failed; the recording will
|
|
/// show up in the UI without duration or replay-gain, but the
|
|
/// transcript still gets persisted.
|
|
fn analysis_to_meta_fields(
|
|
analysis_result: Result<ffmpeg::AudioAnalysis, ffmpeg::FfmpegError>,
|
|
) -> (Option<u32>, Option<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) => (Some(a.duration_seconds), None),
|
|
Err(_) => (None, None),
|
|
}
|
|
}
|
|
|
|
/// Atomically rename a failed recording from `<ts>.m4a` to `<ts>.m4a.failed`
|
|
/// so the recovery scan no longer picks it up. The UI still surfaces these
|
|
/// files (with a "failed" flag) so the user can listen to the audio and
|
|
/// manually decide whether to retry (by renaming back) or to delete.
|
|
async fn mark_failed(audio_path: &Path, events_tx: &EventSender, user_slug: &str) {
|
|
let failed_path = audio_path.with_extension("m4a.failed");
|
|
if let Err(e) = tokio::fs::rename(audio_path, &failed_path).await {
|
|
// Nothing to roll back — if rename fails, the worst case is that we
|
|
// retry the same file on next restart. Log and move on.
|
|
error!(
|
|
audio = %audio_path.display(),
|
|
error = %e,
|
|
"Failed to mark recording as failed; may be retried on restart",
|
|
);
|
|
} else {
|
|
warn!(
|
|
audio = %audio_path.display(),
|
|
failed = %failed_path.display(),
|
|
"Recording marked as failed",
|
|
);
|
|
if let Some(case_dir) = audio_path.parent() {
|
|
events::emit(
|
|
events_tx,
|
|
user_slug,
|
|
events::case_id_of(case_dir),
|
|
CaseEventKind::TranscriptFailed,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Regenerate `case_dir/oneliner.json` from **all** `Content` transcripts
|
|
/// in the case, joined chronologically. Called after every successful
|
|
/// transcript write so later recordings can correct earlier ones (mirror
|
|
/// of the analyze-LLM rule "later recordings override"). Every outcome —
|
|
/// generated text, deliberate empty ("no medical content"), or call error
|
|
/// — is persisted as an [`OnelinerState`] variant, overwriting any
|
|
/// previous state.
|
|
///
|
|
/// Three terminal shapes:
|
|
/// - At least one `Content` transcript → call the LLM; persist `Ready`,
|
|
/// `Empty`, or `Error` based on the response.
|
|
/// - No `Content` but all other recordings are `Silent` (nothing more
|
|
/// coming from the transcriber) → persist `Empty` directly and skip
|
|
/// the LLM call. Without this, silent-only cases would leave the UI
|
|
/// stuck on "Generating" forever.
|
|
/// - Still-`Pending` recordings present → no-op; the next transcript
|
|
/// write re-enters this function with more information.
|
|
pub async fn update_oneliner(
|
|
case_dir: &Path,
|
|
user_slug: &str,
|
|
client: &reqwest::Client,
|
|
settings: &Settings,
|
|
vocab: &Gazetteer,
|
|
events_tx: &EventSender,
|
|
locks: &OnelinerLocks,
|
|
) {
|
|
// Early-Latch: a manual override is sticky. Skip the LLM call
|
|
// entirely so the doctor's edit cannot be overwritten and Ollama
|
|
// does not waste a 60s round-trip on a result that will be dropped.
|
|
// Read-only check, no lock needed.
|
|
let (existing, _) = paths::read_oneliner_state(case_dir).await;
|
|
if matches!(existing, Some(OnelinerState::Manual { .. })) {
|
|
info!(
|
|
user = %user_slug,
|
|
case = %case_dir.display(),
|
|
"oneliner manual override is sticky — skip auto-regen"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let transcript = match all_transcripts_joined(case_dir).await {
|
|
Some(t) => t,
|
|
None => {
|
|
// No `Content` transcripts in the case. Two distinct reasons:
|
|
// a) Some recording is still `Pending` — transcriber will
|
|
// trigger us again when the last one lands. Skip for now.
|
|
// b) Every recording is `Silent` — transcriber is done and
|
|
// will never produce text. Without a persisted state
|
|
// the UI would stick on `Generating` forever (bug that
|
|
// motivated this code path). Persist `Empty` so the UI
|
|
// settles and recovery treats it as terminal.
|
|
if !has_pending_recordings(case_dir).await {
|
|
let generated_at = OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.unwrap_or_default();
|
|
let state = OnelinerState::Empty { generated_at };
|
|
// Re-check under lock: a PUT may have arrived between
|
|
// the early-latch read and now (no LLM call here, but
|
|
// the silent-empty resolution still races with PUTs).
|
|
let _guard = locks.lock_for(case_dir).await;
|
|
let (current, _) = paths::read_oneliner_state(case_dir).await;
|
|
if matches!(current, Some(OnelinerState::Manual { .. })) {
|
|
info!(
|
|
user = %user_slug,
|
|
case = %case_dir.display(),
|
|
"manual override appeared during silent-empty resolution — drop"
|
|
);
|
|
return;
|
|
}
|
|
if let Err(e) = paths::write_oneliner_state(case_dir, &state).await {
|
|
error!(
|
|
case = %case_dir.display(),
|
|
error = %e,
|
|
"writing oneliner.json (all-silent empty) failed"
|
|
);
|
|
return;
|
|
}
|
|
info!(
|
|
user = %user_slug,
|
|
case = %case_dir.display(),
|
|
"oneliner empty — all recordings silent (no content transcripts)"
|
|
);
|
|
events::emit(
|
|
events_tx,
|
|
user_slug,
|
|
events::case_id_of(case_dir),
|
|
CaseEventKind::OnelinerUpdated,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
let generated_at = OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.unwrap_or_default();
|
|
|
|
// LLM call runs WITHOUT the lock — holding the per-case mutex for
|
|
// 60s would block any concurrent PUT for the same case. The
|
|
// post-LLM re-check (below) catches manual overrides that arrived
|
|
// during the call.
|
|
let state = match ollama::generate_oneliner(
|
|
client,
|
|
&settings.ollama.url,
|
|
&settings.ollama.model,
|
|
settings.ollama.keep_alive,
|
|
&transcript,
|
|
ONELINER_TIMEOUT,
|
|
)
|
|
.await
|
|
{
|
|
Ok(line) => {
|
|
let text = vocab.replace(&line);
|
|
info!(
|
|
user = %user_slug,
|
|
case = %case_dir.display(),
|
|
chars = text.chars().count(),
|
|
"Oneliner ready"
|
|
);
|
|
OnelinerState::Ready { text, generated_at }
|
|
}
|
|
Err(ollama::OllamaError::EmptyResponse) => {
|
|
warn!(
|
|
case = %case_dir.display(),
|
|
"oneliner empty — model returned nothing (no medical keyword in transcript)"
|
|
);
|
|
OnelinerState::Empty { generated_at }
|
|
}
|
|
Err(e) => {
|
|
error!(case = %case_dir.display(), error = %e, "oneliner generation failed");
|
|
OnelinerState::Error { generated_at }
|
|
}
|
|
};
|
|
|
|
// Re-check under the per-case lock: if a manual override landed
|
|
// while the LLM was thinking, drop the auto result so the doctor's
|
|
// edit always wins. The PUT handler holds the same lock around its
|
|
// write, so the read-and-write below is atomic vs. that path.
|
|
let _guard = locks.lock_for(case_dir).await;
|
|
let (current, _) = paths::read_oneliner_state(case_dir).await;
|
|
if matches!(current, Some(OnelinerState::Manual { .. })) {
|
|
info!(
|
|
user = %user_slug,
|
|
case = %case_dir.display(),
|
|
"manual override appeared during regen — dropping LLM result"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = paths::write_oneliner_state(case_dir, &state).await {
|
|
error!(case = %case_dir.display(), error = %e, "writing oneliner.json failed");
|
|
return;
|
|
}
|
|
events::emit(
|
|
events_tx,
|
|
user_slug,
|
|
events::case_id_of(case_dir),
|
|
CaseEventKind::OnelinerUpdated,
|
|
);
|
|
}
|
|
|
|
/// Return true if `case_dir` contains at least one `.m4a` recording
|
|
/// whose transcript is still `Pending` (no sidecar file yet). Used to
|
|
/// gate the oneliner-regenerate call: we only run it after the *last*
|
|
/// pending transcription in a batch, not after every single one.
|
|
/// `.m4a.failed` entries are skipped — they don't block the oneliner.
|
|
/// `Silent` recordings count as finished, not pending — that's the
|
|
/// whole point of the three-state model: the transcriber already ran,
|
|
/// it just didn't produce text.
|
|
/// Fail-open on read errors: if we can't scan, return false so the
|
|
/// oneliner runs anyway (one redundant call beats never).
|
|
pub(crate) async fn has_pending_recordings(case_dir: &Path) -> bool {
|
|
let Ok(mut entries) = tokio::fs::read_dir(case_dir).await else {
|
|
return false;
|
|
};
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
|
|
continue;
|
|
}
|
|
if paths::read_transcript_state(&path).await.is_pending() {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Join every `Content` transcript in `case_dir` chronologically with
|
|
/// `\n\n---\n\n` separators. `Pending` (no sidecar) and `Silent` (empty
|
|
/// sidecar) recordings are skipped. Returns None if no `Content`
|
|
/// transcript exists — the caller decides whether that means "wait for
|
|
/// more transcripts" or "this case is terminal silent" by consulting
|
|
/// [`has_pending_recordings`] afterwards.
|
|
pub(crate) async fn all_transcripts_joined(case_dir: &Path) -> Option<String> {
|
|
let mut entries = tokio::fs::read_dir(case_dir).await.ok()?;
|
|
let mut m4as: Vec<std::path::PathBuf> = Vec::new();
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) == Some("m4a") {
|
|
m4as.push(path);
|
|
}
|
|
}
|
|
m4as.sort();
|
|
|
|
let mut parts: Vec<String> = Vec::with_capacity(m4as.len());
|
|
for path in m4as {
|
|
if let TranscriptState::Content { text } = paths::read_transcript_state(&path).await {
|
|
let trimmed = text.trim();
|
|
if !trimmed.is_empty() {
|
|
parts.push(trimmed.to_string());
|
|
}
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
Some(parts.join("\n\n---\n\n"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
/// Helper: seed an m4a plus an optional metadata sidecar, matching
|
|
/// how the worker lays files out on disk. `all_transcripts_joined`
|
|
/// iterates `.m4a` files and reads the sidecar through the shared
|
|
/// reader, so bare meta files without m4as would be ignored.
|
|
///
|
|
/// `transcript`: `None` → no sidecar (Pending); `Some(s)` with
|
|
/// whitespace-only `s` → `Transcript::Silent`; otherwise
|
|
/// `Transcript::Content`.
|
|
async fn seed(dir: &Path, stem: &str, transcript: Option<&str>) {
|
|
tokio::fs::write(dir.join(format!("{stem}.m4a")), b"audio")
|
|
.await
|
|
.unwrap();
|
|
if let Some(text) = transcript {
|
|
let variant = if text.trim().is_empty() {
|
|
Transcript::Silent
|
|
} else {
|
|
Transcript::Content {
|
|
text: text.to_owned(),
|
|
}
|
|
};
|
|
paths::write_recording_meta_sync(dir, stem, variant, None);
|
|
}
|
|
}
|
|
|
|
/// Regression guard for the Womax bug: the joined transcript must
|
|
/// include ALL recordings in chronological order, so the LLM sees
|
|
/// both the initial term and any later correction.
|
|
#[tokio::test]
|
|
async fn all_transcripts_joined_includes_all_in_order() {
|
|
let dir = tempdir().unwrap();
|
|
seed(
|
|
dir.path(),
|
|
"2026-04-16T10-05-00Z",
|
|
Some("Korrektur. Das Mittel heißt Vomex."),
|
|
)
|
|
.await;
|
|
seed(
|
|
dir.path(),
|
|
"2026-04-16T10-00-00Z",
|
|
Some("Jetzt weiß ich es wieder. Es heißt Womax."),
|
|
)
|
|
.await;
|
|
|
|
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
|
assert_eq!(
|
|
got,
|
|
"Jetzt weiß ich es wieder. Es heißt Womax.\n\n---\n\nKorrektur. Das Mittel heißt Vomex."
|
|
);
|
|
}
|
|
|
|
/// Silent recordings are skipped, not treated as end-of-stream — a
|
|
/// later non-empty recording still contributes.
|
|
#[tokio::test]
|
|
async fn all_transcripts_joined_skips_empty_recordings() {
|
|
let dir = tempdir().unwrap();
|
|
seed(dir.path(), "2026-04-16T10-00-00Z", Some("")).await;
|
|
seed(
|
|
dir.path(),
|
|
"2026-04-16T10-05-00Z",
|
|
Some("non-empty content"),
|
|
)
|
|
.await;
|
|
|
|
let got = all_transcripts_joined(dir.path()).await.expect("Some");
|
|
assert_eq!(got, "non-empty content");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn all_transcripts_joined_is_none_when_all_empty() {
|
|
let dir = tempdir().unwrap();
|
|
seed(dir.path(), "2026-04-16T10-00-00Z", Some(" \n ")).await;
|
|
assert!(all_transcripts_joined(dir.path()).await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn all_transcripts_joined_is_none_when_no_transcripts() {
|
|
let dir = tempdir().unwrap();
|
|
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a"), b"audio")
|
|
.await
|
|
.unwrap();
|
|
assert!(all_transcripts_joined(dir.path()).await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn has_pending_recordings_true_when_m4a_without_transcript() {
|
|
let dir = tempdir().unwrap();
|
|
seed(dir.path(), "2026-04-16T10-00-00Z", Some("text")).await;
|
|
tokio::fs::write(dir.path().join("2026-04-16T10-05-00Z.m4a"), b"b")
|
|
.await
|
|
.unwrap();
|
|
assert!(has_pending_recordings(dir.path()).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn has_pending_recordings_false_when_all_transcribed() {
|
|
let dir = tempdir().unwrap();
|
|
seed(dir.path(), "2026-04-16T10-00-00Z", Some("text1")).await;
|
|
seed(dir.path(), "2026-04-16T10-05-00Z", Some("text2")).await;
|
|
assert!(!has_pending_recordings(dir.path()).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn has_pending_recordings_ignores_failed() {
|
|
let dir = tempdir().unwrap();
|
|
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a.failed"), b"x")
|
|
.await
|
|
.unwrap();
|
|
assert!(!has_pending_recordings(dir.path()).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn has_pending_recordings_false_for_empty_dir() {
|
|
let dir = tempdir().unwrap();
|
|
assert!(!has_pending_recordings(dir.path()).await);
|
|
}
|
|
|
|
/// Successful analysis with finite mean and max — both meta fields
|
|
/// are populated and `gain_db` matches the loudness formula
|
|
/// (mean=-22, max=-15 → target-limited at +6 dB; same case as
|
|
/// `loudness::tests::target_limits_gain`).
|
|
#[test]
|
|
fn analysis_to_meta_fields_finite_populates_both() {
|
|
let analysis = ffmpeg::AudioAnalysis {
|
|
duration_seconds: 30,
|
|
mean_db: -22.0,
|
|
max_db: -15.0,
|
|
};
|
|
let (duration, loudness) = analysis_to_meta_fields(Ok(analysis));
|
|
assert_eq!(duration, Some(30));
|
|
assert_eq!(
|
|
loudness,
|
|
Some(Loudness {
|
|
mean_db: -22.0,
|
|
max_db: -15.0,
|
|
gain_db: 6.0,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// Silent file: volumedetect returns `-inf dB` for both. Duration
|
|
/// is still valid (the decode pass measured it), but loudness must
|
|
/// be `None` — `serde_json` would refuse to serialize an
|
|
/// `Infinity` and the worker would loop forever on retry.
|
|
#[test]
|
|
fn analysis_to_meta_fields_silent_drops_loudness() {
|
|
let analysis = ffmpeg::AudioAnalysis {
|
|
duration_seconds: 5,
|
|
mean_db: f64::NEG_INFINITY,
|
|
max_db: f64::NEG_INFINITY,
|
|
};
|
|
let (duration, loudness) = analysis_to_meta_fields(Ok(analysis));
|
|
assert_eq!(duration, Some(5));
|
|
assert_eq!(loudness, None);
|
|
}
|
|
|
|
/// Mixed case: only one of the two values is non-finite. The gate
|
|
/// is `&&` so a single non-finite value still drops loudness — we
|
|
/// never feed a partially-defined measurement into the gain
|
|
/// formula.
|
|
#[test]
|
|
fn analysis_to_meta_fields_partial_inf_drops_loudness() {
|
|
let analysis = ffmpeg::AudioAnalysis {
|
|
duration_seconds: 10,
|
|
mean_db: -27.3,
|
|
max_db: f64::NEG_INFINITY,
|
|
};
|
|
let (duration, loudness) = analysis_to_meta_fields(Ok(analysis));
|
|
assert_eq!(duration, Some(10));
|
|
assert_eq!(loudness, None);
|
|
}
|
|
|
|
/// ffmpeg failed: both fields are `None`, the transcript still
|
|
/// gets persisted by the caller. The worker logs the error before
|
|
/// calling this helper, so the helper itself stays log-free.
|
|
#[test]
|
|
fn analysis_to_meta_fields_err_yields_none_none() {
|
|
let err = ffmpeg::FfmpegError::Parse("synthetic test error".into());
|
|
let (duration, loudness) = analysis_to_meta_fields(Err(err));
|
|
assert_eq!(duration, None);
|
|
assert_eq!(loudness, None);
|
|
}
|
|
}
|