use anyhow::{Context, Result};
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use super::{AudioClip, TtsEngine, error_body};
const OPENAI_MAX_INPUT_CHARS: usize = 4096;
const EXTERNAL_MAX_INPUT_CHARS: usize = 2000;
const OPENAI_PCM_RATE: u32 = 24_000;
pub struct OpenAiTts {
http: reqwest::Client,
base_url: String,
api_key: Option<String>,
model: Option<String>,
voice: Option<String>,
instructions: Option<String>,
speed: f32,
response_format: &'static str,
max_input_chars: usize,
}
impl OpenAiTts {
pub fn cloud(
base_url: String,
api_key: String,
model: String,
voice: Option<String>,
instructions: Option<String>,
speed: f32,
) -> Self {
Self {
http: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
api_key: Some(api_key),
model: Some(model),
voice,
instructions,
speed,
response_format: "pcm",
max_input_chars: OPENAI_MAX_INPUT_CHARS,
}
}
pub fn external(
base_url: String,
api_key: Option<String>,
model: Option<String>,
voice: Option<String>,
speed: f32,
) -> Self {
Self {
http: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
model,
voice,
instructions: None,
speed,
response_format: "wav",
max_input_chars: EXTERNAL_MAX_INPUT_CHARS,
}
}
fn body<'a>(&'a self, text: &'a str) -> SpeechRequest<'a> {
SpeechRequest {
model: self.model.as_deref(),
input: text,
voice: self.voice.as_deref(),
instructions: self.instructions.as_deref(),
response_format: self.response_format,
speed: (self.speed != 1.0).then_some(self.speed),
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct SpeechRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<&'a str>,
input: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<&'a str>,
response_format: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
speed: Option<f32>,
}
#[async_trait::async_trait]
impl TtsEngine for OpenAiTts {
async fn synthesize(&self, text: &str, cancel: &CancellationToken) -> Result<AudioClip> {
let url = format!("{}/audio/speech", self.base_url);
let mut rb = self.http.post(&url).json(&self.body(text));
if let Some(key) = &self.api_key {
rb = rb.bearer_auth(key);
}
let resp = tokio::select! {
biased;
_ = cancel.cancelled() => anyhow::bail!("speech synthesis cancelled"),
r = rb.send() => r.with_context(|| format!("POST {url}"))?,
};
if !resp.status().is_success() {
return Err(error_body("TTS", resp).await);
}
let bytes = tokio::select! {
biased;
_ = cancel.cancelled() => anyhow::bail!("speech synthesis cancelled"),
b = resp.bytes() => b.context("reading TTS audio response")?,
};
Ok(if self.response_format == "pcm" {
AudioClip::Pcm {
sample_rate: OPENAI_PCM_RATE,
channels: 1,
bytes: bytes.to_vec(),
}
} else {
AudioClip::Encoded(bytes.to_vec())
})
}
fn max_input_chars(&self) -> usize {
self.max_input_chars
}
}
#[cfg(test)]
mod tests {
use super::*;
fn json_of(engine: &OpenAiTts, text: &str) -> serde_json::Value {
serde_json::to_value(engine.body(text)).unwrap()
}
#[test]
fn cloud_request_asks_for_raw_pcm_and_carries_instructions() {
let e = OpenAiTts::cloud(
"https://api.openai.com/v1".into(),
"sk-x".into(),
"gpt-4o-mini-tts".into(),
Some("marin".into()),
Some("говори по-русски, спокойно".into()),
1.0,
);
let v = json_of(&e, "привет");
assert_eq!(v["model"], "gpt-4o-mini-tts");
assert_eq!(v["input"], "привет");
assert_eq!(v["voice"], "marin");
assert_eq!(v["response_format"], "pcm");
assert_eq!(v["instructions"], "говори по-русски, спокойно");
assert!(v.get("speed").is_none(), "speed=1.0 isn't sent: {v}");
assert_eq!(e.max_input_chars(), OPENAI_MAX_INPUT_CHARS);
}
#[test]
fn external_request_asks_for_wav_and_omits_unset_fields() {
let e = OpenAiTts::external("http://127.0.0.1:8880/v1/".into(), None, None, None, 1.25);
let v = json_of(&e, "текст");
assert_eq!(v["response_format"], "wav");
assert_eq!(v["speed"], 1.25);
assert!(v.get("model").is_none(), "model isn't sent: {v}");
assert!(v.get("voice").is_none(), "voice isn't sent: {v}");
assert!(
v.get("instructions").is_none(),
"instructions — OpenAI cloud only: {v}"
);
assert_eq!(e.base_url, "http://127.0.0.1:8880/v1");
assert_eq!(e.max_input_chars(), EXTERNAL_MAX_INPUT_CHARS);
}
#[tokio::test]
#[ignore = "requires an OpenAI key (MINDFORK_OPENAI_KEY)"]
async fn openai_synthesizes_russian_speech_live() {
let Ok(key) = std::env::var("MINDFORK_OPENAI_KEY") else {
eprintln!("MINDFORK_OPENAI_KEY not set — smoke skipped");
return;
};
let e = OpenAiTts::cloud(
"https://api.openai.com/v1".into(),
key,
std::env::var("MINDFORK_TTS_MODEL")
.unwrap_or_else(|_| crate::shared::config::DEFAULT_TTS_OPENAI_MODEL.into()),
Some(crate::shared::config::DEFAULT_TTS_OPENAI_VOICE.into()),
Some("говори по-русски, спокойно и разборчиво".into()),
1.0,
);
let clip = e
.synthesize(
"Проверка озвучивания. Латинская вставка: API, JSON.",
&CancellationToken::new(),
)
.await
.expect("synthesis should succeed");
match clip {
AudioClip::Pcm {
sample_rate,
channels,
bytes,
} => {
assert_eq!(sample_rate, OPENAI_PCM_RATE);
assert_eq!(channels, 1);
eprintln!("received PCM: {} bytes", bytes.len());
assert!(bytes.len() > 10_000, "the clip is suspiciously short");
}
AudioClip::Encoded(bytes) => {
panic!(
"the OpenAI cloud should return raw PCM, got a container ({} bytes)",
bytes.len()
)
}
}
}
#[tokio::test]
#[ignore = "requires a local TTS server (MINDFORK_TTS_URL)"]
async fn external_server_synthesizes_live() {
let Ok(url) = std::env::var("MINDFORK_TTS_URL") else {
eprintln!("MINDFORK_TTS_URL not set — smoke skipped");
return;
};
let e = OpenAiTts::external(
url,
None,
std::env::var("MINDFORK_TTS_MODEL").ok(),
std::env::var("MINDFORK_TTS_VOICE").ok(),
1.0,
);
let clip = e
.synthesize("Проверка озвучивания.", &CancellationToken::new())
.await
.expect("synthesis should succeed");
match clip {
AudioClip::Encoded(bytes) => {
eprintln!("received wav: {} bytes", bytes.len());
assert!(bytes.len() > 1000, "the clip is suspiciously short");
}
AudioClip::Pcm { bytes, .. } => panic!(
"the third-party server should return a container, got raw PCM ({} bytes)",
bytes.len()
),
}
}
}