Skip to main content

zai_rs/model/text_to_audio/
request.rs

1use serde::Serialize;
2use validator::Validate;
3
4use super::super::traits::*;
5
6/// Request body for text-to-speech synthesis.
7#[derive(Clone, Serialize, Validate)]
8#[validate(schema(function = "validate_tts_body"))]
9pub struct TextToAudioBody<N>
10where
11    N: TextToAudio,
12{
13    /// TTS model (for example, `glm-tts`).
14    pub(super) model: N,
15
16    /// Text to convert to speech (at most 1,024 Unicode scalar values).
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub(super) input: Option<String>,
19
20    /// Built-in preset or cloned voice identifier.
21    pub(super) voice: Voice,
22
23    /// Speed in [0.5, 2]
24    #[serde(skip_serializing_if = "Option::is_none")]
25    #[validate(range(min = 0.5, max = 2.0))]
26    pub(super) speed: Option<f32>,
27
28    /// Volume in `(0, 10]`. A schema-level validator enforces the strict lower
29    /// bound and rejects non-finite values.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    #[validate(range(min = 0.0, max = 10.0))]
32    pub(super) volume: Option<f32>,
33
34    /// Output audio format
35    pub(super) response_format: TtsAudioFormat,
36
37    /// Whether the endpoint must return SSE audio chunks.
38    pub(super) stream: bool,
39
40    /// Encoding used inside SSE `data:` payloads. Valid only for streaming.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub(super) encode_format: Option<TtsEncodeFormat>,
43
44    /// Watermark toggle
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub(super) watermark_enabled: Option<bool>,
47}
48
49impl<N> std::fmt::Debug for TextToAudioBody<N>
50where
51    N: TextToAudio + std::fmt::Debug,
52{
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        let voice = match &self.voice {
55            Voice::Tongtong => "tongtong",
56            Voice::Chuichui => "chuichui",
57            Voice::Xiaochen => "xiaochen",
58            Voice::Jam => "jam",
59            Voice::Kazi => "kazi",
60            Voice::Douji => "douji",
61            Voice::Luodo => "luodo",
62            Voice::Cloned(_) => "[CLONED]",
63        };
64        formatter
65            .debug_struct("TextToAudioBody")
66            .field("model", &self.model)
67            .field("input_configured", &self.input.is_some())
68            .field("voice", &voice)
69            .field("speed_configured", &self.speed.is_some())
70            .field("volume_configured", &self.volume.is_some())
71            .field("response_format", &self.response_format)
72            .field("stream", &self.stream)
73            .field("encode_format", &self.encode_format)
74            .field("watermark_enabled", &self.watermark_enabled)
75            .finish()
76    }
77}
78
79fn validate_tts_body<N>(body: &TextToAudioBody<N>) -> Result<(), validator::ValidationError>
80where
81    N: TextToAudio,
82{
83    let Some(input) = body.input.as_deref() else {
84        return Err(validator::ValidationError::new("input_required"));
85    };
86    let input_len = input.chars().count();
87    if input.trim().is_empty() || !(1..=1024).contains(&input_len) {
88        return Err(validator::ValidationError::new("input_length"));
89    }
90    if !body.voice.is_valid() {
91        return Err(validator::ValidationError::new("voice_required"));
92    }
93    if body.speed.is_some_and(|speed| !speed.is_finite()) {
94        return Err(validator::ValidationError::new("speed_must_be_finite"));
95    }
96    if body
97        .volume
98        .is_some_and(|volume| !volume.is_finite() || volume <= 0.0)
99    {
100        return Err(validator::ValidationError::new("volume_must_be_positive"));
101    }
102    if body.stream {
103        if body.response_format != TtsAudioFormat::Pcm {
104            return Err(validator::ValidationError::new("stream_requires_pcm"));
105        }
106        if body.encode_format.is_none() {
107            return Err(validator::ValidationError::new(
108                "stream_requires_encode_format",
109            ));
110        }
111    } else if body.encode_format.is_some() {
112        return Err(validator::ValidationError::new(
113            "encode_format_is_stream_only",
114        ));
115    }
116    Ok(())
117}
118
119impl<N> TextToAudioBody<N>
120where
121    N: TextToAudio,
122{
123    /// Create a new TTS request body for the given model.
124    ///
125    /// The built-in `tongtong` voice and documented PCM format are selected.
126    pub fn new(model: N) -> Self {
127        Self {
128            model,
129            input: None,
130            voice: Voice::Tongtong,
131            speed: None,
132            volume: None,
133            response_format: TtsAudioFormat::Pcm,
134            stream: false,
135            encode_format: None,
136            watermark_enabled: None,
137        }
138    }
139
140    /// Borrow the selected model marker.
141    pub fn model(&self) -> &N {
142        &self.model
143    }
144
145    /// Borrow the required input text, when configured.
146    pub fn input(&self) -> Option<&str> {
147        self.input.as_deref()
148    }
149
150    /// Borrow the selected voice.
151    pub fn voice(&self) -> &Voice {
152        &self.voice
153    }
154
155    /// Configured speed, or `None` to use the service default.
156    pub fn speed(&self) -> Option<f32> {
157        self.speed
158    }
159
160    /// Configured volume, or `None` to use the service default.
161    pub fn volume(&self) -> Option<f32> {
162        self.volume
163    }
164
165    /// Selected output format.
166    pub fn response_format(&self) -> TtsAudioFormat {
167        self.response_format
168    }
169
170    /// Whether this body requests SSE audio chunks.
171    pub fn is_streaming(&self) -> bool {
172        self.stream
173    }
174
175    /// Streaming payload encoding, when streaming is enabled.
176    pub fn encode_format(&self) -> Option<TtsEncodeFormat> {
177        self.encode_format
178    }
179
180    /// Optional watermark setting.
181    pub fn watermark_enabled(&self) -> Option<bool> {
182        self.watermark_enabled
183    }
184
185    /// Set the input text to synthesize.
186    pub fn with_input(mut self, input: impl Into<String>) -> Self {
187        self.input = Some(input.into());
188        self
189    }
190
191    /// Set the voice preset.
192    pub fn with_voice(mut self, voice: Voice) -> Self {
193        self.voice = voice;
194        self
195    }
196
197    /// Set the playback speed (`0.5`–`2.0`).
198    pub fn with_speed(mut self, speed: f32) -> Self {
199        self.speed = Some(speed);
200        self
201    }
202
203    /// Set the playback volume (greater than `0.0` and at most `10.0`).
204    pub fn with_volume(mut self, volume: f32) -> Self {
205        self.volume = Some(volume);
206        self
207    }
208
209    /// Set the output audio format.
210    pub fn with_response_format(mut self, fmt: TtsAudioFormat) -> Self {
211        self.response_format = fmt;
212        self
213    }
214
215    /// Enable/disable the audio watermark.
216    pub fn with_watermark_enabled(mut self, enabled: bool) -> Self {
217        self.watermark_enabled = Some(enabled);
218        self
219    }
220
221    pub(super) fn with_stream(mut self, stream: bool) -> Self {
222        self.stream = stream;
223        if stream {
224            self.response_format = TtsAudioFormat::Pcm;
225            self.encode_format.get_or_insert(TtsEncodeFormat::Base64);
226        } else {
227            self.encode_format = None;
228        }
229        self
230    }
231
232    pub(super) fn with_encode_format(mut self, format: TtsEncodeFormat) -> Self {
233        self.encode_format = Some(format);
234        self
235    }
236}
237
238/// A built-in TTS voice or an identifier returned by voice cloning.
239#[derive(Clone, PartialEq, Eq)]
240pub enum Voice {
241    /// Tongtong voice.
242    Tongtong,
243    /// Chuichui voice.
244    Chuichui,
245    /// Xiaochen voice.
246    Xiaochen,
247    /// Jam voice.
248    Jam,
249    /// Kazi voice.
250    Kazi,
251    /// Douji voice.
252    Douji,
253    /// Luodo voice.
254    Luodo,
255    /// Voice identifier returned by the voice-clone API.
256    Cloned(String),
257}
258
259impl std::fmt::Debug for Voice {
260    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            Self::Tongtong => formatter.write_str("Tongtong"),
263            Self::Chuichui => formatter.write_str("Chuichui"),
264            Self::Xiaochen => formatter.write_str("Xiaochen"),
265            Self::Jam => formatter.write_str("Jam"),
266            Self::Kazi => formatter.write_str("Kazi"),
267            Self::Douji => formatter.write_str("Douji"),
268            Self::Luodo => formatter.write_str("Luodo"),
269            Self::Cloned(_) => formatter.write_str("Cloned([REDACTED])"),
270        }
271    }
272}
273
274impl Voice {
275    /// Construct a cloned voice identifier, rejecting blank values.
276    pub fn cloned(id: impl Into<String>) -> crate::ZaiResult<Self> {
277        let id = id.into();
278        if id.trim().is_empty() || id.trim() != id {
279            return Err(crate::ZaiError::ApiError {
280                code: crate::client::error::codes::SDK_VALIDATION,
281                message: "cloned voice id must be non-blank without surrounding whitespace"
282                    .to_string(),
283            });
284        }
285        Ok(Self::Cloned(id))
286    }
287
288    /// Wire identifier used by the TTS endpoint.
289    pub fn as_str(&self) -> &str {
290        match self {
291            Self::Tongtong => "tongtong",
292            Self::Chuichui => "chuichui",
293            Self::Xiaochen => "xiaochen",
294            Self::Jam => "jam",
295            Self::Kazi => "kazi",
296            Self::Douji => "douji",
297            Self::Luodo => "luodo",
298            Self::Cloned(id) => id,
299        }
300    }
301
302    fn is_valid(&self) -> bool {
303        let id = self.as_str();
304        !id.is_empty() && id.trim() == id
305    }
306}
307
308impl Serialize for Voice {
309    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
310    where
311        S: serde::Serializer,
312    {
313        serializer.serialize_str(self.as_str())
314    }
315}
316
317/// Supported output audio formats for TTS.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
319#[serde(rename_all = "lowercase")]
320pub enum TtsAudioFormat {
321    /// WAV container.
322    Wav,
323    /// Headerless PCM audio (the SDK and service default).
324    Pcm,
325}
326
327/// Encoding used for audio bytes inside streaming SSE `data:` payloads.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
329#[serde(rename_all = "lowercase")]
330pub enum TtsEncodeFormat {
331    /// Standard padded base64.
332    Base64,
333    /// Lowercase or uppercase hexadecimal text.
334    Hex,
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::model::text_to_audio::GlmTts;
341
342    #[test]
343    fn validation_requires_non_blank_input() {
344        assert!(TextToAudioBody::new(GlmTts {}).validate().is_err());
345        assert!(
346            TextToAudioBody::new(GlmTts {})
347                .with_input("   ")
348                .validate()
349                .is_err()
350        );
351
352        let invalid = TextToAudioBody::new(GlmTts {})
353            .with_input("hello")
354            .with_voice(Voice::Cloned(String::new()));
355        assert!(invalid.validate().is_err());
356    }
357
358    #[test]
359    fn validation_rejects_zero_and_non_finite_controls() {
360        assert!(
361            TextToAudioBody::new(GlmTts {})
362                .with_input("hello")
363                .with_volume(0.0)
364                .validate()
365                .is_err()
366        );
367        assert!(
368            TextToAudioBody::new(GlmTts {})
369                .with_input("hello")
370                .with_speed(f32::NAN)
371                .validate()
372                .is_err()
373        );
374    }
375
376    #[test]
377    fn default_format_is_explicit_pcm_and_cloned_voice_is_a_plain_string() {
378        let default = TextToAudioBody::new(GlmTts {}).with_input("hello");
379        let json = serde_json::to_value(default).unwrap();
380        assert_eq!(json["voice"], "tongtong");
381        assert_eq!(json["response_format"], "pcm");
382        assert_eq!(json["stream"], false);
383        assert!(json.get("encode_format").is_none());
384
385        let cloned = Voice::cloned("voice-clone-123").unwrap();
386        let body = TextToAudioBody::new(GlmTts {})
387            .with_input("hello")
388            .with_voice(cloned);
389        let json = serde_json::to_value(body).unwrap();
390        assert_eq!(json["voice"], "voice-clone-123");
391        assert!(Voice::cloned("  ").is_err());
392        assert!(Voice::cloned(" voice-clone-123 ").is_err());
393
394        let invalid = TextToAudioBody::new(GlmTts {})
395            .with_input("hello")
396            .with_voice(Voice::Cloned(String::new()));
397        assert!(invalid.validate().is_err());
398    }
399
400    #[test]
401    fn streaming_body_forces_pcm_and_has_stream_only_encoding() {
402        let body = TextToAudioBody::new(GlmTts {})
403            .with_input("hello")
404            .with_response_format(TtsAudioFormat::Wav)
405            .with_stream(true)
406            .with_encode_format(TtsEncodeFormat::Hex);
407        assert!(body.validate().is_ok());
408        let json = serde_json::to_value(body).unwrap();
409        assert_eq!(json["stream"], true);
410        assert_eq!(json["response_format"], "pcm");
411        assert_eq!(json["encode_format"], "hex");
412    }
413
414    #[test]
415    fn input_limit_counts_unicode_scalar_values() {
416        assert!(
417            TextToAudioBody::new(GlmTts {})
418                .with_input("你".repeat(1024))
419                .validate()
420                .is_ok()
421        );
422        assert!(
423            TextToAudioBody::new(GlmTts {})
424                .with_input("你".repeat(1025))
425                .validate()
426                .is_err()
427        );
428    }
429
430    #[test]
431    fn debug_redacts_input_and_cloned_voice_id() {
432        let body = TextToAudioBody::new(GlmTts {})
433            .with_input("private speech")
434            .with_voice(Voice::cloned("private-voice-id").unwrap());
435        let debug = format!("{body:?}");
436        assert!(!debug.contains("private speech"));
437        assert!(!debug.contains("private-voice-id"));
438        assert!(debug.contains("input_configured: true"));
439        assert!(debug.contains("voice: \"[CLONED]\""));
440    }
441}