Skip to main content

openai_tools/audio/
request.rs

1//! OpenAI Audio API Request Module
2//!
3//! This module provides the functionality to interact with the OpenAI Audio API.
4//! It supports text-to-speech (TTS), transcription, and translation.
5//!
6//! # Key Features
7//!
8//! - **Text-to-Speech**: Convert text to natural-sounding audio
9//! - **Transcription**: Convert audio to text (speech-to-text)
10//! - **Translation**: Translate audio to English text
11//!
12//! # Quick Start
13//!
14//! ```rust,no_run
15//! use openai_tools::audio::request::{Audio, TtsOptions, Voice};
16//!
17//! #[tokio::main]
18//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
19//!     let audio = Audio::new()?;
20//!
21//!     // Generate speech from text
22//!     let options = TtsOptions::default();
23//!     let audio_bytes = audio.text_to_speech("Hello, world!", options).await?;
24//!     std::fs::write("output.mp3", audio_bytes)?;
25//!
26//!     Ok(())
27//! }
28//! ```
29
30use crate::audio::response::TranscriptionResponse;
31use crate::common::auth::AuthProvider;
32use crate::common::client::create_http_client;
33use crate::common::errors::{ErrorResponse, OpenAIToolError, Result};
34use request::multipart::{Form, Part};
35use serde::{Deserialize, Serialize};
36use std::path::Path;
37use std::time::Duration;
38
39/// Default API path for Audio
40const AUDIO_PATH: &str = "audio";
41
42/// Text-to-speech models.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
44pub enum TtsModel {
45    /// Standard quality TTS model
46    #[serde(rename = "tts-1")]
47    #[default]
48    Tts1,
49    /// High definition TTS model
50    #[serde(rename = "tts-1-hd")]
51    Tts1Hd,
52    /// GPT-4o Mini TTS model
53    #[serde(rename = "gpt-4o-mini-tts")]
54    Gpt4oMiniTts,
55    /// tts-1-1106 - dated snapshot of the standard TTS model
56    #[serde(rename = "tts-1-1106")]
57    Tts1_1106,
58    /// tts-1-hd-1106 - dated snapshot of the HD TTS model
59    #[serde(rename = "tts-1-hd-1106")]
60    Tts1Hd1106,
61}
62
63impl TtsModel {
64    /// Returns the model identifier string.
65    pub fn as_str(&self) -> &'static str {
66        match self {
67            Self::Tts1 => "tts-1",
68            Self::Tts1Hd => "tts-1-hd",
69            Self::Gpt4oMiniTts => "gpt-4o-mini-tts",
70            Self::Tts1_1106 => "tts-1-1106",
71            Self::Tts1Hd1106 => "tts-1-hd-1106",
72        }
73    }
74
75    /// Checks if this model supports the `instructions` parameter.
76    ///
77    /// Only `gpt-4o-mini-tts` supports the instructions parameter for
78    /// controlling voice characteristics like tone, emotion, and pacing.
79    ///
80    /// # Example
81    ///
82    /// ```rust
83    /// use openai_tools::audio::request::TtsModel;
84    ///
85    /// assert!(TtsModel::Gpt4oMiniTts.supports_instructions());
86    /// assert!(!TtsModel::Tts1.supports_instructions());
87    /// assert!(!TtsModel::Tts1Hd.supports_instructions());
88    /// ```
89    pub fn supports_instructions(&self) -> bool {
90        matches!(self, Self::Gpt4oMiniTts)
91    }
92}
93
94impl std::fmt::Display for TtsModel {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "{}", self.as_str())
97    }
98}
99
100/// Voice options for text-to-speech.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "lowercase")]
103pub enum Voice {
104    /// Alloy voice
105    #[default]
106    Alloy,
107    /// Ash voice
108    Ash,
109    /// Ballad voice
110    Ballad,
111    /// Cedar voice (recommended for quality)
112    Cedar,
113    /// Coral voice
114    Coral,
115    /// Echo voice
116    Echo,
117    /// Fable voice
118    Fable,
119    /// Marin voice (recommended for quality)
120    Marin,
121    /// Nova voice
122    Nova,
123    /// Onyx voice
124    Onyx,
125    /// Sage voice
126    Sage,
127    /// Shimmer voice
128    Shimmer,
129    /// Verse voice
130    Verse,
131}
132
133impl Voice {
134    /// Returns the voice identifier string.
135    pub fn as_str(&self) -> &'static str {
136        match self {
137            Self::Alloy => "alloy",
138            Self::Ash => "ash",
139            Self::Ballad => "ballad",
140            Self::Cedar => "cedar",
141            Self::Coral => "coral",
142            Self::Echo => "echo",
143            Self::Fable => "fable",
144            Self::Marin => "marin",
145            Self::Nova => "nova",
146            Self::Onyx => "onyx",
147            Self::Sage => "sage",
148            Self::Shimmer => "shimmer",
149            Self::Verse => "verse",
150        }
151    }
152}
153
154impl std::fmt::Display for Voice {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(f, "{}", self.as_str())
157    }
158}
159
160/// Audio output formats for TTS.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[serde(rename_all = "lowercase")]
163pub enum AudioFormat {
164    /// MP3 format (default)
165    #[default]
166    Mp3,
167    /// Opus format
168    Opus,
169    /// AAC format
170    Aac,
171    /// FLAC format
172    Flac,
173    /// WAV format
174    Wav,
175    /// PCM format
176    Pcm,
177}
178
179impl AudioFormat {
180    /// Returns the format string.
181    pub fn as_str(&self) -> &'static str {
182        match self {
183            Self::Mp3 => "mp3",
184            Self::Opus => "opus",
185            Self::Aac => "aac",
186            Self::Flac => "flac",
187            Self::Wav => "wav",
188            Self::Pcm => "pcm",
189        }
190    }
191
192    /// Returns the file extension for this format.
193    pub fn file_extension(&self) -> &'static str {
194        self.as_str()
195    }
196}
197
198impl std::fmt::Display for AudioFormat {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        write!(f, "{}", self.as_str())
201    }
202}
203
204/// Speech-to-text models for transcription and translation.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
206pub enum SttModel {
207    /// Whisper v1 model
208    #[serde(rename = "whisper-1")]
209    #[default]
210    Whisper1,
211    /// GPT-4o Transcribe model
212    #[serde(rename = "gpt-4o-transcribe")]
213    Gpt4oTranscribe,
214    /// GPT-4o Mini Transcribe - cheaper GPT-4o Transcribe
215    #[serde(rename = "gpt-4o-mini-transcribe")]
216    Gpt4oMiniTranscribe,
217    /// GPT-4o Transcribe Diarize - transcription with speaker diarization
218    #[serde(rename = "gpt-4o-transcribe-diarize")]
219    Gpt4oTranscribeDiarize,
220    /// GPT Transcribe - high-accuracy speech-to-text for file and realtime input
221    #[serde(rename = "gpt-transcribe")]
222    GptTranscribe,
223    /// GPT Live Transcribe - low-latency streaming transcript deltas
224    ///
225    /// Realtime transcription sessions only; not available on
226    /// `/v1/audio/transcriptions`.
227    #[serde(rename = "gpt-live-transcribe")]
228    GptLiveTranscribe,
229    /// GPT Realtime Whisper - Whisper for realtime transcription sessions
230    ///
231    /// Realtime transcription sessions only; not available on
232    /// `/v1/audio/transcriptions`.
233    #[serde(rename = "gpt-realtime-whisper")]
234    GptRealtimeWhisper,
235}
236
237impl SttModel {
238    /// Returns the model identifier string.
239    pub fn as_str(&self) -> &'static str {
240        match self {
241            Self::Whisper1 => "whisper-1",
242            Self::Gpt4oTranscribe => "gpt-4o-transcribe",
243            Self::Gpt4oMiniTranscribe => "gpt-4o-mini-transcribe",
244            Self::Gpt4oTranscribeDiarize => "gpt-4o-transcribe-diarize",
245            Self::GptTranscribe => "gpt-transcribe",
246            Self::GptLiveTranscribe => "gpt-live-transcribe",
247            Self::GptRealtimeWhisper => "gpt-realtime-whisper",
248        }
249    }
250
251    /// Returns `true` if the model can transcribe uploaded files via
252    /// `/v1/audio/transcriptions`.
253    ///
254    /// [`GptLiveTranscribe`](Self::GptLiveTranscribe) and
255    /// [`GptRealtimeWhisper`](Self::GptRealtimeWhisper) are exposed only on
256    /// `/v1/realtime/transcription_sessions`, so passing them to
257    /// [`Audio::transcribe`] would be rejected by the API.
258    pub fn supports_file_transcription(&self) -> bool {
259        !matches!(self, Self::GptLiveTranscribe | Self::GptRealtimeWhisper)
260    }
261}
262
263impl std::fmt::Display for SttModel {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        write!(f, "{}", self.as_str())
266    }
267}
268
269/// Transcription response formats.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
271#[serde(rename_all = "snake_case")]
272pub enum TranscriptionFormat {
273    /// JSON format
274    #[default]
275    Json,
276    /// Plain text format
277    Text,
278    /// SRT subtitle format
279    Srt,
280    /// Verbose JSON with timestamps
281    VerboseJson,
282    /// VTT subtitle format
283    Vtt,
284}
285
286impl TranscriptionFormat {
287    /// Returns the format string.
288    pub fn as_str(&self) -> &'static str {
289        match self {
290            Self::Json => "json",
291            Self::Text => "text",
292            Self::Srt => "srt",
293            Self::VerboseJson => "verbose_json",
294            Self::Vtt => "vtt",
295        }
296    }
297}
298
299/// Timestamp granularity options.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "lowercase")]
302pub enum TimestampGranularity {
303    /// Word-level timestamps
304    Word,
305    /// Segment-level timestamps
306    Segment,
307}
308
309impl TimestampGranularity {
310    /// Returns the granularity string.
311    pub fn as_str(&self) -> &'static str {
312        match self {
313            Self::Word => "word",
314            Self::Segment => "segment",
315        }
316    }
317}
318
319/// Options for text-to-speech generation.
320#[derive(Debug, Clone, Default)]
321pub struct TtsOptions {
322    /// The model to use (defaults to tts-1)
323    pub model: TtsModel,
324    /// The voice to use (defaults to alloy)
325    pub voice: Voice,
326    /// The output audio format (defaults to mp3)
327    pub response_format: AudioFormat,
328    /// Speech speed (0.25 to 4.0, defaults to 1.0)
329    pub speed: Option<f32>,
330    /// Instructions for controlling voice characteristics.
331    ///
332    /// Only supported by `gpt-4o-mini-tts` model.
333    /// Use natural language to control tone, emotion, and pacing.
334    ///
335    /// # Examples
336    ///
337    /// - `"Speak in a cheerful and positive tone."`
338    /// - `"Use a calm and soothing voice."`
339    /// - `"Speak with enthusiasm and energy."`
340    ///
341    /// If set with an unsupported model (`tts-1` or `tts-1-hd`),
342    /// this parameter will be ignored and a warning will be logged.
343    pub instructions: Option<String>,
344}
345
346/// Options for audio transcription.
347#[derive(Debug, Clone, Default)]
348pub struct TranscribeOptions {
349    /// The model to use (defaults to whisper-1)
350    pub model: Option<SttModel>,
351    /// The language of the input audio (ISO-639-1 code)
352    pub language: Option<String>,
353    /// Optional prompt to guide the model's style
354    pub prompt: Option<String>,
355    /// Response format (defaults to json)
356    pub response_format: Option<TranscriptionFormat>,
357    /// Temperature for sampling (0.0 to 1.0)
358    pub temperature: Option<f32>,
359    /// Timestamp granularities to include
360    pub timestamp_granularities: Option<Vec<TimestampGranularity>>,
361}
362
363/// Options for audio translation.
364#[derive(Debug, Clone, Default)]
365pub struct TranslateOptions {
366    /// The model to use (only whisper-1 is supported)
367    pub model: Option<SttModel>,
368    /// Optional prompt to guide the model's style
369    pub prompt: Option<String>,
370    /// Response format (defaults to json)
371    pub response_format: Option<TranscriptionFormat>,
372    /// Temperature for sampling (0.0 to 1.0)
373    pub temperature: Option<f32>,
374}
375
376/// Request payload for TTS.
377#[derive(Debug, Clone, Serialize)]
378struct TtsRequest {
379    model: String,
380    input: String,
381    voice: String,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    response_format: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    speed: Option<f32>,
386    /// Instructions for voice control (only for gpt-4o-mini-tts).
387    #[serde(skip_serializing_if = "Option::is_none")]
388    instructions: Option<String>,
389}
390
391/// Client for interacting with the OpenAI Audio API.
392///
393/// This struct provides methods for text-to-speech, transcription, and translation.
394/// Use [`Audio::new()`] to create a new instance.
395///
396/// # Example
397///
398/// ```rust,no_run
399/// use openai_tools::audio::request::{Audio, TtsOptions, Voice, AudioFormat};
400///
401/// #[tokio::main]
402/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
403///     let audio = Audio::new()?;
404///
405///     let options = TtsOptions {
406///         voice: Voice::Nova,
407///         response_format: AudioFormat::Mp3,
408///         ..Default::default()
409///     };
410///
411///     let bytes = audio.text_to_speech("Welcome to our app!", options).await?;
412///     std::fs::write("welcome.mp3", bytes)?;
413///
414///     Ok(())
415/// }
416/// ```
417pub struct Audio {
418    /// Authentication provider (OpenAI or Azure)
419    auth: AuthProvider,
420    /// Optional request timeout duration
421    timeout: Option<Duration>,
422}
423
424impl Audio {
425    /// Creates a new Audio client for OpenAI API.
426    ///
427    /// Initializes the client by loading the OpenAI API key from
428    /// the environment variable `OPENAI_API_KEY`. Supports `.env` file loading
429    /// via dotenvy.
430    ///
431    /// # Returns
432    ///
433    /// * `Ok(Audio)` - A new Audio client ready for use
434    /// * `Err(OpenAIToolError)` - If the API key is not found in the environment
435    ///
436    /// # Example
437    ///
438    /// ```rust,no_run
439    /// use openai_tools::audio::request::Audio;
440    ///
441    /// let audio = Audio::new().expect("API key should be set");
442    /// ```
443    pub fn new() -> Result<Self> {
444        let auth = AuthProvider::openai_from_env()?;
445        Ok(Self { auth, timeout: None })
446    }
447
448    /// Creates a new Audio client with a custom authentication provider
449    pub fn with_auth(auth: AuthProvider) -> Self {
450        Self { auth, timeout: None }
451    }
452
453    /// Creates a new Audio client for Azure OpenAI API
454    pub fn azure() -> Result<Self> {
455        let auth = AuthProvider::azure_from_env()?;
456        Ok(Self { auth, timeout: None })
457    }
458
459    /// Creates a new Audio client by auto-detecting the provider
460    pub fn detect_provider() -> Result<Self> {
461        let auth = AuthProvider::from_env()?;
462        Ok(Self { auth, timeout: None })
463    }
464
465    /// Creates a new Audio client with URL-based provider detection
466    pub fn with_url<S: Into<String>>(base_url: S, api_key: S) -> Self {
467        let auth = AuthProvider::from_url_with_key(base_url, api_key);
468        Self { auth, timeout: None }
469    }
470
471    /// Creates a new Audio client from URL using environment variables
472    pub fn from_url<S: Into<String>>(url: S) -> Result<Self> {
473        let auth = AuthProvider::from_url(url)?;
474        Ok(Self { auth, timeout: None })
475    }
476
477    /// Returns the authentication provider
478    pub fn auth(&self) -> &AuthProvider {
479        &self.auth
480    }
481
482    /// Sets the request timeout duration.
483    ///
484    /// # Arguments
485    ///
486    /// * `timeout` - The maximum time to wait for a response
487    ///
488    /// # Returns
489    ///
490    /// A mutable reference to self for method chaining
491    pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
492        self.timeout = Some(timeout);
493        self
494    }
495
496    /// Creates the HTTP client with default headers.
497    fn create_client(&self) -> Result<(request::Client, request::header::HeaderMap)> {
498        let client = create_http_client(self.timeout)?;
499        let mut headers = request::header::HeaderMap::new();
500        self.auth.apply_headers(&mut headers)?;
501        headers.insert("User-Agent", request::header::HeaderValue::from_static("openai-tools-rust"));
502        Ok((client, headers))
503    }
504
505    /// Converts text to speech.
506    ///
507    /// Returns audio bytes in the specified format.
508    ///
509    /// # Arguments
510    ///
511    /// * `text` - The text to convert to speech (max 4096 characters)
512    /// * `options` - TTS options (model, voice, format, speed)
513    ///
514    /// # Returns
515    ///
516    /// * `Ok(Vec<u8>)` - The audio data as bytes
517    /// * `Err(OpenAIToolError)` - If the request fails
518    ///
519    /// # Example
520    ///
521    /// ```rust,no_run
522    /// use openai_tools::audio::request::{Audio, TtsOptions, TtsModel, Voice};
523    ///
524    /// #[tokio::main]
525    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
526    ///     let audio = Audio::new()?;
527    ///
528    ///     let options = TtsOptions {
529    ///         model: TtsModel::Tts1Hd,
530    ///         voice: Voice::Shimmer,
531    ///         speed: Some(1.2),
532    ///         ..Default::default()
533    ///     };
534    ///
535    ///     let bytes = audio.text_to_speech("Hello, this is a test.", options).await?;
536    ///     std::fs::write("speech.mp3", bytes)?;
537    ///
538    ///     Ok(())
539    /// }
540    /// ```
541    pub async fn text_to_speech(&self, text: &str, options: TtsOptions) -> Result<Vec<u8>> {
542        let (client, mut headers) = self.create_client()?;
543        headers.insert("Content-Type", request::header::HeaderValue::from_static("application/json"));
544
545        // Check if instructions parameter is supported by the model
546        let instructions = if options.instructions.is_some() {
547            if options.model.supports_instructions() {
548                options.instructions
549            } else {
550                tracing::warn!("Model '{}' does not support instructions parameter. Ignoring instructions.", options.model);
551                None
552            }
553        } else {
554            None
555        };
556
557        let request_body = TtsRequest {
558            model: options.model.as_str().to_string(),
559            input: text.to_string(),
560            voice: options.voice.as_str().to_string(),
561            response_format: Some(options.response_format.as_str().to_string()),
562            speed: options.speed,
563            instructions,
564        };
565
566        let body = serde_json::to_string(&request_body).map_err(OpenAIToolError::SerdeJsonError)?;
567
568        let url = format!("{}/speech", self.auth.endpoint(AUDIO_PATH));
569
570        let response = client.post(&url).headers(headers).body(body).send().await.map_err(OpenAIToolError::RequestError)?;
571
572        let bytes = response.bytes().await.map_err(OpenAIToolError::RequestError)?;
573
574        Ok(bytes.to_vec())
575    }
576
577    /// Transcribes audio from a file path.
578    ///
579    /// # Arguments
580    ///
581    /// * `audio_path` - Path to the audio file
582    /// * `options` - Transcription options
583    ///
584    /// # Returns
585    ///
586    /// * `Ok(TranscriptionResponse)` - The transcription result
587    /// * `Err(OpenAIToolError)` - If the request fails
588    ///
589    /// # Example
590    ///
591    /// ```rust,no_run
592    /// use openai_tools::audio::request::{Audio, TranscribeOptions};
593    ///
594    /// #[tokio::main]
595    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
596    ///     let audio = Audio::new()?;
597    ///
598    ///     let options = TranscribeOptions {
599    ///         language: Some("en".to_string()),
600    ///         ..Default::default()
601    ///     };
602    ///
603    ///     let response = audio.transcribe("audio.mp3", options).await?;
604    ///     println!("Transcription: {}", response.text);
605    ///
606    ///     Ok(())
607    /// }
608    /// ```
609    pub async fn transcribe(&self, audio_path: &str, options: TranscribeOptions) -> Result<TranscriptionResponse> {
610        let audio_content = tokio::fs::read(audio_path).await.map_err(|e| OpenAIToolError::Error(format!("Failed to read audio file: {}", e)))?;
611
612        let filename = Path::new(audio_path).file_name().and_then(|n| n.to_str()).unwrap_or("audio.mp3").to_string();
613
614        self.transcribe_bytes(&audio_content, &filename, options).await
615    }
616
617    /// Transcribes audio from bytes.
618    ///
619    /// # Arguments
620    ///
621    /// * `audio_data` - The audio data as bytes
622    /// * `filename` - The filename with extension (e.g., "audio.mp3")
623    /// * `options` - Transcription options
624    ///
625    /// # Returns
626    ///
627    /// * `Ok(TranscriptionResponse)` - The transcription result
628    /// * `Err(OpenAIToolError)` - If the request fails
629    ///
630    /// # Example
631    ///
632    /// ```rust,no_run
633    /// use openai_tools::audio::request::{Audio, TranscribeOptions, SttModel};
634    ///
635    /// #[tokio::main]
636    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
637    ///     let audio = Audio::new()?;
638    ///
639    ///     let audio_data = std::fs::read("recording.mp3")?;
640    ///     let options = TranscribeOptions {
641    ///         model: Some(SttModel::Whisper1),
642    ///         ..Default::default()
643    ///     };
644    ///
645    ///     let response = audio.transcribe_bytes(&audio_data, "recording.mp3", options).await?;
646    ///     println!("Transcription: {}", response.text);
647    ///
648    ///     Ok(())
649    /// }
650    /// ```
651    pub async fn transcribe_bytes(&self, audio_data: &[u8], filename: &str, options: TranscribeOptions) -> Result<TranscriptionResponse> {
652        let (client, headers) = self.create_client()?;
653
654        let audio_part = Part::bytes(audio_data.to_vec())
655            .file_name(filename.to_string())
656            .mime_str("audio/mpeg")
657            .map_err(|e| OpenAIToolError::Error(format!("Failed to set MIME type: {}", e)))?;
658
659        let mut form = Form::new().part("file", audio_part);
660
661        // Add model
662        let model = options.model.unwrap_or_default();
663        form = form.text("model", model.as_str().to_string());
664
665        // Add optional parameters
666        if let Some(language) = options.language {
667            form = form.text("language", language);
668        }
669        if let Some(prompt) = options.prompt {
670            form = form.text("prompt", prompt);
671        }
672        if let Some(response_format) = options.response_format {
673            form = form.text("response_format", response_format.as_str().to_string());
674        }
675        if let Some(temperature) = options.temperature {
676            form = form.text("temperature", temperature.to_string());
677        }
678        if let Some(granularities) = options.timestamp_granularities {
679            for g in granularities {
680                form = form.text("timestamp_granularities[]", g.as_str().to_string());
681            }
682        }
683
684        let url = format!("{}/transcriptions", self.auth.endpoint(AUDIO_PATH));
685
686        let response = client.post(&url).headers(headers).multipart(form).send().await.map_err(OpenAIToolError::RequestError)?;
687
688        let status = response.status();
689        let content = response.text().await.map_err(OpenAIToolError::RequestError)?;
690
691        if cfg!(test) {
692            tracing::info!("Response content: {}", content);
693        }
694
695        if !status.is_success() {
696            if let Ok(error_resp) = serde_json::from_str::<ErrorResponse>(&content) {
697                return Err(OpenAIToolError::Error(error_resp.error.message.unwrap_or_default()));
698            }
699            return Err(OpenAIToolError::Error(format!("API error ({}): {}", status, content)));
700        }
701
702        serde_json::from_str::<TranscriptionResponse>(&content).map_err(OpenAIToolError::SerdeJsonError)
703    }
704
705    /// Translates audio to English text.
706    ///
707    /// Only supports translation to English using the whisper-1 model.
708    ///
709    /// # Arguments
710    ///
711    /// * `audio_path` - Path to the audio file
712    /// * `options` - Translation options
713    ///
714    /// # Returns
715    ///
716    /// * `Ok(TranscriptionResponse)` - The translation result
717    /// * `Err(OpenAIToolError)` - If the request fails
718    ///
719    /// # Example
720    ///
721    /// ```rust,no_run
722    /// use openai_tools::audio::request::{Audio, TranslateOptions};
723    ///
724    /// #[tokio::main]
725    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
726    ///     let audio = Audio::new()?;
727    ///
728    ///     let options = TranslateOptions::default();
729    ///     let response = audio.translate("french_audio.mp3", options).await?;
730    ///     println!("English translation: {}", response.text);
731    ///
732    ///     Ok(())
733    /// }
734    /// ```
735    pub async fn translate(&self, audio_path: &str, options: TranslateOptions) -> Result<TranscriptionResponse> {
736        let audio_content = tokio::fs::read(audio_path).await.map_err(|e| OpenAIToolError::Error(format!("Failed to read audio file: {}", e)))?;
737
738        let filename = Path::new(audio_path).file_name().and_then(|n| n.to_str()).unwrap_or("audio.mp3").to_string();
739
740        self.translate_bytes(&audio_content, &filename, options).await
741    }
742
743    /// Translates audio from bytes to English text.
744    ///
745    /// # Arguments
746    ///
747    /// * `audio_data` - The audio data as bytes
748    /// * `filename` - The filename with extension (e.g., "audio.mp3")
749    /// * `options` - Translation options
750    ///
751    /// # Returns
752    ///
753    /// * `Ok(TranscriptionResponse)` - The translation result
754    /// * `Err(OpenAIToolError)` - If the request fails
755    pub async fn translate_bytes(&self, audio_data: &[u8], filename: &str, options: TranslateOptions) -> Result<TranscriptionResponse> {
756        let (client, headers) = self.create_client()?;
757
758        let audio_part = Part::bytes(audio_data.to_vec())
759            .file_name(filename.to_string())
760            .mime_str("audio/mpeg")
761            .map_err(|e| OpenAIToolError::Error(format!("Failed to set MIME type: {}", e)))?;
762
763        let mut form = Form::new().part("file", audio_part);
764
765        // Add model (whisper-1 is the only supported model for translation)
766        let model = options.model.unwrap_or(SttModel::Whisper1);
767        form = form.text("model", model.as_str().to_string());
768
769        // Add optional parameters
770        if let Some(prompt) = options.prompt {
771            form = form.text("prompt", prompt);
772        }
773        if let Some(response_format) = options.response_format {
774            form = form.text("response_format", response_format.as_str().to_string());
775        }
776        if let Some(temperature) = options.temperature {
777            form = form.text("temperature", temperature.to_string());
778        }
779
780        let url = format!("{}/translations", self.auth.endpoint(AUDIO_PATH));
781
782        let response = client.post(&url).headers(headers).multipart(form).send().await.map_err(OpenAIToolError::RequestError)?;
783
784        let status = response.status();
785        let content = response.text().await.map_err(OpenAIToolError::RequestError)?;
786
787        if cfg!(test) {
788            tracing::info!("Response content: {}", content);
789        }
790
791        if !status.is_success() {
792            if let Ok(error_resp) = serde_json::from_str::<ErrorResponse>(&content) {
793                return Err(OpenAIToolError::Error(error_resp.error.message.unwrap_or_default()));
794            }
795            return Err(OpenAIToolError::Error(format!("API error ({}): {}", status, content)));
796        }
797
798        serde_json::from_str::<TranscriptionResponse>(&content).map_err(OpenAIToolError::SerdeJsonError)
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    // =========================================================================
807    // TtsModel Tests
808    // =========================================================================
809
810    #[test]
811    fn test_tts_model_as_str() {
812        assert_eq!(TtsModel::Tts1.as_str(), "tts-1");
813        assert_eq!(TtsModel::Tts1Hd.as_str(), "tts-1-hd");
814        assert_eq!(TtsModel::Gpt4oMiniTts.as_str(), "gpt-4o-mini-tts");
815    }
816
817    #[test]
818    fn test_tts_model_supports_instructions() {
819        // Only gpt-4o-mini-tts supports instructions
820        assert!(TtsModel::Gpt4oMiniTts.supports_instructions());
821        assert!(!TtsModel::Tts1.supports_instructions());
822        assert!(!TtsModel::Tts1Hd.supports_instructions());
823    }
824
825    #[test]
826    fn test_tts_model_default() {
827        let model = TtsModel::default();
828        assert_eq!(model, TtsModel::Tts1);
829    }
830
831    #[test]
832    fn test_tts_model_display() {
833        assert_eq!(format!("{}", TtsModel::Gpt4oMiniTts), "gpt-4o-mini-tts");
834    }
835
836    // =========================================================================
837    // Voice Tests
838    // =========================================================================
839
840    #[test]
841    fn test_voice_as_str_all_voices() {
842        assert_eq!(Voice::Alloy.as_str(), "alloy");
843        assert_eq!(Voice::Ash.as_str(), "ash");
844        assert_eq!(Voice::Ballad.as_str(), "ballad");
845        assert_eq!(Voice::Cedar.as_str(), "cedar");
846        assert_eq!(Voice::Coral.as_str(), "coral");
847        assert_eq!(Voice::Echo.as_str(), "echo");
848        assert_eq!(Voice::Fable.as_str(), "fable");
849        assert_eq!(Voice::Marin.as_str(), "marin");
850        assert_eq!(Voice::Nova.as_str(), "nova");
851        assert_eq!(Voice::Onyx.as_str(), "onyx");
852        assert_eq!(Voice::Sage.as_str(), "sage");
853        assert_eq!(Voice::Shimmer.as_str(), "shimmer");
854        assert_eq!(Voice::Verse.as_str(), "verse");
855    }
856
857    #[test]
858    fn test_voice_new_voices() {
859        // Test the newly added voices
860        assert_eq!(Voice::Ballad.as_str(), "ballad");
861        assert_eq!(Voice::Cedar.as_str(), "cedar");
862        assert_eq!(Voice::Marin.as_str(), "marin");
863        assert_eq!(Voice::Verse.as_str(), "verse");
864    }
865
866    #[test]
867    fn test_voice_default() {
868        let voice = Voice::default();
869        assert_eq!(voice, Voice::Alloy);
870    }
871
872    #[test]
873    fn test_voice_serialization() {
874        let voice = Voice::Coral;
875        let json = serde_json::to_string(&voice).unwrap();
876        assert_eq!(json, "\"coral\"");
877
878        // Test new voices
879        let ballad = Voice::Ballad;
880        let json = serde_json::to_string(&ballad).unwrap();
881        assert_eq!(json, "\"ballad\"");
882    }
883
884    #[test]
885    fn test_voice_deserialization() {
886        let voice: Voice = serde_json::from_str("\"coral\"").unwrap();
887        assert_eq!(voice, Voice::Coral);
888
889        // Test new voices
890        let cedar: Voice = serde_json::from_str("\"cedar\"").unwrap();
891        assert_eq!(cedar, Voice::Cedar);
892
893        let marin: Voice = serde_json::from_str("\"marin\"").unwrap();
894        assert_eq!(marin, Voice::Marin);
895    }
896
897    // =========================================================================
898    // TtsOptions Tests
899    // =========================================================================
900
901    #[test]
902    fn test_tts_options_default() {
903        let options = TtsOptions::default();
904        assert_eq!(options.model, TtsModel::Tts1);
905        assert_eq!(options.voice, Voice::Alloy);
906        assert_eq!(options.response_format, AudioFormat::Mp3);
907        assert!(options.speed.is_none());
908        assert!(options.instructions.is_none());
909    }
910
911    #[test]
912    fn test_tts_options_with_instructions() {
913        let options = TtsOptions {
914            model: TtsModel::Gpt4oMiniTts,
915            voice: Voice::Coral,
916            instructions: Some("Speak in a cheerful tone.".to_string()),
917            ..Default::default()
918        };
919        assert_eq!(options.model, TtsModel::Gpt4oMiniTts);
920        assert_eq!(options.instructions, Some("Speak in a cheerful tone.".to_string()));
921    }
922
923    // =========================================================================
924    // TtsRequest Tests
925    // =========================================================================
926
927    #[test]
928    fn test_tts_request_serialization_with_instructions() {
929        let request = TtsRequest {
930            model: "gpt-4o-mini-tts".to_string(),
931            input: "Hello, world!".to_string(),
932            voice: "coral".to_string(),
933            response_format: Some("mp3".to_string()),
934            speed: None,
935            instructions: Some("Speak cheerfully.".to_string()),
936        };
937        let json = serde_json::to_value(&request).unwrap();
938
939        assert_eq!(json["model"], "gpt-4o-mini-tts");
940        assert_eq!(json["input"], "Hello, world!");
941        assert_eq!(json["voice"], "coral");
942        assert_eq!(json["response_format"], "mp3");
943        assert_eq!(json["instructions"], "Speak cheerfully.");
944        assert!(json.get("speed").is_none());
945    }
946
947    #[test]
948    fn test_tts_request_serialization_without_instructions() {
949        let request = TtsRequest {
950            model: "tts-1".to_string(),
951            input: "Hello".to_string(),
952            voice: "alloy".to_string(),
953            response_format: Some("mp3".to_string()),
954            speed: Some(1.0),
955            instructions: None,
956        };
957        let json = serde_json::to_value(&request).unwrap();
958
959        assert_eq!(json["model"], "tts-1");
960        assert_eq!(json["speed"], 1.0);
961        // instructions should be omitted when None
962        assert!(json.get("instructions").is_none());
963    }
964
965    #[test]
966    fn test_tts_request_skip_serializing_none_fields() {
967        let request = TtsRequest {
968            model: "tts-1".to_string(),
969            input: "Test".to_string(),
970            voice: "echo".to_string(),
971            response_format: None,
972            speed: None,
973            instructions: None,
974        };
975        let json = serde_json::to_value(&request).unwrap();
976
977        // Required fields are present
978        assert!(json.get("model").is_some());
979        assert!(json.get("input").is_some());
980        assert!(json.get("voice").is_some());
981
982        // Optional fields with None are omitted
983        assert!(json.get("response_format").is_none());
984        assert!(json.get("speed").is_none());
985        assert!(json.get("instructions").is_none());
986    }
987
988    // =========================================================================
989    // AudioFormat Tests
990    // =========================================================================
991
992    #[test]
993    fn test_audio_format_as_str() {
994        assert_eq!(AudioFormat::Mp3.as_str(), "mp3");
995        assert_eq!(AudioFormat::Opus.as_str(), "opus");
996        assert_eq!(AudioFormat::Aac.as_str(), "aac");
997        assert_eq!(AudioFormat::Flac.as_str(), "flac");
998        assert_eq!(AudioFormat::Wav.as_str(), "wav");
999        assert_eq!(AudioFormat::Pcm.as_str(), "pcm");
1000    }
1001
1002    #[test]
1003    fn test_audio_format_file_extension() {
1004        assert_eq!(AudioFormat::Mp3.file_extension(), "mp3");
1005        assert_eq!(AudioFormat::Wav.file_extension(), "wav");
1006    }
1007
1008    // =========================================================================
1009    // SttModel Tests
1010    // =========================================================================
1011
1012    #[test]
1013    fn test_stt_model_as_str() {
1014        assert_eq!(SttModel::Whisper1.as_str(), "whisper-1");
1015        assert_eq!(SttModel::Gpt4oTranscribe.as_str(), "gpt-4o-transcribe");
1016    }
1017
1018    // =========================================================================
1019    // TranscriptionFormat Tests
1020    // =========================================================================
1021
1022    #[test]
1023    fn test_transcription_format_as_str() {
1024        assert_eq!(TranscriptionFormat::Json.as_str(), "json");
1025        assert_eq!(TranscriptionFormat::Text.as_str(), "text");
1026        assert_eq!(TranscriptionFormat::Srt.as_str(), "srt");
1027        assert_eq!(TranscriptionFormat::VerboseJson.as_str(), "verbose_json");
1028        assert_eq!(TranscriptionFormat::Vtt.as_str(), "vtt");
1029    }
1030
1031    // =========================================================================
1032    // TimestampGranularity Tests
1033    // =========================================================================
1034
1035    #[test]
1036    fn test_timestamp_granularity_as_str() {
1037        assert_eq!(TimestampGranularity::Word.as_str(), "word");
1038        assert_eq!(TimestampGranularity::Segment.as_str(), "segment");
1039    }
1040
1041    // =========================================================================
1042    // Speech-to-text models added in 2026.
1043    //
1044    // Model IDs verified against the OpenAI API reference
1045    // (https://developers.openai.com/api/docs/models), August 2026.
1046    // =========================================================================
1047
1048    fn new_stt_models() -> Vec<(SttModel, &'static str)> {
1049        vec![
1050            (SttModel::GptTranscribe, "gpt-transcribe"),
1051            (SttModel::GptLiveTranscribe, "gpt-live-transcribe"),
1052            (SttModel::GptRealtimeWhisper, "gpt-realtime-whisper"),
1053            (SttModel::Gpt4oMiniTranscribe, "gpt-4o-mini-transcribe"),
1054        ]
1055    }
1056
1057    #[test]
1058    fn test_new_stt_models_as_str() {
1059        for (model, expected) in new_stt_models() {
1060            assert_eq!(model.as_str(), expected, "Wrong model ID for {:?}", model);
1061        }
1062    }
1063
1064    #[test]
1065    fn test_new_stt_models_serialization() {
1066        for (model, expected) in new_stt_models() {
1067            let json = serde_json::to_string(&model).unwrap();
1068            assert_eq!(json, format!("\"{}\"", expected), "Wrong serialization for {:?}", model);
1069            let deserialized: SttModel = serde_json::from_str(&json).unwrap();
1070            assert_eq!(deserialized, model, "Serialization roundtrip failed for {:?}", model);
1071        }
1072    }
1073
1074    /// `gpt-live-transcribe` and `gpt-realtime-whisper` are only exposed on
1075    /// `v1/realtime/transcription_sessions`, not on `v1/audio/transcriptions`.
1076    #[test]
1077    fn test_realtime_only_stt_models_are_flagged() {
1078        assert!(!SttModel::GptLiveTranscribe.supports_file_transcription());
1079        assert!(!SttModel::GptRealtimeWhisper.supports_file_transcription());
1080
1081        assert!(SttModel::GptTranscribe.supports_file_transcription());
1082        assert!(SttModel::Whisper1.supports_file_transcription());
1083        assert!(SttModel::Gpt4oTranscribe.supports_file_transcription());
1084        assert!(SttModel::Gpt4oMiniTranscribe.supports_file_transcription());
1085    }
1086
1087    /// Audio models present in the live /v1/models listing but previously
1088    /// missing from the enums. Verified live against the API (August 2026).
1089    #[test]
1090    fn test_previously_missing_tts_models() {
1091        for (model, expected) in [(TtsModel::Tts1_1106, "tts-1-1106"), (TtsModel::Tts1Hd1106, "tts-1-hd-1106")] {
1092            assert_eq!(model.as_str(), expected, "Wrong model ID for {:?}", model);
1093            let json = serde_json::to_string(&model).unwrap();
1094            assert_eq!(json, format!("\"{}\"", expected), "Wrong serialization for {:?}", model);
1095        }
1096    }
1097
1098    #[test]
1099    fn test_diarizing_stt_model() {
1100        assert_eq!(SttModel::Gpt4oTranscribeDiarize.as_str(), "gpt-4o-transcribe-diarize");
1101        assert!(SttModel::Gpt4oTranscribeDiarize.supports_file_transcription());
1102    }
1103}