Skip to main content

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}