Skip to main content

shuyi_sound_out/
lib.rs

1//! Sound output: hands a mix to the system and nothing else. [`Output`] calls a
2//! callback for samples, from the system's audio thread where there is one, and the
3//! callback knows nothing of where they go.
4//!
5//! - On Windows it is CPAL (`windows`): it takes the device's format, converts at
6//!   the boundary ([`convert`]) and comes back on the new default device when one
7//!   goes away.
8//! - In the browser it is a worklet of our own (`web`, and `sound.js` beside the
9//!   page), which the page's one thread feeds with blocks.
10//! - [`Output::null`] calls the callback on a timer and discards what it gives: for
11//!   tests, and a machine with no sound, where a caller picks it over a crash.
12//!
13//! [`handover`] moves what can't cross as plain data to a thread that may neither
14//! allocate nor free. Everything that isn't safe code is in `sys`.
15
16mod convert_impl;
17#[cfg(not(target_arch = "wasm32"))]
18mod null;
19mod sys;
20#[cfg(target_arch = "wasm32")]
21pub mod web;
22#[cfg(windows)]
23mod windows;
24
25use std::sync::{Arc, Mutex};
26
27use shuyi_sound::Channels;
28
29/// How samples become what a device takes.
30pub mod convert {
31    pub use crate::convert_impl::{Boundary, DeviceSample};
32}
33
34pub use sys::{HandOut, HandTo, handover};
35
36/// The callback, shared with whichever stream is running now. Only one stream runs
37/// at a time, so its `try_lock` always gets it: it never waits.
38type Callback = Arc<Mutex<dyn FnMut(&mut [i16]) + Send>>;
39
40/// What an output is asked for: the format of what its callback writes. The device's
41/// own may differ, and [`convert`] makes up the difference.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct OutputConfig {
45    /// Frames a second.
46    pub rate: u32,
47    /// How many channels a frame has.
48    pub channels: Channels,
49    /// How many frames the callback is asked for at a time, at most.
50    pub frames: u32,
51}
52
53impl OutputConfig {
54    /// These.
55    pub const fn new(rate: u32, channels: Channels, frames: u32) -> Self {
56        Self {
57            rate,
58            channels,
59            frames,
60        }
61    }
62}
63
64/// Why an output didn't open.
65#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
66#[non_exhaustive]
67pub enum OutputError {
68    /// There is nothing to play on.
69    #[error("no sound device")]
70    NoDevice,
71    /// This target has no way out yet, or this page can't use one.
72    #[error("no sound output here: {0}")]
73    Unavailable(String),
74    /// The device or the system refused.
75    #[error("the sound device refused: {0}")]
76    Device(String),
77}
78
79/// An open output. The callback runs until it is dropped.
80pub struct Output {
81    inner: Inner,
82    rate: u32,
83}
84
85enum Inner {
86    #[cfg(not(target_arch = "wasm32"))]
87    Null(null::NullOutput),
88    #[cfg(windows)]
89    Windows(windows::WindowsOutput),
90    #[cfg(target_arch = "wasm32")]
91    Web(web::WebOutput),
92}
93
94impl std::fmt::Debug for Output {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.debug_struct("Output")
97            .field("rate", &self.rate)
98            .finish_non_exhaustive()
99    }
100}
101
102impl Output {
103    /// The rate the device runs at, if there is one to ask: a mix at that rate needs
104    /// no conversion on its way out.
105    pub fn preferred_rate() -> Option<u32> {
106        #[cfg(windows)]
107        return windows::preferred_rate();
108        #[cfg(not(windows))]
109        None
110    }
111
112    /// Opens the system's output, or says why it can't.
113    pub fn open(
114        config: OutputConfig,
115        callback: impl FnMut(&mut [i16]) + Send + 'static,
116    ) -> Result<Self, OutputError> {
117        Self::open_shared(config, Arc::new(Mutex::new(callback)))
118    }
119
120    fn open_shared(config: OutputConfig, callback: Callback) -> Result<Self, OutputError> {
121        #[cfg(windows)]
122        return windows::WindowsOutput::open(config, callback).map(|output| Self {
123            inner: Inner::Windows(output),
124            rate: config.rate,
125        });
126        #[cfg(target_arch = "wasm32")]
127        return web::WebOutput::open(config, callback, false).map(|output| Self {
128            rate: output.rate(),
129            inner: Inner::Web(output),
130        });
131        #[cfg(not(any(windows, target_arch = "wasm32")))]
132        {
133            let _ = (config, callback);
134            Err(OutputError::Unavailable(
135                "this system's output isn't written yet".to_owned(),
136            ))
137        }
138    }
139
140    /// An output that calls `callback` on a timer and discards what it gives.
141    pub fn null(config: OutputConfig, callback: impl FnMut(&mut [i16]) + Send + 'static) -> Self {
142        Self::null_shared(config, Arc::new(Mutex::new(callback)))
143    }
144
145    fn null_shared(config: OutputConfig, callback: Callback) -> Self {
146        #[cfg(target_arch = "wasm32")]
147        {
148            // Only the page's own timer can call it: the worklet's never runs.
149            let output = web::WebOutput::open(config, callback, true);
150            match output {
151                Ok(output) => Self {
152                    rate: output.rate(),
153                    inner: Inner::Web(output),
154                },
155                Err(_) => Self {
156                    rate: config.rate,
157                    inner: Inner::Web(web::WebOutput::idle(config.rate)),
158                },
159            }
160        }
161        #[cfg(not(target_arch = "wasm32"))]
162        Self {
163            rate: config.rate,
164            inner: Inner::Null(null::NullOutput::open(config, callback)),
165        }
166    }
167
168    /// The system's output, or, if it won't open, [`null`](Self::null) and why: no
169    /// sound rather than a crash, and the caller's to say once.
170    pub fn open_or_null(
171        config: OutputConfig,
172        callback: impl FnMut(&mut [i16]) + Send + 'static,
173    ) -> (Self, Option<OutputError>) {
174        let callback: Callback = Arc::new(Mutex::new(callback));
175        match Self::open_shared(config, Arc::clone(&callback)) {
176            Ok(output) => (output, None),
177            Err(error) => (Self::null_shared(config, callback), Some(error)),
178        }
179    }
180
181    /// The rate its callback's samples play at.
182    pub const fn rate(&self) -> u32 {
183        self.rate
184    }
185
186    /// How many times the system's stream was built again, as when its device went
187    /// away, and how many of its callbacks were silence instead of the mix. Nothing
188    /// asks this but a probe, to say what a recording went through: 0 and 0 where
189    /// there is no stream to build.
190    #[cfg_attr(not(windows), allow(clippy::unused_self))]
191    pub fn stats(&self) -> (u32, u32) {
192        #[cfg(windows)]
193        if let Inner::Windows(output) = &self.inner {
194            return output.stats();
195        }
196        (0, 0)
197    }
198
199    /// Stops calling the callback, for as long as the system suspends the game.
200    pub fn suspend(&self) {
201        self.hold(true);
202    }
203
204    /// Goes on calling it after [`suspend`](Self::suspend).
205    pub fn resume(&self) {
206        self.hold(false);
207    }
208
209    fn hold(&self, on: bool) {
210        match &self.inner {
211            #[cfg(not(target_arch = "wasm32"))]
212            Inner::Null(output) => output.hold(on),
213            #[cfg(windows)]
214            Inner::Windows(output) => output.hold(on),
215            #[cfg(target_arch = "wasm32")]
216            Inner::Web(output) => output.hold(on),
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use std::sync::atomic::{AtomicUsize, Ordering};
224    use std::time::{Duration, Instant};
225
226    use super::*;
227
228    const CONFIG: OutputConfig = OutputConfig::new(48_000, Channels::Stereo, 48);
229
230    fn counting() -> (Arc<AtomicUsize>, impl FnMut(&mut [i16]) + Send + 'static) {
231        let count = Arc::new(AtomicUsize::new(0));
232        let inner = Arc::clone(&count);
233        (count, move |out: &mut [i16]| {
234            inner.fetch_add(out.len(), Ordering::AcqRel);
235        })
236    }
237
238    fn wait_for(count: &AtomicUsize, more_than: usize) {
239        let start = Instant::now();
240        while count.load(Ordering::Acquire) <= more_than {
241            assert!(start.elapsed() < Duration::from_secs(20), "it never asked");
242            std::thread::sleep(Duration::from_millis(1));
243        }
244    }
245
246    #[test]
247    fn the_null_output_runs_at_the_rate_it_was_asked_for_and_can_be_held() {
248        let (count, callback) = counting();
249        let output = Output::null(CONFIG, callback);
250        assert_eq!(output.rate(), 48_000);
251        assert_eq!(output.stats(), (0, 0), "it has no stream to build or lose");
252        assert!(format!("{output:?}").contains("48000"));
253        wait_for(&count, 0);
254        assert_eq!(
255            count.load(Ordering::Acquire) % 96,
256            0,
257            "whole blocks of stereo"
258        );
259        output.suspend();
260        std::thread::sleep(Duration::from_millis(20));
261        let held = count.load(Ordering::Acquire);
262        std::thread::sleep(Duration::from_millis(30));
263        assert_eq!(count.load(Ordering::Acquire), held, "it stopped asking");
264        output.resume();
265        wait_for(&count, held);
266    }
267
268    /// Linux and macOS come with their own ticket: until then they say so, and the
269    /// caller gets an output that discards rather than a crash.
270    #[cfg(not(windows))]
271    #[test]
272    fn a_system_without_its_output_is_quiet_and_says_why() {
273        assert_eq!(Output::preferred_rate(), None);
274        let (_, callback) = counting();
275        assert!(matches!(
276            Output::open(CONFIG, callback),
277            Err(OutputError::Unavailable(_))
278        ));
279        let (count, callback) = counting();
280        let (output, problem) = Output::open_or_null(CONFIG, callback);
281        assert!(matches!(problem, Some(OutputError::Unavailable(_))));
282        assert_eq!(output.rate(), 48_000);
283        wait_for(&count, 0);
284    }
285
286    #[test]
287    fn errors_say_what_went_wrong() {
288        assert_eq!(OutputError::NoDevice.to_string(), "no sound device");
289        let refused = OutputError::Device("busy".to_owned());
290        assert_eq!(refused.to_string(), "the sound device refused: busy");
291    }
292
293    /// The one lint that differs from the workspace's is `unsafe_code`, as ADR-0021
294    /// ยง6 says, and the one added is what makes each block say why it holds.
295    #[test]
296    fn the_lints_are_the_workspace_lints_except_unsafe_code() {
297        let manifest = include_str!("../Cargo.toml");
298        let root = include_str!("../../../Cargo.toml");
299        let section = |text: &'static str, name: &str| -> Vec<&'static str> {
300            text.lines()
301                .skip_while(|line| *line != name)
302                .skip(1)
303                .take_while(|line| !line.starts_with('['))
304                .filter(|line| !line.is_empty() && !line.starts_with('#'))
305                .collect()
306        };
307        let rust = section(manifest, "[lints.rust]");
308        assert!(rust.contains(&"unsafe_code = \"deny\""), "{rust:?}");
309        let workspace_rust: Vec<String> = section(root, "[workspace.lints.rust]")
310            .iter()
311            .map(|line| line.replace("\"forbid\"", "\"deny\""))
312            .collect();
313        assert_eq!(rust, workspace_rust);
314        let mut clippy = section(manifest, "[lints.clippy]");
315        clippy.retain(|line| !line.starts_with("undocumented_unsafe_blocks"));
316        assert_eq!(clippy, section(root, "[workspace.lints.clippy]"));
317    }
318}