Skip to main content

koan_core/remote/
outputs.rs

1//! What a device plays through, told to the devices controlling it, and
2//! changed by them.
3//!
4//! A device publishes its outputs in its `LinkState`: its own audio devices
5//! (on a phone, the route the system chose), the UPnP renderers it can see,
6//! which of them plays now, and each one's DSP preset. A device controlling it
7//! sends `SetOutput`, `SetRendererVolume` or `SetPreset`, and `set`,
8//! `set_volume` and `set_preset` carry them out exactly as the device's own
9//! menu does, so the music carries on from where it is, playing or paused.
10
11use serde::{Deserialize, Serialize};
12
13use crate::config::Config;
14use crate::player::commands::PlayerCommand;
15use crate::player::state::SharedPlayerState;
16
17/// What a device can play through.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct LinkOutputs {
21    /// Its own audio devices: on a phone, the route the system chose.
22    #[serde(default)]
23    pub devices: Vec<LinkOutput>,
24    /// The UPnP renderers it can see.
25    #[serde(default)]
26    pub renderers: Vec<LinkOutput>,
27    /// What it plays through now.
28    #[serde(default)]
29    pub current: OutputChoice,
30    /// The volume of the renderer it plays to, when it has one.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub volume: Option<u8>,
33    /// Its EQ presets, which any of its outputs can be set from, and
34    /// whether processing is on at all (always, since a device is made flat
35    /// instead; kept for apps from before).
36    #[serde(default)]
37    pub profiles: Vec<String>,
38    #[serde(default)]
39    pub dsp_enabled: bool,
40}
41
42/// One output: a device by its name, or a renderer by its UDN.
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct LinkOutput {
46    pub id: String,
47    pub name: String,
48    /// How it is connected: `usb`, `bluetooth`, `upnp` and the rest.
49    #[serde(default)]
50    pub kind: String,
51    /// What to say under its name: a renderer's make and model.
52    #[serde(default, skip_serializing_if = "String::is_empty")]
53    pub detail: String,
54    /// Playing or paused for something else: picking it takes it over.
55    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56    pub busy: bool,
57    /// The EQ preset it was set from.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub preset: Option<String>,
60    /// Its EQ is not a preset as saved: changed since, or never saved. Flat
61    /// is neither this nor a preset.
62    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
63    pub unsaved: bool,
64}
65
66/// An output to play through.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(tag = "kind", rename_all = "camelCase")]
69pub enum OutputChoice {
70    /// The system's default output.
71    #[default]
72    Default,
73    Device {
74        name: String,
75    },
76    Renderer {
77        udn: String,
78    },
79}
80
81/// This device's audio devices, by name and kind, as last listed. Read on
82/// every change to the engine, so listed only when they may have changed:
83/// see `refresh_devices`.
84static DEVICES: parking_lot::Mutex<Option<Vec<(String, String)>>> = parking_lot::Mutex::new(None);
85
86/// List this device's audio devices again: on the platform's device-change
87/// notification, when an output menu opens, and on a phone when the route
88/// changes. Rings the engine's change
89/// signal if they moved, so every view of them follows.
90pub fn refresh_devices() {
91    let now = list_devices();
92    let mut cached = DEVICES.lock();
93    if cached.as_ref() != Some(&now) {
94        *cached = Some(now);
95        drop(cached);
96        crate::signal::engine_changed().bump();
97    }
98}
99
100fn list_devices() -> Vec<(String, String)> {
101    crate::audio::list_output_devices()
102        .unwrap_or_default()
103        .into_iter()
104        .map(|d| (d.name, d.kind.as_str().to_string()))
105        .collect()
106}
107
108/// Refreshes a controller asked for: one at a time, and at most one more
109/// waiting, so a burst of asking costs two at most.
110#[derive(Default)]
111struct RefreshGate {
112    running: bool,
113    queued: bool,
114}
115
116impl RefreshGate {
117    /// Whether to start a refresh now. Asked during one, it is queued.
118    fn ask(&mut self) -> bool {
119        if self.running {
120            self.queued = true;
121            return false;
122        }
123        self.running = true;
124        true
125    }
126
127    /// A refresh ended. Whether to run the one queued meanwhile.
128    fn done(&mut self) -> bool {
129        self.running = std::mem::take(&mut self.queued);
130        self.running
131    }
132}
133
134static REFRESH: parking_lot::Mutex<RefreshGate> = parking_lot::Mutex::new(RefreshGate {
135    running: false,
136    queued: false,
137});
138
139/// A controller opened its output menu: list this device's outputs again, on
140/// a thread of its own rather than the link's. Cheap by design: on a Mac a
141/// CoreAudio query; on a phone or a television the route the app last
142/// reported, with no search, since the system knows it directly; renderers
143/// from the discovery cache, searched for only once it is stale. What moved
144/// reaches the controller in the link state.
145pub fn refresh_for_controller() {
146    if !REFRESH.lock().ask() {
147        return;
148    }
149    let spawned = std::thread::Builder::new()
150        .name("koan-outputs".into())
151        .spawn(|| {
152            loop {
153                refresh_devices();
154                #[cfg(not(any(target_os = "ios", target_os = "tvos")))]
155                crate::upnp::discovery::search_if_stale();
156                if !REFRESH.lock().done() {
157                    break;
158                }
159            }
160        });
161    if let Err(e) = spawned {
162        log::warn!("outputs: no refresh thread: {e}");
163        REFRESH.lock().done();
164    }
165}
166
167/// The preset `device` was set from, and whether its EQ is unsaved. EQ is
168/// keyed by the device's name, which on a phone is the route's port name, so
169/// the preset published for an output is the one its audio goes through.
170fn preset(dsp: &crate::config::DspConfig, device: &str) -> (Option<String>, bool) {
171    let flat = dsp.profile_for(device).is_none() && !dsp.tunings.iter().any(|t| t.device == device);
172    match crate::audio::dsp::profiles::preset_for(device) {
173        Some((name, edited)) => (Some(name), edited),
174        None => (None, !flat),
175    }
176}
177
178/// Audio devices as published: each by its name, which is also its id, since
179/// a name is what `SetOutput` and `SetPreset` address it by.
180fn device_outputs(devices: &[(String, String)], dsp: &crate::config::DspConfig) -> Vec<LinkOutput> {
181    devices
182        .iter()
183        .map(|(name, kind)| {
184            let (preset, unsaved) = preset(dsp, name);
185            LinkOutput {
186                preset,
187                unsaved,
188                id: name.clone(),
189                name: name.clone(),
190                kind: kind.clone(),
191                ..Default::default()
192            }
193        })
194        .collect()
195}
196
197/// This device's outputs, as they are now.
198pub fn local(state: &SharedPlayerState) -> LinkOutputs {
199    let cfg = Config::cached();
200    let devices = device_outputs(DEVICES.lock().get_or_insert_with(list_devices), &cfg.dsp);
201    let renderers = crate::upnp::discovery::renderers()
202        .into_iter()
203        .map(|r| {
204            let (preset, unsaved) = preset(&cfg.dsp, r.device_name());
205            LinkOutput {
206                preset,
207                unsaved,
208                busy: crate::upnp::discovery::busy(&r.udn),
209                detail: [r.manufacturer.as_str(), r.model.as_str()]
210                    .iter()
211                    .filter(|s| !s.is_empty())
212                    .copied()
213                    .collect::<Vec<_>>()
214                    .join(" "),
215                id: r.udn,
216                name: r.name,
217                kind: "upnp".into(),
218            }
219        })
220        .collect();
221    let renderer = state.renderer();
222    LinkOutputs {
223        devices,
224        renderers,
225        current: match (&renderer, &cfg.playback.output_device) {
226            (Some(r), _) => OutputChoice::Renderer { udn: r.udn.clone() },
227            (None, Some(name)) => OutputChoice::Device { name: name.clone() },
228            (None, None) => OutputChoice::Default,
229        },
230        volume: renderer.and_then(|r| r.volume),
231        profiles: cfg
232            .dsp
233            .profiles
234            .iter()
235            .filter(|p| p.preset)
236            .map(|p| p.name.clone())
237            .collect(),
238        dsp_enabled: cfg.dsp.enabled,
239    }
240}
241
242/// Play through `output` from now on. `choice` is what `upnp::choose`
243/// returned when it was picked, taken before anything that could reorder it:
244/// a renderer is a session to open, a few round trips that block here, and a
245/// later pick drops it. Everything else reaches the player in order.
246pub fn set(
247    output: OutputChoice,
248    choice: u64,
249    player: &crossbeam_channel::Sender<PlayerCommand>,
250) -> Result<(), String> {
251    let send = |cmd| {
252        player
253            .send(cmd)
254            .map_err(|_| "The player has stopped.".to_string())
255    };
256    match output {
257        OutputChoice::Default => send(PlayerCommand::ClearOutputDevice),
258        OutputChoice::Device { name } => send(PlayerCommand::SetOutputDevice(name)),
259        OutputChoice::Renderer { udn } => crate::upnp::connect(&udn, choice, player),
260    }
261}
262
263/// Set `device` from the preset `profile`, or flat, and apply it where
264/// playback is. A correction's name, from an app before presets, is chosen
265/// as the correction.
266pub fn set_preset(
267    device: &str,
268    profile: Option<&str>,
269    player: &crossbeam_channel::Sender<PlayerCommand>,
270) -> Result<(), String> {
271    match profile {
272        None => crate::audio::dsp::profiles::apply_preset(device, None)?,
273        Some(name) => crate::audio::dsp::profiles::assign(Some(name), device)?,
274    }
275    player
276        .send(PlayerCommand::ReloadDsp)
277        .map_err(|_| "The player has stopped.".to_string())
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::config::DspProfile;
284    use crate::remote::link::{LinkCommand, LinkState};
285
286    /// A device's outputs survive the trip through the link, and a device
287    /// that publishes none (an older one) reads as having none.
288    #[test]
289    fn outputs_cross_the_link_whole() {
290        let state = LinkState {
291            playing: true,
292            outputs: Some(LinkOutputs {
293                devices: vec![LinkOutput {
294                    id: "Topping E30".into(),
295                    name: "Topping E30".into(),
296                    kind: "usb".into(),
297                    preset: Some("Harman".into()),
298                    ..Default::default()
299                }],
300                renderers: vec![LinkOutput {
301                    id: "uuid:arcam".into(),
302                    name: "Arcam".into(),
303                    kind: "upnp".into(),
304                    detail: "Arcam SA30".into(),
305                    ..Default::default()
306                }],
307                current: OutputChoice::Renderer {
308                    udn: "uuid:arcam".into(),
309                },
310                volume: Some(30),
311                profiles: vec!["Harman".into()],
312                dsp_enabled: true,
313            }),
314            ..Default::default()
315        };
316        let json = serde_json::to_string(&state).unwrap();
317        assert_eq!(serde_json::from_str::<LinkState>(&json).unwrap(), state);
318
319        let older: LinkState = serde_json::from_str(r#"{"playing":false}"#).unwrap();
320        assert_eq!(older.outputs, None);
321
322        let cmd = LinkCommand::SetOutput {
323            output: OutputChoice::Device {
324                name: "Topping E30".into(),
325            },
326        };
327        let json = serde_json::to_string(&cmd).unwrap();
328        assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), cmd);
329    }
330
331    /// A phone publishes its route by the port's own name, with the preset
332    /// that route was set from: what `SetPreset` from another device then
333    /// sets it from, under the same name. A route with EQ no preset holds is
334    /// unsaved; one with none is flat.
335    #[test]
336    fn a_route_is_published_by_name_with_its_preset() {
337        let _guard = crate::config::tests::PERSIST_LOCK
338            .lock()
339            .unwrap_or_else(|e| e.into_inner());
340        let dir = tempfile::tempdir().unwrap();
341        crate::config::set_config_dir(dir.path());
342        Config::persist(|c| {
343            c.dsp.profiles.push(DspProfile {
344                name: "Qudelix Harman".into(),
345                devices: vec!["Qudelix-5K".into(), "Car".into()],
346                role: Some(crate::config::DspRole::Correction),
347                ..Default::default()
348            })
349        })
350        .unwrap();
351        crate::audio::dsp::profiles::save_preset("Qudelix-5K", "Harman").unwrap();
352        let dsp = Config::cached().dsp.clone();
353        let route = [("Qudelix-5K".to_string(), String::new())];
354        assert_eq!(
355            device_outputs(&route, &dsp),
356            vec![LinkOutput {
357                id: "Qudelix-5K".into(),
358                name: "Qudelix-5K".into(),
359                preset: Some("Harman".into()),
360                ..Default::default()
361            }]
362        );
363        let car = [("Car".to_string(), String::new())];
364        assert_eq!(device_outputs(&car, &dsp)[0].preset, None);
365        assert!(device_outputs(&car, &dsp)[0].unsaved);
366        let speaker = [("Speaker".to_string(), String::new())];
367        assert_eq!(device_outputs(&speaker, &dsp)[0].preset, None);
368        assert!(!device_outputs(&speaker, &dsp)[0].unsaved, "flat");
369    }
370
371    /// Anyone on the network may play and pause, but where the sound goes,
372    /// how loud the amplifier is and what a preset does are the account's.
373    #[test]
374    fn a_stranger_on_the_network_cannot_move_the_output() {
375        for cmd in [
376            LinkCommand::SetOutput {
377                output: OutputChoice::Default,
378            },
379            LinkCommand::SetRendererVolume { volume: 100 },
380            LinkCommand::SetPreset {
381                device: "Speakers".into(),
382                profile: None,
383            },
384            LinkCommand::RefreshOutputs,
385        ] {
386            assert!(!cmd.allowed_nearby(), "{cmd:?}");
387        }
388        assert!(LinkCommand::Pause.allowed_nearby());
389    }
390
391    /// An output menu opened on a controller asks the controlled device to
392    /// list its outputs again: a command one device gives another, relayed by
393    /// the server, and run under Full control.
394    #[test]
395    fn a_controller_can_ask_for_the_outputs_again() {
396        let cmd = LinkCommand::RefreshOutputs;
397        let json = serde_json::to_string(&cmd).unwrap();
398        assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), cmd);
399        assert!(cmd.relayable());
400        assert!(cmd.allowed_playback());
401    }
402
403    /// One refresh at a time and one more at most while it runs, however
404    /// often controllers ask.
405    #[test]
406    fn refreshes_for_controllers_coalesce() {
407        let mut gate = RefreshGate::default();
408        assert!(gate.ask());
409        assert!(!gate.ask());
410        assert!(!gate.ask());
411        // The burst ran once more, then nothing.
412        assert!(gate.done());
413        assert!(!gate.done());
414        assert!(gate.ask());
415        assert!(!gate.done());
416    }
417
418    /// Asking for the outputs again is said live or not at all: never queued
419    /// for a device that is away, never a push to wake one.
420    #[test]
421    fn a_refresh_is_never_queued_for_an_absent_device() {
422        assert!(LinkCommand::RefreshOutputs.live_only());
423        assert!(
424            LinkCommand::Shared {
425                command: Box::new(LinkCommand::RefreshOutputs)
426            }
427            .live_only()
428        );
429        assert!(!LinkCommand::Pause.live_only());
430        assert!(!crate::remote::devices::send_live(
431            "nowhere-at-all",
432            LinkCommand::RefreshOutputs
433        ));
434    }
435
436    /// A device switch reaches the player as the device's own menu sends it.
437    #[test]
438    fn setting_a_device_output_is_the_menus_switch() {
439        let (tx, rx) = crossbeam_channel::unbounded();
440        set(
441            OutputChoice::Device {
442                name: "Topping E30".into(),
443            },
444            crate::upnp::choose(),
445            &tx,
446        )
447        .unwrap();
448        assert!(matches!(
449            rx.try_recv(),
450            Ok(PlayerCommand::SetOutputDevice(name)) if name == "Topping E30"
451        ));
452        set(OutputChoice::Default, crate::upnp::choose(), &tx).unwrap();
453        assert!(matches!(
454            rx.try_recv(),
455            Ok(PlayerCommand::ClearOutputDevice)
456        ));
457    }
458}