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, settings: Arc, client: reqwest::Client, worker_busy: WorkerBusy, vocab: Arc, 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 `.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; } }; // 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. 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)"); } /// Atomically rename a failed recording from `.m4a` to `.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 { let mut entries = tokio::fs::read_dir(case_dir).await.ok()?; let mut m4as: Vec = 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 = 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); } }