zai_rs/model/text_to_audio/
data.rs1use std::{
2 marker::PhantomData,
3 pin::Pin,
4 task::{Context, Poll},
5};
6
7use base64::Engine as _;
8use futures_util::{Stream, StreamExt};
9use validator::Validate;
10
11use super::{
12 super::traits::{StreamOff, StreamOn, StreamState, TextToAudio},
13 request::{TextToAudioBody, TtsAudioFormat, TtsEncodeFormat, Voice},
14};
15use crate::{
16 ZaiError, ZaiResult,
17 client::{ZaiClient, error::codes},
18};
19
20pub struct TextToAudioStream {
22 inner: crate::model::sse_parser::DecodedSseStream<bytes::Bytes>,
23}
24
25impl TextToAudioStream {
26 pub async fn next(&mut self) -> Option<ZaiResult<bytes::Bytes>> {
30 self.inner.next().await
31 }
32}
33
34impl Stream for TextToAudioStream {
35 type Item = ZaiResult<bytes::Bytes>;
36
37 fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
38 self.inner.as_mut().poll_next(context)
39 }
40}
41
42pub struct TextToAudioRequest<N, S = StreamOff>
48where
49 N: TextToAudio,
50 S: StreamState,
51{
52 body: TextToAudioBody<N>,
53 _stream: PhantomData<S>,
54}
55
56impl<N> TextToAudioRequest<N, StreamOff>
57where
58 N: TextToAudio,
59{
60 pub fn new(model: N) -> Self {
62 Self {
63 body: TextToAudioBody::new(model),
64 _stream: PhantomData,
65 }
66 }
67
68 pub fn with_response_format(mut self, format: TtsAudioFormat) -> Self {
70 self.body = self.body.with_response_format(format);
71 self
72 }
73
74 pub fn enable_stream(self) -> TextToAudioRequest<N, StreamOn> {
76 TextToAudioRequest {
77 body: self.body.with_stream(true),
78 _stream: PhantomData,
79 }
80 }
81
82 pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<bytes::Bytes> {
84 self.validate()?;
85 let route = crate::client::routes::AUDIO_SYNTHESIZE;
86 let url = client.endpoints().resolve_route(route, &[])?;
87 client
88 .send_json_bytes(route.method(), url, &self.body)
89 .await
90 }
91}
92
93impl<N> TextToAudioRequest<N, StreamOn>
94where
95 N: TextToAudio,
96{
97 pub fn with_encode_format(mut self, format: TtsEncodeFormat) -> Self {
99 self.body = self.body.with_encode_format(format);
100 self
101 }
102
103 pub fn disable_stream(self) -> TextToAudioRequest<N, StreamOff> {
105 TextToAudioRequest {
106 body: self.body.with_stream(false),
107 _stream: PhantomData,
108 }
109 }
110
111 pub async fn stream_via(&self, client: &ZaiClient) -> ZaiResult<TextToAudioStream> {
117 self.validate()?;
118 let Some(encoding) = self.body.encode_format else {
119 return Err(ZaiError::ApiError {
120 code: codes::SDK_VALIDATION,
121 message: "streaming TTS requires encode_format".to_string(),
122 });
123 };
124 let route = crate::client::routes::AUDIO_SYNTHESIZE;
125 let url = client.endpoints().resolve_route(route, &[])?;
126 let raw = client
127 .send_sse_json(route.method(), url, &self.body)
128 .await?;
129 let inner = crate::model::sse_parser::decode_required_done_stream(raw, move |payload| {
130 decode_audio_payload(payload, encoding)
131 });
132 Ok(TextToAudioStream { inner })
133 }
134}
135
136impl<N, S> TextToAudioRequest<N, S>
137where
138 N: TextToAudio,
139 S: StreamState,
140{
141 pub fn body(&self) -> &TextToAudioBody<N> {
144 &self.body
145 }
146
147 pub fn with_input(mut self, input: impl Into<String>) -> Self {
149 self.body = self.body.with_input(input);
150 self
151 }
152
153 pub fn with_voice(mut self, voice: Voice) -> Self {
155 self.body = self.body.with_voice(voice);
156 self
157 }
158
159 pub fn with_speed(mut self, speed: f32) -> Self {
161 self.body = self.body.with_speed(speed);
162 self
163 }
164
165 pub fn with_volume(mut self, volume: f32) -> Self {
167 self.body = self.body.with_volume(volume);
168 self
169 }
170
171 pub fn with_watermark_enabled(mut self, enabled: bool) -> Self {
173 self.body = self.body.with_watermark_enabled(enabled);
174 self
175 }
176
177 pub fn validate(&self) -> ZaiResult<()> {
179 self.body.validate().map_err(ZaiError::from)
180 }
181}
182
183fn decode_audio_payload(payload: &[u8], encoding: TtsEncodeFormat) -> ZaiResult<bytes::Bytes> {
184 let encoded = std::str::from_utf8(payload)
188 .map_err(|_| stream_protocol_error("TTS SSE audio payload must be UTF-8 encoded text"))?;
189 if encoded.is_empty() {
190 return Err(stream_protocol_error(
191 "TTS SSE audio payload must not be empty",
192 ));
193 }
194 let decoded = match encoding {
195 TtsEncodeFormat::Base64 => base64::engine::general_purpose::STANDARD
196 .decode(encoded)
197 .map_err(|_| stream_protocol_error("invalid base64 TTS SSE audio payload"))?,
198 TtsEncodeFormat::Hex => decode_hex(encoded)?,
199 };
200 Ok(bytes::Bytes::from(decoded))
201}
202
203fn decode_hex(encoded: &str) -> ZaiResult<Vec<u8>> {
204 if !encoded.len().is_multiple_of(2) {
205 return Err(stream_protocol_error(
206 "hex TTS SSE audio payload must have an even length",
207 ));
208 }
209 encoded
210 .as_bytes()
211 .chunks_exact(2)
212 .map(|pair| {
213 let high = hex_nibble(pair[0])?;
214 let low = hex_nibble(pair[1])?;
215 Ok((high << 4) | low)
216 })
217 .collect()
218}
219
220fn hex_nibble(byte: u8) -> ZaiResult<u8> {
221 match byte {
222 b'0'..=b'9' => Ok(byte - b'0'),
223 b'a'..=b'f' => Ok(byte - b'a' + 10),
224 b'A'..=b'F' => Ok(byte - b'A' + 10),
225 _ => Err(stream_protocol_error(
226 "invalid hexadecimal TTS SSE audio payload",
227 )),
228 }
229}
230
231fn stream_protocol_error(message: impl Into<String>) -> ZaiError {
232 ZaiError::ApiError {
233 code: codes::SDK_IO,
234 message: message.into(),
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::model::text_to_audio::GlmTts;
242
243 #[test]
244 fn streaming_type_state_forces_pcm_and_scopes_encoding() {
245 let request = TextToAudioRequest::new(GlmTts {})
246 .with_input("hello")
247 .with_response_format(TtsAudioFormat::Wav)
248 .enable_stream();
249 assert!(request.body().is_streaming());
250 assert_eq!(request.body().response_format(), TtsAudioFormat::Pcm);
251 assert_eq!(
252 request.body().encode_format(),
253 Some(TtsEncodeFormat::Base64)
254 );
255
256 let request = request
257 .with_encode_format(TtsEncodeFormat::Hex)
258 .disable_stream();
259 assert!(!request.body().is_streaming());
260 assert_eq!(request.body().response_format(), TtsAudioFormat::Pcm);
261 assert_eq!(request.body().encode_format(), None);
262 }
263
264 #[test]
265 fn base64_and_hex_payloads_decode_to_bytes() {
266 assert_eq!(
267 decode_audio_payload(b"AAEC/w==", TtsEncodeFormat::Base64).unwrap(),
268 bytes::Bytes::from_static(&[0, 1, 2, 255])
269 );
270 assert_eq!(
271 decode_audio_payload(b"000102fF", TtsEncodeFormat::Hex).unwrap(),
272 bytes::Bytes::from_static(&[0, 1, 2, 255])
273 );
274 assert!(decode_audio_payload(b"%%%", TtsEncodeFormat::Base64).is_err());
275 assert!(decode_audio_payload(br#""AAEC/w==""#, TtsEncodeFormat::Base64).is_err());
276 assert!(decode_audio_payload(b" AAEC/w==", TtsEncodeFormat::Base64).is_err());
277 assert!(decode_audio_payload(b"123", TtsEncodeFormat::Hex).is_err());
278 assert!(decode_audio_payload(b"12xz", TtsEncodeFormat::Hex).is_err());
279 }
280}