Skip to main content

pydengjen/
lib.rs

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
220/// Advances `stream` with the GIL released; `Ok(None)` ends the Python iteration.
221fn 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// should_diacritize() is always false without this feature, so this is unreachable.
713#[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    // Round-tripping through `PiperSynthesisConfig` (a closed, fixed-field struct) would
862    // silently drop anything `set_parameters` adds beyond its three known scale fields, so
863    // the fallback config is kept as the generic `SynthesisConfig` instead.
864    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        /// A model whose fallback config has no speaker id set yet, but is
891        /// still a valid `Piper` config — distinct from `with_no_config()`.
892        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        /// A model with no synthesis config at all, as opposed to a `Piper` config
905        /// that simply has no speaker set. Keeps a speaker table so `set_speaker`'s
906        /// name-to-id lookup succeeds and the test exercises the config-write failure,
907        /// not a lookup miss.
908        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            // Above the synth pipeline's trailing-silence trim threshold, else
935            // this stub gets trimmed away to nothing.
936            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}