Add voices api.

Get voice locales known to speech-dispatcher.
Get possible voice list for a given locale.
Added getting and setting the user's chosen voice.
Tell orca to reload settings after changing.
Initialize user's voice from orca settings.
This commit is contained in:
Jeremy Whiting 2025-06-05 12:00:34 -06:00 committed by Jeremy Whiting
parent 36685ce630
commit 02a15c9295
5 changed files with 332 additions and 2 deletions

132
Cargo.lock generated
View file

@ -93,6 +93,26 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "bindgen"
version = "0.72.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f72209734318d0b619a5e0f5129918b848c416e122a3c4ce054e03cb87b726f"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.1" version = "2.9.1"
@ -105,6 +125,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "cfg-expr" name = "cfg-expr"
version = "0.18.0" version = "0.18.0"
@ -127,6 +156,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.40" version = "4.5.40"
@ -490,6 +530,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "glob"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.20.10" version = "0.20.10"
@ -594,6 +640,15 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@ -621,6 +676,16 @@ version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libloading"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
dependencies = [
"cfg-if",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "libudev-sys" name = "libudev-sys"
version = "0.1.4" version = "0.1.4"
@ -637,6 +702,12 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@ -661,6 +732,12 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -706,6 +783,16 @@ dependencies = [
"memoffset", "memoffset",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "ntapi" name = "ntapi"
version = "0.4.1" version = "0.4.1"
@ -820,6 +907,16 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "prettyplease"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.3.0" version = "3.3.0"
@ -924,6 +1021,12 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.7" version = "1.0.7"
@ -1010,6 +1113,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.5" version = "1.4.5"
@ -1041,6 +1150,26 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "speech-dispatcher"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5727d53c474ba5ada07784ad7d203cf896a74854cfee0eb32376b00759eb2972"
dependencies = [
"lazy_static",
"libc",
"speech-dispatcher-sys",
]
[[package]]
name = "speech-dispatcher-sys"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c3e8acdf2b1f4bb13f1813b40b52f3edf4cc94d8a55fe713a584f672a10388d"
dependencies = [
"bindgen",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@ -1058,7 +1187,7 @@ dependencies = [
"gio", "gio",
"inotify", "inotify",
"input-linux", "input-linux",
"itertools", "itertools 0.14.0",
"lazy_static", "lazy_static",
"libc", "libc",
"nix 0.30.1", "nix 0.30.1",
@ -1066,6 +1195,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"speech-dispatcher",
"strum", "strum",
"sysinfo", "sysinfo",
"tempfile", "tempfile",

View file

@ -18,11 +18,12 @@ input-linux = "0.7"
itertools = "0.14" itertools = "0.14"
lazy_static = "1" lazy_static = "1"
libc = "0.2" libc = "0.2"
nix = { version = "0.30", default-features = false, features = ["fs", "poll", "signal", "time"] } nix = { version = "0.30", default-features = false, features = ["fs", "poll", "signal", "time", "user"] }
num_enum = "0.7" num_enum = "0.7"
regex = "1" regex = "1"
serde = { version = "1.0", default-features = false, features = ["derive"] } serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
speech-dispatcher = "0.16"
strum = { version = "0.27", features = ["derive"] } strum = { version = "0.27", features = ["derive"] }
sysinfo = "0.35" sysinfo = "0.35"
tempfile = "3" tempfile = "3"

View file

@ -343,6 +343,22 @@
--> -->
<property name="Mode" type="u" access="readwrite"/> <property name="Mode" type="u" access="readwrite"/>
<!--
Voice
The voice to use for screen reading.
Valid voices can be found from GetVoicesForLocale
-->
<property name="Voice" type="s" access="readwrite"/>
<!--
Voice Locales
The list of known voice locales
-->
<property name="VoiceLocales" type="as" access="read"/>
<!-- <!--
Trigger Action Trigger Action
@ -369,6 +385,20 @@
<arg type="u" name="action" direction="in"/> <arg type="u" name="action" direction="in"/>
<arg type="t" name="timestamp" direction="in"/> <arg type="t" name="timestamp" direction="in"/>
</method> </method>
<!--
Get the voices for a given locale
Get a list of voices for a given locale
@locale: The locale to get the voices for. e.g. en-US
@voices: A list of voice names to present to the user
-->
<method name="GetVoicesForLocale">
<arg type="s" name="locale" direction="in"/>
<arg type="as" name="voices" direction="out"/>
</method>
</interface> </interface>
<!-- <!--

View file

@ -711,6 +711,29 @@ impl ScreenReader0 {
self.mode_changed(&ctx).await.map_err(to_zbus_fdo_error) self.mode_changed(&ctx).await.map_err(to_zbus_fdo_error)
} }
#[zbus(property)]
async fn voice(&self) -> String {
self.screen_reader.voice()
}
#[zbus(property)]
async fn set_voice(
&mut self,
voice: &str,
#[zbus(signal_emitter)] ctx: SignalEmitter<'_>,
) -> fdo::Result<()> {
self.screen_reader
.set_voice(voice)
.await
.map_err(to_zbus_fdo_error)?;
self.voice_changed(&ctx).await.map_err(to_zbus_fdo_error)
}
#[zbus(property)]
async fn voice_locales(&self) -> Vec<String> {
self.screen_reader.get_voice_locales()
}
async fn trigger_action(&mut self, a: u32, timestamp: u64) -> fdo::Result<()> { async fn trigger_action(&mut self, a: u32, timestamp: u64) -> fdo::Result<()> {
let action = match ScreenReaderAction::try_from(a) { let action = match ScreenReaderAction::try_from(a) {
Ok(action) => action, Ok(action) => action,
@ -721,6 +744,12 @@ impl ScreenReader0 {
.await .await
.map_err(to_zbus_fdo_error) .map_err(to_zbus_fdo_error)
} }
async fn get_voices(&self, locale: &str) -> fdo::Result<Vec<String>> {
self.screen_reader
.get_voices(locale)
.ok_or(fdo::Error::Failed(String::from("No voices found")))
}
} }
#[interface(name = "com.steampowered.SteamOSManager1.Storage1")] #[interface(name = "com.steampowered.SteamOSManager1.Storage1")]

View file

@ -12,8 +12,13 @@ use input_linux::Key;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use nix::sys::signal; use nix::sys::signal;
use nix::unistd::Pid; use nix::unistd::Pid;
#[cfg(not(test))]
use nix::unistd::{Uid, User};
use num_enum::TryFromPrimitive; use num_enum::TryFromPrimitive;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use speech_dispatcher::Voice;
#[cfg(not(test))]
use speech_dispatcher::{Connection as SDConnection, Mode};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::ops::RangeInclusive; use std::ops::RangeInclusive;
@ -40,6 +45,8 @@ const ORCA_SETTINGS: &str = "orca/user-settings.conf";
const PITCH_SETTING: &str = "average-pitch"; const PITCH_SETTING: &str = "average-pitch";
const RATE_SETTING: &str = "rate"; const RATE_SETTING: &str = "rate";
const VOLUME_SETTING: &str = "gain"; const VOLUME_SETTING: &str = "gain";
const FAMILY_SETTING: &str = "family";
const VOICE_NAME_SETTING: &str = "name";
const ENABLE_SETTING: &str = "enableSpeech"; const ENABLE_SETTING: &str = "enableSpeech";
const A11Y_SETTING: &str = "org.gnome.desktop.a11y.applications"; const A11Y_SETTING: &str = "org.gnome.desktop.a11y.applications";
@ -49,6 +56,7 @@ const KEYBOARD_NAME: &str = "steamos-manager";
const PITCH_DEFAULT: f64 = 5.0; const PITCH_DEFAULT: f64 = 5.0;
const RATE_DEFAULT: f64 = 50.0; const RATE_DEFAULT: f64 = 50.0;
const VOLUME_DEFAULT: f64 = 10.0; const VOLUME_DEFAULT: f64 = 10.0;
const VOICE_NAME_DEFAULT: &str = "default";
lazy_static! { lazy_static! {
static ref VALID_SETTINGS: HashMap<&'static str, RangeInclusive<f64>> = HashMap::from_iter([ static ref VALID_SETTINGS: HashMap<&'static str, RangeInclusive<f64>> = HashMap::from_iter([
@ -89,7 +97,10 @@ pub(crate) struct OrcaManager<'dbus> {
volume: f64, volume: f64,
enabled: bool, enabled: bool,
mode: ScreenReaderMode, mode: ScreenReaderMode,
voice: String,
keyboard: UInputDevice, keyboard: UInputDevice,
voices: HashMap<String, Voice>,
voices_by_language: HashMap<String, Vec<String>>,
} }
impl<'dbus> OrcaManager<'dbus> { impl<'dbus> OrcaManager<'dbus> {
@ -102,7 +113,10 @@ impl<'dbus> OrcaManager<'dbus> {
enabled: true, enabled: true,
// Always start in browse mode for now, since we have no storage to remember this property // Always start in browse mode for now, since we have no storage to remember this property
mode: ScreenReaderMode::Browse, mode: ScreenReaderMode::Browse,
voice: String::new(),
keyboard: UInputDevice::new()?, keyboard: UInputDevice::new()?,
voices: HashMap::new(),
voices_by_language: HashMap::new(),
}; };
let _ = manager let _ = manager
.load_values() .load_values()
@ -124,9 +138,46 @@ impl<'dbus> OrcaManager<'dbus> {
Key::Up, Key::Up,
])?; ])?;
#[cfg(not(test))]
match manager.init_voice_list() {
Ok(()) => trace!("Voice list loaded"),
Err(e) => error!("Unable to init voice list. {e}"),
}
Ok(manager) Ok(manager)
} }
#[cfg(not(test))]
fn init_voice_list(&mut self) -> Result<()> {
const CLIENT_NAME: &str = "steamos-manager";
const CONNECTION_NAME: &str = "steamos-manager";
let user_name = User::from_uid(Uid::current())?
.ok_or(anyhow!("Unable to get current user"))?
.name;
let connection =
SDConnection::open(CLIENT_NAME, CONNECTION_NAME, &user_name, Mode::Threaded)?;
let voices = connection.list_synthesis_voices()?.to_vec();
for v in voices.iter() {
let name = &v.name;
let lang = &v.language;
self.voices.insert(name.clone(), v.clone());
self.voices_by_language
.entry(lang.clone())
.or_default()
.push(name.clone());
}
Ok(())
}
pub fn get_voices(&self, locale: &str) -> Option<Vec<String>> {
self.voices_by_language.get(locale).cloned()
}
pub fn get_voice_locales(&self) -> Vec<String> {
self.voices_by_language.keys().cloned().collect()
}
#[cfg(not(test))] #[cfg(not(test))]
fn settings_path(&self) -> Result<PathBuf> { fn settings_path(&self) -> Result<PathBuf> {
let xdg_base = BaseDirectories::new(); let xdg_base = BaseDirectories::new();
@ -170,6 +221,21 @@ impl<'dbus> OrcaManager<'dbus> {
Ok(()) Ok(())
} }
pub fn voice(&self) -> String {
self.voice.clone()
}
pub async fn set_voice(&mut self, voice: &str) -> Result<()> {
let properties = self
.voices
.get(voice)
.ok_or(anyhow!("Invalid voice specified"))?;
self.set_orca_voice(properties).await?;
self.voice = voice.to_string();
self.reload_orca().await?;
Ok(())
}
pub fn pitch(&self) -> f64 { pub fn pitch(&self) -> f64 {
self.pitch self.pitch
} }
@ -398,9 +464,83 @@ impl<'dbus> OrcaManager<'dbus> {
self.rate, self.pitch, self.volume self.rate, self.pitch, self.volume
); );
if let Some(family) = default_voice.get(FAMILY_SETTING) {
if let Some(name) = family.get(VOICE_NAME_SETTING) {
self.voice = name
.as_str()
.unwrap_or_else(|| {
error!("Unable to convert orca default voice family name to string value");
VOICE_NAME_DEFAULT
})
.to_string();
} else {
warn!("Unable to load default voice family name from orca user-settings.conf");
}
} else {
warn!("Unable to load default voice family from orca user-settings.conf");
}
Ok(()) Ok(())
} }
async fn set_orca_voice(&self, voice: &Voice) -> Result<()> {
let data = read_to_string(self.settings_path()?).await?;
let mut json: Value = serde_json::from_str(&data)?;
let profiles = json
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf json is not an object"))?
.entry("profiles")
.or_insert(Value::Object(Map::new()));
let default_profile = profiles
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf profiles is not an object"))?
.entry("default")
.or_insert(Value::Object(Map::new()));
let voices = default_profile
.as_object_mut()
.ok_or(anyhow!(
"orca user-settings.conf default profile is not an object"
))?
.entry("voices")
.or_insert(Value::Object(Map::new()));
let default_voice = voices
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf voices is not an object"))?
.entry("default")
.or_insert(Value::Object(Map::new()));
let family = default_voice
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf family is not an object"))?
.entry("family")
.or_insert(Value::Object(Map::new()));
// If we have a dialect use it, otherwise leave it blank
let mut language = voice.language.clone();
let mut dialect = "";
if let Some((l, d)) = voice.language.split_once("-") {
language = l.to_string();
dialect = d;
}
let mut_family = family.as_object_mut().ok_or(anyhow!(
"orca user-settings.conf default voice family is not an object"
))?;
mut_family.insert("name".to_string(), voice.name.clone().into());
mut_family.insert("lang".to_string(), language.into());
mut_family.insert("variant".to_string(), voice.variant.clone().into());
mut_family.insert("dialect".to_string(), dialect.into());
// Set established property
default_voice
.as_object_mut()
.ok_or(anyhow!(
"orca user-settings.conf default voice is not an object"
))?
.insert("established".to_string(), true.into());
let data = serde_json::to_string_pretty(&json)?;
Ok(write(self.settings_path()?, data.as_bytes()).await?)
}
async fn set_orca_option(&self, option: &str, value: f64) -> Result<()> { async fn set_orca_option(&self, option: &str, value: f64) -> Result<()> {
if let Some(range) = VALID_SETTINGS.get(option) { if let Some(range) = VALID_SETTINGS.get(option) {
ensure!( ensure!(