diff --git a/server/src/transcribe/whisper.rs b/server/src/transcribe/whisper.rs index 2c1cd27..be8ec55 100644 --- a/server/src/transcribe/whisper.rs +++ b/server/src/transcribe/whisper.rs @@ -4,6 +4,8 @@ use std::time::Duration; use reqwest::multipart::{Form, Part}; use tracing::debug; +use crate::config::WhisperUserSettings; + #[derive(Debug)] pub enum WhisperError { Io(std::io::Error), @@ -27,15 +29,21 @@ impl std::error::Error for WhisperError {} /// POST the audio file to whisper-asr-webservice and return the transcript text. /// -/// `output=txt` → plain-text response. `language=de` pins German so the model -/// skips language detection. 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. +/// `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()`. pub async fn transcribe( client: &reqwest::Client, whisper_url: &str, audio: &Path, timeout: Duration, + settings: &WhisperUserSettings, ) -> Result { let filename = audio .file_name() @@ -48,9 +56,25 @@ pub async fn transcribe( .file_name(filename) .mime_str("audio/mp4") .map_err(WhisperError::Http)?; - let form = Form::new().part("audio_file", part); + let mut form = Form::new().part("audio_file", part); - let url = format!("{}/asr?output=txt&language=de", whisper_url.trim_end_matches('/')); + 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 url = format!( + "{}/asr?output=txt&language={}", + whisper_url.trim_end_matches('/'), + language + ); debug!(%url, "posting to whisper"); let response = client diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index b40cea4..3548ac0 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -5,7 +5,7 @@ use std::time::Duration; use tracing::{error, info, warn}; use super::{ffmpeg, ollama, whisper, TranscribeReceiver}; -use crate::config::Config; +use crate::config::{Config, WhisperUserSettings}; const ONELINER_TIMEOUT: Duration = Duration::from_secs(60); @@ -23,7 +23,17 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc, client: reqwes continue; } - info!(audio = %audio_path.display(), "Transcribing"); + // 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 settings: 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, @@ -33,7 +43,7 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc, client: reqwes } }; - let text = match whisper::transcribe(&client, &config.whisper_url, remuxed.path(), timeout).await { + let text = match whisper::transcribe(&client, &config.whisper_url, remuxed.path(), timeout, &settings).await { Ok(t) => t, Err(e) => { error!(audio = %audio_path.display(), error = %e, "whisper call failed"); diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 10e2264..4e6ec23 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -5,6 +5,7 @@ use doctate_server::transcribe; use doctate_server::transcribe::ffmpeg::remux_faststart; use doctate_server::transcribe::ollama::{generate_oneliner, OllamaError}; use doctate_server::transcribe::recovery::scan_and_enqueue; +use doctate_server::config::WhisperUserSettings; use doctate_server::transcribe::whisper::{transcribe, WhisperError}; use serde_json::json; use wiremock::matchers::{body_partial_json, method, path, query_param}; @@ -67,9 +68,15 @@ async fn whisper_client_posts_multipart_and_returns_text() { .await; let client = reqwest::Client::new(); - let text = transcribe(&client, &server.uri(), &fixture("sample.m4a"), Duration::from_secs(10)) - .await - .expect("transcribe failed"); + let text = transcribe( + &client, + &server.uri(), + &fixture("sample.m4a"), + Duration::from_secs(10), + &WhisperUserSettings::default(), + ) + .await + .expect("transcribe failed"); assert_eq!(text, expected); } @@ -85,9 +92,15 @@ async fn whisper_client_returns_status_error_on_500() { .await; let client = reqwest::Client::new(); - let err = transcribe(&client, &server.uri(), &fixture("sample.m4a"), Duration::from_secs(10)) - .await - .expect_err("expected error"); + let err = transcribe( + &client, + &server.uri(), + &fixture("sample.m4a"), + Duration::from_secs(10), + &WhisperUserSettings::default(), + ) + .await + .expect_err("expected error"); match err { WhisperError::Status { status, body } => { @@ -114,6 +127,7 @@ async fn whisper_client_times_out() { &server.uri(), &fixture("sample.m4a"), Duration::from_millis(100), + &WhisperUserSettings::default(), ) .await .expect_err("expected timeout");