1#![allow(non_local_definitions)]
2#![forbid(unsafe_code)]
3#![cfg_attr(
4 not(test),
5 deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)
6)]
7
8#[cfg(feature = "tashkeel")]
9use dengjen_tashkeel::{
10 create_inference_engine, do_tashkeel, DengjenTashkeelResult,
11 DynamicInferenceEngine as TashkeelInferenceEngine,
12};
13use dengjen_tts::{
14 default_batch_size, AudioOutputConfig, DengjenSpeechStreamBatched, DengjenSpeechStreamLazy,
15 DengjenSpeechStreamParallel, DengjenSpeechSynthesizer, RealtimeSpeechStream,
16};
17use dengjen_tts_core::{
18 Audio, AudioInfo, CancellationToken, DengjenError, DengjenModel, DengjenResult, SynthesisConfig,
19};
20#[cfg(feature = "tashkeel")]
21use once_cell::sync::Lazy;
22use pyo3::create_exception;
23use pyo3::exceptions::PyException;
24use pyo3::prelude::*;
25use pyo3::types::PyBytes;
26use std::collections::HashMap;
27use std::path::PathBuf;
28use std::sync::Arc;
29
30#[cfg(feature = "stt")]
31mod stt;
32
33#[cfg(feature = "tashkeel")]
34static LIBTASHKEEL_ENGINE: Lazy<DengjenTashkeelResult<TashkeelInferenceEngine>> =
35 Lazy::new(|| create_inference_engine(None));
36
37#[cfg(feature = "tashkeel")]
38fn should_diacritize(language: &str, use_tashkeel: Option<bool>) -> bool {
39 if language != "ar" {
40 return false;
41 }
42 use_tashkeel.unwrap_or(true)
43}
44
45#[cfg(not(feature = "tashkeel"))]
46#[cfg_attr(not(feature = "espeak"), allow(dead_code))]
47fn should_diacritize(_language: &str, _use_tashkeel: Option<bool>) -> bool {
48 false
49}
50
51type PyDengjenResult<T> = Result<T, PyDengjenError>;
52
53create_exception!(
54 pydengjen,
55 DengjenException,
56 PyException,
57 "Base Exception for all exceptions raised by pydengjen."
58);
59
60create_exception!(
61 pydengjen,
62 ResourceLoadError,
63 DengjenException,
64 "A model, config or other resource could not be loaded."
65);
66create_exception!(
67 pydengjen,
68 PhonemizationError,
69 DengjenException,
70 "Text could not be converted to phonemes."
71);
72create_exception!(
73 pydengjen,
74 InferenceError,
75 DengjenException,
76 "Model inference failed."
77);
78create_exception!(
79 pydengjen,
80 InvalidConfigurationError,
81 DengjenException,
82 "A voice config or an argument was invalid."
83);
84create_exception!(
85 pydengjen,
86 UnsupportedOperationError,
87 DengjenException,
88 "The loaded model does not support the requested operation."
89);
90
91#[derive(Debug)]
92struct PyDengjenError(DengjenError);
93
94impl From<DengjenError> for PyDengjenError {
95 fn from(err: DengjenError) -> Self {
96 PyDengjenError(err)
97 }
98}
99
100impl From<PyDengjenError> for PyErr {
101 fn from(py_err: PyDengjenError) -> Self {
102 let message = format!("{}{}", py_err.0, py_err.0.cause_chain());
103 match py_err.0.kind() {
104 DengjenError::FailedToLoadResource(_) => ResourceLoadError::new_err(message),
105 DengjenError::PhonemizationError(_) => PhonemizationError::new_err(message),
106 DengjenError::InferenceError(_) => InferenceError::new_err(message),
107 DengjenError::InvalidConfiguration(_) => InvalidConfigurationError::new_err(message),
108 DengjenError::UnsupportedOperation(_) => UnsupportedOperationError::new_err(message),
109 _ => DengjenException::new_err(message),
110 }
111 }
112}
113
114#[pyclass(weakref, module = "pydengjen", frozen)]
115#[pyo3(name = "AudioInfo")]
116struct PyWaveInfo(AudioInfo);
117
118#[pymethods]
119impl PyWaveInfo {
120 #[getter]
121 fn get_sample_rate(&self) -> usize {
122 self.0.sample_rate
123 }
124
125 #[getter]
126 fn get_num_channels(&self) -> usize {
127 self.0.num_channels
128 }
129
130 #[getter]
131 fn get_sample_width(&self) -> usize {
132 self.0.sample_width
133 }
134}
135
136impl From<AudioInfo> for PyWaveInfo {
137 fn from(info: AudioInfo) -> Self {
138 PyWaveInfo(info)
139 }
140}
141
142#[pyclass(weakref, module = "pydengjen", frozen, from_py_object)]
143#[pyo3(name = "AudioOutputConfig")]
144#[derive(Clone)]
145struct PyAudioOutputConfig(AudioOutputConfig);
146
147#[pymethods]
148impl PyAudioOutputConfig {
149 #[new]
150 #[pyo3(signature = (rate=None, volume=None, pitch=None, appended_silence_ms=None))]
151 fn new(
152 rate: Option<u8>,
153 volume: Option<u8>,
154 pitch: Option<u8>,
155 appended_silence_ms: Option<u32>,
156 ) -> Self {
157 PyAudioOutputConfig(AudioOutputConfig {
158 rate,
159 volume,
160 pitch,
161 appended_silence_ms,
162 })
163 }
164}
165
166impl From<PyAudioOutputConfig> for AudioOutputConfig {
167 fn from(config: PyAudioOutputConfig) -> Self {
168 config.0
169 }
170}
171
172#[pyclass(weakref, module = "pydengjen", frozen)]
173struct WaveSamples(Audio);
174
175#[pymethods]
176impl WaveSamples {
177 fn get_wave_bytes(&self, py: Python) -> Py<PyAny> {
178 let wav_data = py.detach(move || self.0.as_wave_bytes());
179 PyBytes::new(py, &wav_data).into()
180 }
181
182 fn save_to_file(&self, filename: &str) -> PyDengjenResult<()> {
183 self.0
184 .save_to_file(&PathBuf::from(filename))
185 .map_err(|e| PyDengjenError::from(DengjenError::from(e)))?;
186 Ok(())
187 }
188
189 #[getter]
190 fn sample_rate(&self) -> usize {
191 self.0.info.sample_rate
192 }
193
194 #[getter]
195 fn num_channels(&self) -> usize {
196 self.0.info.num_channels
197 }
198
199 #[getter]
200 fn sample_width(&self) -> usize {
201 self.0.info.sample_width
202 }
203
204 #[getter]
205 fn inference_ms(&self) -> Option<f32> {
206 self.0.inference_ms()
207 }
208
209 #[getter]
210 fn duration_ms(&self) -> f32 {
211 self.0.duration_ms()
212 }
213
214 #[getter]
215 fn real_time_factor(&self) -> Option<f32> {
216 self.0.real_time_factor()
217 }
218}
219
220fn next_detached<I, T>(py: Python<'_>, stream: &mut I) -> PyResult<Option<T>>
222where
223 I: Iterator<Item = DengjenResult<T>> + Send,
224 T: Send,
225{
226 py.detach(|| stream.next())
227 .transpose()
228 .map_err(|err| PyDengjenError::from(err).into())
229}
230
231#[pyclass(weakref, module = "pydengjen")]
232struct LazySpeechStream(DengjenSpeechStreamLazy);
233
234impl From<DengjenSpeechStreamLazy> for LazySpeechStream {
235 fn from(stream: DengjenSpeechStreamLazy) -> Self {
236 Self(stream)
237 }
238}
239
240#[pymethods]
241impl LazySpeechStream {
242 fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
243 slf
244 }
245
246 fn __next__(&mut self, py: Python) -> PyResult<Option<WaveSamples>> {
247 Ok(next_detached(py, &mut self.0)?.map(WaveSamples))
248 }
249}
250
251#[pyclass(weakref, module = "pydengjen")]
252struct ParallelSpeechStream(DengjenSpeechStreamParallel);
253
254impl From<DengjenSpeechStreamParallel> for ParallelSpeechStream {
255 fn from(stream: DengjenSpeechStreamParallel) -> Self {
256 Self(stream)
257 }
258}
259
260#[pymethods]
261impl ParallelSpeechStream {
262 fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
263 slf
264 }
265
266 fn __next__(&mut self, py: Python) -> PyResult<Option<WaveSamples>> {
267 Ok(next_detached(py, &mut self.0)?.map(WaveSamples))
268 }
269}
270
271#[pyclass(weakref, module = "pydengjen")]
272struct BatchedSpeechStream(DengjenSpeechStreamBatched);
273
274impl From<DengjenSpeechStreamBatched> for BatchedSpeechStream {
275 fn from(stream: DengjenSpeechStreamBatched) -> Self {
276 Self(stream)
277 }
278}
279
280#[pymethods]
281impl BatchedSpeechStream {
282 fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
283 slf
284 }
285
286 fn __next__(&mut self, py: Python) -> PyResult<Option<WaveSamples>> {
287 Ok(next_detached(py, &mut self.0)?.map(WaveSamples))
288 }
289}
290
291#[pyclass(weakref, module = "pydengjen")]
292struct PyRealtimeSpeechStream(RealtimeSpeechStream);
293
294#[pymethods]
295impl PyRealtimeSpeechStream {
296 fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
297 slf
298 }
299
300 fn __next__(&mut self, py: Python) -> PyResult<Option<Py<PyAny>>> {
301 Ok(next_detached(py, &mut self.0)?
302 .map(|samples| PyBytes::new(py, &samples.as_wave_bytes()).into()))
303 }
304}
305
306#[pyclass(weakref, module = "pydengjen")]
307struct PiperScales {
308 #[pyo3(get)]
309 length_scale: f32,
310 #[pyo3(get)]
311 noise_scale: f32,
312 #[pyo3(get)]
313 noise_w: f32,
314}
315
316#[pymethods]
317impl PiperScales {
318 #[new]
319 fn new(length_scale: f32, noise_scale: f32, noise_w: f32) -> Self {
320 PiperScales {
321 length_scale,
322 noise_scale,
323 noise_w,
324 }
325 }
326}
327
328#[cfg(test)]
329mod value_type_tests {
330 use super::*;
331 use dengjen_tts_core::AudioSamples;
332 use std::io::Read;
333
334 #[test]
335 fn py_wave_info_exposes_the_wrapped_audio_info_fields() {
336 let info = AudioInfo {
337 sample_rate: 22050,
338 num_channels: 1,
339 sample_width: 2,
340 };
341 let py_info = PyWaveInfo::from(info);
342 assert_eq!(py_info.get_sample_rate(), 22050);
343 assert_eq!(py_info.get_num_channels(), 1);
344 assert_eq!(py_info.get_sample_width(), 2);
345 }
346
347 #[test]
348 fn py_audio_output_config_new_stores_every_field() {
349 let config = PyAudioOutputConfig::new(Some(150), Some(80), Some(50), Some(200));
350 let inner: AudioOutputConfig = config.into();
351 assert_eq!(inner.rate, Some(150));
352 assert_eq!(inner.volume, Some(80));
353 assert_eq!(inner.pitch, Some(50));
354 assert_eq!(inner.appended_silence_ms, Some(200));
355 }
356
357 #[test]
358 fn py_audio_output_config_new_accepts_all_none() {
359 let config = PyAudioOutputConfig::new(None, None, None, None);
360 let inner: AudioOutputConfig = config.into();
361 assert_eq!(inner.rate, None);
362 assert_eq!(inner.volume, None);
363 assert_eq!(inner.pitch, None);
364 assert_eq!(inner.appended_silence_ms, None);
365 }
366
367 fn sample_wave_samples() -> WaveSamples {
368 WaveSamples(Audio::new(
369 AudioSamples::new(vec![0.0, 0.25, -0.25, 0.5]),
370 22050,
371 Some(3.5),
372 ))
373 }
374
375 #[test]
376 fn wave_samples_getters_reflect_the_wrapped_audio() {
377 let samples = sample_wave_samples();
378 assert_eq!(samples.sample_rate(), 22050);
379 assert_eq!(samples.num_channels(), 1);
380 assert_eq!(samples.sample_width(), 2);
381 assert_eq!(samples.inference_ms(), Some(3.5));
382 }
383
384 #[test]
385 fn wave_samples_save_to_file_writes_a_readable_wav() {
386 let samples = sample_wave_samples();
387 let dir = std::env::temp_dir().join(format!("dengjen-python-test-{}", std::process::id()));
388 std::fs::create_dir_all(&dir).unwrap();
389 let path = dir.join("wave_samples_save_to_file_writes_a_readable_wav.wav");
390 let path_str = path.to_str().unwrap();
391
392 samples.save_to_file(path_str).unwrap();
393
394 let mut file = std::fs::File::open(&path).unwrap();
395 let mut header = [0u8; 4];
396 file.read_exact(&mut header).unwrap();
397 assert_eq!(&header, b"RIFF");
398
399 std::fs::remove_file(&path).ok();
400 }
401
402 #[test]
403 fn wave_samples_save_to_file_reports_the_underlying_error() {
404 let samples = sample_wave_samples();
405 let result = samples.save_to_file("/nonexistent-directory-dengjen-test/out.wav");
406 assert!(result.is_err());
407 }
408
409 #[test]
410 fn piper_scales_new_stores_every_field() {
411 let scales = PiperScales::new(0.9, 0.5, 0.7);
412 assert_eq!(scales.length_scale, 0.9);
413 assert_eq!(scales.noise_scale, 0.5);
414 assert_eq!(scales.noise_w, 0.7);
415 }
416}
417
418fn load_voice(config_path: &std::path::Path) -> DengjenResult<Arc<dyn DengjenModel + Send + Sync>> {
419 dengjen_tts::load_voice_with(config_path, |model_type, path| match model_type {
420 dengjen_tts::ModelType::Piper => dengjen_tts_piper::from_config_path(path),
421 dengjen_tts::ModelType::Kokoro => dengjen_tts_kokoro::from_config_path(path),
422 dengjen_tts::ModelType::MeloTts => dengjen_tts_melotts::from_config_path(path),
423 other => Err(DengjenError::InvalidConfiguration(format!(
424 "unsupported model type `{other}`"
425 ))),
426 })
427}
428
429#[pyclass(weakref, module = "pydengjen")]
430#[pyo3(name = "PiperModel")]
431struct PiperModel(Arc<dyn DengjenModel + Send + Sync>);
432
433#[pymethods]
434impl PiperModel {
435 #[new]
436 fn new(py: Python<'_>, config_path: &str) -> PyDengjenResult<Self> {
437 let path = PathBuf::from(config_path);
438 let model = py.detach(|| load_voice(&path))?;
439 Ok(Self(model))
440 }
441
442 #[getter]
443 fn get_speaker(&self) -> PyDengjenResult<Option<String>> {
444 let Some(speaker_id) = self.current_speaker_id()? else {
445 return Ok(None);
446 };
447 Ok(self.0.speaker_id_to_name(&speaker_id)?)
448 }
449
450 #[setter]
451 fn set_speaker(&self, name: String) -> PyDengjenResult<()> {
452 let Some(speaker_id) = self.0.speaker_name_to_id(&name)? else {
453 return Err(PyDengjenError::from(DengjenError::OperationError(format!(
454 "no speaker named '{name}'"
455 ))));
456 };
457 let mut config = self.generic_config_or_err()?;
458 config.speaker = Some(speaker_id);
459 Ok(self.0.set_fallback_synthesis_config(&config)?)
460 }
461
462 fn get_scales(&self) -> PyDengjenResult<PiperScales> {
463 let config = self.piper_config_or_err()?;
464 Ok(PiperScales {
465 length_scale: config.length_scale,
466 noise_scale: config.noise_scale,
467 noise_w: config.noise_w,
468 })
469 }
470
471 fn set_scales(&self, length_scale: f32, noise_scale: f32, noise_w: f32) -> PyDengjenResult<()> {
472 let mut config = self.generic_config_or_err()?;
473 config.parameters.insert(
474 dengjen_tts_piper::synth_config::LENGTH_SCALE.to_string(),
475 length_scale,
476 );
477 config.parameters.insert(
478 dengjen_tts_piper::synth_config::NOISE_SCALE.to_string(),
479 noise_scale,
480 );
481 config.parameters.insert(
482 dengjen_tts_piper::synth_config::NOISE_W.to_string(),
483 noise_w,
484 );
485 Ok(self.0.set_fallback_synthesis_config(&config)?)
486 }
487
488 fn set_parameters(&self, parameters: HashMap<String, f32>) -> PyDengjenResult<()> {
489 let mut config = self.0.get_fallback_synthesis_config()?.unwrap_or_default();
490 config.parameters.extend(parameters);
491 Ok(self.0.set_fallback_synthesis_config(&config)?)
492 }
493
494 fn get_parameters(&self) -> PyDengjenResult<HashMap<String, f32>> {
495 Ok(self
496 .0
497 .get_fallback_synthesis_config()?
498 .unwrap_or_default()
499 .parameters)
500 }
501}
502
503impl PiperModel {
504 fn current_speaker_id(&self) -> PyDengjenResult<Option<i64>> {
505 Ok(self
506 .0
507 .get_fallback_synthesis_config()?
508 .and_then(|config| dengjen_tts_piper::PiperSynthesisConfig::from(&config).speaker))
509 }
510
511 fn piper_config_or_err(&self) -> PyDengjenResult<dengjen_tts_piper::PiperSynthesisConfig> {
512 self.generic_config_or_err()
513 .map(|config| dengjen_tts_piper::PiperSynthesisConfig::from(&config))
514 }
515
516 fn generic_config_or_err(&self) -> PyDengjenResult<SynthesisConfig> {
517 self.0.get_fallback_synthesis_config()?.ok_or_else(|| {
518 PyDengjenError::from(DengjenError::InvalidConfiguration(
519 "this model has no Piper synthesis config to read or update".to_string(),
520 ))
521 })
522 }
523}
524
525#[pyclass(weakref, module = "pydengjen", frozen)]
526struct Dengjen(Arc<DengjenSpeechSynthesizer>);
527
528#[pymethods]
529impl Dengjen {
530 #[staticmethod]
531 #[pyo3(name = "with_piper")]
532 fn py_with_piper(py: Python<'_>, vits_model: &PiperModel) -> PyDengjenResult<Self> {
533 py.detach(|| Self::with_piper(vits_model))
534 }
535
536 #[pyo3(name = "synthesize", signature = (text, audio_output_config=None))]
537 fn py_synthesize(
538 &self,
539 py: Python<'_>,
540 text: String,
541 audio_output_config: Option<PyAudioOutputConfig>,
542 ) -> PyDengjenResult<LazySpeechStream> {
543 py.detach(|| self.synthesize(text, audio_output_config))
544 }
545
546 #[pyo3(name = "synthesize_lazy", signature = (text, audio_output_config=None))]
547 fn py_synthesize_lazy(
548 &self,
549 py: Python<'_>,
550 text: String,
551 audio_output_config: Option<PyAudioOutputConfig>,
552 ) -> PyDengjenResult<LazySpeechStream> {
553 py.detach(|| self.synthesize_lazy(text, audio_output_config))
554 }
555
556 #[pyo3(name = "synthesize_parallel", signature = (text, audio_output_config=None))]
557 fn py_synthesize_parallel(
558 &self,
559 py: Python<'_>,
560 text: String,
561 audio_output_config: Option<PyAudioOutputConfig>,
562 ) -> PyDengjenResult<ParallelSpeechStream> {
563 py.detach(|| self.synthesize_parallel(text, audio_output_config))
564 }
565
566 #[pyo3(name = "synthesize_batched", signature = (text, audio_output_config=None, batch_size=None))]
567 fn py_synthesize_batched(
568 &self,
569 py: Python<'_>,
570 text: String,
571 audio_output_config: Option<PyAudioOutputConfig>,
572 batch_size: Option<usize>,
573 ) -> PyDengjenResult<BatchedSpeechStream> {
574 py.detach(|| self.synthesize_batched(text, audio_output_config, batch_size))
575 }
576
577 #[pyo3(name = "synthesize_streamed", signature = (text, audio_output_config=None, chunk_size=None, chunk_padding=None))]
578 fn py_synthesize_streamed(
579 &self,
580 py: Python<'_>,
581 text: String,
582 audio_output_config: Option<PyAudioOutputConfig>,
583 chunk_size: Option<usize>,
584 chunk_padding: Option<usize>,
585 ) -> PyDengjenResult<PyRealtimeSpeechStream> {
586 py.detach(|| self.synthesize_streamed(text, audio_output_config, chunk_size, chunk_padding))
587 }
588
589 #[pyo3(name = "synthesize_to_file", signature = (filename, text, audio_output_config=None))]
590 fn py_synthesize_to_file(
591 &self,
592 py: Python<'_>,
593 filename: &str,
594 text: String,
595 audio_output_config: Option<PyAudioOutputConfig>,
596 ) -> PyDengjenResult<()> {
597 py.detach(|| self.synthesize_to_file(filename, text, audio_output_config))
598 }
599
600 #[getter]
601 fn language(&self) -> PyDengjenResult<Option<String>> {
602 Ok(self.0.get_language()?)
603 }
604
605 #[getter]
606 fn speakers(&self) -> PyDengjenResult<Option<HashMap<i64, String>>> {
607 Ok(self.0.get_speakers()?.cloned())
608 }
609
610 fn get_audio_output_info(&self) -> PyDengjenResult<PyWaveInfo> {
611 Ok(self.0.audio_output_info()?.into())
612 }
613}
614
615impl Dengjen {
616 fn with_piper(vits_model: &PiperModel) -> PyDengjenResult<Self> {
617 let model = Arc::clone(&vits_model.0);
618 Ok(Self(Arc::new(DengjenSpeechSynthesizer::new(model)?)))
619 }
620
621 fn synthesize(
622 &self,
623 text: String,
624 audio_output_config: Option<PyAudioOutputConfig>,
625 ) -> PyDengjenResult<LazySpeechStream> {
626 self.synthesize_lazy(text, audio_output_config)
627 }
628
629 fn synthesize_lazy(
630 &self,
631 text: String,
632 audio_output_config: Option<PyAudioOutputConfig>,
633 ) -> PyDengjenResult<LazySpeechStream> {
634 let stream = self
635 .0
636 .synthesize_lazy(text, audio_output_config.map(|o| o.into()))?;
637 Ok(stream.into())
638 }
639
640 fn synthesize_parallel(
641 &self,
642 text: String,
643 audio_output_config: Option<PyAudioOutputConfig>,
644 ) -> PyDengjenResult<ParallelSpeechStream> {
645 let stream = self
646 .0
647 .synthesize_parallel(text, audio_output_config.map(|o| o.into()))?;
648 Ok(stream.into())
649 }
650
651 fn synthesize_batched(
652 &self,
653 text: String,
654 audio_output_config: Option<PyAudioOutputConfig>,
655 batch_size: Option<usize>,
656 ) -> PyDengjenResult<BatchedSpeechStream> {
657 let batch_size = match batch_size {
658 None => default_batch_size(),
659 Some(n) => std::num::NonZeroUsize::new(n).ok_or_else(|| {
660 PyDengjenError::from(DengjenError::InvalidConfiguration(
661 "batch_size must be greater than zero".to_string(),
662 ))
663 })?,
664 };
665 let stream =
666 self.0
667 .synthesize_batched(text, audio_output_config.map(|o| o.into()), batch_size)?;
668 Ok(stream.into())
669 }
670
671 fn synthesize_streamed(
672 &self,
673 text: String,
674 audio_output_config: Option<PyAudioOutputConfig>,
675 chunk_size: Option<usize>,
676 chunk_padding: Option<usize>,
677 ) -> PyDengjenResult<PyRealtimeSpeechStream> {
678 let stream = self.0.synthesize_streamed(
679 text,
680 audio_output_config.map(|o| o.into()),
681 chunk_size.unwrap_or(45),
682 chunk_padding.unwrap_or(3),
683 CancellationToken::new(),
684 )?;
685 Ok(PyRealtimeSpeechStream(stream))
686 }
687
688 fn synthesize_to_file(
689 &self,
690 filename: &str,
691 text: String,
692 audio_output_config: Option<PyAudioOutputConfig>,
693 ) -> PyDengjenResult<()> {
694 self.0.synthesize_to_file(
695 &PathBuf::from(filename),
696 text,
697 audio_output_config.map(|o| o.into()),
698 )?;
699 Ok(())
700 }
701}
702
703#[cfg(feature = "tashkeel")]
704fn diacritize_text(text: &str) -> PyResult<std::borrow::Cow<'_, str>> {
705 let engine = LIBTASHKEEL_ENGINE
706 .as_ref()
707 .map_err(|err| DengjenException::new_err(err.to_string()))?;
708 do_tashkeel(engine, text, None, false)
709 .map(std::borrow::Cow::from)
710 .map_err(|err| DengjenException::new_err(err.to_string()))
711}
712#[cfg(not(feature = "tashkeel"))]
714#[cfg_attr(not(feature = "espeak"), allow(dead_code))]
715fn diacritize_text(_text: &str) -> PyResult<std::borrow::Cow<'_, str>> {
716 unreachable!("diacritize_text called with the `tashkeel` feature disabled")
717}
718
719#[cfg(feature = "espeak")]
720#[pyfunction]
721#[pyo3(signature = (text, language, phoneme_separator=None, remove_lang_switch_flags=None, remove_stress=None, use_tashkeel=None))]
722pub fn phonemize_text(
723 py: Python<'_>,
724 text: &str,
725 language: &str,
726 phoneme_separator: Option<char>,
727 remove_lang_switch_flags: Option<bool>,
728 remove_stress: Option<bool>,
729 use_tashkeel: Option<bool>,
730) -> PyResult<Vec<String>> {
731 py.detach(|| {
732 let text = if should_diacritize(language, use_tashkeel) {
733 diacritize_text(text)?
734 } else {
735 std::borrow::Cow::from(text)
736 };
737 dengjen_espeak_phonemizer::text_to_phonemes(
738 &text,
739 language,
740 phoneme_separator,
741 remove_lang_switch_flags.unwrap_or(true),
742 remove_stress.unwrap_or(false),
743 )
744 .map_err(|err| {
745 PyErr::from(PyDengjenError::from(DengjenError::PhonemizationError(
746 err.to_string(),
747 )))
748 })
749 })
750}
751
752#[cfg(test)]
753mod error_plumbing_tests {
754 use super::*;
755
756 #[cfg(feature = "tashkeel")]
757 #[test]
758 fn should_diacritize_true_for_arabic_language_by_default() {
759 assert!(should_diacritize("ar", None));
760 }
761
762 #[cfg(feature = "tashkeel")]
763 #[test]
764 fn should_diacritize_false_for_non_arabic_language() {
765 assert!(!should_diacritize("en-us", None));
766 }
767
768 #[cfg(feature = "tashkeel")]
769 #[test]
770 fn should_diacritize_false_for_arabic_language_when_explicitly_disabled() {
771 assert!(!should_diacritize("ar", Some(false)));
772 }
773
774 #[cfg(feature = "tashkeel")]
775 #[test]
776 fn should_diacritize_true_for_arabic_language_when_explicitly_enabled() {
777 assert!(should_diacritize("ar", Some(true)));
778 }
779
780 #[cfg(not(feature = "tashkeel"))]
781 #[test]
782 fn should_diacritize_always_false_when_tashkeel_disabled() {
783 assert!(!should_diacritize("ar", None));
784 assert!(!should_diacritize("ar", Some(true)));
785 }
786
787 #[test]
788 fn py_dengjen_error_wraps_the_original_error_unchanged() {
789 let original = DengjenError::OperationError("boom".to_string());
790 let wrapped = PyDengjenError::from(original);
791 assert!(matches!(wrapped.0, DengjenError::OperationError(ref s) if s == "boom"));
792 }
793
794 #[test]
795 fn py_dengjen_error_preserves_variant_across_each_error_kind() {
796 let cases = [
797 DengjenError::FailedToLoadResource("a".to_string()),
798 DengjenError::PhonemizationError("b".to_string()),
799 DengjenError::InferenceError("c".to_string()),
800 DengjenError::InvalidConfiguration("d".to_string()),
801 DengjenError::UnsupportedOperation("e".to_string()),
802 DengjenError::OperationError("f".to_string()),
803 ];
804 for original in cases {
805 let expected = original.to_string();
806 let wrapped = PyDengjenError::from(original);
807 assert_eq!(wrapped.0.to_string(), expected);
808 }
809 }
810}
811
812#[pymodule]
813fn pydengjen(m: &Bound<'_, PyModule>) -> PyResult<()> {
814 m.add_class::<Dengjen>()?;
815 m.add_class::<PiperModel>()?;
816 m.add_class::<PiperScales>()?;
817 m.add_class::<PyAudioOutputConfig>()?;
818 m.add_class::<WaveSamples>()?;
819
820 m.add_class::<LazySpeechStream>()?;
821 m.add_class::<ParallelSpeechStream>()?;
822 m.add_class::<BatchedSpeechStream>()?;
823 m.add_class::<PyRealtimeSpeechStream>()?;
824
825 m.add("DengjenException", m.py().get_type::<DengjenException>())?;
826 m.add("ResourceLoadError", m.py().get_type::<ResourceLoadError>())?;
827 m.add(
828 "PhonemizationError",
829 m.py().get_type::<PhonemizationError>(),
830 )?;
831 m.add("InferenceError", m.py().get_type::<InferenceError>())?;
832 m.add(
833 "InvalidConfigurationError",
834 m.py().get_type::<InvalidConfigurationError>(),
835 )?;
836 m.add(
837 "UnsupportedOperationError",
838 m.py().get_type::<UnsupportedOperationError>(),
839 )?;
840
841 #[cfg(feature = "espeak")]
842 m.add_function(wrap_pyfunction!(phonemize_text, m)?)?;
843
844 #[cfg(feature = "stt")]
845 {
846 m.add_class::<stt::Recognizer>()?;
847 m.add("SttError", m.py().get_type::<stt::SttError>())?;
848 }
849
850 Ok(())
851}
852
853#[cfg(test)]
854mod model_and_synthesizer_tests {
855 use super::*;
856 use dengjen_tts_core::{AudioSamples, DengjenAudioResult, DengjenResult, Phonemes};
857 use dengjen_tts_piper::PiperSynthesisConfig;
858 use std::collections::HashMap as StdHashMap;
859 use std::sync::Mutex;
860
861 struct FakeModel {
865 speakers: StdHashMap<i64, String>,
866 fallback_config: Mutex<Option<SynthesisConfig>>,
867 }
868
869 impl FakeModel {
870 fn with_one_speaker() -> Self {
871 Self::with_speakers(&[(0, "alice")])
872 }
873
874 fn with_speakers(speakers: &[(i64, &str)]) -> Self {
875 let speakers = speakers
876 .iter()
877 .map(|(id, name)| (*id, name.to_string()))
878 .collect();
879 Self {
880 speakers,
881 fallback_config: Mutex::new(Some(SynthesisConfig::from(&PiperSynthesisConfig {
882 speaker: Some(0),
883 noise_scale: 0.667,
884 length_scale: 1.0,
885 noise_w: 0.8,
886 }))),
887 }
888 }
889
890 fn with_speaker_unset() -> Self {
893 Self {
894 speakers: StdHashMap::from([(0, "alice".to_string())]),
895 fallback_config: Mutex::new(Some(SynthesisConfig::from(&PiperSynthesisConfig {
896 speaker: None,
897 noise_scale: 0.667,
898 length_scale: 1.0,
899 noise_w: 0.8,
900 }))),
901 }
902 }
903
904 fn with_no_config() -> Self {
909 Self {
910 speakers: StdHashMap::from([(0, "alice".to_string())]),
911 fallback_config: Mutex::new(None),
912 }
913 }
914 }
915
916 impl DengjenModel for FakeModel {
917 fn audio_output_info(&self) -> DengjenResult<AudioInfo> {
918 Ok(AudioInfo {
919 sample_rate: 22050,
920 num_channels: 1,
921 sample_width: 2,
922 })
923 }
924 fn phonemize_text(&self, text: &str) -> DengjenResult<Phonemes> {
925 Ok(Phonemes::from(vec![text.to_string()]))
926 }
927 fn speak_batch(&self, phoneme_batches: Vec<String>) -> DengjenResult<Vec<Audio>> {
928 phoneme_batches
929 .into_iter()
930 .map(|p| self.speak_one_sentence(p))
931 .collect()
932 }
933 fn speak_one_sentence(&self, _phonemes: String) -> DengjenAudioResult {
934 Ok(Audio::new(
937 AudioSamples::new(vec![0.5; 100]),
938 22050,
939 Some(1.5),
940 ))
941 }
942 fn get_default_synthesis_config(&self) -> DengjenResult<Option<SynthesisConfig>> {
943 Ok(self.fallback_config.lock().unwrap().clone())
944 }
945 fn get_fallback_synthesis_config(&self) -> DengjenResult<Option<SynthesisConfig>> {
946 Ok(self.fallback_config.lock().unwrap().clone())
947 }
948 fn set_fallback_synthesis_config(&self, c: &SynthesisConfig) -> DengjenResult<()> {
949 *self.fallback_config.lock().unwrap() = Some(c.clone());
950 Ok(())
951 }
952 fn get_language(&self) -> DengjenResult<Option<String>> {
953 Ok(Some("en-us".to_string()))
954 }
955 fn get_speakers(&self) -> DengjenResult<Option<&StdHashMap<i64, String>>> {
956 Ok(Some(&self.speakers))
957 }
958 }
959
960 fn fake_piper_model() -> PiperModel {
961 PiperModel(Arc::new(FakeModel::with_one_speaker()))
962 }
963
964 #[test]
965 fn load_voice_errors_on_a_missing_config_path() {
966 let path = std::path::Path::new("/nonexistent-dengjen-python-load-voice-test.json");
967 assert!(load_voice(path).is_err());
968 }
969
970 fn write_temp_config(dir: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
971 let path = dir.join(name);
972 std::fs::write(&path, contents).unwrap();
973 path
974 }
975
976 #[test]
977 fn load_voice_routes_kokoro_model_type_toward_the_kokoro_loader() {
978 let dir = std::env::temp_dir().join("dengjen_python_load_voice_test_kokoro");
979 std::fs::create_dir_all(&dir).unwrap();
980
981 let path = write_temp_config(&dir, "config.json", r#"{"model_type": "kokoro"}"#);
982 let err = match load_voice(&path) {
983 Err(e) => format!("{}{}", e, e.cause_chain()),
984 Ok(_) => panic!("expected an error for an incomplete Kokoro config"),
985 };
986 assert!(
987 err.contains("model_path"),
988 "expected a Kokoro-loader error naming the missing `model_path` field, got: {err}"
989 );
990 std::fs::remove_dir_all(&dir).ok();
991 }
992
993 #[test]
994 fn load_voice_rejects_an_unknown_model_type_instead_of_loading_as_piper() {
995 let dir = std::env::temp_dir().join(format!(
996 "dengjen_python_load_voice_test_unknown_{}",
997 std::process::id()
998 ));
999 std::fs::create_dir_all(&dir).unwrap();
1000 let path = write_temp_config(&dir, "config.json", r#"{"model_type": "bark"}"#);
1001 let err = match load_voice(&path) {
1002 Err(e) => e,
1003 Ok(_) => panic!("expected an error for an unknown model_type"),
1004 };
1005 assert!(matches!(err, DengjenError::InvalidConfiguration(_)));
1006 assert!(err.to_string().contains("bark"));
1007 std::fs::remove_dir_all(&dir).ok();
1008 }
1009
1010 #[test]
1011 fn load_voice_routes_melotts_model_type_toward_the_melotts_loader() {
1012 let dir = std::env::temp_dir().join("dengjen_python_load_voice_test_melotts");
1013 std::fs::create_dir_all(&dir).unwrap();
1014
1015 let path = write_temp_config(
1016 &dir,
1017 "config.json",
1018 r#"{"model_type": "melotts", "audio": {"sample_rate": 24000}}"#,
1019 );
1020 let err = match load_voice(&path) {
1021 Err(e) => format!("{}{}", e, e.cause_chain()),
1022 Ok(_) => panic!("expected an error for an incomplete MeloTTS config"),
1023 };
1024 assert!(
1025 err.contains("phonemizer"),
1026 "expected a MeloTTS-loader error naming the missing `phonemizer` field, got: {err}"
1027 );
1028 std::fs::remove_dir_all(&dir).ok();
1029 }
1030
1031 #[test]
1032 fn load_voice_routes_vits_model_type_toward_the_piper_loader() {
1033 let dir = std::env::temp_dir().join("dengjen_python_load_voice_test_vits");
1034 std::fs::create_dir_all(&dir).unwrap();
1035
1036 let path = write_temp_config(&dir, "config.json", r#"{"model_type": "vits"}"#);
1037 let err = match load_voice(&path) {
1038 Err(e) => format!("{}{}", e, e.cause_chain()),
1039 Ok(_) => panic!("expected an error for an incomplete VITS config"),
1040 };
1041 assert!(
1042 err.contains("audio"),
1043 "expected a Piper-loader error naming the missing `audio` field, got: {err}"
1044 );
1045 std::fs::remove_dir_all(&dir).ok();
1046 }
1047
1048 #[test]
1049 fn piper_model_get_speaker_resolves_id_to_name() {
1050 let model = fake_piper_model();
1051 assert_eq!(model.get_speaker().unwrap(), Some("alice".to_string()));
1052 }
1053
1054 #[test]
1055 fn piper_model_set_speaker_updates_the_fallback_config_by_name() {
1056 let model = PiperModel(Arc::new(FakeModel::with_speakers(&[
1057 (0, "alice"),
1058 (1, "bob"),
1059 ])));
1060 assert_eq!(model.get_speaker().unwrap(), Some("alice".to_string()));
1061 model.set_speaker("bob".to_string()).unwrap();
1062 assert_eq!(model.get_speaker().unwrap(), Some("bob".to_string()));
1063 }
1064
1065 #[test]
1066 fn piper_model_set_speaker_errors_for_an_unknown_name() {
1067 let model = fake_piper_model();
1068 let result = model.set_speaker("nobody".to_string());
1069 assert!(result.is_err());
1070 }
1071
1072 #[test]
1073 fn piper_model_set_speaker_preserves_generic_parameters_set_earlier() {
1074 let model = PiperModel(Arc::new(FakeModel::with_speakers(&[
1075 (0, "alice"),
1076 (1, "bob"),
1077 ])));
1078 let mut parameters = HashMap::new();
1079 parameters.insert("custom_knob".to_string(), 1.25);
1080 model.set_parameters(parameters).unwrap();
1081 model.set_speaker("bob".to_string()).unwrap();
1082 let result = model.get_parameters().unwrap();
1083 assert_eq!(result.get("custom_knob"), Some(&1.25));
1084 }
1085
1086 #[test]
1087 fn piper_model_get_and_set_scales_round_trip() {
1088 let model = fake_piper_model();
1089 model.set_scales(0.9, 0.5, 0.7).unwrap();
1090 let scales = model.get_scales().unwrap();
1091 assert_eq!(scales.length_scale, 0.9);
1092 assert_eq!(scales.noise_scale, 0.5);
1093 assert_eq!(scales.noise_w, 0.7);
1094 }
1095
1096 #[test]
1097 fn piper_model_set_scales_preserves_generic_parameters_set_earlier() {
1098 let model = PiperModel(Arc::new(FakeModel::with_one_speaker()));
1099 let mut parameters = HashMap::new();
1100 parameters.insert("custom_knob".to_string(), 1.25);
1101 model.set_parameters(parameters).unwrap();
1102 model.set_scales(0.9, 0.5, 0.7).unwrap();
1103 let result = model.get_parameters().unwrap();
1104 assert_eq!(result.get("custom_knob"), Some(&1.25));
1105 }
1106
1107 #[test]
1108 fn set_parameters_merges_into_the_fallback_config() {
1109 let model = PiperModel(Arc::new(FakeModel::with_one_speaker()));
1110 let mut parameters = HashMap::new();
1111 parameters.insert("custom_knob".to_string(), 1.25);
1112 model.set_parameters(parameters).unwrap();
1113 let raw = model.0.get_fallback_synthesis_config().unwrap().unwrap();
1114 assert_eq!(raw.parameters.get("custom_knob"), Some(&1.25));
1115 }
1116
1117 #[test]
1118 fn get_parameters_reads_a_value_set_via_set_parameters() {
1119 let model = PiperModel(Arc::new(FakeModel::with_one_speaker()));
1120 let mut parameters = HashMap::new();
1121 parameters.insert("custom_knob".to_string(), 1.25);
1122 model.set_parameters(parameters).unwrap();
1123 let result = model.get_parameters().unwrap();
1124 assert_eq!(result.get("custom_knob"), Some(&1.25));
1125 }
1126
1127 #[test]
1128 fn get_parameters_returns_an_empty_dict_for_a_configless_backend() {
1129 let model = PiperModel(Arc::new(FakeModel::with_no_config()));
1130 let result = model.get_parameters().unwrap();
1131 assert!(result.is_empty());
1132 }
1133
1134 #[test]
1135 fn piper_model_get_speaker_returns_none_for_a_piper_config_with_no_speaker_set() {
1136 let model = PiperModel(Arc::new(FakeModel::with_speaker_unset()));
1137 assert_eq!(model.get_speaker().unwrap(), None);
1138 }
1139
1140 #[test]
1141 fn piper_model_get_speaker_returns_none_when_there_is_no_synthesis_config() {
1142 let model = PiperModel(Arc::new(FakeModel::with_no_config()));
1143 assert_eq!(model.get_speaker().unwrap(), None);
1144 }
1145
1146 #[test]
1147 fn piper_model_get_scales_errors_when_there_is_no_synthesis_config() {
1148 let model = PiperModel(Arc::new(FakeModel::with_no_config()));
1149 assert!(model.get_scales().is_err());
1150 }
1151
1152 #[test]
1153 fn piper_model_set_scales_errors_when_there_is_no_synthesis_config() {
1154 let model = PiperModel(Arc::new(FakeModel::with_no_config()));
1155 assert!(model.set_scales(0.9, 0.5, 0.7).is_err());
1156 }
1157
1158 #[test]
1159 fn piper_model_set_speaker_errors_when_there_is_no_synthesis_config() {
1160 let model = PiperModel(Arc::new(FakeModel::with_no_config()));
1161 assert!(model.set_speaker("alice".to_string()).is_err());
1162 }
1163
1164 fn fake_dengjen() -> Dengjen {
1165 let piper_model = fake_piper_model();
1166 Dengjen::with_piper(&piper_model).unwrap()
1167 }
1168
1169 #[test]
1170 fn dengjen_with_piper_succeeds_for_a_valid_model() {
1171 let _ = fake_dengjen();
1172 }
1173
1174 #[test]
1175 fn dengjen_language_delegates_to_the_model() {
1176 let dengjen = fake_dengjen();
1177 assert_eq!(dengjen.language().unwrap(), Some("en-us".to_string()));
1178 }
1179
1180 #[test]
1181 fn dengjen_speakers_delegates_to_the_model() {
1182 let dengjen = fake_dengjen();
1183 let speakers = dengjen.speakers().unwrap().unwrap();
1184 assert_eq!(speakers.get(&0), Some(&"alice".to_string()));
1185 }
1186
1187 #[test]
1188 fn dengjen_get_audio_output_info_delegates_to_the_model() {
1189 let dengjen = fake_dengjen();
1190 let info = dengjen.get_audio_output_info().unwrap();
1191 assert_eq!(info.get_sample_rate(), 22050);
1192 }
1193
1194 #[test]
1195 fn dengjen_synthesize_lazy_produces_a_stream_whose_inner_iterator_yields_the_fake_models_audio()
1196 {
1197 let dengjen = fake_dengjen();
1198 let mut stream = dengjen.synthesize_lazy("hello".to_string(), None).unwrap();
1199 let first = stream.0.next().unwrap().unwrap();
1200 assert_eq!(first.into_vec().len(), 100);
1201 assert!(stream.0.next().is_none());
1202 }
1203
1204 #[test]
1205 fn dengjen_synthesize_parallel_produces_a_stream_whose_inner_iterator_yields_the_fake_models_audio(
1206 ) {
1207 let dengjen = fake_dengjen();
1208 let mut stream = dengjen
1209 .synthesize_parallel("hello".to_string(), None)
1210 .unwrap();
1211 let first = stream.0.next().unwrap().unwrap();
1212 assert_eq!(first.into_vec().len(), 100);
1213 assert!(stream.0.next().is_none());
1214 }
1215
1216 #[test]
1217 fn dengjen_synthesize_batched_produces_a_stream_whose_inner_iterator_yields_the_fake_models_audio(
1218 ) {
1219 let dengjen = fake_dengjen();
1220 let mut stream = dengjen
1221 .synthesize_batched("hello".to_string(), None, Some(1))
1222 .unwrap();
1223 let first = stream.0.next().unwrap().unwrap();
1224 assert_eq!(first.into_vec().len(), 100);
1225 assert!(stream.0.next().is_none());
1226 }
1227
1228 #[test]
1229 fn dengjen_synthesize_batched_rejects_an_explicit_zero_batch_size() {
1230 let dengjen = fake_dengjen();
1231 let result = dengjen.synthesize_batched("hello".to_string(), None, Some(0));
1232 assert!(result.is_err());
1233 }
1234
1235 #[test]
1236 fn dengjen_synthesize_streamed_constructs_successfully() {
1237 let dengjen = fake_dengjen();
1238 let result = dengjen.synthesize_streamed("hello".to_string(), None, None, None);
1239 assert!(result.is_ok());
1240 }
1241
1242 #[test]
1243 fn dengjen_synthesize_defaults_to_the_lazy_strategy() {
1244 let dengjen = fake_dengjen();
1245 let mut via_synthesize = dengjen.synthesize("hello".to_string(), None).unwrap();
1246 let first = via_synthesize.0.next().unwrap().unwrap();
1247 assert_eq!(first.into_vec().len(), 100);
1248 }
1249
1250 #[test]
1251 fn dengjen_synthesize_to_file_writes_a_readable_wav() {
1252 let dengjen = fake_dengjen();
1253 let dir = std::env::temp_dir().join(format!("dengjen-python-test-{}", std::process::id()));
1254 std::fs::create_dir_all(&dir).unwrap();
1255 let path = dir.join("dengjen_synthesize_to_file_writes_a_readable_wav.wav");
1256
1257 dengjen
1258 .synthesize_to_file(path.to_str().unwrap(), "hello".to_string(), None)
1259 .unwrap();
1260
1261 assert!(path.exists());
1262 std::fs::remove_file(&path).ok();
1263 }
1264}