Skip to main content

synapto_plugin_tts_google/
lib.rs

1use async_trait::async_trait;
2use data_encoding::BASE64;
3use serde::Deserialize;
4use synapto_credentials_google::GoogleCloudTarget;
5use synapto_interface::cognitive::CognitiveOutputSpeech;
6use synapto_interface::cognitive_output_audio::CognitiveOutputAudio;
7use synapto_interface::credentials::CredentialsHandle;
8use synapto_interface::plugin::Plugin;
9use synapto_interface::speech_to_text::TTSPlugin;
10use synapto_interface::sync::{broadcast, mpsc};
11use tracing::{Instrument, info_span, instrument};
12use unicode_segmentation::UnicodeSegmentation;
13
14#[derive(Deserialize, Clone, Debug, Default)]
15pub struct GoogleTtsConfig {
16    /// BCP-47 language code of the voice (e.g., "cs-CZ", "en-US").
17    pub language_code: String,
18    /// Exact voice name to use (e.g., "cs-CZ-Wavenet-A", "cs-CZ-Chirp3-HD-Schedar").
19    pub voice_name: String,
20    /// Gender of the voice ("MALE", "FEMALE", or "NEUTRAL").
21    pub voice_gender: String,
22    /// Whether to relax safety filters for speech synthesis.
23    #[serde(default)]
24    pub relax_safety_filters: bool,
25}
26
27pub struct TtsGooglePlugin {
28    config: GoogleTtsConfig,
29    credentials: CredentialsHandle,
30}
31
32#[async_trait::async_trait]
33impl Plugin for TtsGooglePlugin {
34    fn register<R: synapto_interface::plugin::PluginRegistry + ?Sized>(
35        self: std::sync::Arc<Self>,
36        registry: &mut R,
37    ) where
38        Self: Sized,
39    {
40        registry.register_tts(self);
41    }
42
43    async fn create(
44        context: &synapto_interface::plugin::PluginInitContext<'_>,
45    ) -> Result<Self, String> {
46        let config: GoogleTtsConfig = context.config()?;
47        Ok(Self {
48            config,
49            credentials: context.credentials(),
50        })
51    }
52}
53
54#[async_trait]
55impl TTSPlugin for TtsGooglePlugin {
56    async fn start(
57        &self,
58        cognitive_speech_rx: broadcast::Receiver<CognitiveOutputSpeech>,
59        cognitive_output_audio_tx: mpsc::Sender<CognitiveOutputAudio>,
60    ) -> Result<(), String> {
61        run_google_tts(
62            self.config.clone(),
63            self.credentials.clone(),
64            cognitive_speech_rx,
65            cognitive_output_audio_tx,
66        )
67        .await
68    }
69}
70
71fn escape_xml(text: &str) -> String {
72    let mut escaped = String::with_capacity(text.len());
73    for c in text.chars() {
74        match c {
75            '&' => escaped.push_str("&amp;"),
76            '<' => escaped.push_str("&lt;"),
77            '>' => escaped.push_str("&gt;"),
78            '"' => escaped.push_str("&quot;"),
79            '\'' => escaped.push_str("&apos;"),
80            _ => escaped.push(c),
81        }
82    }
83    escaped
84}
85
86fn normalize(text: &str) -> String {
87    let fixed = fix_shouting(text).replace('`', "'");
88    format!(
89        "<speak><prosody rate=\"120%\">{}</prosody></speak>",
90        escape_xml(&fixed)
91    )
92}
93
94fn fix_shouting(text: &str) -> String {
95    let mut result = String::with_capacity(text.len());
96    for sentence in text.split_sentence_bounds() {
97        let mut first_word_seen = false;
98        for word in sentence.split_word_bounds() {
99            let is_word = word.chars().any(|c| c.is_alphabetic());
100            if is_word {
101                if !first_word_seen {
102                    result.push_str(word);
103                    first_word_seen = true;
104                } else {
105                    let is_uppercased = word
106                        .chars()
107                        .filter(|c| c.is_alphabetic())
108                        .all(|c| c.is_uppercase());
109                    if is_uppercased && word != "I" {
110                        result.push_str(&word.to_lowercase());
111                    } else {
112                        result.push_str(word);
113                    }
114                }
115            } else {
116                result.push_str(word);
117            }
118        }
119    }
120    result
121}
122
123#[instrument(skip_all)]
124async fn run_google_tts(
125    config: GoogleTtsConfig,
126    credentials: CredentialsHandle,
127    mut cognitive_speech_rx: broadcast::Receiver<CognitiveOutputSpeech>,
128    cognitive_output_audio_tx: mpsc::Sender<CognitiveOutputAudio>,
129) -> Result<(), String> {
130    let client = reqwest::Client::new();
131    let target = GoogleCloudTarget {
132        scopes: vec!["https://www.googleapis.com/auth/cloud-platform".to_string()],
133    };
134    let url = "https://texttospeech.googleapis.com/v1/text:synthesize";
135
136    loop {
137        match cognitive_speech_rx.recv().await {
138            Ok(text) => {
139                let token = match credentials.resolve_bearer_token(&target).await {
140                    Ok(t) => t,
141                    Err(e) => {
142                        tracing::error!("Failed to resolve bearer token for Google TTS: {e}");
143                        continue;
144                    }
145                };
146
147                let request_body = serde_json::json!({
148                    "input": {
149                        "ssml": normalize(text.text.as_str())
150                    },
151                    "voice": {
152                        "languageCode": config.language_code,
153                        "name": config.voice_name,
154                        "ssmlGender": config.voice_gender
155                    },
156                    "audioConfig": {
157                        "audioEncoding": "OGG_OPUS",
158                        "sampleRateHertz": 16000
159                    },
160                    "advancedVoiceOptions": {
161                        "relaxSafetyFilters": config.relax_safety_filters
162                    }
163                });
164
165                let response = client
166                    .post(url)
167                    .bearer_auth(token.expose_secret())
168                    .json(&request_body)
169                    .send()
170                    .instrument(info_span!("Google TTS"))
171                    .await;
172
173                match response {
174                    Ok(resp) => {
175                        if !resp.status().is_success() {
176                            tracing::error!("Google TTS API returned HTTP {}", resp.status());
177                            continue;
178                        }
179                        match resp.json::<serde_json::Value>().await {
180                            Ok(json) => {
181                                if let Some(audio_b64) = json["audioContent"].as_str() {
182                                    match BASE64.decode(audio_b64.as_bytes()) {
183                                        Ok(audio_bytes) => {
184                                            if let Err(e) = cognitive_output_audio_tx
185                                                .send(CognitiveOutputAudio(audio_bytes))
186                                                .await
187                                            {
188                                                tracing::error!(
189                                                    "Failed to send output audio: {:?}",
190                                                    e
191                                                );
192                                            }
193                                        }
194                                        Err(e) => {
195                                            tracing::error!(
196                                                "Failed to decode base64 audio: {:?}",
197                                                e
198                                            );
199                                        }
200                                    }
201                                } else {
202                                    tracing::error!("Missing audioContent in TTS response");
203                                }
204                            }
205                            Err(e) => {
206                                tracing::error!("Failed to parse TTS JSON response: {:?}", e);
207                            }
208                        }
209                    }
210                    Err(e) => {
211                        tracing::error!("Google TTS request error: {:?}", e);
212                    }
213                }
214            }
215            Err(synapto_interface::sync::broadcast::error::RecvError::Lagged(_)) => continue,
216            Err(synapto_interface::sync::broadcast::error::RecvError::Closed) => break,
217        }
218    }
219    Ok(())
220}