From 84f8df2bf4015df3e668709d74d2c7d43cb54fbe Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 13 Apr 2026 16:40:20 +0200 Subject: [PATCH] feat: Add Whisper transcription client Introduces a new module for interacting with the Whisper ASR service. This includes an async function to POST audio files and retrieve transcripts. Error handling for network and HTTP status codes is also implemented. New tests are added to cover successful transcription, HTTP error responses, and request timeouts. --- server/src/transcribe/mod.rs | 1 + server/src/transcribe/whisper.rs | 73 ++++++++++++++++++++++++++++++++ server/tests/transcribe_test.rs | 73 ++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 server/src/transcribe/whisper.rs diff --git a/server/src/transcribe/mod.rs b/server/src/transcribe/mod.rs index 0620ccc..1edb0d6 100644 --- a/server/src/transcribe/mod.rs +++ b/server/src/transcribe/mod.rs @@ -1 +1,2 @@ pub mod ffmpeg; +pub mod whisper; diff --git a/server/src/transcribe/whisper.rs b/server/src/transcribe/whisper.rs new file mode 100644 index 0000000..7360c99 --- /dev/null +++ b/server/src/transcribe/whisper.rs @@ -0,0 +1,73 @@ +use std::path::Path; +use std::time::Duration; + +use reqwest::multipart::{Form, Part}; +use tracing::debug; + +#[derive(Debug)] +pub enum WhisperError { + Io(std::io::Error), + Http(reqwest::Error), + Status { status: u16, body: String }, +} + +impl std::fmt::Display for WhisperError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "whisper io error: {e}"), + Self::Http(e) => write!(f, "whisper http error: {e}"), + Self::Status { status, body } => { + write!(f, "whisper returned {status}: {body}") + } + } + } +} + +impl std::error::Error for WhisperError {} + +/// POST the audio file to whisper-asr-webservice and return the transcript text. +/// +/// Uses `output=txt` for a plain-text response and `encode=false` to skip +/// whisper's internal ffmpeg pass — we remux ourselves upstream. +pub async fn transcribe( + client: &reqwest::Client, + whisper_url: &str, + audio: &Path, + timeout: Duration, +) -> Result { + 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(WhisperError::Io)?; + + let part = Part::bytes(bytes) + .file_name(filename) + .mime_str("audio/mp4") + .map_err(WhisperError::Http)?; + let form = Form::new().part("audio_file", part); + + let url = format!("{}/asr?output=txt&encode=false", whisper_url.trim_end_matches('/')); + debug!(%url, "posting to whisper"); + + let response = client + .post(&url) + .timeout(timeout) + .multipart(form) + .send() + .await + .map_err(WhisperError::Http)?; + + let status = response.status(); + let body = response.text().await.map_err(WhisperError::Http)?; + + if !status.is_success() { + return Err(WhisperError::Status { + status: status.as_u16(), + body, + }); + } + + Ok(body) +} diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index feac4d8..1988709 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -1,6 +1,10 @@ use std::path::PathBuf; +use std::time::Duration; use doctate_server::transcribe::ffmpeg::remux_faststart; +use doctate_server::transcribe::whisper::{transcribe, WhisperError}; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -43,3 +47,72 @@ async fn ffmpeg_remux_fails_on_missing_input() { let result = remux_faststart(&input).await; assert!(result.is_err(), "expected error for missing input"); } + +#[tokio::test] +async fn whisper_client_posts_multipart_and_returns_text() { + let server = MockServer::start().await; + let expected = "Hallo Welt, das ist ein Test."; + + Mock::given(method("POST")) + .and(path("/asr")) + .and(query_param("output", "txt")) + .and(query_param("encode", "false")) + .respond_with(ResponseTemplate::new(200).set_body_string(expected)) + .expect(1) + .mount(&server) + .await; + + let client = reqwest::Client::new(); + let text = transcribe(&client, &server.uri(), &fixture("sample.m4a"), Duration::from_secs(10)) + .await + .expect("transcribe failed"); + + assert_eq!(text, expected); +} + +#[tokio::test] +async fn whisper_client_returns_status_error_on_500() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/asr")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + let client = reqwest::Client::new(); + let err = transcribe(&client, &server.uri(), &fixture("sample.m4a"), Duration::from_secs(10)) + .await + .expect_err("expected error"); + + match err { + WhisperError::Status { status, body } => { + assert_eq!(status, 500); + assert_eq!(body, "boom"); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + +#[tokio::test] +async fn whisper_client_times_out() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/asr")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2))) + .mount(&server) + .await; + + let client = reqwest::Client::new(); + let err = transcribe( + &client, + &server.uri(), + &fixture("sample.m4a"), + Duration::from_millis(100), + ) + .await + .expect_err("expected timeout"); + + assert!(matches!(err, WhisperError::Http(_)), "expected Http error, got {err:?}"); +}