use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use anyhow::{Context, Result};
use reqwest::StatusCode;
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use super::{AudioClip, GatewaySpeech, TtsEngine, refusal};
use crate::shared::api::openai::attributed;
const OPENAI_MAX_INPUT_CHARS: usize = 4096;
const EXTERNAL_MAX_INPUT_CHARS: usize = 2000;
const GATEWAY_MAX_INPUT_CHARS: usize = 2000;
const OPENAI_PCM_RATE: u32 = 24_000;
const UNLABELLED_PCM_RATE: u32 = 24_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GatewayFormat {
Pcm,
Mp3,
}
impl GatewayFormat {
fn wire(self) -> &'static str {
match self {
GatewayFormat::Pcm => "pcm",
GatewayFormat::Mp3 => "mp3",
}
}
fn other(self) -> Self {
match self {
GatewayFormat::Pcm => GatewayFormat::Mp3,
GatewayFormat::Mp3 => GatewayFormat::Pcm,
}
}
}
#[derive(Debug, Default)]
pub struct FormatMemo(Mutex<HashMap<String, GatewayFormat>>);
impl FormatMemo {
pub fn of(&self, model: &str) -> GatewayFormat {
let known = self.0.lock().unwrap_or_else(PoisonError::into_inner);
known.get(model).copied().unwrap_or(GatewayFormat::Pcm)
}
fn keep(&self, model: &str, format: GatewayFormat) {
let mut known = self.0.lock().unwrap_or_else(PoisonError::into_inner);
known.insert(model.to_string(), format);
}
}
enum Format {
Fixed(&'static str),
Negotiated(Arc<FormatMemo>),
}
pub struct OpenAiTts {
http: reqwest::Client,
base_url: String,
api_key: Option<String>,
model: Option<String>,
voice: Option<String>,
instructions: Option<String>,
speed: f32,
format: Format,
attribution: bool,
max_input_chars: usize,
}
enum Answer {
Audio {
label: Option<String>,
bytes: Vec<u8>,
},
Refused {
status: StatusCode,
body: String,
},
}
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,
format: Format::Fixed("pcm"),
attribution: false,
max_input_chars: OPENAI_MAX_INPUT_CHARS,
}
}
pub fn gateway(
base_url: String,
api_key: String,
model: String,
voice: Option<String>,
speed: f32,
gateway: &GatewaySpeech,
) -> 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: None,
speed,
format: Format::Negotiated(gateway.formats.clone()),
attribution: gateway.attribution,
max_input_chars: GATEWAY_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,
format: Format::Fixed("wav"),
attribution: false,
max_input_chars: EXTERNAL_MAX_INPUT_CHARS,
}
}
fn body<'a>(&'a self, text: &'a str, response_format: &'static str) -> SpeechRequest<'a> {
SpeechRequest {
model: self.model.as_deref(),
input: text,
voice: self.voice.as_deref(),
instructions: self.instructions.as_deref(),
response_format,
speed: (self.speed != 1.0).then_some(self.speed),
}
}
async fn ask(
&self,
text: &str,
response_format: &'static str,
cancel: &CancellationToken,
) -> Result<Answer> {
let url = format!("{}/audio/speech", self.base_url);
let mut rb = self.http.post(&url).json(&self.body(text, response_format));
if let Some(key) = &self.api_key {
rb = rb.bearer_auth(key);
}
if self.attribution {
rb = attributed(rb);
}
let resp = tokio::select! {
biased;
_ = cancel.cancelled() => anyhow::bail!("speech synthesis cancelled"),
r = rb.send() => r.with_context(|| format!("POST {url}"))?,
};
let status = resp.status();
if !status.is_success() {
let body = tokio::select! {
biased;
_ = cancel.cancelled() => anyhow::bail!("speech synthesis cancelled"),
b = resp.text() => b.unwrap_or_default(),
};
return Ok(Answer::Refused { status, body });
}
let label = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = tokio::select! {
biased;
_ = cancel.cancelled() => anyhow::bail!("speech synthesis cancelled"),
b = resp.bytes() => b.context("reading TTS audio response")?,
};
Ok(Answer::Audio {
label,
bytes: bytes.to_vec(),
})
}
async fn negotiate(
&self,
memo: &FormatMemo,
text: &str,
cancel: &CancellationToken,
) -> Result<AudioClip> {
let model = self.model.as_deref().unwrap_or_default();
let first = memo.of(model);
let (status, body) = match self.ask(text, first.wire(), cancel).await? {
Answer::Audio { label, bytes } => {
return Ok(labelled(label.as_deref(), first, bytes));
}
Answer::Refused { status, body } => (status, body),
};
if !names_the_format(status, &body) {
return Err(gateway_refusal(status, &body));
}
let second = first.other();
tracing::info!(
model,
refused = first.wire(),
asking = second.wire(),
"the gateway's model does not take this audio format"
);
match self.ask(text, second.wire(), cancel).await? {
Answer::Audio { label, bytes } => {
memo.keep(model, second);
Ok(labelled(label.as_deref(), second, bytes))
}
Answer::Refused { status, body } => Err(gateway_refusal(status, &body)),
}
}
}
fn names_the_format(status: StatusCode, body: &str) -> bool {
status == StatusCode::BAD_REQUEST && body.contains("response_format")
}
fn gateway_refusal(status: StatusCode, body: &str) -> anyhow::Error {
let said = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.pointer("/error/message")?.as_str().map(str::to_string))
.filter(|m| !m.trim().is_empty());
refusal("TTS", status, said.as_deref().unwrap_or(body))
}
fn labelled(label: Option<&str>, asked: GatewayFormat, bytes: Vec<u8>) -> AudioClip {
let label = label.unwrap_or_default().to_ascii_lowercase();
let mut parts = label.split(';').map(str::trim);
let raw = match parts.next().unwrap_or_default() {
"audio/pcm" | "audio/l16" => true,
"" => asked == GatewayFormat::Pcm,
_ => false,
};
if !raw {
return AudioClip::Encoded(bytes);
}
let named = |name: &str| {
parts.clone().find_map(|p| {
p.strip_prefix(name)?
.trim_start()
.strip_prefix('=')?
.trim()
.parse()
.ok()
})
};
let sample_rate = named("rate").unwrap_or_else(|| {
tracing::warn!(%label, "raw speech with no rate in its label — taken for 24 kHz");
UNLABELLED_PCM_RATE
});
AudioClip::Pcm {
sample_rate,
channels: named("channels")
.and_then(|n: u32| u16::try_from(n).ok())
.unwrap_or(1),
bytes,
}
}
#[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 asked = match &self.format {
Format::Negotiated(memo) => return self.negotiate(memo, text, cancel).await,
Format::Fixed(asked) => *asked,
};
match self.ask(text, asked, cancel).await? {
Answer::Refused { status, body } => Err(refusal("TTS", status, &body)),
Answer::Audio { bytes, .. } if asked == "pcm" => Ok(AudioClip::Pcm {
sample_rate: OPENAI_PCM_RATE,
channels: 1,
bytes,
}),
Answer::Audio { bytes, .. } => Ok(AudioClip::Encoded(bytes)),
}
}
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 {
let Format::Fixed(asked) = engine.format else {
panic!("a client that asks for one format");
};
serde_json::to_value(engine.body(text, asked)).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()
),
}
}
}