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.
This commit is contained in:
2026-04-14 11:04:28 +02:00
parent fc8efc62e0
commit b63a269776
3 changed files with 63 additions and 15 deletions
+30 -6
View File
@@ -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<String, WhisperError> {
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
+13 -3
View File
@@ -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<Config>, 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<Config>, 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");
+20 -6
View File
@@ -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");