Coverage Report

Created: 2026-05-25 08:50

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}