From b63a269776a52ffe8ab763fcc61250ec1b8a6421 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 14 Apr 2026 11:04:28 +0200 Subject: [PATCH] feat: Forward user settings to Whisper API Include per-user hotwords and initial prompts in the multipart form data sent to the Whisper API. The language parameter is now dynamically set based on user configuration, defaulting to "de" if not specified. --- server/src/transcribe/whisper.rs | 36 ++++++++++++++++++++++++++------ server/src/transcribe/worker.rs | 16 +++++++++++--- server/tests/transcribe_test.rs | 26 +++++++++++++++++------ 3 files changed, 63 insertions(+), 15 deletions(-) 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");