## 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
    }