1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::{fmt, sync::Arc};
4
5#[derive(Clone, PartialEq, Eq)]
6pub struct TranscriptionRequest {
7 pub bytes: Vec<u8>,
8 pub file_name: String,
9 pub content_type: String,
10 pub language: Option<String>,
11 pub prompt: Option<String>,
12}
13
14impl fmt::Debug for TranscriptionRequest {
15 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
16 formatter
17 .debug_struct("TranscriptionRequest")
18 .field("audio", &"[REDACTED]")
19 .field("size_bytes", &self.bytes.len())
20 .field("file_name", &"[REDACTED]")
21 .field("content_type", &self.content_type)
22 .field("language", &self.language)
23 .field("prompt", &self.prompt.as_ref().map(|_| "[REDACTED]"))
24 .finish()
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct TranscriptionResult {
30 pub text: String,
31 pub provider: String,
32 pub model: String,
33}
34
35pub type AudioInput = TranscriptionRequest;
36pub type Transcript = TranscriptionResult;
37
38#[async_trait]
39pub trait Transcriber: Send + Sync + fmt::Debug {
40 async fn transcribe(
41 &self,
42 request: TranscriptionRequest,
43 ) -> Result<TranscriptionResult, TranscriptionError>;
44}
45
46#[derive(Clone)]
47pub struct TranscriptionService(pub Arc<dyn Transcriber>);
48
49impl fmt::Debug for TranscriptionService {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 formatter.debug_tuple("TranscriptionService").finish()
52 }
53}
54
55impl TranscriptionService {
56 pub fn new(transcriber: Arc<dyn Transcriber>) -> Self {
57 Self(transcriber)
58 }
59
60 pub async fn transcribe(
61 &self,
62 request: TranscriptionRequest,
63 ) -> Result<TranscriptionResult, TranscriptionError> {
64 self.0.transcribe(request).await
65 }
66}
67
68#[derive(Debug, Default)]
69pub struct DisabledTranscriber;
70
71#[async_trait]
72impl Transcriber for DisabledTranscriber {
73 async fn transcribe(
74 &self,
75 _request: TranscriptionRequest,
76 ) -> Result<TranscriptionResult, TranscriptionError> {
77 Err(TranscriptionError::NotConfigured)
78 }
79}
80
81#[cfg(feature = "openai-transcription")]
82#[derive(Clone)]
83pub struct OpenAiTranscriber {
84 client: reqwest::Client,
85 api_key: Arc<str>,
86 endpoint: Arc<str>,
87 model: Arc<str>,
88}
89
90#[cfg(feature = "openai-transcription")]
91impl fmt::Debug for OpenAiTranscriber {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 formatter
94 .debug_struct("OpenAiTranscriber")
95 .field("client", &self.client)
96 .field("endpoint", &"[CONFIGURED]")
97 .field("model", &self.model)
98 .field("api_key", &"[REDACTED]")
99 .finish()
100 }
101}
102
103#[cfg(feature = "openai-transcription")]
104impl OpenAiTranscriber {
105 pub fn new(api_key: impl Into<String>) -> Result<Self, TranscriptionError> {
106 Self::with_options(
107 api_key,
108 "https://api.openai.com/v1/audio/transcriptions",
109 "gpt-4o-mini-transcribe",
110 )
111 }
112
113 pub fn from_env(variable: &str) -> Result<Self, TranscriptionError> {
114 let api_key = std::env::var(variable)
115 .map_err(|_| TranscriptionError::MissingApiKey(variable.to_owned()))?;
116 Self::new(api_key)
117 }
118
119 pub fn with_options(
120 api_key: impl Into<String>,
121 endpoint: impl Into<String>,
122 model: impl Into<String>,
123 ) -> Result<Self, TranscriptionError> {
124 let api_key = api_key.into();
125 if api_key.trim().is_empty() {
126 return Err(TranscriptionError::MissingApiKey(
127 "configured OpenAI API key".into(),
128 ));
129 }
130 let client = reqwest::Client::builder()
131 .timeout(std::time::Duration::from_mins(1))
132 .build()
133 .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
134 Ok(Self {
135 client,
136 api_key: api_key.into(),
137 endpoint: endpoint.into().into(),
138 model: model.into().into(),
139 })
140 }
141}
142
143#[cfg(feature = "openai-transcription")]
144#[derive(Debug, Deserialize)]
145struct OpenAiTranscriptionResponse {
146 text: String,
147}
148
149#[cfg(feature = "openai-transcription")]
150#[async_trait]
151impl Transcriber for OpenAiTranscriber {
152 async fn transcribe(
153 &self,
154 request: TranscriptionRequest,
155 ) -> Result<TranscriptionResult, TranscriptionError> {
156 validate_audio(&request)?;
157 let part = reqwest::multipart::Part::bytes(request.bytes)
158 .file_name(request.file_name)
159 .mime_str(&request.content_type)
160 .map_err(|error| TranscriptionError::InvalidAudio(error.to_string()))?;
161 let mut form = reqwest::multipart::Form::new()
162 .text("model", self.model.to_string())
163 .part("file", part);
164 if let Some(language) = request.language.filter(|value| !value.trim().is_empty()) {
165 form = form.text("language", language);
166 }
167 if let Some(prompt) = request.prompt.filter(|value| !value.trim().is_empty()) {
168 form = form.text("prompt", prompt);
169 }
170 let response = self
171 .client
172 .post(self.endpoint.as_ref())
173 .bearer_auth(self.api_key.as_ref())
174 .multipart(form)
175 .send()
176 .await
177 .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
178 let status = response.status();
179 if !status.is_success() {
180 let detail = response.text().await.unwrap_or_default();
181 return Err(TranscriptionError::Provider(format!(
182 "OpenAI transcription returned {status}: {}",
183 truncate_text(&detail, 500)
184 )));
185 }
186 let payload = response
187 .json::<OpenAiTranscriptionResponse>()
188 .await
189 .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
190 if payload.text.trim().is_empty() {
191 return Err(TranscriptionError::Provider(
192 "transcription provider returned empty text".into(),
193 ));
194 }
195 Ok(TranscriptionResult {
196 text: payload.text,
197 provider: "openai".into(),
198 model: self.model.to_string(),
199 })
200 }
201}
202
203#[cfg(feature = "command-transcription")]
204#[derive(Clone)]
205pub struct CommandTranscriber {
206 program: Arc<std::path::PathBuf>,
207 arguments: Arc<Vec<String>>,
208 provider: Arc<str>,
209 model: Arc<str>,
210 timeout: std::time::Duration,
211}
212
213#[cfg(feature = "command-transcription")]
214impl fmt::Debug for CommandTranscriber {
215 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216 formatter
217 .debug_struct("CommandTranscriber")
218 .field("program", &self.program)
219 .field("argument_count", &self.arguments.len())
220 .field("provider", &self.provider)
221 .field("model", &self.model)
222 .field("timeout", &self.timeout)
223 .finish()
224 }
225}
226
227#[cfg(feature = "command-transcription")]
228impl CommandTranscriber {
229 #[must_use]
230 pub fn new(program: impl Into<std::path::PathBuf>) -> Self {
231 Self {
232 program: Arc::new(program.into()),
233 arguments: Arc::new(Vec::new()),
234 provider: "command".into(),
235 model: "local".into(),
236 timeout: std::time::Duration::from_mins(2),
237 }
238 }
239
240 #[must_use]
241 pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
242 where
243 I: IntoIterator<Item = S>,
244 S: Into<String>,
245 {
246 self.arguments = Arc::new(arguments.into_iter().map(Into::into).collect());
247 self
248 }
249
250 #[must_use]
251 pub fn with_identity(mut self, provider: impl Into<String>, model: impl Into<String>) -> Self {
252 self.provider = provider.into().into();
253 self.model = model.into().into();
254 self
255 }
256
257 #[must_use]
258 pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
259 self.timeout = timeout;
260 self
261 }
262
263 fn rendered_arguments(&self, input: &std::path::Path) -> Vec<std::ffi::OsString> {
264 let input = input.as_os_str().to_os_string();
265 let input_text = input.to_string_lossy();
266 let mut replaced = false;
267 let mut arguments = self
268 .arguments
269 .iter()
270 .map(|argument| {
271 if argument.contains("{input}") {
272 replaced = true;
273 std::ffi::OsString::from(argument.replace("{input}", &input_text))
274 } else {
275 std::ffi::OsString::from(argument)
276 }
277 })
278 .collect::<Vec<_>>();
279 if !replaced {
280 arguments.push(input);
281 }
282 arguments
283 }
284}
285
286#[cfg(feature = "command-transcription")]
287#[async_trait]
288impl Transcriber for CommandTranscriber {
289 async fn transcribe(
290 &self,
291 request: TranscriptionRequest,
292 ) -> Result<TranscriptionResult, TranscriptionError> {
293 validate_audio(&request)?;
294 if self.timeout.is_zero() {
295 return Err(TranscriptionError::Provider(
296 "command transcription timeout must be greater than zero".into(),
297 ));
298 }
299 let directory =
300 tempfile::tempdir().map_err(|error| TranscriptionError::Provider(error.to_string()))?;
301 let extension = safe_audio_extension(&request.file_name, &request.content_type);
302 let input = directory
303 .path()
304 .join(format!("interaction-audio.{extension}"));
305 tokio::fs::write(&input, request.bytes)
306 .await
307 .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
308 let mut command = tokio::process::Command::new(self.program.as_ref());
309 command
310 .args(self.rendered_arguments(&input))
311 .kill_on_drop(true)
312 .stdin(std::process::Stdio::null())
313 .stdout(std::process::Stdio::piped())
314 .stderr(std::process::Stdio::piped());
315 let output = tokio::time::timeout(self.timeout, command.output())
316 .await
317 .map_err(|_| {
318 TranscriptionError::Provider(format!(
319 "transcription command timed out after {} seconds",
320 self.timeout.as_secs()
321 ))
322 })?
323 .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
324 if !output.status.success() {
325 let detail = String::from_utf8_lossy(&output.stderr);
326 return Err(TranscriptionError::Provider(format!(
327 "transcription command exited with {}: {}",
328 output.status,
329 truncate_text(&detail, 500)
330 )));
331 }
332 let text = String::from_utf8(output.stdout)
333 .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
334 let text = text.trim().to_owned();
335 if text.is_empty() {
336 return Err(TranscriptionError::Provider(
337 "transcription command returned empty stdout".into(),
338 ));
339 }
340 Ok(TranscriptionResult {
341 text,
342 provider: self.provider.to_string(),
343 model: self.model.to_string(),
344 })
345 }
346}
347
348#[cfg(any(feature = "openai-transcription", feature = "command-transcription"))]
349fn validate_audio(request: &TranscriptionRequest) -> Result<(), TranscriptionError> {
350 if request.bytes.is_empty() {
351 Err(TranscriptionError::InvalidAudio("audio is empty".into()))
352 } else {
353 Ok(())
354 }
355}
356
357#[cfg(feature = "command-transcription")]
358fn safe_audio_extension(file_name: &str, content_type: &str) -> String {
359 let extension = std::path::Path::new(file_name)
360 .extension()
361 .and_then(std::ffi::OsStr::to_str)
362 .filter(|value| {
363 !value.is_empty()
364 && value.len() <= 10
365 && value.bytes().all(|byte| byte.is_ascii_alphanumeric())
366 });
367 extension.map_or_else(
368 || match content_type.to_ascii_lowercase().as_str() {
369 "audio/mpeg" | "audio/mp3" => "mp3".into(),
370 "audio/mp4" | "audio/x-m4a" => "m4a".into(),
371 "audio/ogg" => "ogg".into(),
372 "audio/wav" | "audio/x-wav" => "wav".into(),
373 _ => "webm".into(),
374 },
375 str::to_ascii_lowercase,
376 )
377}
378
379#[cfg(any(feature = "openai-transcription", feature = "command-transcription"))]
380fn truncate_text(value: &str, maximum: usize) -> String {
381 value.chars().take(maximum).collect()
382}
383
384#[derive(Debug, thiserror::Error)]
385pub enum TranscriptionError {
386 #[error("voice transcription provider is not configured")]
387 NotConfigured,
388 #[error("missing transcription API key: {0}")]
389 MissingApiKey(String),
390 #[error("invalid audio: {0}")]
391 InvalidAudio(String),
392 #[error("transcription provider failed: {0}")]
393 Provider(String),
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[tokio::test]
401 async fn disabled_transcriber_fails_explicitly_and_debug_hides_audio() {
402 let request = TranscriptionRequest {
403 bytes: b"secret-audio".to_vec(),
404 file_name: "voice.webm".into(),
405 content_type: "audio/webm".into(),
406 language: None,
407 prompt: Some("private prompt".into()),
408 };
409 let debug = format!("{request:?}");
410 assert!(!debug.contains("secret-audio"));
411 assert!(!debug.contains("private prompt"));
412 assert!(matches!(
413 DisabledTranscriber.transcribe(request).await,
414 Err(TranscriptionError::NotConfigured)
415 ));
416 }
417
418 #[cfg(feature = "command-transcription")]
419 #[test]
420 fn command_arguments_are_direct_and_bounded() {
421 let input = std::path::Path::new("/tmp/example.webm");
422 let arguments = CommandTranscriber::new("whisper")
423 .with_arguments(["--file", concat!("{", "input", "}")])
424 .rendered_arguments(input);
425 assert_eq!(arguments[1], std::ffi::OsString::from("/tmp/example.webm"));
426 assert_eq!(safe_audio_extension("voice", "audio/ogg"), "ogg");
427 }
428}