378 lines
15 KiB
Rust
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"));
|
|
}
|
|
}
|