Skip to main content

qts/
mode.rs

1use crate::{Qwen3TtsError, SynthesizeRequest, VoiceClonePromptV2};
2
3/// Qwen3-TTS checkpoint family. Modes require corresponding model weights.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum QtsMode {
6    #[default]
7    Base,
8    CustomVoice,
9    VoiceDesign,
10}
11
12impl QtsMode {
13    pub fn as_str(self) -> &'static str {
14        match self {
15            Self::Base => "base",
16            Self::CustomVoice => "customvoice",
17            Self::VoiceDesign => "voicedesign",
18        }
19    }
20}
21
22/// Names only; codec ids are resolved from each checkpoint's GGUF speaker table.
23#[allow(non_camel_case_types)]
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum PresetSpeaker {
26    Vivian,
27    Serena,
28    Uncle_Fu,
29    Dylan,
30    Eric,
31    Ryan,
32    Aiden,
33    Ono_Anna,
34    Sohee,
35}
36
37impl PresetSpeaker {
38    pub const ALL: [Self; 9] = [
39        Self::Vivian,
40        Self::Serena,
41        Self::Uncle_Fu,
42        Self::Dylan,
43        Self::Eric,
44        Self::Ryan,
45        Self::Aiden,
46        Self::Ono_Anna,
47        Self::Sohee,
48    ];
49
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Vivian => "vivian",
53            Self::Serena => "serena",
54            Self::Uncle_Fu => "uncle_fu",
55            Self::Dylan => "dylan",
56            Self::Eric => "eric",
57            Self::Ryan => "ryan",
58            Self::Aiden => "aiden",
59            Self::Ono_Anna => "ono_anna",
60            Self::Sohee => "sohee",
61        }
62    }
63}
64
65/// Additive mode API: existing `SynthesizeRequest` literals remain compatible.
66#[derive(Debug, Clone, Default)]
67pub struct ModeSynthesizeRequest<'a> {
68    pub synthesis: SynthesizeRequest,
69    pub mode: QtsMode,
70    pub instruct: Option<String>,
71    pub speaker: Option<PresetSpeaker>,
72    pub voice_clone_prompt: Option<&'a VoiceClonePromptV2>,
73    /// Optional Base embedding override; never used for VoiceDesign or presets.
74    pub speaker_embedding: Option<&'a [f32]>,
75}
76
77impl ModeSynthesizeRequest<'_> {
78    pub fn validate(&self) -> Result<(), Qwen3TtsError> {
79        let error = |message: &str| Err(Qwen3TtsError::InvalidInput(message.into()));
80        match self.mode {
81            QtsMode::Base => {
82                if self.voice_clone_prompt.is_none() {
83                    return error("Base needs a voice-clone prompt");
84                }
85                if self.speaker.is_some() || self.instruct.is_some() {
86                    return error("Base does not accept preset speaker or instruct");
87                }
88            }
89            QtsMode::CustomVoice => {
90                if self.speaker.is_none() {
91                    return error("CustomVoice needs a preset speaker");
92                }
93                if self.voice_clone_prompt.is_some() || self.speaker_embedding.is_some() {
94                    return error(
95                        "CustomVoice does not accept voice-clone prompt or speaker embedding",
96                    );
97                }
98            }
99            QtsMode::VoiceDesign => {
100                if self
101                    .instruct
102                    .as_ref()
103                    .is_none_or(|text| text.trim().is_empty())
104                {
105                    return error("VoiceDesign needs nonempty instruct");
106                }
107                if self.speaker.is_some()
108                    || self.voice_clone_prompt.is_some()
109                    || self.speaker_embedding.is_some()
110                {
111                    return error("VoiceDesign does not accept speaker, speaker embedding or voice-clone prompt");
112                }
113            }
114        }
115        if let Some(prompt) = self.voice_clone_prompt {
116            prompt.validate()?;
117        }
118        Ok(())
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::TensorF32;
126
127    fn clone_prompt() -> VoiceClonePromptV2 {
128        VoiceClonePromptV2 {
129            schema_version: 2,
130            x_vector_only_mode: true,
131            ref_spk_embedding: Some(TensorF32 {
132                shape: vec![4],
133                values: vec![0.0; 4],
134            }),
135            ..Default::default()
136        }
137    }
138
139    #[test]
140    fn valid_mode_combinations() {
141        let prompt = clone_prompt();
142        ModeSynthesizeRequest {
143            voice_clone_prompt: Some(&prompt),
144            speaker_embedding: Some(&[1.0; 4]),
145            ..Default::default()
146        }
147        .validate()
148        .unwrap();
149        ModeSynthesizeRequest {
150            mode: QtsMode::CustomVoice,
151            speaker: Some(PresetSpeaker::Vivian),
152            instruct: Some("hi".into()),
153            ..Default::default()
154        }
155        .validate()
156        .unwrap();
157        ModeSynthesizeRequest {
158            mode: QtsMode::VoiceDesign,
159            instruct: Some("hi".into()),
160            ..Default::default()
161        }
162        .validate()
163        .unwrap();
164    }
165
166    #[test]
167    fn missing_required_mode_inputs() {
168        for (mode, expected) in [
169            (QtsMode::Base, "Base needs a voice-clone prompt"),
170            (QtsMode::CustomVoice, "CustomVoice needs a preset speaker"),
171            (QtsMode::VoiceDesign, "VoiceDesign needs nonempty instruct"),
172        ] {
173            let error = ModeSynthesizeRequest {
174                mode,
175                ..Default::default()
176            }
177            .validate()
178            .unwrap_err();
179            assert!(error.to_string().contains(expected));
180        }
181        assert!(ModeSynthesizeRequest {
182            mode: QtsMode::VoiceDesign,
183            instruct: Some("  ".into()),
184            ..Default::default()
185        }
186        .validate()
187        .is_err());
188    }
189
190    #[test]
191    fn incompatible_conditioning_is_rejected() {
192        let prompt = clone_prompt();
193        for req in [
194            ModeSynthesizeRequest {
195                voice_clone_prompt: Some(&prompt),
196                speaker: Some(PresetSpeaker::Ryan),
197                ..Default::default()
198            },
199            ModeSynthesizeRequest {
200                voice_clone_prompt: Some(&prompt),
201                instruct: Some("hi".into()),
202                ..Default::default()
203            },
204            ModeSynthesizeRequest {
205                mode: QtsMode::CustomVoice,
206                speaker: Some(PresetSpeaker::Ryan),
207                voice_clone_prompt: Some(&prompt),
208                ..Default::default()
209            },
210            ModeSynthesizeRequest {
211                mode: QtsMode::CustomVoice,
212                speaker: Some(PresetSpeaker::Ryan),
213                speaker_embedding: Some(&[0.0; 4]),
214                ..Default::default()
215            },
216            ModeSynthesizeRequest {
217                mode: QtsMode::VoiceDesign,
218                instruct: Some("hi".into()),
219                speaker: Some(PresetSpeaker::Ryan),
220                ..Default::default()
221            },
222            ModeSynthesizeRequest {
223                mode: QtsMode::VoiceDesign,
224                instruct: Some("hi".into()),
225                speaker_embedding: Some(&[0.0; 4]),
226                ..Default::default()
227            },
228            ModeSynthesizeRequest {
229                mode: QtsMode::VoiceDesign,
230                instruct: Some("hi".into()),
231                voice_clone_prompt: Some(&prompt),
232                ..Default::default()
233            },
234        ] {
235            assert!(req.validate().is_err());
236        }
237    }
238}