zai_rs/model/text_to_audio/
request.rs1use serde::Serialize;
2use validator::Validate;
3
4use super::super::traits::*;
5
6#[derive(Clone, Serialize, Validate)]
8#[validate(schema(function = "validate_tts_body"))]
9pub struct TextToAudioBody<N>
10where
11 N: TextToAudio,
12{
13 pub(super) model: N,
15
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub(super) input: Option<String>,
19
20 pub(super) voice: Voice,
22
23 #[serde(skip_serializing_if = "Option::is_none")]
25 #[validate(range(min = 0.5, max = 2.0))]
26 pub(super) speed: Option<f32>,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
31 #[validate(range(min = 0.0, max = 10.0))]
32 pub(super) volume: Option<f32>,
33
34 pub(super) response_format: TtsAudioFormat,
36
37 pub(super) stream: bool,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub(super) encode_format: Option<TtsEncodeFormat>,
43
44 #[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 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 pub fn model(&self) -> &N {
142 &self.model
143 }
144
145 pub fn input(&self) -> Option<&str> {
147 self.input.as_deref()
148 }
149
150 pub fn voice(&self) -> &Voice {
152 &self.voice
153 }
154
155 pub fn speed(&self) -> Option<f32> {
157 self.speed
158 }
159
160 pub fn volume(&self) -> Option<f32> {
162 self.volume
163 }
164
165 pub fn response_format(&self) -> TtsAudioFormat {
167 self.response_format
168 }
169
170 pub fn is_streaming(&self) -> bool {
172 self.stream
173 }
174
175 pub fn encode_format(&self) -> Option<TtsEncodeFormat> {
177 self.encode_format
178 }
179
180 pub fn watermark_enabled(&self) -> Option<bool> {
182 self.watermark_enabled
183 }
184
185 pub fn with_input(mut self, input: impl Into<String>) -> Self {
187 self.input = Some(input.into());
188 self
189 }
190
191 pub fn with_voice(mut self, voice: Voice) -> Self {
193 self.voice = voice;
194 self
195 }
196
197 pub fn with_speed(mut self, speed: f32) -> Self {
199 self.speed = Some(speed);
200 self
201 }
202
203 pub fn with_volume(mut self, volume: f32) -> Self {
205 self.volume = Some(volume);
206 self
207 }
208
209 pub fn with_response_format(mut self, fmt: TtsAudioFormat) -> Self {
211 self.response_format = fmt;
212 self
213 }
214
215 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#[derive(Clone, PartialEq, Eq)]
240pub enum Voice {
241 Tongtong,
243 Chuichui,
245 Xiaochen,
247 Jam,
249 Kazi,
251 Douji,
253 Luodo,
255 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
319#[serde(rename_all = "lowercase")]
320pub enum TtsAudioFormat {
321 Wav,
323 Pcm,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
329#[serde(rename_all = "lowercase")]
330pub enum TtsEncodeFormat {
331 Base64,
333 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}