synapto_plugin_tts_google/
lib.rs1use 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 pub language_code: String,
18 pub voice_name: String,
20 pub voice_gender: String,
22 #[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("&"),
76 '<' => escaped.push_str("<"),
77 '>' => escaped.push_str(">"),
78 '"' => escaped.push_str("""),
79 '\'' => escaped.push_str("'"),
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}