use std::io::{IsTerminal, Read};
use std::path::PathBuf;
use crate::config::{Config, MistralConfig, SynthesisProvider};
use crate::error::TalkError;
use crate::synthesis::resolve::{guard_voice_lang, LangSource, VoiceMeta};
use crate::synthesis::{self, detect_lang, SynthesisRequest};
#[derive(Debug, Default, Clone)]
pub struct SpeakOpts {
pub text: Option<String>,
pub file: Option<PathBuf>,
pub provider: Option<SynthesisProvider>,
pub voice: Option<String>,
pub lang: Option<String>,
pub speed: Option<f32>,
pub output: Option<PathBuf>,
pub force: bool,
}
#[derive(Debug)]
struct ResolvedRequest {
lang: String,
voice: Option<String>,
}
fn resolve_text(text: &Option<String>, file: &Option<PathBuf>) -> Result<String, TalkError> {
if let Some(t) = text {
let t = t.trim();
if !t.is_empty() {
return Ok(t.to_string());
}
return Err(TalkError::Config(
"speak: the provided text is empty".to_string(),
));
}
if let Some(path) = file {
let content = std::fs::read_to_string(path).map_err(|e| {
TalkError::Config(format!(
"speak: failed to read --file {}: {}",
path.display(),
e
))
})?;
let trimmed = content.trim();
if trimmed.is_empty() {
return Err(TalkError::Config(format!(
"speak: --file {} is empty",
path.display()
)));
}
return Ok(trimmed.to_string());
}
if !std::io::stdin().is_terminal() {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(TalkError::Io)?;
let trimmed = buf.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
Err(TalkError::Config(
"speak: no text provided. Pass text as an argument, use --file <PATH>, \
or pipe text via stdin."
.to_string(),
))
}
fn resolve_provider(config: &Config, cli_provider: Option<SynthesisProvider>) -> SynthesisProvider {
if let Some(p) = cli_provider {
return p;
}
if let Some(p) = config.speak.as_ref().and_then(|s| s.default_provider) {
return p;
}
if config.providers.kokoro.is_some() {
SynthesisProvider::Kokoro
} else {
SynthesisProvider::Mistral
}
}
fn concrete_config_lang(raw: Option<&str>) -> Option<String> {
let l = raw?.trim();
if l.is_empty() || l.eq_ignore_ascii_case("auto") {
None
} else {
Some(l.to_ascii_lowercase())
}
}
fn provider_config_lang(config: &Config, provider: SynthesisProvider) -> Option<String> {
match provider {
SynthesisProvider::Kokoro => concrete_config_lang(
config
.providers
.kokoro
.as_ref()
.and_then(|k| k.lang.as_deref()),
),
SynthesisProvider::Mistral => None,
}
}
fn resolve_lang(
cli_lang: Option<&str>,
config: &Config,
provider: SynthesisProvider,
text: &str,
) -> (String, LangSource) {
if let Some(l) = cli_lang {
let l = l.trim();
if !l.is_empty() {
return (l.to_ascii_lowercase(), LangSource::Cli);
}
}
if let Some(l) = provider_config_lang(config, provider) {
return (l, LangSource::Config);
}
match detect_lang(text) {
Some(l) => (l, LangSource::Detected),
None => ("en".to_string(), LangSource::Detected),
}
}
fn resolve_mistral_voice(
cli_voice: Option<&str>,
cfg: &MistralConfig,
lang: &str,
) -> Result<(String, Option<VoiceMeta>), TalkError> {
use synthesis::mistral_presets;
if let Some(v) = cli_voice {
let v = v.trim().to_string();
let meta = mistral_presets::voice_language(&v);
return Ok((v, meta));
}
if let Some(v) = &cfg.tts_voice {
let v = v.trim().to_string();
let meta = mistral_presets::voice_language(&v);
return Ok((v, meta));
}
if let Some(map) = &cfg.tts_voices {
let primary = synthesis::primary_subtag(lang);
if let Some(v) = map
.get(&primary)
.or_else(|| map.get(lang))
.map(|s| s.trim().to_string())
{
return Ok((v, None));
}
}
if let Some(v) = mistral_presets::default_voice_for_lang(lang) {
return Ok((v.to_string(), None));
}
let supported: Vec<&str> = mistral_presets::supported_langs().collect();
Err(TalkError::Config(format!(
"no Mistral voice for language '{}': pass --voice <id>, set \
providers.mistral.tts_voice, or add providers.mistral.tts_voices.{} . \
Built-in defaults exist for: {}. List all voices with \
`GET /v1/audio/voices?voice_type=preset`.",
lang,
synthesis::primary_subtag(lang),
supported.join(", ")
)))
}
fn mistral_can_voice(lang: &str) -> bool {
let primary = synthesis::primary_subtag(lang);
synthesis::mistral_presets::supported_langs().any(|l| l == primary)
}
fn resolve_request(
config: &Config,
provider: SynthesisProvider,
opts: &SpeakOpts,
text: &str,
) -> Result<ResolvedRequest, TalkError> {
let (mut lang, lang_source) = resolve_lang(opts.lang.as_deref(), config, provider, text);
let cli_voice = opts
.voice
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let (voice, explicit_meta): (Option<String>, Option<VoiceMeta>) = match provider {
SynthesisProvider::Mistral => {
if lang_source == LangSource::Detected
&& cli_voice.is_none()
&& !mistral_can_voice(&lang)
{
log::info!(
"speak: detected language '{}' has no Mistral voice; falling back to 'en'",
lang
);
lang = "en".to_string();
}
let cfg = config.providers.mistral.as_ref().ok_or_else(|| {
TalkError::Config(
"Mistral synthesis selected but providers.mistral is not configured"
.to_string(),
)
})?;
let (v, meta) = resolve_mistral_voice(cli_voice, cfg, &lang)?;
(Some(v), meta)
}
SynthesisProvider::Kokoro => {
resolve_kokoro_voice(cli_voice, config, &mut lang, lang_source)?
}
};
guard_voice_lang(explicit_meta.as_ref(), &lang, lang_source, opts.force)?;
Ok(ResolvedRequest { lang, voice })
}
#[cfg(feature = "kokoro")]
fn resolve_kokoro_voice(
cli_voice: Option<&str>,
config: &Config,
lang: &mut String,
lang_source: LangSource,
) -> Result<(Option<String>, Option<VoiceMeta>), TalkError> {
use crate::synthesis::kokoro::voices;
let kcfg = config.providers.kokoro.clone().unwrap_or_default();
if let Some(v) = cli_voice {
let meta = voices::voice_language(v);
return Ok((Some(v.to_string()), meta));
}
if let Some(v) = &kcfg.voice {
let meta = voices::voice_language(v);
return Ok((Some(v.clone()), meta));
}
if lang_source == LangSource::Detected && voices::default_voice_for_lang(lang).is_none() {
log::info!(
"speak: detected language '{}' has no Kokoro speaker; falling back to 'en'",
lang
);
*lang = "en".to_string();
}
Ok((None, None))
}
#[cfg(not(feature = "kokoro"))]
fn resolve_kokoro_voice(
_cli_voice: Option<&str>,
_config: &Config,
_lang: &mut String,
_lang_source: LangSource,
) -> Result<(Option<String>, Option<VoiceMeta>), TalkError> {
Ok((None, None))
}
pub async fn speak(opts: SpeakOpts) -> Result<(), TalkError> {
let text = resolve_text(&opts.text, &opts.file)?;
let config = Config::load(None)?;
let provider = resolve_provider(&config, opts.provider);
#[cfg(feature = "kokoro")]
if provider == SynthesisProvider::Kokoro {
crate::synthesis::kokoro::consent::ensure_with_cli_consent(&config).await?;
}
let resolved = resolve_request(&config, provider, &opts, &text)?;
let req = SynthesisRequest {
text,
voice: resolved.voice,
speed: opts.speed,
lang: Some(resolved.lang),
};
let result = synthesis::synthesize(&config, provider, req).await?;
log::info!(
"speak: synthesized {:.2}s of audio ({} samples @ {} Hz) via {}",
result.duration_secs(),
result.pcm.len(),
result.sample_rate,
provider
);
match opts.output {
Some(path) => {
save_wav(&path, &result.pcm, result.sample_rate)?;
println!("Saved synthesized audio to: {}", path.display());
Ok(())
}
None => play(&result.pcm, result.sample_rate),
}
}
fn save_wav(path: &std::path::Path, pcm: &[i16], sample_rate: u32) -> Result<(), TalkError> {
use crate::audio::{AudioWriter, WavWriter};
use crate::config::AudioConfig;
use std::io::Write;
let cfg = AudioConfig {
sample_rate,
channels: 1,
bitrate: 0, };
let mut writer = WavWriter::new(cfg);
let mut out = writer.header()?;
out.extend_from_slice(&writer.write_pcm(pcm)?);
let final_header = writer.finalize()?;
out[..final_header.len()].copy_from_slice(&final_header);
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(TalkError::Io)?;
}
}
let mut file = std::fs::File::create(path).map_err(TalkError::Io)?;
file.write_all(&out).map_err(TalkError::Io)?;
file.sync_all().map_err(TalkError::Io)?;
Ok(())
}
#[cfg(feature = "playback")]
fn play(pcm: &[i16], sample_rate: u32) -> Result<(), TalkError> {
let player = crate::audio::AudioPlayer::new()?;
player.play_pcm_blocking(pcm, sample_rate)
}
#[cfg(not(feature = "playback"))]
fn play(_pcm: &[i16], _sample_rate: u32) -> Result<(), TalkError> {
Err(TalkError::Config(
"talk-rs was built without the 'playback' feature; audio playback is \
unavailable. Rebuild with --features playback, or use -o <PATH> to \
save a WAV file instead."
.to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{KokoroConfig, ProvidersConfig};
use std::collections::HashMap;
fn mistral_cfg(
tts_voice: Option<&str>,
tts_voices: Option<HashMap<String, String>>,
) -> MistralConfig {
MistralConfig {
api_key: "k".to_string(),
url: None,
model: "m".to_string(),
context_bias: None,
tts_model: "voxtral-mini-tts-latest".to_string(),
tts_voice: tts_voice.map(str::to_string),
tts_voices,
}
}
fn config_mistral(m: MistralConfig) -> Config {
Config {
output_dir: std::path::PathBuf::from("/tmp"),
providers: ProvidersConfig {
mistral: Some(m),
openai: None,
parakeet: None,
kokoro: None,
},
indicators: None,
transcription: None,
speak: None,
paste: None,
audio: None,
recording: None,
}
}
fn config_kokoro(lang: Option<&str>, voice: Option<&str>) -> Config {
Config {
output_dir: std::path::PathBuf::from("/tmp"),
providers: ProvidersConfig {
mistral: None,
openai: None,
parakeet: None,
kokoro: Some(KokoroConfig {
variant: None,
model_dir: None,
voice: voice.map(str::to_string),
num_threads: None,
lang: lang.map(str::to_string),
}),
},
indicators: None,
transcription: None,
speak: None,
paste: None,
audio: None,
recording: None,
}
}
#[test]
fn lang_cli_beats_auto_detection() {
let cfg = config_kokoro(None, None);
let (lang, src) = resolve_lang(
Some("en"),
&cfg,
SynthesisProvider::Kokoro,
"Bonjour tout le monde comment allez vous",
);
assert_eq!(lang, "en");
assert_eq!(src, LangSource::Cli);
}
#[test]
fn lang_concrete_config_beats_auto_detection() {
let cfg = config_kokoro(Some("fr"), None);
let (lang, src) = resolve_lang(
None,
&cfg,
SynthesisProvider::Kokoro,
"The quick brown fox jumps over the lazy dog.",
);
assert_eq!(lang, "fr");
assert_eq!(src, LangSource::Config);
}
#[test]
fn lang_auto_used_when_config_is_auto() {
let cfg = config_kokoro(Some("auto"), None);
let (lang, src) = resolve_lang(
None,
&cfg,
SynthesisProvider::Kokoro,
"Bonjour tout le monde, je suis ravi de vous voir",
);
assert_eq!(lang, "fr");
assert_eq!(src, LangSource::Detected);
}
#[test]
fn lang_auto_used_when_config_absent() {
let cfg = config_kokoro(None, None);
let (lang, src) = resolve_lang(
None,
&cfg,
SynthesisProvider::Kokoro,
"The quick brown fox jumps over the lazy dog.",
);
assert_eq!(lang, "en");
assert_eq!(src, LangSource::Detected);
}
#[test]
fn mistral_tts_voices_map_selects_by_lang() {
let mut map = HashMap::new();
map.insert("en".to_string(), "en-voice".to_string());
map.insert("fr".to_string(), "fr-voice".to_string());
let cfg = mistral_cfg(None, Some(map));
let (voice, meta) = resolve_mistral_voice(None, &cfg, "fr").unwrap();
assert_eq!(voice, "fr-voice");
assert!(meta.is_none());
}
#[test]
fn mistral_builtin_default_when_map_absent() {
let cfg = mistral_cfg(None, None);
let (voice, meta) = resolve_mistral_voice(None, &cfg, "fr").unwrap();
assert_eq!(voice, "5a271406-039d-46fe-835b-fbbb00eaf08d");
assert!(meta.is_none());
let (voice_en, _) = resolve_mistral_voice(None, &cfg, "en").unwrap();
assert_eq!(voice_en, "c69964a6-ab8b-4f8a-9465-ec0925096ec8");
}
#[test]
fn mistral_pin_returns_guard_metadata_for_known_preset() {
let cfg = mistral_cfg(Some("5a271406-039d-46fe-835b-fbbb00eaf08d"), None);
let (voice, meta) = resolve_mistral_voice(None, &cfg, "en").unwrap();
assert_eq!(voice, "5a271406-039d-46fe-835b-fbbb00eaf08d");
let meta = meta.expect("known preset ⇒ guard metadata");
assert_eq!(meta.name, "Marie - Neutral");
assert_eq!(meta.lang, "fr_fr");
}
#[test]
fn mistral_cli_voice_beats_pin_and_map() {
let mut map = HashMap::new();
map.insert("fr".to_string(), "fr-voice".to_string());
let cfg = mistral_cfg(Some("pinned"), Some(map));
let (voice, _) = resolve_mistral_voice(Some("cli-voice"), &cfg, "fr").unwrap();
assert_eq!(voice, "cli-voice");
}
#[test]
fn mistral_unknown_uuid_has_no_guard_metadata() {
let cfg = mistral_cfg(Some("00000000-0000-0000-0000-000000000000"), None);
let (_, meta) = resolve_mistral_voice(None, &cfg, "fr").unwrap();
assert!(meta.is_none());
}
#[test]
fn mistral_no_voice_for_unsupported_lang_errors() {
let cfg = mistral_cfg(None, None);
let err = resolve_mistral_voice(None, &cfg, "de").expect_err("no de voice");
assert!(err
.to_string()
.contains("no Mistral voice for language 'de'"));
}
#[test]
fn resolve_request_mistral_auto_french_picks_marie() {
let cfg = config_mistral(mistral_cfg(None, None));
let opts = SpeakOpts::default();
let r = resolve_request(
&cfg,
SynthesisProvider::Mistral,
&opts,
"Bonjour tout le monde, je suis ravi de vous voir",
)
.unwrap();
assert_eq!(r.lang, "fr");
assert_eq!(
r.voice.as_deref(),
Some("5a271406-039d-46fe-835b-fbbb00eaf08d")
);
}
#[test]
fn resolve_request_mistral_auto_english_picks_paul() {
let cfg = config_mistral(mistral_cfg(None, None));
let opts = SpeakOpts::default();
let r = resolve_request(
&cfg,
SynthesisProvider::Mistral,
&opts,
"The quick brown fox jumps over the lazy dog.",
)
.unwrap();
assert_eq!(r.lang, "en");
assert_eq!(
r.voice.as_deref(),
Some("c69964a6-ab8b-4f8a-9465-ec0925096ec8")
);
}
#[test]
fn resolve_request_mistral_pin_mismatch_errors() {
let cfg = config_mistral(mistral_cfg(
Some("c69964a6-ab8b-4f8a-9465-ec0925096ec8"),
None,
));
let opts = SpeakOpts::default();
let err = resolve_request(
&cfg,
SynthesisProvider::Mistral,
&opts,
"Bonjour tout le monde, je suis ravi de vous voir",
)
.expect_err("mismatch must error");
let msg = err.to_string();
assert!(msg.contains("Paul - Neutral"), "msg: {msg}");
assert!(msg.contains("en_us"), "msg: {msg}");
assert!(msg.contains("'fr'"), "msg: {msg}");
assert!(msg.contains("--force"), "msg: {msg}");
}
#[test]
fn resolve_request_mistral_pin_mismatch_force_proceeds() {
let cfg = config_mistral(mistral_cfg(
Some("c69964a6-ab8b-4f8a-9465-ec0925096ec8"),
None,
));
let opts = SpeakOpts {
force: true,
..Default::default()
};
let r = resolve_request(
&cfg,
SynthesisProvider::Mistral,
&opts,
"Bonjour tout le monde, je suis ravi de vous voir",
)
.expect("--force bypasses the guard");
assert_eq!(
r.voice.as_deref(),
Some("c69964a6-ab8b-4f8a-9465-ec0925096ec8")
);
}
#[test]
fn resolve_request_mistral_custom_voice_skips_guard() {
let cfg = config_mistral(mistral_cfg(
Some("00000000-0000-0000-0000-000000000000"),
None,
));
let opts = SpeakOpts::default();
let r = resolve_request(
&cfg,
SynthesisProvider::Mistral,
&opts,
"Bonjour tout le monde, je suis ravi de vous voir",
)
.expect("custom voice skips guard");
assert_eq!(
r.voice.as_deref(),
Some("00000000-0000-0000-0000-000000000000")
);
}
#[cfg(feature = "kokoro")]
#[test]
fn resolve_request_kokoro_pin_mismatch_errors() {
let cfg = config_kokoro(None, Some("af_heart"));
let opts = SpeakOpts {
lang: Some("fr".to_string()),
..Default::default()
};
let err = resolve_request(&cfg, SynthesisProvider::Kokoro, &opts, "anything")
.expect_err("prefix mismatch must error");
let msg = err.to_string();
assert!(msg.contains("af_heart"), "msg: {msg}");
assert!(msg.contains("--force"), "msg: {msg}");
}
#[cfg(feature = "kokoro")]
#[test]
fn resolve_request_kokoro_auto_french_selects_default_voice() {
let cfg = config_kokoro(None, None);
let opts = SpeakOpts::default();
let r = resolve_request(
&cfg,
SynthesisProvider::Kokoro,
&opts,
"Bonjour tout le monde, je suis ravi de vous voir",
)
.unwrap();
assert_eq!(r.lang, "fr");
assert!(r.voice.is_none());
}
#[cfg(feature = "kokoro")]
#[test]
fn resolve_request_kokoro_pin_match_ok() {
let cfg = config_kokoro(None, Some("ff_siwis"));
let opts = SpeakOpts {
lang: Some("fr".to_string()),
..Default::default()
};
let r = resolve_request(&cfg, SynthesisProvider::Kokoro, &opts, "anything").unwrap();
assert_eq!(r.lang, "fr");
assert_eq!(r.voice.as_deref(), Some("ff_siwis"));
}
#[test]
fn resolve_text_positional_wins() {
let t = resolve_text(&Some("hello".to_string()), &None).unwrap();
assert_eq!(t, "hello");
}
#[test]
fn resolve_text_trims_positional() {
let t = resolve_text(&Some(" hi \n".to_string()), &None).unwrap();
assert_eq!(t, "hi");
}
#[test]
fn resolve_text_empty_positional_errors() {
let err = resolve_text(&Some(" ".to_string()), &None).expect_err("empty");
assert!(err.to_string().contains("empty"));
}
#[test]
fn resolve_text_from_file() {
let tmp = tempfile::TempDir::new().unwrap();
let p = tmp.path().join("t.txt");
std::fs::write(&p, "from file\n").unwrap();
let t = resolve_text(&None, &Some(p)).unwrap();
assert_eq!(t, "from file");
}
#[test]
fn resolve_text_empty_file_errors() {
let tmp = tempfile::TempDir::new().unwrap();
let p = tmp.path().join("t.txt");
std::fs::write(&p, " \n").unwrap();
let err = resolve_text(&None, &Some(p)).expect_err("empty file");
assert!(err.to_string().contains("empty"));
}
#[test]
fn resolve_text_missing_file_errors() {
let err = resolve_text(&None, &Some(PathBuf::from("/nonexistent/xyz.txt")))
.expect_err("missing file");
assert!(err.to_string().contains("failed to read"));
}
#[test]
fn save_wav_roundtrips_via_parser() {
let tmp = tempfile::TempDir::new().unwrap();
let p = tmp.path().join("out.wav");
let pcm = vec![0i16, 1000, -1000, 32767, -32768];
save_wav(&p, &pcm, 24_000).unwrap();
let bytes = std::fs::read(&p).unwrap();
let (parsed, rate) =
crate::synthesis::mistral::parse_wav_pcm_i16(&bytes).expect("valid WAV");
assert_eq!(rate, 24_000);
assert_eq!(parsed, pcm);
}
#[test]
fn save_wav_creates_parent_dirs() {
let tmp = tempfile::TempDir::new().unwrap();
let p = tmp.path().join("nested/dir/out.wav");
save_wav(&p, &[1i16, 2, 3], 24_000).unwrap();
assert!(p.exists());
}
}