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:?}"); +}