synapto_plugin_tts_google/
lib.rs1use 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 pub google_service_account_credentials: GoogleServiceAccountCredentials,
33 pub language_code: String,
35 pub voice_name: String,
37 pub voice_gender: String,
39 #[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("&"),
90 '<' => escaped.push_str("<"),
91 '>' => escaped.push_str(">"),
92 '"' => escaped.push_str("""),
93 '\'' => escaped.push_str("'"),
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>", 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 #[allow(deprecated)]
158 let prepared_response = text_to_speech_client
159 .synthesize_speech()
160 .set_audio_config(
161 AudioConfig::new()
162 .set_audio_encoding(AudioEncoding::OggOpus)
163 .set_sample_rate_hertz(16_000),
164 )
165 .set_voice(
166 VoiceSelectionParams::new()
167 .set_name(config.voice_name)
168 .set_ssml_gender(SsmlVoiceGender::from(config.voice_gender.as_str()))
169 .set_language_code(config.language_code),
170 )
171 .set_advanced_voice_options(
172 AdvancedVoiceOptions::default().set_relax_safety_filters(config.relax_safety_filters),
173 );
174
175 loop {
176 match cognitive_speech_rx.recv().await {
177 Ok(text) => {
178 match prepared_response
179 .clone()
180 .set_input(SynthesisInput::new().set_ssml(normalize(text.text.as_str())))
181 .send()
182 .instrument(info_span!("Google TTS"))
183 .await
184 {
185 Ok(response) => {
186 if let Err(e) = cognitive_output_audio_tx
187 .send(CognitiveOutputAudio(response.audio_content.to_vec()))
188 .await
189 {
190 tracing::error!("Failed to send output audio: {:?}", e);
191 }
192 }
193 Err(e) => {
194 tracing::error!("Google TTS error: {:?}", e);
195 }
196 }
197 }
198 Err(synapto_interface::sync::broadcast::error::RecvError::Lagged(_)) => continue,
199 Err(synapto_interface::sync::broadcast::error::RecvError::Closed) => break,
200 }
201 }
202 Ok(())
203}