open-standards/aac/aac-rs/crates/aac-web/src/voices.rs

378 lines
15 KiB
Rust

//! Which voice speaks for the person, and what they chose.
//!
//! For the person's language, in order:
//! 1. the natural voice they downloaded and chose (neural_voice.rs: runs on
//! this device, rated by the intelligibility gate); while it starts, or if
//! it fails, the device voice below stands in;
//! 2. the device voice they chose, while the device has it;
//! 3. else the best device voice (access-core voice.rs ranks them: premium,
//! enhanced, basic, and robotic or novelty voices last, only when nothing
//! else speaks the language). A voice that sends the words away is never
//! used.
//!
//! Nobody who spoke before this rule loses their voice: the first time each
//! language is used, the voice that spoke it for them is recorded as their
//! choice ([`settle`]); if it is a robotic or novelty voice (or another
//! variety of the language) and a better one is there, the best one takes
//! its place, and a toast says so, once. Nothing about voices covers,
//! resizes or takes over the board: what the person needs to know is a
//! text-only toast, once, and the details are in Settings > Voice.
use std::collections::{BTreeMap, BTreeSet};
use aac_core::voice::{ChosenVoice, Rejection, VoiceTier};
use serde::{Deserialize, Serialize};
use crate::i18n::{tr, tr_args};
/// The kind of voice a natural (downloaded) voice is, in the app's words.
pub fn natural_kind() -> String {
tr("voices-kind-natural").text
}
/// A device voice's kind, in the app's words.
pub fn kind_words(tier: VoiceTier, last_resort: bool) -> String {
match (tier, last_resort) {
(_, true) => tr("voices-kind-last-resort").text,
(VoiceTier::Premium | VoiceTier::Enhanced, false) => tr("voices-kind-good").text,
(VoiceTier::Basic, false) => tr("voices-kind-basic").text,
}
}
/// Why a voice is a last resort (or never used), in plain words: "{voice}
/// {words}".
pub fn rejection_words(why: Rejection) -> String {
match why {
Rejection::Network => tr("voices-reason-network").text,
Rejection::Espeak | Rejection::SpeechDispatcher | Rejection::Eloquence => tr("voices-reason-robotic").text,
Rejection::Novelty => tr("voices-reason-novelty").text,
Rejection::SuperCompact => tr("voices-reason-lowest-quality").text,
}
}
/// Why someone's earlier voice gave way to a better one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Replaced {
/// It is a last resort (robotic, novelty...) or never used (online).
Rejected(Rejection),
/// It speaks another variety of the language (Cantonese for Mandarin).
OtherVariety,
}
/// What speaks the language now, for the person to read.
#[derive(Clone, Debug, PartialEq)]
pub enum InUse {
Natural { name: String },
/// The natural voice chosen is starting (loading); the device voice
/// that speaks meanwhile, if any.
Starting { name: String, meanwhile: Option<String> },
Device { name: String, tier: VoiceTier, last_resort: bool, chosen: bool },
/// No voice on the device speaks the language.
Nothing,
/// The browser has not listed its voices yet.
Checking,
/// The browser has no speech of its own.
NoSpeech,
}
impl InUse {
/// One plain sentence: "Speaking with Ava (Premium) — good device voice."
pub fn sentence(&self) -> String {
match self {
InUse::Natural { name } => tr_args("voices-in-use", &crate::ui_args!(name = name, kind = natural_kind())).text,
InUse::Starting { name, meanwhile: Some(device) } => {
tr_args("voices-in-use-starting-meanwhile", &crate::ui_args!(name = name, device = device)).text
}
InUse::Starting { name, meanwhile: None } => tr_args("voices-in-use-starting", &crate::ui_args!(name = name)).text,
InUse::Device { name, tier, last_resort, chosen } => {
let args = crate::ui_args!(name = name, kind = kind_words(*tier, *last_resort));
if *chosen && !*last_resort {
tr_args("voices-in-use-chosen", &args).text
} else {
tr_args("voices-in-use", &args).text
}
}
InUse::Nothing => tr("voices-in-use-nothing").text,
InUse::Checking => tr("voices-in-use-checking").text,
InUse::NoSpeech => tr("voices-in-use-no-speech").text,
}
}
}
/// The person's device-voice choices, kept on this device.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DevicePrefs {
/// The device voice chosen for each language (registry code).
pub chosen: BTreeMap<String, ChosenVoice>,
/// Whether the person used the app before voices were ranked: `None`
/// until decided (the first time the device lists its voices, or at
/// setup), so prefs a test build wrote without it are decided afresh.
pub earlier: Option<bool>,
/// Languages (registry codes) whose earlier voice has been settled.
pub kept: BTreeSet<String>,
/// Languages the person was told have no natural voice yet: when the
/// catalogue offers one later, Settings says it is new.
pub awaiting_natural: BTreeSet<String>,
}
#[cfg(feature = "hydrate")]
pub use browser::*;
#[cfg(feature = "hydrate")]
mod browser {
use std::cell::RefCell;
use aac_core::voice::{best_voice, earlier_choice, rank_voices, Platform, SystemVoice, VoiceContext};
use aac_core::VoiceConfig;
use wasm_bindgen::JsCast;
use super::*;
use crate::neural_voice::lang_code;
const PREFS_KEY: &str = "aac_device_voices";
type ToastHandler = Box<dyn Fn(String)>;
thread_local! {
static PREFS: RefCell<Option<DevicePrefs>> = const { RefCell::new(None) };
/// The platform, the browser's language and its user agent.
static CONTEXT: RefCell<Option<(Platform, String, String)>> = const { RefCell::new(None) };
static ON_TOAST: RefCell<Option<ToastHandler>> = const { RefCell::new(None) };
/// Toasts already given this session.
static TOLD: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
}
fn context() -> (Platform, String, String) {
CONTEXT.with(|c| {
c.borrow_mut()
.get_or_insert_with(|| {
let nav = web_sys::window().map(|w| w.navigator());
let ua = nav.as_ref().and_then(|n| n.user_agent().ok()).unwrap_or_default();
let touch = nav.as_ref().map_or(0, |n| u32::try_from(n.max_touch_points()).unwrap_or(0));
let ui = nav.as_ref().and_then(web_sys::Navigator::language).unwrap_or_default();
(Platform::from_user_agent(&ua, touch), ui, ua)
})
.clone()
})
}
fn listed() -> Vec<SystemVoice> {
web_sys::window()
.and_then(|w| w.speech_synthesis().ok())
.map(|s| {
s.get_voices()
.iter()
.filter_map(|v| v.dyn_into::<web_sys::SpeechSynthesisVoice>().ok())
.map(|v| SystemVoice { name: v.name(), lang: v.lang(), uri: v.voice_uri(), local: v.local_service(), default: v.default() })
.collect()
})
.unwrap_or_default()
}
/// The device the app runs on (an Android tablet in desktop mode is
/// Android: its voices say so).
pub fn platform() -> Platform {
context().0.with_voices(&listed())
}
/// Whether this browser gives pages only the device's built-in (basic)
/// voices: every iPhone and iPad browser, and Safari on a Mac.
pub fn builtin_voices_only() -> bool {
let (platform, _, ua) = context();
platform.builtin_voices_only(&ua)
}
/// Run `f` with what ranking needs besides the voices.
pub fn with_context<R>(f: impl FnOnce(&VoiceContext<'_>) -> R) -> R {
let (platform, ui, _) = context();
f(&VoiceContext { platform, ui_language: &ui })
}
fn prefs() -> DevicePrefs {
PREFS.with(|p| p.borrow_mut().get_or_insert_with(|| crate::persistence::load(PREFS_KEY).unwrap_or_default()).clone())
}
fn save(p: DevicePrefs) {
crate::persistence::save(PREFS_KEY, &p);
PREFS.with(|c| *c.borrow_mut() = Some(p));
}
/// The device voice chosen for a language (code or locale).
pub fn chosen(lang: &str) -> Option<ChosenVoice> {
prefs().chosen.get(&lang_code(lang)).cloned()
}
/// Choose a device voice for a language; the natural voice chosen for
/// it, if any, steps aside.
pub fn choose_device(lang: &str, voice: ChosenVoice) {
let mut p = prefs();
p.chosen.insert(lang_code(lang), voice);
save(p);
crate::neural_voice::choose_pack(lang, None);
crate::neural_voice::unload();
}
/// For someone new: nothing to keep from before (setup's voice step, or a
/// language change, is where they choose).
pub fn mark_settled() {
let mut p = prefs();
if p.earlier != Some(false) {
p.earlier = Some(false);
save(p);
}
}
/// Once for each language, the first time it is used with the device's
/// voices listed: if the person used the app before voices were ranked,
/// keep the voice that spoke that language for them, recorded as their
/// choice. If it is a robotic or novelty voice, another variety of the
/// language, or an online voice, and a better one is there, the best one
/// takes its place, and a toast says so (once: the language is settled).
pub fn settle(voices: &[SystemVoice], config: &VoiceConfig) {
if voices.is_empty() {
return;
}
let mut p = prefs();
let mut changed = false;
if p.earlier.is_none() {
p.earlier = Some(crate::persistence::is_setup_complete());
changed = true;
}
let lang = lang_code(&config.locale);
if p.earlier != Some(true) || p.kept.contains(&lang) {
if changed {
save(p);
}
return;
}
p.kept.insert(lang.clone());
let mut told = None;
if crate::neural_voice::chosen_pack(&lang).is_none()
&& !p.chosen.contains_key(&lang)
&& let Some(i) = earlier_choice(voices, &config.locale, config.allow_online_voices)
{
let ranked = with_context(|ctx| rank_voices(voices, &config.locale, ctx));
let why = match ranked.iter().find(|r| r.index == i) {
None => Some(Replaced::Rejected(Rejection::Network)),
Some(r) => match r.rejected {
Some(why) => Some(Replaced::Rejected(why)),
None if r.other_variety => Some(Replaced::OtherVariety),
None => None,
},
};
match why {
None => {
p.chosen.insert(lang.clone(), ChosenVoice::of(&voices[i]));
}
Some(why) => {
if let Some(best) = with_context(|ctx| best_voice(voices, &config.locale, ctx)) {
p.chosen.insert(lang.clone(), ChosenVoice::of(&voices[best.index]));
told = Some(replaced_words(&voices[i].name, why, &voices[best.index].name, &lang));
}
}
}
}
save(p);
if let Some(message) = told {
toast(message);
}
}
/// The toast for someone whose earlier voice gave way to a better one.
fn replaced_words(old: &str, why: Replaced, now: &str, lang: &str) -> String {
let name = crate::components::voice_offer::language_name(lang);
let reason = match why {
Replaced::Rejected(why) => rejection_words(why),
Replaced::OtherVariety => tr_args("voices-reason-other-variety", &crate::ui_args!(language = name.clone())).text,
};
tr_args("voices-replaced", &crate::ui_args!(old = old, language = name, reason = reason, now = now)).text
}
/// The person was told `lang` has no natural voice yet.
pub fn await_natural(lang: &str) {
let mut p = prefs();
if p.awaiting_natural.insert(lang_code(lang)) {
save(p);
}
}
/// Whether the person was told `lang` had no natural voice (so one
/// offered now is new to them).
pub fn awaiting_natural(lang: &str) -> bool {
prefs().awaiting_natural.contains(&lang_code(lang))
}
/// The person has been told `lang` now has a natural voice.
pub fn natural_arrived(lang: &str) {
let mut p = prefs();
if p.awaiting_natural.remove(&lang_code(lang)) {
save(p);
}
}
/// Who shows the app's text-only toasts about voices (the shell).
pub fn on_toast(handler: impl Fn(String) + 'static) {
ON_TOAST.with(|h| *h.borrow_mut() = Some(Box::new(handler)));
}
/// Show a text-only toast about voices.
pub fn toast(message: String) {
ON_TOAST.with(|h| {
if let Some(handler) = h.borrow().as_ref() {
handler(message);
}
});
}
/// Show a toast once this session for `what` (a language and a reason).
pub fn toast_once(what: &str, message: String) {
if TOLD.with(|t| t.borrow_mut().insert(what.to_string())) {
toast(message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefs_read_back_and_older_ones_are_decided_afresh() {
// Prefs a test build wrote, before `earlier` was kept as undecided.
let old: DevicePrefs = serde_json::from_str(r#"{"settled":true,"told":["settled:en"]}"#).unwrap();
assert_eq!(old.earlier, None, "undecided: settled again at the next start");
let mut p = DevicePrefs::default();
p.chosen.insert(
"en".into(),
ChosenVoice { name: "Samantha".into(), uri: "com.apple.voice.compact.en-US.Samantha".into(), lang: "en-US".into() },
);
p.earlier = Some(true);
p.kept.insert("en".into());
let back: DevicePrefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
assert_eq!(back, p);
}
#[test]
fn kinds_in_plain_words() {
assert_eq!(kind_words(VoiceTier::Premium, false), "good device voice");
assert_eq!(kind_words(VoiceTier::Enhanced, false), "good device voice");
assert_eq!(kind_words(VoiceTier::Basic, false), "basic device voice");
assert!(kind_words(VoiceTier::Basic, true).starts_with("robotic voice"));
assert_eq!(
InUse::Device { name: "Ava (Premium)".into(), tier: VoiceTier::Premium, last_resort: false, chosen: false }.sentence(),
"Speaking with Ava (Premium) \u{2014} good device voice."
);
assert_eq!(
InUse::Device { name: "Samantha".into(), tier: VoiceTier::Basic, last_resort: false, chosen: true }.sentence(),
"Speaking with Samantha \u{2014} basic device voice, which you chose."
);
assert!(InUse::Natural { name: "Heart".into() }.sentence().contains(&natural_kind()));
assert_eq!(
InUse::Starting { name: "Heart".into(), meanwhile: Some("Samantha".into()) }.sentence(),
"Heart is starting; Samantha speaks meanwhile."
);
assert_eq!(InUse::Starting { name: "Heart".into(), meanwhile: None }.sentence(), "Heart is starting.");
assert!(InUse::Nothing.sentence().starts_with("No voice on this device"));
}
}