mod azure;
mod corpus;
mod elevenlabs;
mod google;
mod qwen;
#[cfg(feature = "qwen3-local")]
mod qwen3local;
mod registry;
pub use corpus::VoiceCorpus;
pub use registry::CloneRegistry;
use crate::types::{TtsError, TtsResult};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone, Default)]
pub struct VoiceIdentity {
pub name: String,
pub clips: Vec<AudioClip>,
pub language: Option<String>,
pub consent: Vec<ConsentRecording>,
}
#[derive(Debug, Clone)]
pub struct ConsentRecording {
pub engine: String,
pub pcm: Vec<u8>,
pub sample_rate: u32,
pub metadata: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsentSpec {
pub script: String,
pub locale: String,
pub metadata_keys: Vec<&'static str>,
}
impl VoiceIdentity {
#[must_use]
pub fn builder(name: &str) -> VoiceIdentityBuilder {
VoiceIdentityBuilder::new(name)
}
pub fn from_transcribed_pairs(
name: &str,
pairs: &[(std::path::PathBuf, &str)],
language: Option<&str>,
) -> TtsResult<Self> {
let mut clips = Vec::with_capacity(pairs.len());
for (path, transcript) in pairs {
let mut clip = AudioClip::from_audio_file(path)?;
clip.set_transcript(transcript);
clips.push(clip);
}
Ok(Self {
name: name.to_string(),
clips,
language: language.map(str::to_string),
consent: Vec::new(),
})
}
}
#[derive(Debug, Default)]
pub struct VoiceIdentityBuilder {
name: String,
language: Option<String>,
clips: Vec<AudioClip>,
consent: Vec<ConsentRecording>,
}
impl VoiceIdentityBuilder {
#[must_use]
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
..Self::default()
}
}
#[must_use]
pub fn language(mut self, language: &str) -> Self {
self.language = Some(language.to_string());
self
}
#[must_use]
pub fn add_clip(mut self, clip: AudioClip) -> Self {
self.clips.push(clip);
self
}
#[must_use]
pub fn start_clip(&mut self, name: &str, sample_rate: u32) -> AudioClip {
AudioClip {
name: name.to_string(),
pcm: Vec::new(),
sample_rate,
transcript: None,
}
}
pub fn finish_clip(&mut self, clip: AudioClip, transcript: Option<&str>) {
let mut clip = clip;
if let Some(t) = transcript {
clip.transcript = Some(t.to_string());
}
self.clips.push(clip);
}
pub fn add_consent(&mut self, consent: ConsentRecording) {
self.consent.push(consent);
}
#[must_use]
pub fn total_duration_secs(&self) -> u32 {
self.clips.iter().map(AudioClip::duration_secs).sum()
}
pub fn finish(self) -> TtsResult<VoiceIdentity> {
if self.clips.iter().all(|c| c.pcm.is_empty()) {
return Err(TtsError(
"voice identity needs at least one non-empty clip".into(),
));
}
Ok(VoiceIdentity {
name: self.name,
clips: self.clips,
language: self.language,
consent: self.consent,
})
}
}
#[derive(Debug, Clone)]
pub struct AudioClip {
pub name: String,
pub pcm: Vec<u8>,
pub sample_rate: u32,
pub transcript: Option<String>,
}
impl AudioClip {
#[must_use]
pub fn duration_secs(&self) -> u32 {
let bytes_per_sec = u64::from(self.sample_rate) * 2;
if bytes_per_sec == 0 {
return 0;
}
u32::try_from(self.pcm.len() as u64 / bytes_per_sec).unwrap_or(u32::MAX)
}
pub fn from_audio_file(path: impl AsRef<std::path::Path>) -> TtsResult<Self> {
let path = path.as_ref();
let bytes =
std::fs::read(path).map_err(|e| TtsError(format!("read {}: {e}", path.display())))?;
if bytes.len() as u64 > crate::cloning::corpus::MAX_IMPORT_FILE_BYTES {
return Err(TtsError(format!(
"{} is larger than the import cap",
path.display()
)));
}
let pcm = corpus::decode_to_canonical_pcm(&bytes)?;
Ok(Self {
name: path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("clip")
.to_string(),
pcm,
sample_rate: corpus::CORPUS_SAMPLE_RATE,
transcript: None,
})
}
#[must_use]
pub fn from_pcm(name: &str, pcm: Vec<u8>, sample_rate: u32, transcript: Option<&str>) -> Self {
Self {
name: name.to_string(),
pcm,
sample_rate,
transcript: transcript.map(str::to_string),
}
}
pub fn push_pcm(&mut self, chunk: &[u8], sample_rate: u32) -> TtsResult<()> {
if sample_rate != self.sample_rate {
return Err(TtsError(format!(
"stream chunk rate {sample_rate} != clip rate {}",
self.sample_rate
)));
}
if !chunk.len().is_multiple_of(2) {
return Err(TtsError(
"stream chunk splits a 16-bit sample (odd byte length)".into(),
));
}
self.pcm.extend_from_slice(chunk);
Ok(())
}
pub fn set_transcript(&mut self, transcript: &str) {
self.transcript = Some(transcript.to_string());
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CloneHandle {
pub engine: String,
pub voice_id: String,
pub model: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloningMode {
Instant,
Job,
ZeroShot,
}
#[derive(Debug, Clone)]
pub enum CloneOutcome {
Ready(CloneHandle),
Pending { engine: String, job_id: String },
}
pub trait VoiceCloning: Send + Sync {
fn engine_id(&self) -> &'static str;
fn cloning_mode(&self) -> CloningMode;
fn consent_spec(&self) -> Option<ConsentSpec> {
None
}
fn clone_voice(&self, identity: &VoiceIdentity) -> TtsResult<CloneOutcome>;
fn poll_clone(&self, _job_id: &str) -> TtsResult<CloneOutcome> {
Err(TtsError(format!(
"{} cloning never returns pending jobs",
self.engine_id()
)))
}
fn list_cloned(&self) -> TtsResult<Vec<CloneHandle>>;
fn delete_cloned(&self, handle: &CloneHandle) -> TtsResult<()>;
}
#[must_use]
pub fn create_cloner(engine_id: &str, credentials_json: &str) -> Option<Arc<dyn VoiceCloning>> {
let creds: std::collections::HashMap<String, String> = if credentials_json.is_empty() {
std::collections::HashMap::new()
} else {
serde_json::from_str(credentials_json).unwrap_or_default()
};
match engine_id {
#[cfg(feature = "cloning")]
"qwen" => Some(Arc::new(qwen::QwenCloner::new(&creds))),
#[cfg(feature = "cloning")]
"elevenlabs" => Some(Arc::new(elevenlabs::ElevenLabsCloner::new(&creds))),
#[cfg(feature = "cloning")]
"google" => Some(Arc::new(google::GoogleCloner::new(&creds))),
#[cfg(feature = "cloning")]
"azure" => Some(Arc::new(azure::AzureCloner::new(&creds))),
#[cfg(all(feature = "cloning", feature = "qwen3-local"))]
"qwen3-local" => match qwen3local::Qwen3LocalCloner::new(&creds) {
Ok(cloner) => Some(Arc::new(cloner)),
Err(e) => {
eprintln!("qwen3-local cloner failed to load: {e}");
None
}
},
_ => None,
}
}
#[must_use]
pub fn cloning_engines() -> Vec<&'static str> {
let mut ids = Vec::new();
#[cfg(feature = "cloning")]
{
ids.push("qwen");
ids.push("elevenlabs");
ids.push("google");
ids.push("azure");
#[cfg(feature = "qwen3-local")]
ids.push("qwen3-local");
}
ids
}
#[must_use]
pub(crate) fn select_clips(clips: &[AudioClip], target_secs: u32) -> Vec<&AudioClip> {
let mut sorted: Vec<&AudioClip> = clips.iter().collect();
sorted.sort_by_key(|c| std::cmp::Reverse(c.duration_secs()));
let mut out = Vec::new();
let mut total = 0u32;
for clip in sorted {
if !out.is_empty() && total >= target_secs {
break;
}
total += clip.duration_secs().max(1);
out.push(clip);
}
out
}
pub(crate) fn concat_clips(clips: &[&AudioClip], gap_ms: u32) -> TtsResult<(Vec<u8>, u32)> {
let Some(first) = clips.first() else {
return Err(TtsError("no clips to concatenate".into()));
};
let rate = first.sample_rate;
if clips.iter().any(|c| c.sample_rate != rate) {
return Err(TtsError(
"clips must share a sample rate to concatenate".into(),
));
}
let gap_bytes = (u64::from(rate) * 2 * u64::from(gap_ms) / 1000) as usize;
let mut pcm = Vec::new();
for (i, clip) in clips.iter().enumerate() {
if i > 0 {
pcm.resize(pcm.len() + gap_bytes, 0);
}
pcm.extend_from_slice(&clip.pcm);
}
Ok((pcm, rate))
}
#[must_use]
pub(crate) fn wav_bytes(pcm: &[u8], sample_rate: u32) -> Vec<u8> {
let mut wav = Vec::with_capacity(44 + pcm.len());
let data_len = pcm.len() as u32;
let byte_rate = sample_rate * 2;
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + data_len).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes()); wav.extend_from_slice(&1u16.to_le_bytes()); wav.extend_from_slice(&sample_rate.to_le_bytes());
wav.extend_from_slice(&byte_rate.to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes()); wav.extend_from_slice(&16u16.to_le_bytes()); wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_len.to_le_bytes());
wav.extend_from_slice(pcm);
wav
}
#[must_use]
pub fn default_registry_path() -> Option<PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()?;
Some(
PathBuf::from(home)
.join(".rust-tts-wrapper")
.join("clones.json"),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn clip(secs: u32) -> AudioClip {
AudioClip {
name: format!("c{secs}"),
pcm: vec![0; secs as usize * 48_000],
sample_rate: 24_000,
transcript: None,
}
}
#[test]
fn select_clips_prefers_longest_and_caps() {
let clips = vec![clip(4), clip(9), clip(6)];
let picked = select_clips(&clips, 10);
assert_eq!(picked.len(), 2);
assert_eq!(picked[0].name, "c9");
assert_eq!(picked[1].name, "c6");
let one = select_clips(&clips, 0);
assert_eq!(one.len(), 1);
}
#[test]
fn concat_clips_inserts_gap_bytes() {
let a = clip(1);
let b = clip(1);
let (pcm, rate) = concat_clips(&[&a, &b], 400).unwrap();
assert_eq!(rate, 24_000);
assert_eq!(pcm.len(), 48_000 * 2 + 19_200);
}
#[test]
fn concat_clips_rejects_mixed_rates() {
let mut a = clip(1);
a.sample_rate = 22_050;
let b = clip(1);
assert!(concat_clips(&[&a, &b], 400).is_err());
}
#[test]
fn builder_streams_clips_and_finishes() {
let mut b = VoiceIdentityBuilder::new("Dad").language("en");
let mut clip = b.start_clip("p1", 24_000);
clip.push_pcm(&vec![0u8; 48_000], 24_000).expect("push");
clip.push_pcm(&vec![0u8; 48_000], 24_000).expect("push2");
assert_eq!(clip.duration_secs(), 2);
clip.set_transcript("read this");
b.finish_clip(clip, None);
let identity = b.finish().expect("identity");
assert_eq!(identity.name, "Dad");
assert_eq!(identity.language.as_deref(), Some("en"));
assert_eq!(identity.clips.len(), 1);
assert_eq!(identity.clips[0].transcript.as_deref(), Some("read this"));
assert!(VoiceIdentityBuilder::new("x").finish().is_err());
}
#[test]
fn push_pcm_rejects_rate_change() {
let mut clip = AudioClip::from_pcm("c", Vec::new(), 24_000, None);
assert!(clip.push_pcm(&[0u8; 100], 22_050).is_err());
}
#[test]
fn from_transcribed_pairs_loads_files() {
let dir = tempfile::tempdir().unwrap();
let wav = wav_bytes(&vec![0u8; 4_800], 24_000);
let p1 = dir.path().join("a.wav");
let p2 = dir.path().join("b.wav");
std::fs::write(&p1, &wav).unwrap();
std::fs::write(&p2, &wav).unwrap();
let identity = VoiceIdentity::from_transcribed_pairs(
"corpus",
&[(p1.clone(), "first"), (p2.clone(), "second")],
Some("en"),
)
.expect("identity");
assert_eq!(identity.clips.len(), 2);
assert_eq!(identity.clips[0].transcript.as_deref(), Some("first"));
assert_eq!(identity.clips[0].sample_rate, 24_000);
}
#[test]
fn wav_bytes_has_well_formed_header() {
let wav = wav_bytes(&[0xAB, 0xCD], 24_000);
assert_eq!(&wav[..4], b"RIFF");
assert_eq!(&wav[8..12], b"WAVE");
assert_eq!(wav.len(), 44 + 2);
assert_eq!(u32::from_le_bytes(wav[24..28].try_into().unwrap()), 24_000);
}
}