Refactor: Remove unused Whisper variants

This commit removes the `whisper_variant` and `whisper_hotwords_variant`
fields from the `run_id` generation and the `print_meta_summary`
function.

These variants are no longer used as the project is shifting focus to
LLM-based generation. The `run_full_case.rs` example has also been
updated to reflect this change.
This commit is contained in:
2026-04-30 16:56:12 +02:00
parent 6b7a1cea49
commit bb584b6ea0
18 changed files with 639 additions and 215 deletions
+143
View File
@@ -0,0 +1,143 @@
use std::path::Path;
use std::time::Duration;
use doctate_common::join_url;
use reqwest::multipart::{Form, Part};
use tracing::debug;
#[derive(Debug)]
pub enum CanaryError {
Io(std::io::Error),
Http(reqwest::Error),
Status { status: u16, body: String },
}
impl std::fmt::Display for CanaryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "canary io error: {e}"),
Self::Http(e) => write!(f, "canary http error: {e}"),
Self::Status { status, body } => {
write!(f, "canary returned {status}: {body}")
}
}
}
}
impl std::error::Error for CanaryError {}
impl CanaryError {
/// Mirrors `WhisperError::is_transient`: `Http` (network), 5xx/408/429
/// → transient (page-load heal re-enqueues); `Io` and other 4xx →
/// permanent (rename to `.m4a.failed`).
pub fn is_transient(&self) -> bool {
match self {
Self::Http(_) => true,
Self::Status { status, .. } => {
*status == 408 || *status == 429 || (500..=599).contains(status)
}
Self::Io(_) => false,
}
}
}
/// POST the audio file to the Canary `/inference` endpoint and return the
/// transcript text.
///
/// Form fields per docs/canary.md §2.4:
/// - `file`: the multipart upload (any container — Canary's service
/// ffmpegs to 16 kHz mono internally).
/// - `language`: defaults to `de` when the caller passes `None` or empty.
/// - `pnc=yes`: Punctuation and Capitalization, matching the Whisper path.
/// - `timestamps=no`: pipeline does not consume word/segment timestamps.
/// - `response_format=text`: plain-text body, structurally identical to
/// Whisper's `output=txt` response — no downstream parsing needed.
pub async fn transcribe(
client: &reqwest::Client,
canary_url: &str,
audio: &Path,
timeout: Duration,
language: Option<&str>,
) -> Result<String, CanaryError> {
let filename = audio
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "audio.m4a".to_string());
let bytes = tokio::fs::read(audio).await.map_err(CanaryError::Io)?;
let part = Part::bytes(bytes)
.file_name(filename)
.mime_str("audio/mp4")
.map_err(CanaryError::Http)?;
let lang = language.filter(|s| !s.is_empty()).unwrap_or("de");
let form = Form::new()
.part("file", part)
.text("language", lang.to_owned())
.text("pnc", "yes")
.text("timestamps", "no")
.text("response_format", "text");
let url = join_url(canary_url, "/inference");
debug!(%url, language = lang, "posting to canary");
let response = client
.post(&url)
.timeout(timeout)
.multipart(form)
.send()
.await
.map_err(CanaryError::Http)?;
let status = response.status();
let body = response.text().await.map_err(CanaryError::Http)?;
if !status.is_success() {
return Err(CanaryError::Status {
status: status.as_u16(),
body,
});
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_transient_classifies_status_codes() {
let cases: &[(u16, bool)] = &[
(400, false),
(401, false),
(403, false),
(404, false),
(408, true),
(415, false),
(422, false),
(429, true),
(500, true),
(502, true),
(503, true),
(504, true),
];
for &(status, expected) in cases {
let err = CanaryError::Status {
status,
body: String::new(),
};
assert_eq!(
err.is_transient(),
expected,
"status {status} expected is_transient={expected}"
);
}
}
#[test]
fn is_transient_io_is_permanent() {
let err = CanaryError::Io(std::io::Error::other("disk gone"));
assert!(!err.is_transient());
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use tokio::sync::mpsc;
pub mod canary;
pub mod ffmpeg;
pub mod ollama;
pub mod recovery;
@@ -14,7 +15,7 @@ pub const QUEUE_DEPTH: usize = 64;
pub struct TranscribeJob {
pub audio_path: PathBuf,
/// Slug of the user who owns this recording. Used by the worker to look up
/// per-user transcription settings (hotwords, language, initial_prompt).
/// the per-user transcription language.
pub user_slug: String,
}
+8 -25
View File
@@ -5,8 +5,6 @@ use doctate_common::join_url;
use reqwest::multipart::{Form, Part};
use tracing::debug;
use crate::config::WhisperUserSettings;
#[derive(Debug)]
pub enum WhisperError {
Io(std::io::Error),
@@ -59,21 +57,17 @@ impl WhisperError {
/// POST the audio file to whisper-asr-webservice and return the transcript text.
///
/// `output=txt` → plain-text response. `language` pins the language so the model
/// skips detection (defaults to `de` when the user has no override). We send
/// AAC-in-mp4 and let the service decode it internally (the default
/// `encode=true`) — passing `encode=false` would tell the service "this is
/// already raw PCM", which ours isn't.
///
/// Per-user `hotwords` and `initial_prompt` are forwarded as multipart form
/// fields when non-empty; our `whisper/`-service passes them straight into
/// `faster_whisper.WhisperModel.transcribe()`.
/// `output=txt` → plain-text response. `language` pins the language so the
/// model skips detection (defaults to `de` when the caller passes `None` or
/// an empty string). We send AAC-in-mp4 and let the service decode it
/// internally (the default `encode=true`) — passing `encode=false` would
/// tell the service "this is already raw PCM", which ours isn't.
pub async fn transcribe(
client: &reqwest::Client,
whisper_url: &str,
audio: &Path,
timeout: Duration,
settings: &WhisperUserSettings,
language: Option<&str>,
) -> Result<String, WhisperError> {
let filename = audio
.file_name()
@@ -86,20 +80,9 @@ pub async fn transcribe(
.file_name(filename)
.mime_str("audio/mp4")
.map_err(WhisperError::Http)?;
let mut form = Form::new().part("audio_file", part);
let form = Form::new().part("audio_file", part);
if let Some(hotwords) = settings.hotwords.as_deref().filter(|s| !s.is_empty()) {
form = form.part("hotwords", Part::text(hotwords.to_owned()));
}
if let Some(prompt) = settings.initial_prompt.as_deref().filter(|s| !s.is_empty()) {
form = form.part("initial_prompt", Part::text(prompt.to_owned()));
}
let language = settings
.language
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("de");
let language = language.filter(|s| !s.is_empty()).unwrap_or("de");
let url = format!(
"{}?output=txt&language={language}",
join_url(whisper_url, "/asr")
+86 -29
View File
@@ -8,8 +8,8 @@ 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 super::{TranscribeReceiver, canary, ffmpeg, ollama, whisper};
use crate::config::{AsrBackend, Config};
use crate::events::{self, CaseEventKind, EventSender};
use crate::gazetteer::Gazetteer;
use crate::loudness;
@@ -20,8 +20,36 @@ 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.
/// Backend-agnostic ASR error wrapper. Lets the worker handle Whisper
/// and Canary failures with identical match logic — both expose
/// `is_transient()` and `Display`, which is all the surrounding code
/// needs. Cheaper than a trait object: statically dispatched, no vtable.
enum AsrError {
Whisper(whisper::WhisperError),
Canary(canary::CanaryError),
}
impl AsrError {
fn is_transient(&self) -> bool {
match self {
Self::Whisper(e) => e.is_transient(),
Self::Canary(e) => e.is_transient(),
}
}
}
impl std::fmt::Display for AsrError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Whisper(e) => e.fmt(f),
Self::Canary(e) => e.fmt(f),
}
}
}
/// Consume transcription jobs sequentially. The active ASR backend
/// (Whisper or Canary) 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,
@@ -34,7 +62,23 @@ pub async fn run(
oneliner_locks: OnelinerLocks,
) {
info!("Transcription worker started");
let timeout = Duration::from_secs(settings.whisper.timeout_seconds);
// Resolve the active backend once at worker start. URL and timeout
// are frozen for the worker's lifetime — switching backends requires
// a server restart (tied to the GPU-VRAM exclusivity of the two
// models, see docs/canary.md §2.6).
let backend = config.asr_backend;
let (asr_url, timeout) = match backend {
AsrBackend::Whisper => (
settings.whisper.url.clone(),
Duration::from_secs(settings.whisper.timeout_seconds),
),
AsrBackend::Canary => (
settings.canary.url.clone(),
Duration::from_secs(settings.canary.timeout_seconds),
),
};
info!(?backend, url = %asr_url, "ASR backend selected");
while let Some(job) = rx.recv().await {
let _guard = BusyGuard::new(worker_busy.clone());
@@ -49,15 +93,14 @@ pub async fn run(
continue;
}
// Look up per-user Whisper settings live from the shared config so that
// Look up per-user language 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
// Unknown slug or missing field → None (service falls back to "de").
let user_language: Option<String> = config
.users
.iter()
.find(|u| u.slug == job.user_slug)
.map(|u| u.whisper.clone())
.unwrap_or_default();
.and_then(|u| u.language.clone());
info!(audio = %audio_path.display(), user = %job.user_slug, "Transcribing");
@@ -70,12 +113,12 @@ pub async fn run(
}
};
// 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.
// Run audio analysis (duration + volumedetect) and the active
// ASR backend concurrently against the remuxed tempfile. The
// volumedetect pass is decode-bound on a CPU core (~100-500 ms
// for typical dictation lengths); the ASR call 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
@@ -83,23 +126,37 @@ pub async fn run(
// 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 asr_future = async {
match backend {
AsrBackend::Whisper => whisper::transcribe(
&client,
&asr_url,
remuxed.path(),
timeout,
user_language.as_deref(),
)
.await
.map_err(AsrError::Whisper),
AsrBackend::Canary => canary::transcribe(
&client,
&asr_url,
remuxed.path(),
timeout,
user_language.as_deref(),
)
.await
.map_err(AsrError::Canary),
}
};
let (analysis_result, asr_result) =
tokio::join!(ffmpeg::analyze_audio(remuxed.path()), asr_future,);
// Whisper is the gating outcome: without a transcript we don't
// ASR is the gating outcome: without a transcript we don't
// write any meta, regardless of whether the analysis succeeded.
let text = match whisper_result {
let text = match asr_result {
Ok(t) => t,
Err(e) => {
error!(audio = %audio_path.display(), error = %e, "whisper call failed");
error!(audio = %audio_path.display(), error = %e, "ASR 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