openai_interface/audio/transcriptions.rs
1//! Transcribes audio into the input language.
2//!
3//! Endpoint: `POST /audio/transcriptions` (multipart/form-data request).
4//!
5//! Response shapes depend on `response_format`:
6//!
7//! - `json` and `verbose_json` deserialize into the typed
8//! [`TranscriptionResponse`] via `get_response`.
9//! - `text`, `srt`, and `vtt` return plain text; use
10//! `get_response_string` for those.
11//!
12//! Streaming transcriptions (`stream`) is not supported yet.
13//!
14//! > ![warn] This module is untested!
15//! > No OpenAI-compatible provider accessible to this project implements
16//! > this endpoint, and no OpenAI API key was available for testing. If you
17//! > encounter any issues, please report them on the repository.
18
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22use url::Url;
23
24use crate::{
25 audio::AudioResponseFormat,
26 errors::OapiError,
27 rest::RequestOptions,
28 rest::post::{Post, PostNoStream},
29};
30
31/// Transcribes audio into the input language.
32///
33/// The `keywords`, `languages`, `known_speaker_names`, and
34/// `known_speaker_references` parameters are not covered by this type yet.
35#[derive(Debug, Serialize, Default, Clone)]
36pub struct TranscriptionRequest {
37 /// The audio file (as a path) to transcribe, in one of these formats:
38 /// flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm. The request must
39 /// include enough format metadata for the file to be identified; an
40 /// extension-bearing filename satisfies this.
41 #[serde(skip_serializing)]
42 pub file: PathBuf,
43 /// ID of the model to use. The options are `gpt-transcribe`,
44 /// `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `whisper-1` (which is
45 /// powered by the open source Whisper V2 model), and
46 /// `gpt-4o-transcribe-diarize`.
47 pub model: String,
48 /// The language of the input audio. Supplying the input language in
49 /// [ISO-639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
50 /// (e.g. `en`) format will improve accuracy and latency.
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub language: Option<String>,
53 /// An optional text to guide the model's style or continue a previous
54 /// audio segment. The prompt should match the audio language.
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub prompt: Option<String>,
57 /// The format of the output, in one of these options: `json`, `text`,
58 /// `srt`, `verbose_json`, or `vtt`. For `gpt-4o-transcribe` and
59 /// `gpt-4o-mini-transcribe`, the only supported format is `json`.
60 ///
61 /// With `json` / `verbose_json`, use `get_response`; with `text` /
62 /// `srt` / `vtt`, use `get_response_string`.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub response_format: Option<AudioResponseFormat>,
65 /// The sampling temperature, between 0 and 1. Higher values like 0.8
66 /// will make the output more random, while lower values like 0.2 will
67 /// make it more focused and deterministic.
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub temperature: Option<f32>,
70 /// The timestamp granularities to populate for this transcription.
71 /// `response_format` must be set to `verbose_json` to use timestamp
72 /// granularities. Either or both of these options are supported: `word`,
73 /// or `segment`.
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub timestamp_granularities: Option<Vec<TimestampGranularity>>,
76 /// Controls how the audio is cut into chunks.
77 ///
78 /// When set to `auto`, the server first normalizes loudness and then
79 /// uses voice activity detection (VAD) to choose boundaries. A
80 /// `server_vad` object can be provided to tweak VAD detection
81 /// parameters manually. If unset, the audio is transcribed as a single
82 /// block.
83 #[serde(skip_serializing)]
84 pub chunking_strategy: Option<ChunkingStrategy>,
85 /// Additional information to include in the transcription response.
86 /// `logprobs` will return the log probabilities of the tokens in the
87 /// response.
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub include: Option<Vec<Include>>,
90}
91
92/// The timestamp granularities to populate for a transcription.
93#[derive(Debug, Serialize, Clone, Copy)]
94#[serde(rename_all = "snake_case")]
95pub enum TimestampGranularity {
96 Word,
97 Segment,
98}
99
100/// Additional information to include in the transcription response.
101#[derive(Debug, Serialize, Clone, Copy)]
102#[serde(rename_all = "snake_case")]
103pub enum Include {
104 Logprobs,
105}
106
107/// Controls how the audio is cut into chunks.
108#[derive(Debug, Clone)]
109pub enum ChunkingStrategy {
110 /// The server normalizes loudness and then uses voice activity detection
111 /// (VAD) to choose boundaries.
112 Auto,
113 /// Tweak VAD detection parameters manually.
114 ServerVad(ServerVadConfig),
115}
116
117/// Manual VAD detection parameters, sent as the `server_vad` chunking
118/// strategy.
119#[derive(Debug, Clone, Default)]
120pub struct ServerVadConfig {
121 /// Amount of audio to include before the VAD detected speech (in
122 /// milliseconds).
123 pub prefix_padding_ms: Option<u32>,
124 /// Duration of silence to detect speech stop (in milliseconds).
125 pub silence_duration_ms: Option<u32>,
126 /// Sensitivity threshold (0.0 to 1.0) for voice activity detection.
127 pub threshold: Option<f32>,
128}
129
130/// The typed transcription response: `json` yields a plain
131/// [`Transcription`], `verbose_json` yields a
132/// [`TranscriptionVerbose`].
133#[derive(Debug, Deserialize, Clone)]
134#[serde(untagged)]
135pub enum TranscriptionResponse {
136 /// The `verbose_json` response shape (requires `duration` and
137 /// `language`).
138 Verbose(TranscriptionVerbose),
139 /// The `json` response shape.
140 Plain(Transcription),
141}
142
143/// Represents a transcription response returned by the model.
144#[derive(Debug, Deserialize, Clone)]
145pub struct Transcription {
146 /// The transcribed text.
147 pub text: String,
148 /// The languages detected in the audio.
149 ///
150 /// Returned by `gpt-transcribe`. An empty array indicates that no
151 /// language could be reliably detected.
152 pub languages: Option<Vec<TranscriptionLanguage>>,
153 /// The log probabilities of the tokens in the transcription.
154 ///
155 /// Only returned with the models `gpt-4o-transcribe` and
156 /// `gpt-4o-mini-transcribe` if `logprobs` is added to the `include`
157 /// array.
158 pub logprobs: Option<Vec<TranscriptionLogprob>>,
159 /// Usage statistics for the request.
160 pub usage: Option<TranscriptionUsage>,
161}
162
163/// A language detected in transcribed audio.
164#[derive(Debug, Deserialize, Clone, PartialEq)]
165pub struct TranscriptionLanguage {
166 /// The code of a language detected in the audio.
167 pub code: String,
168}
169
170/// The log probability of a token in the transcription.
171#[derive(Debug, Deserialize, Clone, PartialEq)]
172pub struct TranscriptionLogprob {
173 /// The token in the transcription.
174 pub token: Option<String>,
175 /// The bytes of the token.
176 pub bytes: Option<Vec<f32>>,
177 /// The log probability of the token.
178 pub logprob: Option<f32>,
179}
180
181/// Usage statistics for a transcription request. Billed either by token
182/// usage or by audio input duration, discriminated by `type`.
183#[derive(Debug, Deserialize, Clone, PartialEq)]
184#[serde(tag = "type", rename_all = "snake_case")]
185pub enum TranscriptionUsage {
186 /// Usage statistics for models billed by token usage.
187 Tokens {
188 /// Number of input tokens billed for this request.
189 input_tokens: u64,
190 /// Number of output tokens generated.
191 output_tokens: u64,
192 /// Total number of tokens used (input + output).
193 total_tokens: u64,
194 /// Details about the input tokens billed for this request.
195 input_token_details: Option<UsageTokensInputTokenDetails>,
196 },
197 /// Usage statistics for models billed by audio input duration.
198 Duration {
199 /// Duration of the input audio in seconds.
200 seconds: f64,
201 },
202}
203
204/// Details about the input tokens billed for a request.
205#[derive(Debug, Deserialize, Clone, PartialEq)]
206pub struct UsageTokensInputTokenDetails {
207 /// Number of audio tokens billed for this request.
208 pub audio_tokens: Option<u64>,
209 /// Number of text tokens billed for this request.
210 pub text_tokens: Option<u64>,
211}
212
213/// Represents a verbose json transcription response.
214#[derive(Debug, Deserialize, Clone)]
215pub struct TranscriptionVerbose {
216 /// The duration of the input audio.
217 pub duration: f64,
218 /// The language of the input audio.
219 pub language: String,
220 /// The transcribed text.
221 pub text: String,
222 /// Segments of the transcribed text and their corresponding details.
223 pub segments: Option<Vec<TranscriptionSegment>>,
224 /// Usage statistics for models billed by audio input duration.
225 pub usage: Option<TranscriptionVerboseUsage>,
226 /// Extracted words and their corresponding timestamps.
227 pub words: Option<Vec<TranscriptionWord>>,
228}
229
230/// Usage statistics for models billed by audio input duration.
231#[derive(Debug, Deserialize, Clone)]
232pub struct TranscriptionVerboseUsage {
233 /// Duration of the input audio in seconds.
234 pub seconds: f64,
235}
236
237/// A segment of the transcribed text and its corresponding details.
238#[derive(Debug, Deserialize, Clone)]
239pub struct TranscriptionSegment {
240 /// Unique identifier of the segment.
241 pub id: u64,
242 /// Average logprob of the segment.
243 ///
244 /// If the value is lower than -1, consider the logprobs failed.
245 pub avg_logprob: f64,
246 /// Compression ratio of the segment.
247 ///
248 /// If the value is greater than 2.4, consider the compression failed.
249 pub compression_ratio: f64,
250 /// End time of the segment in seconds.
251 pub end: f64,
252 /// Probability of no speech in the segment.
253 pub no_speech_prob: f64,
254 /// Seek offset of the segment.
255 pub seek: u64,
256 /// Start time of the segment in seconds.
257 pub start: f64,
258 /// Temperature parameter used for generating the segment.
259 pub temperature: f64,
260 /// Text content of the segment.
261 pub text: String,
262 /// Array of token IDs for the text content.
263 pub tokens: Vec<u64>,
264}
265
266/// An extracted word and its corresponding timestamp.
267#[derive(Debug, Deserialize, Clone)]
268pub struct TranscriptionWord {
269 /// End time of the word in seconds.
270 pub end: f64,
271 /// Start time of the word in seconds.
272 pub start: f64,
273 /// The text content of the word.
274 pub word: String,
275}
276
277crate::impl_from_str!(TranscriptionResponse);
278
279impl Post for TranscriptionRequest {
280 #[inline]
281 fn is_streaming(&self) -> bool {
282 false
283 }
284
285 /// Builds the URL for the request.
286 ///
287 /// `base_url` should be like <https://api.openai.com/v1>
288 fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
289 let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
290 url.path_segments_mut()
291 .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
292 .push("audio")
293 .push("transcriptions");
294
295 Ok(url.to_string())
296 }
297}
298
299impl PostNoStream for TranscriptionRequest {
300 type Response = TranscriptionResponse;
301
302 /// Sends a transcription POST request using multipart/form-data format,
303 /// following the field layout of the official SDK.
304 async fn get_response_string(
305 &self,
306 client: &reqwest::Client,
307 base_url: &str,
308 options: &RequestOptions,
309 ) -> Result<String, OapiError> {
310 if !self.file.exists() {
311 return Err(OapiError::FileNotFoundError(self.file.clone()));
312 }
313
314 let content = tokio::fs::read(&self.file).await?;
315 let file_name = self
316 .file
317 .file_name()
318 .and_then(|name| name.to_str())
319 .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))?
320 .to_string();
321
322 let file_part = reqwest::multipart::Part::bytes(content).file_name(file_name);
323 let mut form = reqwest::multipart::Form::new().part("file", file_part);
324
325 form = form.text("model", self.model.clone());
326
327 if let Some(language) = &self.language {
328 form = form.text("language", language.clone());
329 }
330 if let Some(prompt) = &self.prompt {
331 form = form.text("prompt", prompt.clone());
332 }
333 if let Some(response_format) = self.response_format {
334 let literal = crate::audio::enum_to_literal(&response_format)?;
335 form = form.text("response_format", literal);
336 }
337 if let Some(temperature) = self.temperature {
338 form = form.text("temperature", temperature.to_string());
339 }
340 // List parameters are sent as repeated `name[]` parts, matching the
341 // official SDK.
342 if let Some(granularities) = &self.timestamp_granularities {
343 for granularity in granularities {
344 let literal = crate::audio::enum_to_literal(granularity)?;
345 form = form.text("timestamp_granularities[]", literal);
346 }
347 }
348 if let Some(include) = &self.include {
349 for item in include {
350 let literal = crate::audio::enum_to_literal(item)?;
351 form = form.text("include[]", literal);
352 }
353 }
354 if let Some(chunking_strategy) = &self.chunking_strategy {
355 let value = match chunking_strategy {
356 ChunkingStrategy::Auto => "auto".to_string(),
357 ChunkingStrategy::ServerVad(config) => {
358 let mut map = serde_json::Map::new();
359 map.insert("type".to_string(), "server_vad".into());
360 if let Some(v) = config.prefix_padding_ms {
361 map.insert("prefix_padding_ms".to_string(), v.into());
362 }
363 if let Some(v) = config.silence_duration_ms {
364 map.insert("silence_duration_ms".to_string(), v.into());
365 }
366 if let Some(v) = config.threshold {
367 map.insert("threshold".to_string(), v.into());
368 }
369 serde_json::to_string(&map).map_err(|e| {
370 OapiError::ResponseError(format!(
371 "Failed to serialize chunking_strategy: {e}"
372 ))
373 })?
374 }
375 };
376 form = form.text("chunking_strategy", value);
377 }
378
379 let url = self.build_url(base_url)?;
380 crate::rest::post::post_multipart_json(client, url, form, options).await
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_build_url() {
390 let request = TranscriptionRequest::default();
391 let url = request.build_url("https://api.openai.com/v1/").unwrap();
392 assert_eq!(url, "https://api.openai.com/v1/audio/transcriptions");
393 }
394
395 /// Enum literals serialize to their official wire values.
396 #[test]
397 fn enum_literals() {
398 assert_eq!(
399 crate::audio::enum_to_literal(&AudioResponseFormat::VerboseJson).unwrap(),
400 "verbose_json"
401 );
402 assert_eq!(
403 crate::audio::enum_to_literal(&TimestampGranularity::Word).unwrap(),
404 "word"
405 );
406 assert_eq!(
407 crate::audio::enum_to_literal(&Include::Logprobs).unwrap(),
408 "logprobs"
409 );
410 }
411
412 /// Deserializes a `json` response (plain transcription shape).
413 ///
414 /// No accessible provider implements this endpoint, so this fixture is
415 /// NOT captured from a live response. The structure follows the schema
416 /// of openai-python `types/audio/transcription.py`; the values are
417 /// constructed for the test.
418 #[test]
419 fn parse_plain_response() {
420 let content = r#"{
421 "text": "The quick brown fox jumped over the lazy dog."
422 }"#;
423
424 let response: TranscriptionResponse = content.parse().unwrap();
425 let TranscriptionResponse::Plain(transcription) = response else {
426 panic!("expected plain transcription");
427 };
428 assert_eq!(
429 transcription.text,
430 "The quick brown fox jumped over the lazy dog."
431 );
432 assert_eq!(transcription.languages, None);
433 assert_eq!(transcription.logprobs, None);
434 assert_eq!(transcription.usage, None);
435 }
436
437 /// Deserializes a `verbose_json` response with segments, words, and
438 /// duration-billed usage.
439 ///
440 /// No accessible provider implements this endpoint, so this fixture is
441 /// NOT captured from a live response. The structure follows the schema
442 /// of openai-python `types/audio/transcription_verbose.py` +
443 /// `transcription_segment.py` + `transcription_word.py`; the values are
444 /// constructed for the test.
445 #[test]
446 fn parse_verbose_response() {
447 let content = r#"{
448 "duration": 8.47,
449 "language": "english",
450 "text": "The quick brown fox jumped over the lazy dog.",
451 "segments": [
452 {
453 "id": 0,
454 "avg_logprob": -0.2365,
455 "compression_ratio": 1.7174,
456 "end": 3.48,
457 "no_speech_prob": 0.01485,
458 "seek": 0,
459 "start": 0.0,
460 "temperature": 0.0,
461 "text": " The quick brown fox jumped over the lazy dog.",
462 "tokens": [464, 2069, 7586, 21831, 18045, 625, 262, 16931, 3290, 13]
463 }
464 ],
465 "words": [
466 {
467 "end": 0.36,
468 "start": 0.06,
469 "word": "The"
470 }
471 ],
472 "usage": {
473 "type": "duration",
474 "seconds": 8.47
475 }
476 }"#;
477
478 let response: TranscriptionResponse = content.parse().unwrap();
479 let TranscriptionResponse::Verbose(verbose) = response else {
480 panic!("expected verbose transcription");
481 };
482 assert_eq!(verbose.duration, 8.47);
483 assert_eq!(verbose.language, "english");
484 assert_eq!(
485 verbose.text,
486 "The quick brown fox jumped over the lazy dog."
487 );
488 let segments = verbose.segments.unwrap();
489 assert_eq!(segments.len(), 1);
490 assert_eq!(segments[0].id, 0);
491 assert_eq!(segments[0].start, 0.0);
492 assert_eq!(segments[0].end, 3.48);
493 assert_eq!(segments[0].tokens.len(), 10);
494 let words = verbose.words.unwrap();
495 assert_eq!(words[0].word, "The");
496 assert_eq!(words[0].start, 0.06);
497 let usage = verbose.usage.unwrap();
498 assert_eq!(usage.seconds, 8.47);
499 }
500
501 /// Deserializes a token-billed usage object (discriminated by `type`).
502 ///
503 /// No accessible provider implements this endpoint, so this fixture is
504 /// NOT captured from a live response. The structure follows the schema
505 /// of openai-python `types/audio/transcription.py` (`UsageTokens`);
506 /// the values are constructed for the test.
507 #[test]
508 fn parse_tokens_usage() {
509 let content = r#"{
510 "text": "Hello.",
511 "usage": {
512 "type": "tokens",
513 "input_tokens": 76,
514 "output_tokens": 13,
515 "total_tokens": 89,
516 "input_token_details": {
517 "audio_tokens": 76,
518 "text_tokens": 0
519 }
520 }
521 }"#;
522
523 let response: TranscriptionResponse = content.parse().unwrap();
524 let TranscriptionResponse::Plain(transcription) = response else {
525 panic!("expected plain transcription");
526 };
527 let TranscriptionUsage::Tokens {
528 input_tokens,
529 output_tokens,
530 total_tokens,
531 input_token_details,
532 } = transcription.usage.unwrap()
533 else {
534 panic!("expected tokens usage");
535 };
536 assert_eq!(input_tokens, 76);
537 assert_eq!(output_tokens, 13);
538 assert_eq!(total_tokens, 89);
539 let details = input_token_details.unwrap();
540 assert_eq!(details.audio_tokens, Some(76));
541 assert_eq!(details.text_tokens, Some(0));
542 }
543}