Merge branch 'work/whiting/screenreadervoices' into 'master'

Add voices api.

See merge request holo/steamos-manager!18
This commit is contained in:
Vicki Pfau 2025-07-04 15:00:22 -07:00
commit 98774dea76
8 changed files with 435 additions and 12 deletions

View file

@ -8,7 +8,7 @@ test:
tags:
- x86_64-linux-kvm-docker
script:
- pacman -Sy --noconfirm --needed dbus rust gsettings-desktop-schemas
- pacman -Sy --noconfirm --needed dbus rust gsettings-desktop-schemas clang speech-dispatcher
- dbus-run-session cargo test
workflow:

132
Cargo.lock generated
View file

@ -93,6 +93,26 @@ dependencies = [
"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]]
name = "bitflags"
version = "2.9.1"
@ -105,6 +125,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-expr"
version = "0.18.0"
@ -127,6 +156,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "clap"
version = "4.5.40"
@ -490,6 +530,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "glob"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "gobject-sys"
version = "0.20.10"
@ -594,6 +640,15 @@ dependencies = [
"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]]
name = "itertools"
version = "0.14.0"
@ -621,6 +676,16 @@ version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "libudev-sys"
version = "0.1.4"
@ -637,6 +702,12 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "matchers"
version = "0.1.0"
@ -661,6 +732,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -706,6 +783,16 @@ dependencies = [
"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]]
name = "ntapi"
version = "0.4.1"
@ -820,6 +907,16 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "proc-macro-crate"
version = "3.3.0"
@ -924,6 +1021,12 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.0.7"
@ -1010,6 +1113,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
@ -1041,6 +1150,26 @@ dependencies = [
"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]]
name = "static_assertions"
version = "1.1.0"
@ -1058,7 +1187,7 @@ dependencies = [
"gio",
"inotify",
"input-linux",
"itertools",
"itertools 0.14.0",
"lazy_static",
"libc",
"nix 0.30.1",
@ -1066,6 +1195,7 @@ dependencies = [
"regex",
"serde",
"serde_json",
"speech-dispatcher",
"strum",
"sysinfo",
"tempfile",

View file

@ -18,11 +18,12 @@ input-linux = "0.7"
itertools = "0.14"
lazy_static = "1"
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"
regex = "1"
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = "1.0"
speech-dispatcher = "0.16"
strum = { version = "0.27", features = ["derive"] }
sysinfo = "0.35"
tempfile = "3"

View file

@ -343,6 +343,27 @@
-->
<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"/>
<!--
Map of voice names per locale
-->
<property name="VoicesForLocale" type="a{sas}" access="read"/>
<!--
Trigger Action
@ -369,6 +390,7 @@
<arg type="u" name="action" direction="in"/>
<arg type="t" name="timestamp" direction="in"/>
</method>
</interface>
<!--

View file

@ -5,7 +5,7 @@
* SPDX-License-Identifier: MIT
*/
use anyhow::Result;
use anyhow::{anyhow, Result};
use clap::{ArgAction, Parser, Subcommand};
use itertools::Itertools;
use nix::time::{clock_gettime, ClockId};
@ -254,6 +254,24 @@ enum Commands {
mode: ScreenReaderMode,
},
/// Get screen reader known locales
GetScreenReaderLocales,
/// Get screen reader voices for given locale
GetScreenReaderVoicesForLocale {
/// Valid locales can be found using get-screen-reader-locales.
locale: String,
},
/// Get screen reader voice
GetScreenReaderVoice,
/// Set screen reader voice
SetScreenReaderVoice {
/// The voice name to use for screen reader. Valid voices can be found using get-screen-reader-voices-for-locale
voice: String,
},
/// Trigger screen reader action
TriggerScreenReaderAction {
/// Valid actions are
@ -638,6 +656,34 @@ async fn main() -> Result<()> {
.trigger_action(*action as u32, now.try_into()?)
.await?;
}
Commands::GetScreenReaderVoice => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
let voice = proxy.voice().await?;
println!("Voice: {voice}");
}
Commands::SetScreenReaderVoice { voice } => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
proxy.set_voice(voice).await?;
}
Commands::GetScreenReaderLocales => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
let locales = proxy.voice_locales().await?;
println!("Locales:\n");
for locale in locales.into_iter().sorted() {
println!("- {locale}");
}
}
Commands::GetScreenReaderVoicesForLocale { locale } => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
let voice_list = proxy.voices_for_locale().await?;
let voices = voice_list
.get(locale)
.ok_or_else(|| anyhow!("Unable to load voices map"))?;
println!("Voices:\n");
for voice in voices.iter().sorted() {
println!("- {voice}");
}
}
}
Ok(())

View file

@ -711,6 +711,34 @@ impl ScreenReader0 {
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()
}
#[zbus(property)]
async fn voices_for_locale(&self) -> HashMap<String, Vec<String>> {
self.screen_reader.get_voices()
}
async fn trigger_action(&mut self, a: u32, timestamp: u64) -> fdo::Result<()> {
let action = match ScreenReaderAction::try_from(a) {
Ok(action) => action,

View file

@ -19,12 +19,21 @@ use zbus::proxy;
assume_defaults = true
)]
pub trait ScreenReader0 {
/// TriggerAction method
fn trigger_action(&self, action: u32, timestamp: u64) -> zbus::Result<()>;
/// Enabled property
#[zbus(property)]
fn enabled(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn set_enabled(&self, value: bool) -> zbus::Result<()>;
/// Mode property
#[zbus(property)]
fn mode(&self) -> zbus::Result<u32>;
#[zbus(property)]
fn set_mode(&self, value: u32) -> zbus::Result<()>;
/// Pitch property
#[zbus(property)]
fn pitch(&self) -> zbus::Result<f64>;
@ -37,17 +46,23 @@ pub trait ScreenReader0 {
#[zbus(property)]
fn set_rate(&self, value: f64) -> zbus::Result<()>;
/// Voice property
#[zbus(property)]
fn voice(&self) -> zbus::Result<String>;
#[zbus(property)]
fn set_voice(&self, value: &str) -> zbus::Result<()>;
/// VoiceLocales property
#[zbus(property)]
fn voice_locales(&self) -> zbus::Result<Vec<String>>;
/// VoicesForLocale property
#[zbus(property)]
fn voices_for_locale(&self) -> zbus::Result<std::collections::HashMap<String, Vec<String>>>;
/// Volume property
#[zbus(property)]
fn volume(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn set_volume(&self, value: f64) -> zbus::Result<()>;
/// Mode property
#[zbus(property)]
fn mode(&self) -> zbus::Result<u32>;
#[zbus(property)]
fn set_mode(&self, mode: u32) -> zbus::Result<()>;
fn trigger_action(&self, action: u32, timestamp: u64) -> zbus::Result<()>;
}

View file

@ -12,8 +12,13 @@ use input_linux::Key;
use lazy_static::lazy_static;
use nix::sys::signal;
use nix::unistd::Pid;
#[cfg(not(test))]
use nix::unistd::{Uid, User};
use num_enum::TryFromPrimitive;
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::io::ErrorKind;
use std::ops::RangeInclusive;
@ -34,12 +39,20 @@ use crate::uinput::UInputDevice;
const TEST_ORCA_SETTINGS: &str = "data/test-orca-settings.conf";
#[cfg(test)]
const ORCA_SETTINGS: &str = "orca-settings.conf";
#[cfg(test)]
const TEST_VOICE_NAME: &str = "testvoice";
#[cfg(test)]
const TEST_VOICE_LANGUAGE: &str = "testvoicelanguage";
#[cfg(test)]
const TEST_VOICE_VARIANT: &str = "testvoicevariant";
#[cfg(not(test))]
const ORCA_SETTINGS: &str = "orca/user-settings.conf";
const PITCH_SETTING: &str = "average-pitch";
const RATE_SETTING: &str = "rate";
const VOLUME_SETTING: &str = "gain";
const FAMILY_SETTING: &str = "family";
const VOICE_NAME_SETTING: &str = "name";
const ENABLE_SETTING: &str = "enableSpeech";
const A11Y_SETTING: &str = "org.gnome.desktop.a11y.applications";
@ -49,6 +62,7 @@ const KEYBOARD_NAME: &str = "steamos-manager";
const PITCH_DEFAULT: f64 = 5.0;
const RATE_DEFAULT: f64 = 50.0;
const VOLUME_DEFAULT: f64 = 10.0;
const VOICE_NAME_DEFAULT: &str = "default";
lazy_static! {
static ref VALID_SETTINGS: HashMap<&'static str, RangeInclusive<f64>> = HashMap::from_iter([
@ -89,7 +103,10 @@ pub(crate) struct OrcaManager<'dbus> {
volume: f64,
enabled: bool,
mode: ScreenReaderMode,
voice: String,
keyboard: UInputDevice,
voices: HashMap<String, Voice>,
voices_by_language: HashMap<String, Vec<String>>,
}
impl<'dbus> OrcaManager<'dbus> {
@ -102,7 +119,10 @@ impl<'dbus> OrcaManager<'dbus> {
enabled: true,
// Always start in browse mode for now, since we have no storage to remember this property
mode: ScreenReaderMode::Browse,
voice: String::new(),
keyboard: UInputDevice::new()?,
voices: HashMap::new(),
voices_by_language: HashMap::new(),
};
let _ = manager
.load_values()
@ -124,9 +144,60 @@ impl<'dbus> OrcaManager<'dbus> {
Key::Up,
])?;
match manager.init_voice_list() {
Ok(()) => trace!("Voice list loaded"),
Err(e) => error!("Unable to init voice list. {e}"),
}
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) -> HashMap<String, Vec<String>> {
self.voices_by_language.clone()
}
#[cfg(test)]
fn init_voice_list(&mut self) -> Result<()> {
let test_voice = Voice {
name: TEST_VOICE_NAME.to_string(),
language: TEST_VOICE_LANGUAGE.to_string(),
variant: Some(TEST_VOICE_VARIANT.to_string()),
};
self.voices.insert(TEST_VOICE_NAME.to_string(), test_voice);
self.voices_by_language
.entry(TEST_VOICE_LANGUAGE.to_string())
.or_default()
.push(TEST_VOICE_NAME.to_string());
Ok(())
}
pub fn get_voice_locales(&self) -> Vec<String> {
self.voices_by_language.keys().cloned().collect()
}
#[cfg(not(test))]
fn settings_path(&self) -> Result<PathBuf> {
let xdg_base = BaseDirectories::new();
@ -170,6 +241,21 @@ impl<'dbus> OrcaManager<'dbus> {
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 {
self.pitch
}
@ -398,9 +484,83 @@ impl<'dbus> OrcaManager<'dbus> {
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(())
}
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<()> {
if let Some(range) = VALID_SETTINGS.get(option) {
ensure!(
@ -605,6 +765,27 @@ mod test {
assert!(nofile_result.is_err());
}
#[tokio::test]
async fn test_voice() {
let mut h = testing::start();
copy(TEST_ORCA_SETTINGS, h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let mut manager = OrcaManager::new(&h.new_dbus().await.expect("new_dbus"))
.await
.expect("OrcaManager::new");
let set_result = manager.set_voice(TEST_VOICE_NAME).await;
assert!(set_result.is_ok());
assert_eq!(manager.voice(), TEST_VOICE_NAME);
remove_file(h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let nofile_result = manager.set_voice(TEST_VOICE_NAME).await;
assert_eq!(manager.voice(), TEST_VOICE_NAME);
assert!(nofile_result.is_err());
}
#[tokio::test]
async fn test_read_next_word() {
let mut h = testing::start();