/home/runner/work/MathCAT/MathCAT/src/tts.rs
Line | Count | Source |
1 | | //! #Speech Engine Information |
2 | | //! |
3 | | //! ## Pitch (default 140hz) |
4 | | //! ### SAPI4: Relative pitch |
5 | | //! * Number is relative to the default/current pitch. |
6 | | //! * 50 is 1/2 of the default/current pitch, 200 is 2 times the default/current pitch. |
7 | | //! |
8 | | //! Note: no range is specified by the spec |
9 | | //! ### SAPI5: Relative pitch |
10 | | //! From https://documentation.help/SAPI-5/sapi.xsd |
11 | | //! * A value of +10 sets a voice to speak at four-thirds (or 4/3) of its default pitch. |
12 | | //! * Each increment between –10 and +10 is logarithmically distributed such that |
13 | | //! incrementing/decrementing by 1 is multiplying/dividing the pitch by the 24th root of 2 (about 1.03). |
14 | | //! * Values more extreme than –10 and 10 will be passed to an engine but SAPI 5compliant engines may not support |
15 | | //! such extremes and instead may clip the pitch to the maximum or minimum pitch it supports. |
16 | | //! * Values of –24 and +24 must lower and raise pitch by 1 octave respectively. |
17 | | //! All incrementing/decrementing by 1 must multiply/divide the pitch by the 24th root of 2. |
18 | | //! |
19 | | //! Note: an octave is a doubling of frequency, so pitch change of 100% should turn into +/- 24 |
20 | | //! ### SSML: Relative pitch |
21 | | //! * pitch in hertz (default/current man's voice is about 100hz, woman's 180hz) |
22 | | //! |
23 | | //! Note: other legal values for SSML are not supported, and all numbers are interpreted as relative changes |
24 | | //! ### Eloquence: Absolute pitch (relative pitch not supported by Eloquence) |
25 | | //! * Range is 0 - 100. Guess is that 0 ~= 42hz, 100 ~= 422hz based on supported \"sapi\" values |
26 | | //! ## Rate (default 180 words/min) |
27 | | //! ### SAPI4: Absolute rate |
28 | | //! * Number is relative to the default/current rate |
29 | | //! * 50 is 1/2 of the default/current rate, 200 is 2 times the default/current rate |
30 | | //! |
31 | | //! Note: no range is specified by the spec |
32 | | //! ### SAPI5: Relative rate |
33 | | //! * Number is in range -10 to 10 |
34 | | //! * -10 is 1/3 of the default/current speed; 10 3 times the default/current speech |
35 | | //! * changes are logarithmic -- a change of +/-1 corresponds to multiplying/dividing by 10th root of 3 (10*log_3(change)) |
36 | | //! ### SSML: Relative rate % |
37 | | //! * 100% is no change, 50% is half the current rate, 200% is doubling the rate |
38 | | //! |
39 | | //! Note: other legal values for SSML are not supported, and all numbers are interpreted as relative changes |
40 | | //! ### Eloquence: Absolute rate (relative rate not supported by Eloquence) |
41 | | //! * Range is 0 - 250, which manual seems to indicate corresponds to 70 - 1297 words/min. |
42 | | //! * * Window-Eyes only seems to give values in range 1 - 150. |
43 | | //! * On the low end, 1 ~= 72words/min |
44 | | //! * On the high end, I can't tell, but 80 seems to be a bit over twice normal (~400 words/min?) |
45 | | //! 250 ~= 1297 words/min based on supported "sapi" values |
46 | | //! |
47 | | //! Note: this means words/min = 4.18 * Eloquence rate + 66 |
48 | | //! So the relative pause rate is 180/computed value |
49 | | //! |
50 | | //! |
51 | | //! ## Volume (default 100 \[full]) |
52 | | //! ### SAPI4: Relative volume |
53 | | //! * Number is relative to the default/current rate |
54 | | //! * Range is 0 - 065535 |
55 | | //! ### SAPI5: Relative volume |
56 | | //! * Number is in range 0 to 100 |
57 | | //! ### SSML: Relative volume |
58 | | //! * Number is in range 0 to 100 |
59 | | //! |
60 | | //! Note: other legal values for SSML are not supported, and all numbers are interpreted as relative changes |
61 | | //! ### Eloquence: Absolute volume (relative volume not supported by Eloquence) |
62 | | //! * Range is 0 - 100 |
63 | | //! |
64 | | //! ## Pause |
65 | | //! * All systems -- pauses are given in milliseconds |
66 | | //! |
67 | | //! Note: Pauses on output are scaled based on the ratio of the current rate to the default rate (180 wpm) |
68 | | #![allow(clippy::needless_return)] |
69 | | |
70 | | use crate::{errors::*, prefs::PreferenceManager, speech::ReplacementArray}; |
71 | | use sxd_document::dom::Element; |
72 | | use yaml_rust::Yaml; |
73 | | |
74 | | use std::fmt; |
75 | | use crate::speech::{SpeechRulesWithContext, MyXPath, TreeOrString}; |
76 | | use std::string::ToString; |
77 | | use std::str::FromStr; |
78 | | use strum_macros::{Display, EnumString}; |
79 | | use regex::Regex; |
80 | | use std::sync::LazyLock; |
81 | | use sxd_xpath::Value; |
82 | | use html_escape::encode_safe; |
83 | | |
84 | | const MIN_PAUSE:f64 = 50.0; // ms -- avoids clutter of putting out pauses that probably can't be heard |
85 | | const PAUSE_SHORT:f64 = 200.0; // ms |
86 | | const PAUSE_MEDIUM:f64 = 400.0; // ms |
87 | | const PAUSE_LONG:f64 = 800.0; // ms |
88 | | const PAUSE_XLONG:f64 = 1600.0; // ms |
89 | | const PAUSE_AUTO:f64 = 987654321.5; // ms -- hopefully unique |
90 | | pub const PAUSE_AUTO_STR: &str = "\u{F8FA}\u{F8FA}"; |
91 | | const RATE_FROM_CONTEXT:f64 = 987654321.5; // hopefully unique |
92 | | |
93 | | const MAX_TRANSLATE_RECURSION: usize = 5; // probably never more than three -- prevents infinite loop/stack overflows bugs |
94 | | |
95 | | /// TTSCommand are the supported TTS commands |
96 | | /// When parsing the YAML rule files, they are converted to these enums |
97 | | #[derive(Debug, Clone, PartialEq, Eq, Display, EnumString)] |
98 | | #[strum(serialize_all = "snake_case")] // allows lower case |
99 | | pub enum TTSCommand { |
100 | | Pause, |
101 | | Rate, |
102 | | Volume, |
103 | | Pitch, |
104 | | Audio, |
105 | | Gender, |
106 | | Voice, |
107 | | Spell, |
108 | | Bookmark, |
109 | | Pronounce, |
110 | | } |
111 | | |
112 | | #[derive(Debug, Clone)] |
113 | | pub struct Pronounce { |
114 | | text: String, // plain text |
115 | | ipa: String, // ipa |
116 | | sapi5: String, |
117 | | eloquence: String, |
118 | | } |
119 | | |
120 | | |
121 | | impl fmt::Display for Pronounce { |
122 | 1 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { |
123 | 1 | let mut comma = ""; // comma separator so it looks right |
124 | 1 | write!(f, "pronounce: [")?0 ; |
125 | 1 | if !self.text.is_empty() { |
126 | 1 | write!(f, "text: '{}'", self.text)?0 ; |
127 | 1 | comma = ","; |
128 | 0 | } |
129 | 1 | write!(f, "pronounce: [")?0 ; |
130 | 1 | if !self.ipa.is_empty() { |
131 | 1 | write!(f, "{}ipa: '{}'", comma, self.ipa)?0 ; |
132 | 1 | comma = ","; |
133 | 0 | } |
134 | 1 | write!(f, "pronounce: [")?0 ; |
135 | 1 | if !self.sapi5.is_empty() { |
136 | 1 | write!(f, "{}sapi5: '{}'", comma, self.sapi5)?0 ; |
137 | 1 | comma = ","; |
138 | 0 | } |
139 | 1 | write!(f, "pronounce: [")?0 ; |
140 | 1 | if !self.eloquence.is_empty() { |
141 | 1 | write!(f, "{}eloquence: '{}'", comma, self.eloquence)?0 ; |
142 | 0 | } |
143 | 1 | return writeln!(f, "]"); |
144 | 1 | } |
145 | | } |
146 | | |
147 | | impl Pronounce { |
148 | 5.03k | fn build(values: &Yaml) -> Result<Pronounce> { |
149 | | use crate::speech::{as_str_checked, yaml_to_type}; |
150 | | use crate::pretty_print::yaml_to_string; |
151 | | |
152 | 5.03k | let mut text = ""; |
153 | 5.03k | let mut ipa = ""; |
154 | 5.03k | let mut sapi5 = ""; |
155 | 5.03k | let mut eloquence = ""; |
156 | | // values should be an array with potential values for Pronounce |
157 | 5.03k | let values = values.as_vec().ok_or_else(|| |
158 | 0 | anyhow!("'pronounce' value '{}' is not an array", yaml_to_type(values)))?; |
159 | 20.1k | for key_value in values5.03k { |
160 | 20.1k | let key_value_hash = key_value.as_hash().ok_or_else(|| |
161 | 0 | anyhow!("pronounce value '{}' is not key/value pair", yaml_to_string(key_value, 0)))?; |
162 | 20.1k | if key_value_hash.len() != 1 { |
163 | 0 | bail!("pronounce value {:?} is not a single key/value pair", key_value_hash); |
164 | 20.1k | } |
165 | | |
166 | 20.1k | for (key, value) in key_value_hash { |
167 | 20.1k | match as_str_checked(key)?0 { |
168 | 20.1k | "text" => text = as_str_checked5.03k (value5.03k )?0 , |
169 | 15.1k | "ipa" => ipa = as_str_checked5.03k (value5.03k )?0 , |
170 | 10.0k | "sapi5" => sapi5 = as_str_checked5.03k (value5.03k )?0 , |
171 | 5.03k | "eloquence" => eloquence = as_str_checked(value)?0 , |
172 | 0 | _ => bail!("unknown pronounce type: {} with value {}", yaml_to_string(key, 0), yaml_to_string(value, 0)), |
173 | | } |
174 | | } |
175 | | } |
176 | 5.03k | if text.is_empty() { |
177 | 1 | bail!("'text' key/value is required for 'pronounce' -- it is used is the speech engine is unknown.") |
178 | 5.03k | } |
179 | 5.03k | return Ok( Pronounce{ |
180 | 5.03k | text: text.to_string(), |
181 | 5.03k | ipa: ipa.to_string(), |
182 | 5.03k | sapi5: sapi5.to_string(), |
183 | 5.03k | eloquence: eloquence.to_string() |
184 | 5.03k | } ); |
185 | | |
186 | | |
187 | 5.03k | } |
188 | | } |
189 | | /// TTSCommands are either numbers (f64 because of YAML) or strings |
190 | | #[derive(Debug, Clone)] |
191 | | pub enum TTSCommandValue { |
192 | | Number(f64), |
193 | | String(String), |
194 | | XPath(MyXPath), |
195 | | Pronounce(Box<Pronounce>), |
196 | | } |
197 | | |
198 | | impl TTSCommandValue { |
199 | 77.2k | fn get_num(&self) -> f64 { |
200 | 77.2k | match self { |
201 | 77.2k | TTSCommandValue::Number(n) => return *n, |
202 | 0 | _ => panic!("Internal error: TTSCommandValue is not a number"), |
203 | | } |
204 | 77.2k | } |
205 | | |
206 | 0 | fn get_string(&self) -> &String { |
207 | 0 | match self { |
208 | 0 | TTSCommandValue::String(s) => return s, |
209 | 0 | _ => panic!("Internal error: TTSCommandValue is not a string"), |
210 | | } |
211 | 0 | } |
212 | | |
213 | 0 | fn get_pronounce(&self) -> &Pronounce { |
214 | 0 | match self { |
215 | 0 | TTSCommandValue::Pronounce(p) => return p, |
216 | 0 | _ => panic!("Internal error: TTSCommandValue is not a 'pronounce' command'"), |
217 | | } |
218 | | |
219 | 0 | } |
220 | | } |
221 | | |
222 | | /// A TTS rule consists of the command, the value, and its replacement |
223 | | #[derive(Debug, Clone)] |
224 | | pub struct TTSCommandRule { |
225 | | command: TTSCommand, |
226 | | value: TTSCommandValue, |
227 | | replacements: ReplacementArray |
228 | | } |
229 | | |
230 | | impl fmt::Display for TTSCommandRule { |
231 | 1 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { |
232 | 1 | let value = match &self.value { |
233 | 0 | TTSCommandValue::String(s) => s.to_string(), |
234 | 0 | TTSCommandValue::Number(f) => f.to_string(), |
235 | 0 | TTSCommandValue::XPath(p) => p.to_string(), |
236 | 1 | TTSCommandValue::Pronounce(p) => p.to_string(), |
237 | | }; |
238 | 1 | if self.command == TTSCommand::Pause { |
239 | 0 | return write!(f, "pause: {value}"); |
240 | | } else { |
241 | 1 | return write!(f, "{}: {}{}", self.command, value, self.replacements); |
242 | | }; |
243 | 1 | } |
244 | | } |
245 | | |
246 | | |
247 | | impl TTSCommandRule { |
248 | 2.44M | pub fn new(command: TTSCommand, value: TTSCommandValue, replacements: ReplacementArray) -> TTSCommandRule { |
249 | 2.44M | return TTSCommandRule{ |
250 | 2.44M | command, |
251 | 2.44M | value, |
252 | 2.44M | replacements |
253 | 2.44M | } |
254 | 2.44M | } |
255 | | } |
256 | | |
257 | | /// Supported TTS engines |
258 | | /// These types should do something for all the TTSCommands |
259 | | #[allow(clippy::upper_case_acronyms)] |
260 | | #[allow(dead_code)] |
261 | | #[derive(Debug, Clone, PartialEq, Eq)] |
262 | | pub enum TTS { |
263 | | None, |
264 | | SSML, |
265 | | SAPI5, |
266 | | // Eloquence, |
267 | | // Mac, |
268 | | } |
269 | | |
270 | | impl TTS { |
271 | | /// Given the tts command ("pause", "rate", etc) and its value, build the TTS data structure for it. |
272 | | /// |
273 | | /// `tts_command`: one of "pause", "rate", etc |
274 | | /// |
275 | | /// `value`: keyword 'value' or dict with 'value' and 'replace' (optional) keys |
276 | 2.42M | pub fn build(tts_command: &str, values: &Yaml) -> Result<Box<TTSCommandRule>> { |
277 | | use crate::pretty_print::yaml_to_string; |
278 | 2.42M | let hashmap = values.as_hash(); |
279 | | let tts_value; |
280 | | let replacements; |
281 | 2.42M | if hashmap.is_some() { |
282 | 446k | tts_value = &values["value"]; |
283 | 446k | if tts_value.is_badvalue() { |
284 | 0 | bail!("{} TTS command is missing a 'value' sub-key. Found\n{}", tts_command, yaml_to_string(values, 1)); |
285 | 446k | }; |
286 | 446k | replacements = ReplacementArray::build(&values["replace"])?0 ; |
287 | 1.97M | } else { |
288 | 1.97M | tts_value = values; |
289 | 1.97M | replacements = ReplacementArray::build_empty(); |
290 | 1.97M | } |
291 | 2.42M | let tts_str_value = yaml_to_string(tts_value, 0); |
292 | 2.42M | let tts_str_value = tts_str_value.trim(); |
293 | 2.42M | let tts_enum = match TTSCommand::from_str(tts_command) { |
294 | 2.42M | Ok(t) => t, |
295 | 0 | Err(_) => bail!("Internal error in build_tts: unexpected rule ({:?}) encountered", tts_command), |
296 | | }; |
297 | | |
298 | 2.42M | let tts_command_value2.42M = match tts_enum { |
299 | | TTSCommand::Pause | TTSCommand::Rate | TTSCommand::Volume | TTSCommand::Pitch => { |
300 | | // these strings are almost always what the value will be, so we try them first |
301 | 1.05M | let val = match tts_str_value { |
302 | 1.05M | "auto" => Ok( PAUSE_AUTO )71.3k , |
303 | 983k | "short" => Ok( PAUSE_SHORT )556k , |
304 | 427k | "medium" => Ok( PAUSE_MEDIUM )112k , |
305 | 314k | "long" => Ok( PAUSE_LONG )84.9k , |
306 | 229k | "xlong" => Ok( PAUSE_XLONG )3.88k , |
307 | 225k | "$MathRate" => Ok( RATE_FROM_CONTEXT )4.33k , // special case hack -- value determined in replace |
308 | 221k | _ => tts_str_value.parse::<f64>() |
309 | | }; |
310 | | |
311 | 1.05M | match val { |
312 | 833k | Ok(num) => TTSCommandValue::Number(num), |
313 | | Err(_) => { |
314 | | // let's try as an xpath (e.g., could be '$CapitalLetters_Pitch') |
315 | | TTSCommandValue::XPath( |
316 | 221k | MyXPath::build(tts_value).with_context(|| format!0 ("while trying to evaluate value of '{tts_enum}:'"))?0 |
317 | | ) |
318 | | } |
319 | | } |
320 | | }, |
321 | | TTSCommand::Bookmark | TTSCommand::Spell => { |
322 | | TTSCommandValue::XPath( |
323 | 1.14M | MyXPath::build(values).with_context(|| format!0 ("while trying to evaluate value of '{tts_enum}:'"))?0 |
324 | | ) |
325 | | }, |
326 | | TTSCommand::Pronounce => { |
327 | 5.03k | TTSCommandValue::Pronounce( Box::new5.03k ( Pronounce::build(values)?1 ) ) |
328 | | }, |
329 | | _ => { |
330 | 221k | TTSCommandValue::String(tts_str_value.to_string()) |
331 | | }, |
332 | | }; |
333 | 2.42M | return Ok( Box::new( TTSCommandRule::new(tts_enum, tts_command_value, replacements) ) ); |
334 | 2.42M | } |
335 | | |
336 | | /// The rule called to execute the TTSCommand `command` |
337 | | /// `prefs` are used for scaling the speech rate |
338 | | /// some rules have MathML nested inside, so we need to do replacements on them (hence `rules` and `mathml` are needed) |
339 | | /// |
340 | | /// A string is returned for the speech engine. |
341 | | /// |
342 | | /// `auto` pausing is handled at a later phase and a special char is used for it |
343 | 60.7k | pub fn replace<'c, 's:'c, 'm:'c, 'r, T:TreeOrString<'c, 'm, T>>(&self, command: &TTSCommandRule, prefs: &PreferenceManager, rules_with_context: &'r mut SpeechRulesWithContext<'c, 's, 'm>, mathml: Element<'c>) -> Result<T> { |
344 | 60.7k | return T::replace_tts(self, command, prefs, rules_with_context, mathml); |
345 | 60.7k | } |
346 | | |
347 | 60.7k | pub fn replace_string<'c, 's:'c, 'm, 'r>(&self, command: &TTSCommandRule, prefs: &PreferenceManager, rules_with_context: &'r mut SpeechRulesWithContext<'c, 's, 'm>, mathml: Element<'c>) -> Result<String> { |
348 | | // The general idea is we handle the begin tag, the contents, and then the end tag |
349 | | // For the begin/end tag, we dispatch off to specialized code for each TTS engine |
350 | | |
351 | | // 'bookmark' is special in that we need to eval the xpath |
352 | | // rather than pass a bunch of extra info into the generic handling routines, we just deal with them here |
353 | 60.7k | if command.command == TTSCommand::Bookmark { |
354 | | // if we aren't suppose to generate bookmarks, short circuit and just return |
355 | 26.7k | if prefs.pref_to_string("Bookmark") != "true"{ |
356 | 26.7k | return Ok("".to_string()); |
357 | 0 | } |
358 | 0 | return Ok( match self { |
359 | 0 | TTS::None => "".to_string(), |
360 | 0 | TTS::SSML => compute_bookmark_element(&command.value, "mark name", rules_with_context, mathml)?, |
361 | 0 | TTS::SAPI5 => compute_bookmark_element(&command.value, "bookmark mark", rules_with_context, mathml)?, |
362 | | } ); |
363 | 33.9k | } |
364 | | |
365 | 33.9k | let mut command = command.clone(); |
366 | 33.9k | if command.command == TTSCommand::Spell { |
367 | | // spell is also special because we need to eval the xpath to get the string to spell (typically the text content of an mi) |
368 | 2.78k | match command.value { |
369 | 2.78k | TTSCommandValue::XPath(xpath) => { |
370 | 2.78k | let value = xpath.evaluate(rules_with_context.get_context(), mathml) |
371 | 2.78k | .with_context(|| format!0 ("in 'spell': can't evaluate xpath \"{}\"", &xpath.to_string()0 ) )?0 ; |
372 | 2.78k | let value_string = match value527 { |
373 | 2.25k | Value::String(s) => s, |
374 | 527 | Value::Nodeset(nodes) if nodes.size() == 1 => { |
375 | 527 | let node = nodes.iter().next().unwrap(); |
376 | 527 | if let Some(text) = node.text() { |
377 | 527 | text.text().to_string() |
378 | 0 | } else if let Some(el) = node.element() { |
379 | 0 | if crate::xpath_functions::is_leaf(el) { |
380 | 0 | crate::canonicalize::as_text(el).to_string() |
381 | | } else { |
382 | 0 | bail!("in 'spell': value returned from xpath '{}' does not evaluate to a string", &xpath.to_string()); |
383 | | } |
384 | | } else { |
385 | 0 | bail!("in 'spell': value returned from xpath '{}' does not evaluate to a string, it is {} nodes", |
386 | 0 | &xpath.to_string(), nodes.size()); |
387 | | } |
388 | | }, |
389 | 0 | _ => bail!("in 'spell': value returned from xpath '{}' does not evaluate to a string", &xpath.to_string()), |
390 | | }; |
391 | | // Chemistry wants to spell elements like "Na". But we also have the issue of capitalization (SpeechOverrides_CapitalLetters) |
392 | | // so the "N" need to use that. The logic for that is already in unicode.yaml. We could replicate that here. |
393 | | // Rather than duplicate the logic (we would need to handle 'a', and who knows what in other languages), |
394 | | // we split the token into each letter and call the replacement on each letter. |
395 | | // That in turns calls spell again. We end up in an infinite loop. To prevent this we set a flag that says don't recurse. |
396 | | // The only structure to put that in is SpeechRulesWithContext. A bit of a hack to put it there, but better than a static var. |
397 | | // Also, to avoid repeating the code for "cap" over and over, "spell" with "translate" is used. So keep going until no "translate" |
398 | 2.78k | let xpath_str = xpath.to_string(); |
399 | 2.78k | if rules_with_context.inside_spell && !xpath_str.contains("translate")848 { |
400 | 0 | command.value = TTSCommandValue::String(value_string); |
401 | 0 | rules_with_context.translate_count = 0; |
402 | 2.78k | } else if rules_with_context.translate_count > MAX_TRANSLATE_RECURSION { |
403 | 0 | bail!("Rule error: potential infinite recursion found in translate: {}", xpath_str); |
404 | | } else { |
405 | | // let the call to replace call spell on the individual chars -- that lets an "cap" be outside "spell" |
406 | 2.78k | rules_with_context.translate_count += 1; |
407 | 2.78k | let str_with_spaces = value_string.chars() |
408 | 2.95k | .map2.78k (|ch| { |
409 | 2.95k | rules_with_context.inside_spell = true; |
410 | 2.95k | let spelled_char = rules_with_context.replace_chars(ch.to_string().as_str(), mathml); |
411 | 2.95k | rules_with_context.inside_spell = false; |
412 | 2.95k | spelled_char |
413 | 2.95k | }) |
414 | 2.78k | .collect::<Result<Vec<String>>>()?0 |
415 | 2.78k | .join(" "); |
416 | 2.78k | return Ok(str_with_spaces); |
417 | | } |
418 | | }, |
419 | 0 | _ => bail!("Implementation error: found non-xpath value for spell"), |
420 | | } |
421 | 31.1k | } else if command.command == TTSCommand::Rate && self != &TTS::None0 && |
422 | 0 | let TTSCommandValue::Number(number_value) = command.value && |
423 | 0 | number_value == RATE_FROM_CONTEXT { |
424 | | // handle hack for $Rate -- need to look up in context |
425 | 0 | let rate_from_context = crate::navigate::context_get_variable(rules_with_context.get_context(), "MathRate", mathml)?.parse::<usize>().unwrap_or(100); |
426 | 0 | command.value = TTSCommandValue::Number(rate_from_context as f64); |
427 | 31.1k | } |
428 | | |
429 | | // evaluate any xpath value now to simplify later code |
430 | 31.1k | if let TTSCommandValue::XPath(xpath1.31k ) = command.value { |
431 | 1.31k | let eval_str = xpath.replace::<String>(rules_with_context, mathml)?0 ; |
432 | | // can it be a number? |
433 | 1.31k | command.value = match eval_str.parse::<f64>() { |
434 | 1.31k | Ok(num) => TTSCommandValue::Number(num), |
435 | 0 | Err(_) => TTSCommandValue::String(eval_str), |
436 | | } |
437 | 29.8k | }; |
438 | | |
439 | | |
440 | | // small optimization to avoid generating tags that do nothing |
441 | 31.1k | if ((command.command == TTSCommand::Pitch || command.command == TTSCommand::Volume29.8k || command.command == TTSCommand::Pause29.8k ) && command.value.get_num() == 0.031.1k ) || |
442 | 29.8k | (command.command == TTSCommand::Rate && command.value.get_num() == 100.00 ) { |
443 | 1.31k | return command.replacements.replace::<String>(rules_with_context, mathml); |
444 | 29.8k | } |
445 | | |
446 | 29.8k | let mut result = String::with_capacity(255); |
447 | 29.8k | result += &match self { |
448 | 29.8k | TTS::None => self.get_string_none(&command, prefs, true), |
449 | 0 | TTS::SSML => self.get_string_ssml(&command, prefs, true), |
450 | 0 | TTS::SAPI5 => self.get_string_sapi5(&command, prefs, true), |
451 | | }; |
452 | | |
453 | | |
454 | 29.8k | if !command.replacements.is_empty() { |
455 | 0 | if result.is_empty() { |
456 | 0 | result += " "; |
457 | 0 | } |
458 | | // need to sanitize string so that SSML is not injected into it via mtext, etc. |
459 | 0 | let speech = command.replacements.replace::<String>(rules_with_context, mathml)?; |
460 | 0 | result += &encode_safe(&speech); |
461 | 29.8k | } |
462 | | |
463 | 29.8k | let end_tag = match self { |
464 | 29.8k | TTS::None => self.get_string_none(&command, prefs, false), |
465 | 0 | TTS::SSML => self.get_string_ssml(&command, prefs, false), |
466 | 0 | TTS::SAPI5 => self.get_string_sapi5(&command, prefs, false), |
467 | | }; |
468 | | |
469 | 29.8k | if end_tag.is_empty() { |
470 | 29.8k | return Ok( result ); // avoids adding in " " |
471 | | } else { |
472 | 0 | return Ok( result + &end_tag ); |
473 | | } |
474 | | |
475 | | |
476 | 0 | fn compute_bookmark_element<'c, 's:'c, 'm, 'r>(value: &TTSCommandValue, tag_and_attr: &str, rules_with_context: &'r mut SpeechRulesWithContext<'c, 's, 'm>, mathml: Element<'c>) -> Result<String> { |
477 | 0 | match value { |
478 | 0 | TTSCommandValue::XPath(xpath) => { |
479 | 0 | let id = xpath.replace::<String>(rules_with_context, mathml)?; |
480 | 0 | return Ok( format!("<{tag_and_attr}='{id}'/>") ); |
481 | | }, |
482 | 0 | _ => bail!("Implementation error: found bookmark value that did not evaluate to a string"), |
483 | | } |
484 | 0 | } |
485 | | |
486 | 60.7k | } |
487 | | |
488 | | // auto pausing can't be known until neighboring strings are computed |
489 | | // we create a unique string in this case and compute the real value later |
490 | 75.9k | fn get_string_none(&self, command: &TTSCommandRule, prefs: &PreferenceManager, is_start_tag: bool) -> String { |
491 | | // they only thing to do is handle "pause" with some punctuation hacks along with 'spell' |
492 | 75.9k | if is_start_tag { |
493 | 46.1k | if command.command == TTSCommand::Pause { |
494 | 46.0k | let amount = command.value.get_num(); |
495 | | // only ',' and ';' are used as '.' didn't seem to reliably generate pauses in tests |
496 | 46.0k | return crate::speech::CONCAT_INDICATOR.to_string() + ( |
497 | 46.0k | if amount == PAUSE_AUTO { |
498 | 19.5k | PAUSE_AUTO_STR |
499 | | } else { |
500 | 26.5k | let amount = amount * TTS::get_pause_multiplier(prefs); |
501 | 26.5k | if amount <= MIN_PAUSE { |
502 | 11.3k | "" |
503 | 15.1k | } else if amount <= 250.0 { |
504 | 9.92k | "," |
505 | | } else { |
506 | 5.21k | ";" |
507 | | } |
508 | | } |
509 | | ); |
510 | 32 | } else if command.command == TTSCommand::Spell { |
511 | | // debug!("spell rule: {}", command.value.get_string()); |
512 | 0 | return command.value.get_string().to_string(); |
513 | 32 | } else if let TTSCommandValue::Pronounce(p) = &command.value { |
514 | 32 | return crate::speech::CONCAT_INDICATOR.to_string() + &p.text; |
515 | 0 | } |
516 | 29.8k | }; |
517 | 29.8k | return "".to_string(); |
518 | 75.9k | } |
519 | | |
520 | 0 | fn get_string_sapi5(&self, command: &TTSCommandRule, prefs: &PreferenceManager, is_start_tag: bool) -> String { |
521 | 0 | return match &command.command { |
522 | 0 | TTSCommand::Pause => if is_start_tag { |
523 | 0 | let amount = command.value.get_num(); |
524 | 0 | if amount == PAUSE_AUTO { |
525 | 0 | PAUSE_AUTO_STR.to_string() |
526 | | } else { |
527 | 0 | let amount = amount * TTS::get_pause_multiplier(prefs); |
528 | 0 | if amount > MIN_PAUSE { |
529 | 0 | format!("<silence msec=='{}ms'/>", (amount * 180.0/prefs.get_rate()).round()) |
530 | | } else { |
531 | 0 | "".to_string() |
532 | | } |
533 | | } |
534 | | } else { |
535 | 0 | "".to_string() |
536 | | }, |
537 | | // pitch must be in [-10, 10], logarithmic based on octaves |
538 | | // note MathPlayer uses 'absmiddle' (requires keeping a stack) -- could be 'middle' is not well supported |
539 | 0 | TTSCommand::Pitch => if is_start_tag {format!("<pitch middle=\"{}\">", (24.0*(1.0+command.value.get_num()/100.0).log2()).round())} else {String::from("</prosody>")}, |
540 | | // rate must be in [-10, 10], but we get relative %s. 300% => 10 (see comments at top of file) |
541 | 0 | TTSCommand::Rate => if is_start_tag {format!("<rate speed='{:.1}'>", 10.0*(0.01*command.value.get_num()).log(3.0))} else {String::from("</rate>")}, |
542 | 0 | TTSCommand::Volume =>if is_start_tag {format!("<volume level='{}'>", command.value.get_num())} else {String::from("</volume>")}, |
543 | 0 | TTSCommand::Audio => "".to_string(), // SAPI5 doesn't support audio |
544 | 0 | TTSCommand::Gender =>if is_start_tag {format!("<voice required=\"Gender={}\">", command.value.get_string())} else {String::from("</prosody>")}, |
545 | 0 | TTSCommand::Voice =>if is_start_tag {format!("<voice required=\"Name={}\">", command.value.get_string())} else {String::from("</prosody>")}, |
546 | 0 | TTSCommand::Spell =>if is_start_tag {format!("<spell>{}", command.value.get_string())} else {String::from("</spell>")}, |
547 | 0 | TTSCommand::Pronounce =>if is_start_tag { |
548 | 0 | format!("<pron sym='{}'>{}", &command.value.get_pronounce().sapi5, &command.value.get_pronounce().text) |
549 | | } else { |
550 | 0 | String::from("</pron>") |
551 | | }, |
552 | 0 | TTSCommand::Bookmark => panic!("Internal error: bookmarks should have been handled earlier"), |
553 | | }; |
554 | 0 | } |
555 | | |
556 | 0 | fn get_string_ssml(&self, command: &TTSCommandRule, prefs: &PreferenceManager, is_start_tag: bool) -> String { |
557 | 0 | return match &command.command { |
558 | | TTSCommand::Pause => { |
559 | 0 | if is_start_tag { |
560 | 0 | let amount = command.value.get_num(); |
561 | 0 | if amount == PAUSE_AUTO { |
562 | 0 | PAUSE_AUTO_STR.to_string() |
563 | | } else { |
564 | 0 | let amount = amount * TTS::get_pause_multiplier(prefs); |
565 | 0 | if amount > MIN_PAUSE { |
566 | 0 | format!("<break time='{}ms'/>", (amount * 180.0/prefs.get_rate()).round()) |
567 | | } else { |
568 | 0 | "".to_string() |
569 | | } |
570 | | } |
571 | | } else { |
572 | 0 | "".to_string() |
573 | | } |
574 | | }, |
575 | 0 | TTSCommand::Pitch => if is_start_tag {format!("<prosody pitch='{}%'>", command.value.get_num())} else {String::from("</prosody>")}, |
576 | 0 | TTSCommand::Rate => if is_start_tag {format!("<prosody rate='{}%'>", command.value.get_num())} else {String::from("</prosody>")}, |
577 | 0 | TTSCommand::Volume =>if is_start_tag {format!("<prosody volume='{}db'>", command.value.get_num())} else {String::from("</prosody>")}, |
578 | 0 | TTSCommand::Audio =>if is_start_tag {format!("<audio src='{}'>", command.value.get_string())} else {String::from("</audio>")}, // only 'beep' is supported for now |
579 | 0 | TTSCommand::Gender =>if is_start_tag {format!("<voice required='gender=\"{}\"'>", command.value.get_string())} else {String::from("</voice>")}, |
580 | 0 | TTSCommand::Voice =>if is_start_tag {format!("<voice required='{}'>", command.value.get_string())} else {String::from("</voice>")}, |
581 | 0 | TTSCommand::Spell =>if is_start_tag {format!("<say-as interpret-as='characters'>{}", command.value.get_string())} else {String::from("</say-as>")}, |
582 | 0 | TTSCommand::Pronounce =>if is_start_tag { |
583 | 0 | format!("<phoneme alphabet='ipa' ph='{}'>{}", &command.value.get_pronounce().ipa, &command.value.get_pronounce().text) |
584 | | } else { |
585 | 0 | String::from("</phoneme>") |
586 | | }, |
587 | 0 | TTSCommand::Bookmark => panic!("Internal error: bookmarks should have been handled earlier"), |
588 | | } |
589 | 0 | } |
590 | | |
591 | 26.5k | fn get_pause_multiplier(prefs: &PreferenceManager) -> f64 { |
592 | 26.5k | return prefs.pref_to_string("PauseFactor").parse::<f64>().unwrap_or(100.)/100.0; |
593 | 26.5k | } |
594 | | |
595 | | /// Compute the length of the pause to use. |
596 | | /// |
597 | | /// The computation is based on the length of the speech strings (after removing tagging). |
598 | | /// There is a bias towards pausing more _after_ longer strings. |
599 | 19.5k | pub fn compute_auto_pause(&self, prefs: &PreferenceManager, before: &str, after: &str) -> String { |
600 | 0 | static REMOVE_XML: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<.+?>").unwrap()); // punctuation ending with a '.' |
601 | | let before_len; |
602 | | let after_len; |
603 | 19.5k | match self { |
604 | 0 | TTS::SSML | TTS::SAPI5 => { |
605 | 0 | before_len = REMOVE_XML.replace_all(before, "").len(); |
606 | 0 | after_len = REMOVE_XML.replace_all(after, "").len(); |
607 | 0 | }, |
608 | 19.5k | _ => { |
609 | 19.5k | before_len = before.len(); |
610 | 19.5k | after_len = after.len(); |
611 | 19.5k | }, |
612 | | } |
613 | | |
614 | | // pause values are not cut in stone |
615 | | // the calculation bias to 'previous' is based on MathPlayer which used '30 * #-of-descendants-on-left |
616 | | // I think I did this as a sort of "take a breath" after saying something long although one might want to do that |
617 | | // before speaking something long. |
618 | 19.5k | if after_len < 3 { |
619 | | // hack to prevent pausing before "of" in exprs like "the fourth power of secant, of x" |
620 | | // if it should pause anywhere, it should be after the "of" |
621 | 3.32k | return "".to_string(); |
622 | 16.2k | } |
623 | 16.2k | let pause = std::cmp::min(3000, ((2 * before_len + after_len)/48) * 128); |
624 | | // create a TTSCommandRule so we reuse code |
625 | 16.2k | let command = TTSCommandRule::new( |
626 | 16.2k | TTSCommand::Pause, |
627 | 16.2k | TTSCommandValue::Number(pause as f64), |
628 | 16.2k | ReplacementArray::build_empty(), |
629 | | ); |
630 | 16.2k | return match self { |
631 | 16.2k | TTS::None => self.get_string_none(&command, prefs, true), |
632 | 0 | TTS::SSML => self.get_string_ssml(&command, prefs, true), |
633 | 0 | TTS::SAPI5 => self.get_string_sapi5(&command, prefs, true), |
634 | | }; |
635 | | |
636 | 19.5k | } |
637 | | |
638 | | /// Take the longest of the pauses |
639 | | /// |
640 | | /// Two other options are: |
641 | | /// 1. average the pauses |
642 | | /// 2. add the pauses together. |
643 | | /// |
644 | | /// Until evidence points otherwise, use 'longest'. |
645 | 5.10k | pub fn merge_pauses(&self, str: &str) -> String { |
646 | | // we need specialized merges for each TTS engine because we need to know the format of the commands |
647 | 5.10k | return match self { |
648 | 5.10k | TTS::None => self.merge_pauses_none(str), |
649 | 1 | TTS::SSML => self.merge_pauses_ssml(str), |
650 | 1 | TTS::SAPI5 => self.merge_pauses_sapi5(str), |
651 | | }; |
652 | 5.10k | } |
653 | | |
654 | 5.10k | fn merge_pauses_none(&self, str: &str) -> String { |
655 | | // punctuation used for pauses is ",", ";" |
656 | 2 | static SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+([;,])").unwrap()); // two or more pauses |
657 | 2 | static MULTIPLE_PAUSES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([,;][,;]+)").unwrap()); // two or more pauses |
658 | | // we reduce all sequences of two or more pauses to a single medium pause |
659 | 5.10k | let merges_string = SPACES.replace_all(str, "$1").to_string(); |
660 | 5.10k | let merges_string = MULTIPLE_PAUSES.replace_all(&merges_string, ";").to_string(); |
661 | 5.10k | return merges_string; |
662 | 5.10k | } |
663 | | |
664 | 2 | fn merge_pauses_xml<F>(str: &str, full_attr_re: &Regex, sub_attr_re: &Regex, replace_with: F) -> String |
665 | 2 | where F: Fn(usize) -> String { |
666 | | // we reduce all sequences of two or more pauses to the max pause amount |
667 | | // other options would be the sum or an average |
668 | | // maybe some amount a little longer than the max would be best??? |
669 | 2 | let mut merges_string = str.to_string(); |
670 | 2 | for cap in full_attr_re.captures_iter(str) { |
671 | 2 | let mut amount = 0; |
672 | 4 | for c in sub_attr_re2 .captures_iter2 (&cap[0]2 ) { |
673 | 4 | amount = std::cmp::max(amount, c[1].parse::<usize>().unwrap()); |
674 | 4 | }; |
675 | 2 | merges_string = merges_string.replace(&cap[0], &replace_with(amount)); |
676 | | } |
677 | 2 | return merges_string; |
678 | 2 | } |
679 | | |
680 | 1 | fn merge_pauses_sapi5(&self, str: &str) -> String { |
681 | 1 | static CONSECUTIVE_BREAKS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(<silence msec[^>]+?> *){2,}").unwrap()); // two or more pauses |
682 | 1 | static PAUSE_AMOUNT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"msec=.*?(\d+)").unwrap()); // amount after 'time' |
683 | 1 | let replacement = |amount: usize| format!("<silence msec=='{amount}ms'/>"); |
684 | 1 | return TTS::merge_pauses_xml(str, &CONSECUTIVE_BREAKS, &PAUSE_AMOUNT, replacement); |
685 | 1 | } |
686 | | |
687 | 1 | fn merge_pauses_ssml(&self, str: &str) -> String { |
688 | 1 | static CONSECUTIVE_BREAKS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(<break time=[^>]+?> *){2,}").unwrap()); // two or more pauses |
689 | 1 | static PAUSE_AMOUNT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"time=.*?(\d+)").unwrap()); // amount after 'time' |
690 | 1 | let replacement = |amount: usize| format!("<break time='{amount}ms'/>"); |
691 | 1 | return TTS::merge_pauses_xml(str, &CONSECUTIVE_BREAKS, &PAUSE_AMOUNT, replacement); |
692 | 1 | } |
693 | | } |
694 | | |
695 | | #[cfg(test)] |
696 | | mod tests { |
697 | | use super::*; |
698 | | use yaml_rust::YamlLoader; |
699 | | |
700 | | #[test] |
701 | | /// Verifies pronounce YAML builds and renders all supported fields. |
702 | 1 | fn pronounce_build_and_display() { |
703 | 1 | let yaml = YamlLoader::load_from_str( |
704 | 1 | r#" |
705 | 1 | - text: "alpha" |
706 | 1 | - ipa: "a" |
707 | 1 | - sapi5: "b" |
708 | 1 | - eloquence: "c" |
709 | 1 | "#, |
710 | | ) |
711 | 1 | .unwrap(); |
712 | 1 | let values = &yaml[0]; |
713 | 1 | let rule = TTS::build("pronounce", values).unwrap(); |
714 | 1 | let rendered = format!("{rule}"); |
715 | | |
716 | 1 | assert!(rendered.contains("text: 'alpha'")); |
717 | 1 | assert!(rendered.contains("ipa: 'a'")); |
718 | 1 | assert!(rendered.contains("sapi5: 'b'")); |
719 | 1 | assert!(rendered.contains("eloquence: 'c'")); |
720 | 1 | } |
721 | | |
722 | | #[test] |
723 | | /// Ensures pronounce requires a text entry and rejects missing text. |
724 | 1 | fn pronounce_requires_text() { |
725 | 1 | let yaml = YamlLoader::load_from_str( |
726 | 1 | r#" |
727 | 1 | - ipa: "a" |
728 | 1 | "#, |
729 | | ) |
730 | 1 | .unwrap(); |
731 | 1 | let values = &yaml[0]; |
732 | 1 | let err = TTS::build("pronounce", values).unwrap_err(); |
733 | 1 | assert!(err.to_string().contains("'text' key/value is required")); |
734 | 1 | } |
735 | | |
736 | | #[test] |
737 | | /// Coalesces adjacent punctuation pauses for the None engine. |
738 | 1 | fn merge_pauses_none_coalesces() { |
739 | 1 | let input = "a,,;b"; |
740 | 1 | let output = TTS::None.merge_pauses(input); |
741 | 1 | assert!(!output.contains(",,")); |
742 | 1 | assert!(output.contains(";")); |
743 | 1 | } |
744 | | |
745 | | #[test] |
746 | | /// Uses the maximum pause when merging consecutive SSML breaks. |
747 | 1 | fn merge_pauses_ssml_keeps_max() { |
748 | 1 | let input = "<break time='100ms'/><break time='300ms'/>"; |
749 | 1 | let output = TTS::SSML.merge_pauses(input); |
750 | 1 | assert!(!output.contains("100ms")); |
751 | 1 | assert!(output.contains("300ms")); |
752 | 1 | } |
753 | | |
754 | | #[test] |
755 | | /// Uses the maximum pause when merging consecutive SAPI5 breaks. |
756 | 1 | fn merge_pauses_sapi5_keeps_max() { |
757 | 1 | let input = "<silence msec=='100ms'/><silence msec=='300ms'/>"; |
758 | 1 | let output = TTS::SAPI5.merge_pauses(input); |
759 | 1 | assert!(!output.contains("100ms")); |
760 | 1 | assert!(output.contains("300ms")); |
761 | 1 | } |
762 | | } |