#![allow(clippy::needless_return)]
use crate::{errors::*, prefs::PreferenceManager, speech::ReplacementArray};
use sxd_document::dom::Element;
use yaml_rust::Yaml;
use std::{fmt};
use crate::speech::{SpeechRulesWithContext, MyXPath, TreeOrString};
use std::string::ToString;
use std::str::FromStr;
use strum_macros::{Display, EnumString};
use regex::Regex;
use sxd_xpath::Value;
const MIN_PAUSE:f64 = 50.0; const PAUSE_SHORT:f64 = 150.0; const PAUSE_MEDIUM:f64 = 300.0; const PAUSE_LONG:f64 = 600.0; const PAUSE_AUTO:f64 = 987654321.5; pub const PAUSE_AUTO_STR: &str = "\u{F8FA}\u{F8FA}";
const RATE_FROM_CONTEXT:f64 = 987654321.5;
#[derive(Debug, Clone, PartialEq, Eq, Display, EnumString)]
#[strum(serialize_all = "snake_case")] pub enum TTSCommand {
Pause,
Rate,
Volume,
Pitch ,
Gender,
Voice,
Spell,
Bookmark,
Pronounce,
}
#[derive(Debug, Clone)]
pub struct Pronounce {
text: String, ipa: String, sapi5: String,
eloquence: String,
}
impl fmt::Display for Pronounce {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut comma = ""; write!(f, "pronounce: [")?;
if !self.text.is_empty() {
write!(f, "text: '{}'", self.text)?;
comma = ",";
}
write!(f, "pronounce: [")?;
if !self.ipa.is_empty() {
write!(f, "{}ipa: '{}'", comma, self.ipa)?;
comma = ",";
}
write!(f, "pronounce: [")?;
if !self.sapi5.is_empty() {
write!(f, "{}sapi5: '{}'", comma, self.sapi5)?;
comma = ",";
}
write!(f, "pronounce: [")?;
if !self.eloquence.is_empty() {
write!(f, "{}eloquence: '{}'", comma, self.eloquence)?;
}
return writeln!(f, "]");
}
}
impl Pronounce {
fn build(values: &Yaml) -> Result<Pronounce> {
use crate::speech::{as_str_checked, yaml_to_type};
use crate::pretty_print::yaml_to_string;
let mut text = "";
let mut ipa = "";
let mut sapi5 = "";
let mut eloquence = "";
let values = values.as_vec().ok_or_else(||
format!("'pronounce' value '{}' is not an array", yaml_to_type(values)))?;
for key_value in values {
let key_value_hash = key_value.as_hash().ok_or_else(||
format!("pronounce value '{}' is not key/value pair", yaml_to_string(key_value, 0)))?;
if key_value_hash.len() != 1 {
bail!("pronounce value {:?} is not a single key/value pair", key_value_hash);
}
for (key, value) in key_value_hash {
match as_str_checked(key)? {
"text" => text = as_str_checked(value)?,
"ipa" => ipa = as_str_checked(value)?,
"sapi5" => sapi5 = as_str_checked(value)?,
"eloquence" => eloquence = as_str_checked(value)?,
_ => bail!("unknown pronounce type: {} with value {}", yaml_to_string(key, 0), yaml_to_string(value, 0)),
}
}
}
if text.is_empty() {
bail!("'text' key/value is required for 'pronounce' -- it is used is the speech engine is unknown.")
}
return Ok( Pronounce{
text: text.to_string(),
ipa: ipa.to_string(),
sapi5: sapi5.to_string(),
eloquence: eloquence.to_string()
} );
}
}
#[derive(Debug, Clone)]
pub enum TTSCommandValue {
Number(f64),
String(String),
XPath(MyXPath),
Pronounce(Box<Pronounce>),
}
impl TTSCommandValue {
fn get_num(&self) -> f64 {
match self {
TTSCommandValue::Number(n) => return *n,
_ => panic!("Internal error: TTSCommandValue is not a number"),
}
}
fn get_string(&self) -> &String {
match self {
TTSCommandValue::String(s) => return s,
_ => panic!("Internal error: TTSCommandValue is not a string"),
}
}
fn get_pronounce(&self) -> &Pronounce {
match self {
TTSCommandValue::Pronounce(p) => return p,
_ => panic!("Internal error: TTSCommandValue is not a 'pronounce' command'"),
}
}
}
#[derive(Debug, Clone)]
pub struct TTSCommandRule {
command: TTSCommand,
value: TTSCommandValue,
replacements: ReplacementArray
}
impl fmt::Display for TTSCommandRule {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let value = match &self.value {
TTSCommandValue::String(s) => s.to_string(),
TTSCommandValue::Number(f) => f.to_string(),
TTSCommandValue::XPath(p) => p.to_string(),
TTSCommandValue::Pronounce(p) => p.to_string(),
};
if self.command == TTSCommand::Pause {
return write!(f, "pause: {}", value);
} else {
return write!(f, "{}: {}{}", self.command, value, self.replacements);
};
}
}
impl TTSCommandRule {
pub fn new(command: TTSCommand, value: TTSCommandValue, replacements: ReplacementArray) -> TTSCommandRule {
return TTSCommandRule{
command,
value,
replacements
}
}
}
#[allow(clippy::upper_case_acronyms)]
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TTS {
None,
SSML,
SAPI5,
}
impl TTS {
pub fn build(tts_command: &str, values: &Yaml) -> Result<Box<TTSCommandRule>> {
use crate::pretty_print::yaml_to_string;
let hashmap = values.as_hash();
let tts_value;
let replacements;
if hashmap.is_some() {
tts_value = &values["value"];
if tts_value.is_badvalue() {
bail!("{} TTS command is missing a 'value' sub-key. Found\n{}", tts_command, yaml_to_string(values, 1));
};
replacements = ReplacementArray::build(&values["replace"])?;
} else {
tts_value = values;
replacements = ReplacementArray::build_empty();
}
let tts_str_value = yaml_to_string(tts_value, 0);
let tts_str_value = tts_str_value.trim();
let tts_enum = match TTSCommand::from_str(tts_command) {
Ok(t) => t,
Err(_) => bail!("Internal error in build_tts: unexpected rule ({:?}) encountered", tts_command),
};
let tts_command_value = match tts_enum {
TTSCommand::Pause | TTSCommand::Rate | TTSCommand::Volume | TTSCommand::Pitch => {
let val = match tts_str_value {
"short" => Ok( PAUSE_SHORT ),
"medium" => Ok( PAUSE_MEDIUM ),
"long" => Ok( PAUSE_LONG ),
"auto" => Ok( PAUSE_AUTO ),
"$MathRate" => Ok( RATE_FROM_CONTEXT ), _ => tts_str_value.parse::<f64>()
};
match val {
Ok(num) => TTSCommandValue::Number(num),
Err(_) => {
TTSCommandValue::XPath(
MyXPath::build(tts_value).chain_err(|| format!("while trying to evaluate value of '{}:'", tts_enum))?
)
}
}
},
TTSCommand::Bookmark | TTSCommand::Spell => {
TTSCommandValue::XPath(
MyXPath::build(values).chain_err(|| format!("while trying to evaluate value of '{}:'", tts_enum))?
)
},
TTSCommand::Pronounce => {
TTSCommandValue::Pronounce( Box::new( Pronounce::build(values)? ) )
},
_ => {
TTSCommandValue::String(tts_str_value.to_string())
},
};
return Ok( Box::new( TTSCommandRule::new(tts_enum, tts_command_value, replacements) ) );
}
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> {
return T::replace_tts(self, command, prefs, rules_with_context, mathml);
}
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> {
if command.command == TTSCommand::Bookmark {
if prefs.get_api_prefs().to_string("Bookmark") != "true"{
return Ok("".to_string());
}
return Ok( match self {
TTS::None => "".to_string(),
TTS::SSML => compute_bookmark_element(&command.value, "mark name", rules_with_context, mathml)?,
TTS::SAPI5 => compute_bookmark_element(&command.value, "bookmark mark", rules_with_context, mathml)?,
} );
}
let mut command = command.clone();
if command.command == TTSCommand::Spell {
match command.value {
TTSCommandValue::XPath(xpath) => {
let value = xpath.evaluate(rules_with_context.get_context(), mathml)
.chain_err(|| format!("in 'spell': can't evaluate xpath \"{}\"", &xpath.to_string()) )?;
let value_string = match value {
Value::String(s) => s,
Value::Nodeset(nodes) if nodes.size() == 1 => {
let node = nodes.iter().next().unwrap();
if let Some(text) = node.text() {
text.text().to_string()
} else {
bail!("in 'spell': value returned from xpath '{}' does not evaluate to a string, it is {} nodes",
&xpath.to_string(), nodes.size());
}
},
_ => bail!("in 'spell': value returned from xpath '{}' does not evaluate to a string", &xpath.to_string()),
};
if rules_with_context.inside_spell {
command.value = TTSCommandValue::String(value_string);
} else {
let str_with_spaces = value_string.chars()
.map(|ch| {
rules_with_context.inside_spell = true;
let spelled_char = rules_with_context.replace_chars(ch.to_string().as_str(), mathml);
rules_with_context.inside_spell = false;
spelled_char
})
.collect::<Result<Vec<String>>>()?
.join(" ");
return Ok(str_with_spaces);
}
},
_ => bail!("Implementation error: found non-xpath value for spell"),
}
} else if command.command == TTSCommand::Rate && self != &TTS::None {
if let TTSCommandValue::Number(number_value) = command.value {
if number_value == RATE_FROM_CONTEXT {
let rate_from_context = crate::navigate::context_get_variable(rules_with_context.get_context(), "MathRate", mathml)?.1;
assert!(rate_from_context.is_some());
command.value = TTSCommandValue::Number(rate_from_context.unwrap());
}
}
}
if let TTSCommandValue::XPath(xpath) = command.value {
let eval_str = xpath.replace::<String>(rules_with_context, mathml)?;
command.value = match eval_str.parse::<f64>() {
Ok(num) => TTSCommandValue::Number(num),
Err(_) => TTSCommandValue::String(eval_str),
}
};
if ((command.command == TTSCommand::Pitch || command.command == TTSCommand::Volume || command.command == TTSCommand::Pause) && command.value.get_num() == 0.0) ||
(command.command == TTSCommand::Rate && command.value.get_num() == 100.0) {
return command.replacements.replace::<String>(rules_with_context, mathml);
}
let mut result = String::with_capacity(255);
result += &match self {
TTS::None => self.get_string_none(&command, prefs, true),
TTS::SSML => self.get_string_ssml(&command, prefs, true),
TTS::SAPI5 => self.get_string_sapi5(&command, prefs, true),
};
if !command.replacements.is_empty() {
if result.is_empty() {
result += " ";
}
result += &command.replacements.replace::<String>(rules_with_context, mathml)?;
}
let end_tag = match self {
TTS::None => self.get_string_none(&command, prefs, false),
TTS::SSML => self.get_string_ssml(&command, prefs, false),
TTS::SAPI5 => self.get_string_sapi5(&command, prefs, false),
};
if end_tag.is_empty() {
return Ok( result ); } else {
return Ok( result + &end_tag );
}
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> {
match value {
TTSCommandValue::XPath(xpath) => {
let id = xpath.replace::<String>(rules_with_context, mathml)?;
return Ok( format!("<{}='{}'/>", tag_and_attr, id) );
},
_ => bail!("Implementation error: found bookmark value that did not evaluate to a string"),
}
}
}
fn get_string_none(&self, command: &TTSCommandRule, prefs: &PreferenceManager, is_start_tag: bool) -> String {
if is_start_tag {
if command.command == TTSCommand::Pause {
let amount = command.value.get_num();
return crate::speech::CONCAT_INDICATOR.to_string() + (
if amount == PAUSE_AUTO {
PAUSE_AUTO_STR
} else {
let amount = amount * TTS::get_pause_multiplier(prefs);
if amount <= MIN_PAUSE {
""
} else if amount <= 250.0 {
","
} else {
";"
}
}
);
} else if command.command == TTSCommand::Spell {
return command.value.get_string().to_string();
} else if let TTSCommandValue::Pronounce(p) = &command.value {
return crate::speech::CONCAT_INDICATOR.to_string() + &p.text;
}
};
return "".to_string();
}
fn get_string_sapi5(&self, command: &TTSCommandRule, prefs: &PreferenceManager, is_start_tag: bool) -> String {
return match &command.command {
TTSCommand::Pause => if is_start_tag {
let amount = command.value.get_num();
if amount == PAUSE_AUTO {
PAUSE_AUTO_STR.to_string()
} else {
let amount = amount * TTS::get_pause_multiplier(prefs);
if amount > MIN_PAUSE {
format!("<silence msec=='{}ms'/>", (amount * 180.0/prefs.get_rate()).round())
} else {
"".to_string()
}
}
} else {
"".to_string()
},
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>")},
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>")},
TTSCommand::Volume =>if is_start_tag {format!("<volume level='{}'>", command.value.get_num())} else {String::from("</volume>")},
TTSCommand::Gender =>if is_start_tag {format!("<voice required=\"Gender={}\">", command.value.get_string())} else {String::from("</prosody>")},
TTSCommand::Voice =>if is_start_tag {format!("<voice required=\"Name={}\">", command.value.get_string())} else {String::from("</prosody>")},
TTSCommand::Spell =>if is_start_tag {format!("<spell>{}", command.value.get_string())} else {String::from("</spell>")},
TTSCommand::Pronounce =>if is_start_tag {
format!("<pron sym='{}'>{}", &command.value.get_pronounce().sapi5, &command.value.get_pronounce().text)
} else {
String::from("</pron>")
},
TTSCommand::Bookmark => panic!("Internal error: bookmarks should have been handled earlier"),
};
}
fn get_string_ssml(&self, command: &TTSCommandRule, prefs: &PreferenceManager, is_start_tag: bool) -> String {
return match &command.command {
TTSCommand::Pause => {
if is_start_tag {
let amount = command.value.get_num();
if amount == PAUSE_AUTO {
PAUSE_AUTO_STR.to_string()
} else {
let amount = amount * TTS::get_pause_multiplier(prefs);
if amount > MIN_PAUSE {
format!("<break time='{}ms'/>", (amount * 180.0/prefs.get_rate()).round())
} else {
"".to_string()
}
}
} else {
"".to_string()
}
},
TTSCommand::Pitch => if is_start_tag {format!("<prosody pitch='{}%'>", command.value.get_num())} else {String::from("</prosody>")},
TTSCommand::Rate => if is_start_tag {format!("<prosody rate='{}%'>", command.value.get_num())} else {String::from("</prosody>")},
TTSCommand::Volume =>if is_start_tag {format!("<prosody volume='{}db'>", command.value.get_num())} else {String::from("</prosody>")},
TTSCommand::Gender =>if is_start_tag {format!("<voice required='gender=\"{}\"'>", command.value.get_string())} else {String::from("</voice>")},
TTSCommand::Voice =>if is_start_tag {format!("<voice required='{}'>", command.value.get_string())} else {String::from("</voice>")},
TTSCommand::Spell =>if is_start_tag {format!("<say-as interpret-as='characters'>{}", command.value.get_string())} else {String::from("</say-as>")},
TTSCommand::Pronounce =>if is_start_tag {
format!("<phoneme alphabet='ipa' ph='{}'>{}", &command.value.get_pronounce().ipa, &command.value.get_pronounce().text)
} else {
String::from("</phoneme>")
},
TTSCommand::Bookmark => panic!("Internal error: bookmarks should have been handled earlier"),
}
}
fn get_pause_multiplier(prefs: &PreferenceManager) -> f64 {
return prefs.get_user_prefs().to_string("PauseFactor").parse::<f64>().unwrap_or(100.)/100.0;
}
pub fn compute_auto_pause(&self, prefs: &PreferenceManager, before: &str, after: &str) -> String {
lazy_static! {
static ref REMOVE_XML: Regex = Regex::new(r"<.+?>").unwrap(); }
let before_len;
let after_len;
match self {
TTS::SSML | TTS::SAPI5 => {
before_len = REMOVE_XML.replace_all(before, "").len();
after_len = REMOVE_XML.replace_all(after, "").len();
},
_ => {
before_len = before.len();
after_len = after.len();
},
}
if after_len < 3 {
return "".to_string();
}
let pause = std::cmp::min(3000, ((2 * before_len + after_len)/48) * 128);
let command = TTSCommandRule::new(
TTSCommand::Pause,
TTSCommandValue::Number(pause as f64),
ReplacementArray::build_empty(),
);
return match self {
TTS::None => self.get_string_none(&command, prefs, true),
TTS::SSML => self.get_string_ssml(&command, prefs, true),
TTS::SAPI5 => self.get_string_sapi5(&command, prefs, true),
};
}
pub fn merge_pauses(&self, str: &str) -> String {
return match self {
TTS::None => self.merge_pauses_none(str),
TTS::SSML => self.merge_pauses_ssml(str),
TTS::SAPI5 => self.merge_pauses_sapi5(str),
};
}
fn merge_pauses_none(&self, str: &str) -> String {
lazy_static! {
static ref MULTIPLE_PAUSES: Regex = Regex::new(r"[,;][,;]+").unwrap(); }
let mut merges_string = str.to_string();
for cap in MULTIPLE_PAUSES.captures_iter(str) {
merges_string = merges_string.replace(&cap[0], ";");
}
return merges_string;
}
fn merge_pauses_xml<F>(str: &str, full_attr_re: &Regex, sub_attr_re: &Regex, replace_with: F) -> String
where F: Fn(usize) -> String {
let mut merges_string = str.to_string();
for cap in full_attr_re.captures_iter(str) {
let mut amount = 0;
for c in sub_attr_re.captures_iter(&cap[0]) {
amount = std::cmp::max(amount, c[1].parse::<usize>().unwrap());
};
merges_string = merges_string.replace(&cap[0], &replace_with(amount));
}
return merges_string;
}
fn merge_pauses_sapi5(&self, str: &str) -> String {
lazy_static! {
static ref CONSECUTIVE_BREAKS: Regex = Regex::new(r"(<silence msec[^>]+?> *){2,}").unwrap(); static ref PAUSE_AMOUNT: Regex = Regex::new(r"msec=.*?(\d+)").unwrap(); }
let replacement = |amount: usize| format!("<silence msec=='{}ms'/>", amount);
return TTS::merge_pauses_xml(str, &CONSECUTIVE_BREAKS, &PAUSE_AMOUNT, replacement);
}
fn merge_pauses_ssml(&self, str: &str) -> String {
lazy_static! {
static ref CONSECUTIVE_BREAKS: Regex = Regex::new(r"(<break time=[^>]+?> *){2,}").unwrap(); static ref PAUSE_AMOUNT: Regex = Regex::new(r"time=.*?(\d+)").unwrap(); }
let replacement = |amount: usize| format!("<break time='{}ms'/>", amount);
return TTS::merge_pauses_xml(str, &CONSECUTIVE_BREAKS, &PAUSE_AMOUNT, replacement);
}
}