collective

Browse / github.com/storytold/soundcraft / patch

Playback: one ASIO host and one device value per driver

Playback: one ASIO host and one device value per driver Two ways the ASIO backend could go silent once both input and output were in use, both because cpal's ASIO objects carry driver state: 1. **A second host reloads the driver.** asio-sys remembers the loaded driver per `Host`. Every device lookup made a fresh `cpal::host_from_id(Asio)`, which loaded and initialised the driver again under the streams already running on it, and unloaded it again when its last device dropped, silencing them. ASIO now gets one host for the whole process (`OnceLock`), handed out as `HostRef::Shared`. Other hosts are still made per call (`HostRef::Owned`). 2. **A

Status
open
Score
0.00
Adopters
0 other operators
Author
mrrobbiec (imported from pull request #807, open)
Base
25d52c646aea780c8076ff966076f994ad256221
Size
+148 −13 in 4 files
Review
pending
Shared
2026-10-28
Id
270efeefb20d5e0066c49c69

How it went

No other operator has reported using it yet.

Diff

raw · crates/playback/src/devices.rs crates/playback/src/lib.rs crates/playback/src/record.rs crates/ui-egui/src/lib.rs

diff --git a/crates/playback/src/devices.rs b/crates/playback/src/devices.rs--- a/crates/playback/src/devices.rs+++ b/crates/playback/src/devices.rs@@ -96,7 +96,7 @@ pub fn audio_systems() -> Vec<AudioSystem> {     cpal::available_hosts()         .into_iter()         .filter_map(|id| {-            let host = cpal::host_from_id(id).ok()?;+            let host = host_for(id)?;             let def_out = host.default_output_device().as_ref().and_then(device_name);             let def_in = host.default_input_device().as_ref().and_then(device_name);             let outputs = host.output_devices().map(|ds| ds.filter_map(|d| describe(&d, def_out.as_deref(), true)).collect()).unwrap_or_default();@@ -106,22 +106,54 @@ pub fn audio_systems() -> Vec<AudioSystem> {         .collect() } +/// An audio system handle: ASIO's is shared by the whole process, the others are made per call.+pub(crate) enum HostRef {+    Owned(cpal::Host),+    Shared(&'static cpal::Host),+}++impl std::ops::Deref for HostRef {+    type Target = cpal::Host;+    fn deref(&self) -> &cpal::Host {+        match self {+            HostRef::Owned(h) => h,+            HostRef::Shared(h) => h,+        }+    }+}++/// The host for `id`. ASIO gets one host for the process: asio-sys remembers the loaded driver+/// per host, so a second host would load and initialise the driver again under the streams+/// already running on it (and drop it again with its last device), silencing them.+fn host_for(id: cpal::HostId) -> Option<HostRef> {+    if id.name().eq_ignore_ascii_case("ASIO") {+        static ASIO: std::sync::OnceLock<Option<cpal::Host>> = std::sync::OnceLock::new();+        return ASIO.get_or_init(|| cpal::host_from_id(id).ok()).as_ref().map(HostRef::Shared);+    }+    cpal::host_from_id(id).ok().map(HostRef::Owned)+}+ /// The chosen audio system, else the default one.-pub(crate) fn host(cfg: &AudioConfig) -> cpal::Host {+pub(crate) fn host(cfg: &AudioConfig) -> HostRef {     cfg.system         .as_deref()         .and_then(|want| cpal::available_hosts().into_iter().find(|id| id.name().eq_ignore_ascii_case(want)))-        .and_then(|id| cpal::host_from_id(id).ok())+        .and_then(host_for)         .unwrap_or_else(|| {             if let Some(s) = &cfg.system {                 log::warn!("audio system `{s}` is not available; using the default");             }-            cpal::default_host()+            host_for(cpal::default_host().id()).unwrap_or_else(|| HostRef::Owned(cpal::default_host()))         }) } -/// The named output (or input) device of `host`, else its default device.-pub(crate) fn device(host: &cpal::Host, name: Option<&str>, output: bool) -> Option<cpal::Device> {+/// The named output (or input) device of `host`, else its default device, and the lease to keep+/// with the stream opened from it (see [`shared_asio`]).+pub(crate) fn device(host: &cpal::Host, name: Option<&str>, output: bool) -> Option<(cpal::Device, DeviceLease)> {+    shared_asio(host, find_device(host, name, output))+}++fn find_device(host: &cpal::Host, name: Option<&str>, output: bool) -> Option<cpal::Device> {     if let Some(want) = name {         let found = if output { host.output_devices().ok() } else { host.input_devices().ok() }             .and_then(|mut ds| ds.find(|d| device_name(d).as_deref() == Some(want)));@@ -133,6 +165,63 @@ pub(crate) fn device(host: &cpal::Host, name: Option<&str>, output: bool) -> Opt     if output { host.default_output_device() } else { host.default_input_device() } } +/// One device value per ASIO driver for the whole process. An ASIO driver has a single set of+/// buffers for input and output together, and cpal keeps the record of that set in the device+/// value: an input opened from a second value of the same driver would recreate the buffers for+/// input only, leaving the output stream (opened from the first value) writing into buffers the+/// driver has disposed, and silent. Sharing the value makes cpal create both sides together.+///+/// The value is kept only while a lease on it lives: ASIO loads one driver per process, and a+/// value kept for good would keep its driver loaded, hiding every other driver from the device+/// lists and from a later choice in the settings.+fn shared_asio(host: &cpal::Host, found: Option<cpal::Device>) -> Option<(cpal::Device, DeviceLease)> {+    let d = found?;+    if !host.id().name().eq_ignore_ascii_case("ASIO") {+        return Some((d, DeviceLease(None)));+    }+    let Some(name) = device_name(&d) else { return Some((d, DeviceLease(None))) };+    let d = acquire(&mut shared_devices(), &name, d);+    Some((d, DeviceLease(Some(name))))+}++/// One kept device value per driver name, with the number of leases on it.+type SharedDevices = Vec<(String, cpal::Device, usize)>;++fn shared_devices() -> std::sync::MutexGuard<'static, SharedDevices> {+    static SHARED: std::sync::OnceLock<std::sync::Mutex<SharedDevices>> = std::sync::OnceLock::new();+    SHARED.get_or_init(Default::default).lock().unwrap_or_else(std::sync::PoisonError::into_inner)+}++/// The kept value for `name` (else `found`, kept from now on), with one more lease on it.+fn acquire<D: Clone>(shared: &mut Vec<(String, D, usize)>, name: &str, found: D) -> D {+    if let Some((_, kept, leases)) = shared.iter_mut().find(|(n, ..)| n == name) {+        *leases = leases.saturating_add(1);+        return kept.clone();+    }+    shared.push((name.to_string(), found.clone(), 1));+    found+}++/// Keeps the shared value of an ASIO driver while the stream opened from it lives (nothing for+/// other systems). Dropping the last lease on a driver forgets the value, so the driver can unload.+#[must_use]+pub(crate) struct DeviceLease(Option<String>);++impl Drop for DeviceLease {+    fn drop(&mut self) {+        let Some(name) = self.0.take() else { return };+        release(&mut shared_devices(), &name);+    }+}++/// One lease on `name` ends; the last one removes its value.+fn release<D>(shared: &mut Vec<(String, D, usize)>, name: &str) {+    if let Some((_, _, leases)) = shared.iter_mut().find(|(n, ..)| n == name) {+        *leases = leases.saturating_sub(1);+    }+    shared.retain(|(_, _, leases)| *leases > 0);+}+ /// The buffer to request: the chosen size when the device's range allows it, else the default. pub(crate) fn buffer_size(want: Option<u32>, supported: &cpal::SupportedBufferSize) -> cpal::BufferSize {     match (want, supported) {@@ -191,10 +280,26 @@ mod tests {         assert_eq!(host(&cfg).id(), cpal::default_host().id());         // No device with this name exists: the default (or none, on a machine without audio).         let h = cpal::default_host();-        let fallback = device(&h, Some("No Such Device 1234"), true).as_ref().and_then(device_name);+        let fallback = device(&h, Some("No Such Device 1234"), true).and_then(|(d, _lease)| device_name(&d));         assert_eq!(fallback, h.default_output_device().as_ref().and_then(device_name));     } +    /// Input and output of one driver share a value while either stream is open; once both+    /// close the value is forgotten, so another driver can load.+    #[test]+    fn a_shared_driver_is_released_with_its_last_stream() {+        let mut shared: Vec<(String, u32, usize)> = Vec::new();+        assert_eq!(acquire(&mut shared, "A", 1), 1, "output opens A");+        assert_eq!(acquire(&mut shared, "A", 2), 1, "input reuses the output's value");+        release(&mut shared, "A");+        assert_eq!(shared, [("A".to_string(), 1, 1)], "the input still holds A");+        release(&mut shared, "A");+        assert!(shared.is_empty(), "A is no longer kept");+        assert_eq!(acquire(&mut shared, "B", 3), 3, "B is kept fresh");+        release(&mut shared, "missing");+        assert_eq!(shared.len(), 1);+    }+     #[test]     fn enumeration_does_not_fail() {         let systems = audio_systems();diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs--- a/crates/playback/src/lib.rs+++ b/crates/playback/src/lib.rs@@ -465,6 +465,9 @@ pub struct Player {     _stream: Option<cpal::Stream>,     #[cfg(target_arch = "wasm32")]     _stream: Option<cpal::Stream>,+    /// Keeps the stream's device value shared while the stream lives (declared after it, so the+    /// stream closes first).+    _lease: Option<devices::DeviceLease>,     pub device_name: String,     pub device_rate: u32,     /// Output channels the device was opened with (0 for the silent clock).@@ -520,7 +523,7 @@ impl Player {         let side_rate = session.sample_rate.as_f64() as f32;         let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| Mutex::new(Side::new(side_rate, garbage));         match open_device(want, want_ch, cfg) {-            Ok((device, config, format, name)) => {+            Ok((device, lease, config, format, name)) => {                 // The chosen buffer size first; if the device refuses it, its default.                 let mut attempts = vec![config.clone()];                 if config.buffer_size != cpal::BufferSize::Default {@@ -564,6 +567,7 @@ impl Player {                             tx,                             side: new_side(grx),                             _stream: Some(stream),+                            _lease: Some(lease),                             device_name: name,                             device_rate: rate,                             device_channels: config.channels,@@ -641,6 +645,7 @@ impl Player {             tx,             side,             _stream: None,+            _lease: None,             device_name: "No audio device (silent)".into(),             device_rate: rate,             device_channels: 0,@@ -664,6 +669,7 @@ impl Player {             tx,             side,             _stream: None,+            _lease: None,             device_name: "No audio device".into(),             device_rate: session.sample_rate.hz(),             device_channels: 0,@@ -964,10 +970,14 @@ where /// rate) if possible, in the best sample format it offers (f32, else 32- or 16-bit integers), /// with as many channels as it offers up to `want_ch` (at least stereo when available), and the /// chosen buffer size when the device allows it.-fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal::Device, cpal::StreamConfig, cpal::SampleFormat, String), String> {+fn open_device(+    want_rate: u32,+    want_ch: u16,+    cfg: &AudioConfig,+) -> Result<(cpal::Device, devices::DeviceLease, cpal::StreamConfig, cpal::SampleFormat, String), String> {     use cpal::traits::DeviceTrait;     let host = devices::host(cfg);-    let device = devices::device(&host, cfg.output.as_deref(), true).ok_or_else(|| "no output device".to_string())?;+    let (device, lease) = devices::device(&host, cfg.output.as_deref(), true).ok_or_else(|| "no output device".to_string())?;     #[allow(deprecated)]     let name = device.name().unwrap_or_else(|_| "Audio device".into());     // Prefer a config at the chosen (else session) rate; else the device default (we resample).@@ -1015,7 +1025,7 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal:             (c, format)         }     };-    Ok((device, config, format, name))+    Ok((device, lease, config, format, name)) }  #[cfg(test)]diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs--- a/crates/playback/src/record.rs+++ b/crates/playback/src/record.rs@@ -128,6 +128,9 @@ pub struct Recorder {     pub monitor: Arc<InputRing>,     #[cfg(not(target_os = "freebsd"))]     _stream: Option<cpal::Stream>,+    /// Keeps the stream's device value shared while the stream lives (after it: dropped later).+    #[cfg(not(target_os = "freebsd"))]+    _lease: Option<crate::devices::DeviceLease>,     pub device_name: String,     pub sample_rate: u32,     pub channels: usize,@@ -201,7 +204,7 @@ impl Recorder {     pub fn open_with(audio: &crate::AudioConfig) -> Result<Recorder, String> {         use cpal::traits::{DeviceTrait, StreamTrait};         let host = crate::devices::host(audio);-        let device = crate::devices::device(&host, audio.input.as_deref(), false).ok_or("no audio input device")?;+        let (device, lease) = crate::devices::device(&host, audio.input.as_deref(), false).ok_or("no audio input device")?;         #[allow(deprecated)]         let name = device.name().unwrap_or_else(|_| "Input".into());         // At the chosen rate when the device offers it, so input and output run together: the@@ -245,7 +248,17 @@ impl Recorder {         };         stream.play().map_err(|e| e.to_string())?;         let seen = (0, Instant::now());-        Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen })+        Ok(Recorder {+            monitor,+            shared,+            _stream: Some(stream),+            _lease: Some(lease),+            device_name: name,+            sample_rate: config.sample_rate.0,+            channels,+            writer: None,+            seen,+        })     }      #[cfg(target_os = "freebsd")]@@ -469,6 +482,8 @@ mod tests {             monitor: InputRing::new(1, 48_000),             #[cfg(not(target_os = "freebsd"))]             _stream: None,+            #[cfg(not(target_os = "freebsd"))]+            _lease: None,             device_name: "Mic In".into(),             sample_rate: 48_000,             channels: 1,diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -812,6 +812,9 @@ impl SoundApp {         // The input reopens on the next record or monitor with the new choice.         self.recorder = None;         self.recorder_failed = false;+        // Closed before the new one opens: an ASIO driver stays loaded while a stream uses it,+        // and only one can be loaded at a time.+        self.player = None;         let player = soundcraft_playback::Player::with_config(self.engine.session_arc(), &self.ui.audio);         player.locate(at);         self.player = Some(player);@@ -907,6 +910,8 @@ impl SoundApp {         }         self.recorder = None;         self.recorder_failed = false;+        // Closed before the new one opens (see `apply_audio_config`).+        self.player = None;         self.player = Some(Player::with_config(self.engine.session_arc(), &self.ui.audio));     }