#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DeviceClass {
Private,
Reachy,
Speaker,
}
impl DeviceClass {
pub fn label(self) -> &'static str {
match self {
DeviceClass::Private => "private",
DeviceClass::Reachy => "reachy",
DeviceClass::Speaker => "speaker",
}
}
}
pub fn classify(name: &str) -> DeviceClass {
let n = name.to_lowercase();
const SPEAKER_MARKERS: &[&str] = &["pill", "speaker", "soundlink", "sonos", "homepod"];
if SPEAKER_MARKERS.iter().any(|h| n.contains(h)) {
return DeviceClass::Speaker;
}
const PRIVATE_HINTS: &[&str] = &[
"airpods",
"headphone",
"headset",
"earbud",
"earphone",
"earpod",
"ear pod",
"in-ear",
"beats",
"buds",
" wf-",
" wh-",
"powerbeats",
];
if PRIVATE_HINTS.iter().any(|h| n.contains(h)) {
return DeviceClass::Private;
}
if n.contains("reachy") {
return DeviceClass::Reachy;
}
DeviceClass::Speaker
}
#[derive(Clone, Debug)]
pub struct AudioDevice {
pub name: String,
pub is_default_output: bool,
}
impl AudioDevice {
pub fn class(&self) -> DeviceClass {
classify(&self.name)
}
}
fn connected_matches<'a>(
pat: &str,
devices: &'a [AudioDevice],
) -> impl Iterator<Item = &'a AudioDevice> {
let needle = pat.to_lowercase();
devices
.iter()
.filter(move |d| d.name.to_lowercase().contains(&needle))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Routed {
Soma(String),
Reachy,
Devices(Vec<String>),
Text(String),
}
impl Routed {
pub fn describe(&self) -> String {
match self {
Routed::Soma(endpoint) => format!("Soma speaker ({endpoint})"),
Routed::Reachy => "Reachy speaker (daemon)".to_string(),
Routed::Devices(ladder) => {
let (first, rest) = ladder.split_first().expect("ladder is never empty");
if rest.is_empty() {
format!("{first} (native sink)")
} else {
format!("{first} (native sink; fallbacks: {})", rest.join(" → "))
}
}
Routed::Text(why) => format!("TEXT fallback — {why}"),
}
}
}
pub fn route_say(prefs: &[String], devices: &[AudioDevice]) -> Routed {
let mut ladder: Vec<String> = Vec::new();
for pat in prefs {
for dev in connected_matches(pat, devices) {
if dev.class() == DeviceClass::Private && !ladder.contains(&dev.name) {
ladder.push(dev.name.clone());
}
}
}
if ladder.is_empty() {
Routed::Text("no connected private (in-ear/headphone) device".into())
} else {
Routed::Devices(ladder)
}
}
pub fn route_shout(
prefs: &[String],
devices: &[AudioDevice],
daemon_up: bool,
soma: Option<&str>,
) -> Routed {
if let Some(endpoint) = soma {
return Routed::Soma(endpoint.to_owned());
}
let mut ladder: Vec<String> = Vec::new();
for pat in prefs {
for dev in connected_matches(pat, devices) {
match dev.class() {
DeviceClass::Reachy if daemon_up && ladder.is_empty() => return Routed::Reachy,
DeviceClass::Reachy => continue,
_ => {
if !ladder.contains(&dev.name) {
ladder.push(dev.name.clone());
}
}
}
}
}
if let Some(default) = devices.iter().find(|d| d.is_default_output) {
if !ladder.contains(&default.name) {
ladder.push(default.name.clone());
}
}
if ladder.is_empty() {
Routed::Text("no audible output device connected".into())
} else {
Routed::Devices(ladder)
}
}