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