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    /// Additional JSON properties, sent as extra multipart text fields
91    /// (strings verbatim, other JSON values serialized).
92    pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
93}
94
95/// The timestamp granularities to populate for a transcription.
96#[derive(Debug, Serialize, Clone, Copy)]
97#[serde(rename_all = "snake_case")]
98pub enum TimestampGranularity {
99    Word,
100    Segment,
101}
102
103/// Additional information to include in the transcription response.
104#[derive(Debug, Serialize, Clone, Copy)]
105#[serde(rename_all = "snake_case")]
106pub enum Include {
107    Logprobs,
108}
109
110/// Controls how the audio is cut into chunks.
111#[derive(Debug, Clone)]
112pub enum ChunkingStrategy {
113    /// The server normalizes loudness and then uses voice activity detection
114    /// (VAD) to choose boundaries.
115    Auto,
116    /// Tweak VAD detection parameters manually.
117    ServerVad(ServerVadConfig),
118}
119
120/// Manual VAD detection parameters, sent as the `server_vad` chunking
121/// strategy.
122#[derive(Debug, Clone, Default)]
123pub struct ServerVadConfig {
124    /// Amount of audio to include before the VAD detected speech (in
125    /// milliseconds).
126    pub prefix_padding_ms: Option<u32>,
127    /// Duration of silence to detect speech stop (in milliseconds).
128    pub silence_duration_ms: Option<u32>,
129    /// Sensitivity threshold (0.0 to 1.0) for voice activity detection.
130    pub threshold: Option<f32>,
131}
132
133/// The typed transcription response: `json` yields a plain
134/// [`Transcription`], `verbose_json` yields a
135/// [`TranscriptionVerbose`].
136#[derive(Debug, Deserialize, Serialize, Clone)]
137#[serde(untagged)]
138pub enum TranscriptionResponse {
139    /// The `verbose_json` response shape (requires `duration` and
140    /// `language`).
141    Verbose(TranscriptionVerbose),
142    /// The `json` response shape.
143    Plain(Transcription),
144}
145
146/// Represents a transcription response returned by the model.
147#[derive(Debug, Deserialize, Serialize, Clone)]
148pub struct Transcription {
149    /// The transcribed text.
150    pub text: String,
151    /// The languages detected in the audio.
152    ///
153    /// Returned by `gpt-transcribe`. An empty array indicates that no
154    /// language could be reliably detected.
155    pub languages: Option<Vec<TranscriptionLanguage>>,
156    /// The log probabilities of the tokens in the transcription.
157    ///
158    /// Only returned with the models `gpt-4o-transcribe` and
159    /// `gpt-4o-mini-transcribe` if `logprobs` is added to the `include`
160    /// array.
161    pub logprobs: Option<Vec<TranscriptionLogprob>>,
162    /// Usage statistics for the request.
163    pub usage: Option<TranscriptionUsage>,
164}
165
166/// A language detected in transcribed audio.
167#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
168pub struct TranscriptionLanguage {
169    /// The code of a language detected in the audio.
170    pub code: String,
171}
172
173/// The log probability of a token in the transcription.
174#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
175pub struct TranscriptionLogprob {
176    /// The token in the transcription.
177    pub token: Option<String>,
178    /// The bytes of the token.
179    pub bytes: Option<Vec<f32>>,
180    /// The log probability of the token.
181    pub logprob: Option<f32>,
182}
183
184/// Usage statistics for a transcription request. Billed either by token
185/// usage or by audio input duration, discriminated by `type`.
186#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
187#[serde(tag = "type", rename_all = "snake_case")]
188pub enum TranscriptionUsage {
189    /// Usage statistics for models billed by token usage.
190    Tokens {
191        /// Number of input tokens billed for this request.
192        input_tokens: u64,
193        /// Number of output tokens generated.
194        output_tokens: u64,
195        /// Total number of tokens used (input + output).
196        total_tokens: u64,
197        /// Details about the input tokens billed for this request.
198        input_token_details: Option<UsageTokensInputTokenDetails>,
199    },
200    /// Usage statistics for models billed by audio input duration.
201    Duration {
202        /// Duration of the input audio in seconds.
203        seconds: f64,
204    },
205}
206
207/// Details about the input tokens billed for a request.
208#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
209pub struct UsageTokensInputTokenDetails {
210    /// Number of audio tokens billed for this request.
211    pub audio_tokens: Option<u64>,
212    /// Number of text tokens billed for this request.
213    pub text_tokens: Option<u64>,
214}
215
216/// Represents a verbose json transcription response.
217#[derive(Debug, Deserialize, Serialize, Clone)]
218pub struct TranscriptionVerbose {
219    /// The duration of the input audio.
220    pub duration: f64,
221    /// The language of the input audio.
222    pub language: String,
223    /// The transcribed text.
224    pub text: String,
225    /// Segments of the transcribed text and their corresponding details.
226    pub segments: Option<Vec<TranscriptionSegment>>,
227    /// Usage statistics for models billed by audio input duration.
228    pub usage: Option<TranscriptionVerboseUsage>,
229    /// Extracted words and their corresponding timestamps.
230    pub words: Option<Vec<TranscriptionWord>>,
231}
232
233/// Usage statistics for models billed by audio input duration.
234#[derive(Debug, Deserialize, Serialize, Clone)]
235pub struct TranscriptionVerboseUsage {
236    /// Duration of the input audio in seconds.
237    pub seconds: f64,
238}
239
240/// A segment of the transcribed text and its corresponding details.
241#[derive(Debug, Deserialize, Serialize, Clone)]
242pub struct TranscriptionSegment {
243    /// Unique identifier of the segment.
244    pub id: u64,
245    /// Average logprob of the segment.
246    ///
247    /// If the value is lower than -1, consider the logprobs failed.
248    pub avg_logprob: f64,
249    /// Compression ratio of the segment.
250    ///
251    /// If the value is greater than 2.4, consider the compression failed.
252    pub compression_ratio: f64,
253    /// End time of the segment in seconds.
254    pub end: f64,
255    /// Probability of no speech in the segment.
256    pub no_speech_prob: f64,
257    /// Seek offset of the segment.
258    pub seek: u64,
259    /// Start time of the segment in seconds.
260    pub start: f64,
261    /// Temperature parameter used for generating the segment.
262    pub temperature: f64,
263    /// Text content of the segment.
264    pub text: String,
265    /// Array of token IDs for the text content.
266    pub tokens: Vec<u64>,
267}
268
269/// An extracted word and its corresponding timestamp.
270#[derive(Debug, Deserialize, Serialize, Clone)]
271pub struct TranscriptionWord {
272    /// End time of the word in seconds.
273    pub end: f64,
274    /// Start time of the word in seconds.
275    pub start: f64,
276    /// The text content of the word.
277    pub word: String,
278}
279
280crate::impl_from_str!(TranscriptionResponse);
281
282impl Post for TranscriptionRequest {
283    #[inline]
284    fn is_streaming(&self) -> bool {
285        false
286    }
287
288    /// Builds the URL for the request.
289    ///
290    /// `base_url` should be like <https://api.openai.com/v1>
291    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
292        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
293        url.path_segments_mut()
294            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
295            .push("audio")
296            .push("transcriptions");
297
298        Ok(url.to_string())
299    }
300}
301
302impl PostNoStream for TranscriptionRequest {
303    type Response = TranscriptionResponse;
304
305    /// Sends a transcription POST request using multipart/form-data format,
306    /// following the field layout of the official SDK.
307    async fn get_response_string(
308        &self,
309        client: &reqwest::Client,
310        base_url: &str,
311        options: &RequestOptions,
312    ) -> Result<String, OapiError> {
313        if !self.file.exists() {
314            return Err(OapiError::FileNotFoundError(self.file.clone()));
315        }
316
317        let content = tokio::fs::read(&self.file).await?;
318        let file_name = self
319            .file
320            .file_name()
321            .and_then(|name| name.to_str())
322            .ok_or_else(|| OapiError::ResponseError("Invalid file name".to_string()))?
323            .to_string();
324
325        let file_part = reqwest::multipart::Part::bytes(content).file_name(file_name);
326        let mut form = reqwest::multipart::Form::new().part("file", file_part);
327
328        form = form.text("model", self.model.clone());
329
330        if let Some(language) = &self.language {
331            form = form.text("language", language.clone());
332        }
333        if let Some(prompt) = &self.prompt {
334            form = form.text("prompt", prompt.clone());
335        }
336        if let Some(response_format) = self.response_format {
337            let literal = crate::audio::enum_to_literal(&response_format)?;
338            form = form.text("response_format", literal);
339        }
340        if let Some(temperature) = self.temperature {
341            form = form.text("temperature", temperature.to_string());
342        }
343        // List parameters are sent as repeated `name[]` parts, matching the
344        // official SDK.
345        if let Some(granularities) = &self.timestamp_granularities {
346            for granularity in granularities {
347                let literal = crate::audio::enum_to_literal(granularity)?;
348                form = form.text("timestamp_granularities[]", literal);
349            }
350        }
351        if let Some(include) = &self.include {
352            for item in include {
353                let literal = crate::audio::enum_to_literal(item)?;
354                form = form.text("include[]", literal);
355            }
356        }
357        if let Some(chunking_strategy) = &self.chunking_strategy {
358            let value = match chunking_strategy {
359                ChunkingStrategy::Auto => "auto".to_string(),
360                ChunkingStrategy::ServerVad(config) => {
361                    let mut map = serde_json::Map::new();
362                    map.insert("type".to_string(), "server_vad".into());
363                    if let Some(v) = config.prefix_padding_ms {
364                        map.insert("prefix_padding_ms".to_string(), v.into());
365                    }
366                    if let Some(v) = config.silence_duration_ms {
367                        map.insert("silence_duration_ms".to_string(), v.into());
368                    }
369                    if let Some(v) = config.threshold {
370                        map.insert("threshold".to_string(), v.into());
371                    }
372                    serde_json::to_string(&map).map_err(|e| {
373                        OapiError::ResponseError(format!(
374                            "Failed to serialize chunking_strategy: {e}"
375                        ))
376                    })?
377                }
378            };
379            form = form.text("chunking_strategy", value);
380        }
381
382        form = crate::rest::post::append_extra_body_map(form, &self.extra_body_map);
383
384        let url = self.build_url(base_url)?;
385        crate::rest::post::post_multipart_json(client, url, form, options).await
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_build_url() {
395        let request = TranscriptionRequest::default();
396        let url = request.build_url("https://api.openai.com/v1/").unwrap();
397        assert_eq!(url, "https://api.openai.com/v1/audio/transcriptions");
398    }
399
400    /// Enum literals serialize to their official wire values.
401    #[test]
402    fn enum_literals() {
403        assert_eq!(
404            crate::audio::enum_to_literal(&AudioResponseFormat::VerboseJson).unwrap(),
405            "verbose_json"
406        );
407        assert_eq!(
408            crate::audio::enum_to_literal(&TimestampGranularity::Word).unwrap(),
409            "word"
410        );
411        assert_eq!(
412            crate::audio::enum_to_literal(&Include::Logprobs).unwrap(),
413            "logprobs"
414        );
415    }
416
417    /// Deserializes a `json` response (plain transcription shape).
418    ///
419    /// No accessible provider implements this endpoint, so this fixture is
420    /// NOT captured from a live response. The structure follows the schema
421    /// of openai-python `types/audio/transcription.py`; the values are
422    /// constructed for the test.
423    #[test]
424    fn parse_plain_response() {
425        let content = r#"{
426            "text": "The quick brown fox jumped over the lazy dog."
427        }"#;
428
429        let response: TranscriptionResponse = content.parse().unwrap();
430        let TranscriptionResponse::Plain(transcription) = response else {
431            panic!("expected plain transcription");
432        };
433        assert_eq!(
434            transcription.text,
435            "The quick brown fox jumped over the lazy dog."
436        );
437        assert_eq!(transcription.languages, None);
438        assert_eq!(transcription.logprobs, None);
439        assert_eq!(transcription.usage, None);
440    }
441
442    /// Deserializes a `verbose_json` response with segments, words, and
443    /// duration-billed usage.
444    ///
445    /// No accessible provider implements this endpoint, so this fixture is
446    /// NOT captured from a live response. The structure follows the schema
447    /// of openai-python `types/audio/transcription_verbose.py` +
448    /// `transcription_segment.py` + `transcription_word.py`; the values are
449    /// constructed for the test.
450    #[test]
451    fn parse_verbose_response() {
452        let content = r#"{
453            "duration": 8.47,
454            "language": "english",
455            "text": "The quick brown fox jumped over the lazy dog.",
456            "segments": [
457                {
458                    "id": 0,
459                    "avg_logprob": -0.2365,
460                    "compression_ratio": 1.7174,
461                    "end": 3.48,
462                    "no_speech_prob": 0.01485,
463                    "seek": 0,
464                    "start": 0.0,
465                    "temperature": 0.0,
466                    "text": " The quick brown fox jumped over the lazy dog.",
467                    "tokens": [464, 2069, 7586, 21831, 18045, 625, 262, 16931, 3290, 13]
468                }
469            ],
470            "words": [
471                {
472                    "end": 0.36,
473                    "start": 0.06,
474                    "word": "The"
475                }
476            ],
477            "usage": {
478                "type": "duration",
479                "seconds": 8.47
480            }
481        }"#;
482
483        let response: TranscriptionResponse = content.parse().unwrap();
484        let TranscriptionResponse::Verbose(verbose) = response else {
485            panic!("expected verbose transcription");
486        };
487        assert_eq!(verbose.duration, 8.47);
488        assert_eq!(verbose.language, "english");
489        assert_eq!(
490            verbose.text,
491            "The quick brown fox jumped over the lazy dog."
492        );
493        let segments = verbose.segments.unwrap();
494        assert_eq!(segments.len(), 1);
495        assert_eq!(segments[0].id, 0);
496        assert_eq!(segments[0].start, 0.0);
497        assert_eq!(segments[0].end, 3.48);
498        assert_eq!(segments[0].tokens.len(), 10);
499        let words = verbose.words.unwrap();
500        assert_eq!(words[0].word, "The");
501        assert_eq!(words[0].start, 0.06);
502        let usage = verbose.usage.unwrap();
503        assert_eq!(usage.seconds, 8.47);
504    }
505
506    /// Deserializes a token-billed usage object (discriminated by `type`).
507    ///
508    /// No accessible provider implements this endpoint, so this fixture is
509    /// NOT captured from a live response. The structure follows the schema
510    /// of openai-python `types/audio/transcription.py` (`UsageTokens`);
511    /// the values are constructed for the test.
512    #[test]
513    fn parse_tokens_usage() {
514        let content = r#"{
515            "text": "Hello.",
516            "usage": {
517                "type": "tokens",
518                "input_tokens": 76,
519                "output_tokens": 13,
520                "total_tokens": 89,
521                "input_token_details": {
522                    "audio_tokens": 76,
523                    "text_tokens": 0
524                }
525            }
526        }"#;
527
528        let response: TranscriptionResponse = content.parse().unwrap();
529        let TranscriptionResponse::Plain(transcription) = response else {
530            panic!("expected plain transcription");
531        };
532        let TranscriptionUsage::Tokens {
533            input_tokens,
534            output_tokens,
535            total_tokens,
536            input_token_details,
537        } = transcription.usage.unwrap()
538        else {
539            panic!("expected tokens usage");
540        };
541        assert_eq!(input_tokens, 76);
542        assert_eq!(output_tokens, 13);
543        assert_eq!(total_tokens, 89);
544        let details = input_token_details.unwrap();
545        assert_eq!(details.audio_tokens, Some(76));
546        assert_eq!(details.text_tokens, Some(0));
547    }
548}