Skip to main content

synapto_plugin_tts_google/
lib.rs

1//! # Google Text-to-Speech (TTS) Plugin
2//!
3//! Provides a high-fidelity Text-to-Speech (TTS) engine integration using Google Cloud Text-to-Speech API.
4//!
5//! ## Provided Plugins
6//!
7//! - `TtsGooglePlugin`: Connects to Google's Cloud Text-to-Speech API, handling speech synthesis requests, text normalization, shouting fixes, and robust XML/SSML escaping.
8
9use async_trait::async_trait;
10use google_cloud_texttospeech_v1::{
11    client::TextToSpeech,
12    model::{
13        AdvancedVoiceOptions, AudioConfig, AudioEncoding, SsmlVoiceGender, SynthesisInput,
14        VoiceSelectionParams,
15    },
16};
17use serde::Deserialize;
18use synapto_interface::cognitive::CognitiveOutputSpeech;
19use synapto_interface::cognitive_output_audio::CognitiveOutputAudio;
20use synapto_interface::plugin::Plugin;
21use synapto_interface::speech_to_text::TTSPlugin;
22use synapto_interface::sync::mpsc;
23use tracing::{Instrument, info_span, instrument};
24use unicode_segmentation::UnicodeSegmentation;
25
26#[derive(Deserialize, Clone, Debug, Default)]
27pub struct GoogleServiceAccountCredentials(pub serde_json::Value);
28
29#[derive(Deserialize, Clone, Debug, Default)]
30pub struct GoogleTtsConfig {
31    /// Google service account credentials (standard service_account JSON key format).
32    pub google_service_account_credentials: GoogleServiceAccountCredentials,
33    /// BCP-47 language code of the voice (e.g., "cs-CZ", "en-US").
34    pub language_code: String,
35    /// Exact voice name to use (e.g., "cs-CZ-Wavenet-A", "cs-CZ-Chirp3-HD-Schedar").
36    pub voice_name: String,
37    /// Gender of the voice ("MALE", "FEMALE", or "NEUTRAL").
38    pub voice_gender: String,
39    /// Whether to relax safety filters for speech synthesis.
40    #[serde(default)]
41    pub relax_safety_filters: bool,
42}
43
44#[derive(Deserialize)]
45pub struct TtsGooglePlugin {
46    #[serde(default)]
47    config: GoogleTtsConfig,
48}
49
50#[async_trait::async_trait]
51impl Plugin for TtsGooglePlugin {
52    fn register<R: synapto_interface::plugin::PluginRegistry + ?Sized>(
53        self: std::sync::Arc<Self>,
54        registry: &mut R,
55    ) where
56        Self: Sized,
57    {
58        registry.register_tts(self);
59    }
60
61    async fn create(
62        context: &synapto_interface::plugin::PluginInitContext<'_>,
63    ) -> Result<Self, String> {
64        let config: GoogleTtsConfig = context.config()?;
65        Ok(Self { config })
66    }
67}
68
69#[async_trait]
70impl TTSPlugin for TtsGooglePlugin {
71    async fn start(
72        &self,
73        cognitive_speech_rx: synapto_interface::sync::broadcast::Receiver<CognitiveOutputSpeech>,
74        cognitive_output_audio_tx: mpsc::Sender<CognitiveOutputAudio>,
75    ) -> Result<(), String> {
76        run_google_tts(
77            self.config.clone(),
78            cognitive_speech_rx,
79            cognitive_output_audio_tx,
80        )
81        .await
82    }
83}
84
85fn escape_xml(text: &str) -> String {
86    let mut escaped = String::with_capacity(text.len());
87    for c in text.chars() {
88        match c {
89            '&' => escaped.push_str("&amp;"),
90            '<' => escaped.push_str("&lt;"),
91            '>' => escaped.push_str("&gt;"),
92            '"' => escaped.push_str("&quot;"),
93            '\'' => escaped.push_str("&apos;"),
94            _ => escaped.push(c),
95        }
96    }
97    escaped
98}
99
100fn normalize(text: &str) -> String {
101    let fixed = fix_shouting(text).replace("`", "'");
102    format!(
103        "<speak><prosody rate=\"120%\">{}</prosody></speak>", // TODO configurable - also cognitive itself should change the speed when user want it explicitly
104        escape_xml(&fixed)
105    )
106}
107
108fn fix_shouting(text: &str) -> String {
109    let mut result = String::with_capacity(text.len());
110    for sentence in text.split_sentence_bounds() {
111        let mut first_word_seen = false;
112        for word in sentence.split_word_bounds() {
113            let is_word = word.chars().any(|c| c.is_alphabetic());
114            if is_word {
115                if !first_word_seen {
116                    result.push_str(word);
117                    first_word_seen = true;
118                } else {
119                    let is_uppercased = word
120                        .chars()
121                        .filter(|c| c.is_alphabetic())
122                        .all(|c| c.is_uppercase());
123                    if is_uppercased && word != "I" {
124                        result.push_str(&word.to_lowercase());
125                    } else {
126                        result.push_str(word);
127                    }
128                }
129            } else {
130                result.push_str(word);
131            }
132        }
133    }
134    result
135}
136
137#[instrument(skip_all)]
138async fn run_google_tts(
139    config: GoogleTtsConfig,
140    mut cognitive_speech_rx: synapto_interface::sync::broadcast::Receiver<CognitiveOutputSpeech>,
141    cognitive_output_audio_tx: mpsc::Sender<CognitiveOutputAudio>,
142) -> Result<(), String> {
143    let json_value = serde_json::to_value(&config.google_service_account_credentials.0)
144        .map_err(|e| format!("Failed to serialize credentials: {e}"))?;
145    let creds = google_cloud_auth::credentials::service_account::Builder::new(json_value)
146        .build()
147        .map_err(|e| format!("Failed to build Google credentials: {e}"))?;
148
149    let text_to_speech_client = TextToSpeech::builder()
150        .with_credentials(creds)
151        .build()
152        .await
153        .map_err(|e| format!("Failed to create Google TTS client: {e}"))?;
154
155    let prepared_response = text_to_speech_client
156        .synthesize_speech()
157        .set_audio_config(
158            AudioConfig::new()
159                .set_audio_encoding(AudioEncoding::OggOpus)
160                .set_sample_rate_hertz(16_000),
161        )
162        .set_voice(
163            VoiceSelectionParams::new()
164                .set_name(config.voice_name)
165                .set_ssml_gender(SsmlVoiceGender::from(config.voice_gender.as_str()))
166                .set_language_code(config.language_code),
167        )
168        .set_advanced_voice_options(
169            AdvancedVoiceOptions::default().set_relax_safety_filters(config.relax_safety_filters),
170        );
171
172    loop {
173        match cognitive_speech_rx.recv().await {
174            Ok(text) => {
175                match prepared_response
176                    .clone()
177                    .set_input(SynthesisInput::new().set_ssml(normalize(text.text.as_str())))
178                    .send()
179                    .instrument(info_span!("Google TTS"))
180                    .await
181                {
182                    Ok(response) => {
183                        if let Err(e) = cognitive_output_audio_tx
184                            .send(CognitiveOutputAudio(response.audio_content.to_vec()))
185                            .await
186                        {
187                            tracing::error!("Failed to send output audio: {:?}", e);
188                        }
189                    }
190                    Err(e) => {
191                        tracing::error!("Google TTS error: {:?}", e);
192                    }
193                }
194            }
195            Err(synapto_interface::sync::broadcast::error::RecvError::Lagged(_)) => continue,
196            Err(synapto_interface::sync::broadcast::error::RecvError::Closed) => break,
197        }
198    }
199    Ok(())
200}