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:
@@ -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,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user