diff --git a/Cargo.lock b/Cargo.lock index 3228925..d353096 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -243,6 +252,20 @@ dependencies = [ "libloading", ] +[[package]] +name = "asio-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826194e1612938c9be09b78b58323fbb2e326de3d491b4230186cf6e832d8ded" +dependencies = [ + "bindgen", + "cc", + "num-derive", + "num-traits", + "parse_cfg", + "walkdir", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -423,6 +446,26 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -596,7 +639,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -605,6 +648,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -626,6 +678,17 @@ dependencies = [ "libc", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap-sys" version = "0.5.0" @@ -763,6 +826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" dependencies = [ "alsa", + "asio-sys", "coreaudio-rs", "dasp_sample", "jni 0.21.1", @@ -980,7 +1044,7 @@ dependencies = [ "bitflags 2.13.2", "emath", "epaint", - "itertools", + "itertools 0.15.0", "log", "nohash-hasher", "profiling", @@ -1435,6 +1499,12 @@ dependencies = [ "vello_common", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.17.0" @@ -1741,6 +1811,15 @@ dependencies = [ "once_cell", ] +[[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.15.0" @@ -2019,6 +2098,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" @@ -2160,6 +2245,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[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 = "num-complex" version = "0.4.6" @@ -2643,6 +2738,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse_cfg" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905787a434a2c721408e7c9a252e85f3d93ca0f118a5283022636c0e05a7ea49" +dependencies = [ + "nom", +] + [[package]] name = "peniko" version = "0.6.1" @@ -2841,6 +2945,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "primal-check" version = "0.3.4" @@ -3061,6 +3175,29 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -3275,6 +3412,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -3573,6 +3716,8 @@ version = "0.6.1" dependencies = [ "cpal", "log", + "serde", + "serde_json", "soundcraft-audio-io", "soundcraft-dsp", "soundcraft-mix", diff --git a/ROADMAP.md b/ROADMAP.md index 25ae055..0f121dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,8 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change. + - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. - **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index 783c7b5..0f30c7a 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -10,6 +10,10 @@ default-run = "soundcraft" [lints] workspace = true +[features] +# ASIO audio devices on Windows (see soundcraft-playback's `asio` feature). +asio = ["soundcraft-playback/asio"] + [dependencies] soundcraft-engine.workspace = true soundcraft-ui-egui.workspace = true diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7..ba55a70 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -255,7 +255,8 @@ fn main() -> eframe::Result { "SoundCraft", options, Box::new(move |cc| { - let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) }; + let player = + if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) }; let mut app = SoundApp::new(engine, player, services()); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); diff --git a/crates/playback/Cargo.toml b/crates/playback/Cargo.toml index 5ff9904..514879a 100644 --- a/crates/playback/Cargo.toml +++ b/crates/playback/Cargo.toml @@ -14,6 +14,7 @@ soundcraft-model.workspace = true soundcraft-mix.workspace = true soundcraft-dsp.workspace = true log.workspace = true +serde.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] cpal.workspace = true @@ -21,5 +22,13 @@ cpal.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] cpal = { workspace = true, features = ["wasm-bindgen"] } +[features] +# ASIO on Windows (cpal's ASIO host). Off by default: building it downloads Steinberg's ASIO SDK +# (its own licence) and needs LLVM (libclang) for the bindings. `cargo build -p soundcraft --features asio`. +asio = ["cpal/asio"] + +[dev-dependencies] +serde_json.workspace = true + [lints] workspace = true diff --git a/crates/playback/src/devices.rs b/crates/playback/src/devices.rs new file mode 100644 index 0000000..19df07b --- /dev/null +++ b/crates/playback/src/devices.rs @@ -0,0 +1,205 @@ +//! Audio device selection: which audio system (cpal host), output and input device, sample rate +//! and buffer size to open. The choice is per machine (the app keeps it with its preferences, not +//! in sessions). Anything that cannot be honoured falls back to the system default: a device that +//! is unplugged, a rate or buffer size the device does not offer. + +use cpal::traits::{DeviceTrait, HostTrait}; + +/// Rates offered when a device supports them (any rate inside a supported range is possible, but +/// these are the ones interfaces actually run at). +pub const STANDARD_RATES: [u32; 6] = [44_100, 48_000, 88_200, 96_000, 176_400, 192_000]; +/// Buffer sizes offered when the device reports a range that contains them. +pub const STANDARD_BUFFERS: [u32; 9] = [32, 64, 128, 256, 512, 1024, 2048, 4096, 8192]; + +/// Sample formats the streams convert to and from, best first (ASIO drivers usually run in +/// 32-bit integers, WASAPI and Core Audio in float). +pub(crate) const FORMATS: [cpal::SampleFormat; 3] = [cpal::SampleFormat::F32, cpal::SampleFormat::I32, cpal::SampleFormat::I16]; + +/// Preference of a sample format (lower is better); `None` when it is not supported. +pub(crate) fn format_rank(f: cpal::SampleFormat) -> Option { + FORMATS.iter().position(|x| *x == f) +} + +/// The user's audio device choice. Every field is optional: `None` means "the system default". +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct AudioConfig { + /// Audio system (cpal host name: "WASAPI", "ASIO", "CoreAudio", "ALSA", "JACK", ...). + pub system: Option, + pub output: Option, + pub input: Option, + /// Device sample rate; the mix resamples when it differs from the session's. + pub sample_rate: Option, + /// Device buffer in frames. + pub buffer_size: Option, +} + +/// One device as the settings window lists it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct DeviceInfo { + pub name: String, + pub is_default: bool, + /// Most channels any usable configuration offers. + pub max_channels: u16, + /// Standard rates the device accepts (in a format the streams convert). + pub sample_rates: Vec, + /// Buffer range the device reports (`None` when it does not say). + pub buffer_min: Option, + pub buffer_max: Option, +} + +impl DeviceInfo { + /// Standard buffer sizes inside the device's range (all of them when the range is unknown). + pub fn buffer_sizes(&self) -> Vec { + STANDARD_BUFFERS.iter().copied().filter(|b| self.buffer_min.is_none_or(|m| *b >= m) && self.buffer_max.is_none_or(|m| *b <= m)).collect() + } +} + +/// An audio system and its devices. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct AudioSystem { + pub name: String, + pub is_default: bool, + pub outputs: Vec, + pub inputs: Vec, +} + +#[allow(deprecated)] +fn device_name(d: &cpal::Device) -> Option { + d.name().ok() +} + +fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Option { + let name = device_name(d)?; + let ranges: Vec = if output { + d.supported_output_configs().ok()?.filter(|c| format_rank(c.sample_format()).is_some()).collect() + } else { + d.supported_input_configs().ok()?.filter(|c| format_rank(c.sample_format()).is_some()).collect() + }; + let max_channels = ranges.iter().map(cpal::SupportedStreamConfigRange::channels).max().unwrap_or(0); + let sample_rates = + STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.min_sample_rate().0 <= *r && c.max_sample_rate().0 >= *r)).collect(); + let (mut buffer_min, mut buffer_max) = (None::, None::); + for c in &ranges { + if let cpal::SupportedBufferSize::Range { min, max } = c.buffer_size() { + buffer_min = Some(buffer_min.map_or(*min, |m| m.min(*min))); + buffer_max = Some(buffer_max.map_or(*max, |m| m.max(*max))); + } + } + Some(DeviceInfo { is_default: default_name == Some(name.as_str()), name, max_channels, sample_rates, buffer_min, buffer_max }) +} + +/// Every audio system on this machine with its devices. Enumerating can take a moment (some +/// systems probe each device): call it when the settings window opens, not every frame. +pub fn audio_systems() -> Vec { + let default_id = cpal::default_host().id(); + cpal::available_hosts() + .into_iter() + .filter_map(|id| { + let host = cpal::host_from_id(id).ok()?; + 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(); + let inputs = host.input_devices().map(|ds| ds.filter_map(|d| describe(&d, def_in.as_deref(), false)).collect()).unwrap_or_default(); + Some(AudioSystem { name: id.name().to_string(), is_default: id == default_id, outputs, inputs }) + }) + .collect() +} + +/// The chosen audio system, else the default one. +pub(crate) fn host(cfg: &AudioConfig) -> cpal::Host { + 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()) + .unwrap_or_else(|| { + if let Some(s) = &cfg.system { + log::warn!("audio system `{s}` is not available; using the default"); + } + 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 { + 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))); + if found.is_some() { + return found; + } + log::warn!("audio device `{want}` is not available; using the default"); + } + if output { host.default_output_device() } else { host.default_input_device() } +} + +/// The buffer to request: the chosen size when the device's range allows it, else the default. +pub(crate) fn buffer_size(want: Option, supported: &cpal::SupportedBufferSize) -> cpal::BufferSize { + match (want, supported) { + (Some(n), cpal::SupportedBufferSize::Range { min, max }) if (*min..=*max).contains(&n) => cpal::BufferSize::Fixed(n), + // The device does not say: ask anyway (cpal reports an error if it cannot, and the caller + // falls back to the default). + (Some(n), cpal::SupportedBufferSize::Unknown) if (16..=8192).contains(&n) => cpal::BufferSize::Fixed(n), + _ => cpal::BufferSize::Default, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_round_trips_and_tolerates_missing_fields() { + let c = AudioConfig { + system: Some("WASAPI".into()), + output: Some("Speakers".into()), + input: None, + sample_rate: Some(48_000), + buffer_size: Some(256), + }; + let s = serde_json::to_string(&c).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), c); + assert_eq!(serde_json::from_str::("{}").unwrap(), AudioConfig::default()); + assert_eq!(serde_json::from_str::(r#"{"output":"X","unknown":1}"#).unwrap().output.as_deref(), Some("X")); + } + + #[test] + fn buffer_sizes_follow_the_device_range() { + let r = cpal::SupportedBufferSize::Range { min: 64, max: 1024 }; + assert_eq!(buffer_size(Some(256), &r), cpal::BufferSize::Fixed(256)); + assert_eq!(buffer_size(Some(32), &r), cpal::BufferSize::Default); + assert_eq!(buffer_size(Some(4096), &r), cpal::BufferSize::Default); + assert_eq!(buffer_size(None, &r), cpal::BufferSize::Default); + assert_eq!(buffer_size(Some(128), &cpal::SupportedBufferSize::Unknown), cpal::BufferSize::Fixed(128)); + assert_eq!(buffer_size(Some(1 << 20), &cpal::SupportedBufferSize::Unknown), cpal::BufferSize::Default); + let d = DeviceInfo { + name: "x".into(), + is_default: true, + max_channels: 2, + sample_rates: vec![48_000], + buffer_min: Some(64), + buffer_max: Some(1024), + }; + assert_eq!(d.buffer_sizes(), [64, 128, 256, 512, 1024]); + let open = DeviceInfo { buffer_min: None, buffer_max: None, ..d }; + assert_eq!(open.buffer_sizes().len(), STANDARD_BUFFERS.len()); + } + + #[test] + fn unknown_systems_and_devices_fall_back_to_the_defaults() { + let cfg = AudioConfig { system: Some("No Such System".into()), ..Default::default() }; + 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); + assert_eq!(fallback, h.default_output_device().as_ref().and_then(device_name)); + } + + #[test] + fn enumeration_does_not_fail() { + let systems = audio_systems(); + // Every listed system has a name; on a machine without audio the lists are just empty. + assert!(systems.iter().all(|s| !s.name.is_empty())); + assert!(systems.iter().filter(|s| s.is_default).count() <= 1); + } +} diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..d682b52 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -22,8 +22,11 @@ //! through handles taken from each instance before it leaves ([`Player::open_editor`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod devices; pub mod record; +pub use devices::AudioConfig; + use soundcraft_dsp::{Plugin, PluginEditor}; use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels}; use soundcraft_model::{Session, TrackId}; @@ -357,6 +360,8 @@ pub struct Player { pub device_rate: u32, /// Output channels the device was opened with (0 for the silent clock). pub device_channels: u16, + /// The device buffer in frames when one was chosen (`None`: the device default). + pub device_buffer: Option, /// True when no audio device could be opened (silent clock). pub silent: bool, } @@ -364,7 +369,13 @@ pub struct Player { impl Player { /// Open the default output device. Never fails: without a device it runs a silent clock. pub fn new(session: Arc) -> Player { - let p = Player::open(Arc::clone(&session)); + Player::with_config(session, &AudioConfig::default()) + } + + /// Open the output device `cfg` chooses (system, device, rate, buffer size), falling back to the + /// defaults for whatever is unavailable. Never fails: without a device it runs a silent clock. + pub fn with_config(session: Arc, cfg: &AudioConfig) -> Player { + let p = Player::open(Arc::clone(&session), cfg); // Hand the initial session's third-party plugins over. p.update_session(session); p @@ -374,70 +385,75 @@ impl Player { self.side.lock().unwrap_or_else(PoisonError::into_inner) } - fn open(session: Arc) -> Player { - let (gtx, grx) = channel(); - let side = Mutex::new(Side { shadow: HashMap::new(), rate: session.sample_rate.as_f64() as f32, editors: HashMap::new(), garbage: grx }); + fn open(session: Arc, cfg: &AudioConfig) -> Player { let shared = Arc::new(Shared { position: AtomicI64::new(0), playing: AtomicBool::new(false), speed: AtomicU32::new(1000), meters: Mutex::new(MeterSnapshot::default()), }); - let (tx, rx) = channel(); let want = session.sample_rate.hz(); let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2); - match open_device(want, want_ch) { - Ok((device, config, name)) => { - let rate = config.sample_rate.0; - let channels = usize::from(config.channels); - let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); - // cpal may call this on the audio thread (on ALSA in a loop while the device - // stays broken): marked, so the logger hands the record off instead of writing it. - let err_fn = |e: cpal::StreamError| { - mark_audio_thread(); - log::warn!("audio stream error: {e}"); - }; - use cpal::traits::{DeviceTrait, StreamTrait}; - match device.build_output_stream( - &config, - move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - mark_audio_thread(); - state.fill(data, channels); - }, - err_fn, - None, - ) { - Ok(stream) => { - if let Err(e) = stream.play() { - log::warn!("audio stream failed to start: {e}"); + let new_side = |garbage: Receiver>>| { + Mutex::new(Side { shadow: HashMap::new(), rate: session.sample_rate.as_f64() as f32, editors: HashMap::new(), garbage }) + }; + match open_device(want, want_ch, cfg) { + Ok((device, 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 { + attempts.push(cpal::StreamConfig { buffer_size: cpal::BufferSize::Default, ..config }); + } + for config in attempts { + let rate = config.sample_rate.0; + let channels = usize::from(config.channels); + let (tx, rx) = channel(); + let (gtx, grx) = channel(); + let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx); + use cpal::traits::StreamTrait; + let built = match format { + cpal::SampleFormat::I32 => build_output::(&device, &config, state, channels), + cpal::SampleFormat::I16 => build_output::(&device, &config, state, channels), + _ => build_output::(&device, &config, state, channels), + }; + match built { + Ok(stream) => { + if let Err(e) = stream.play() { + log::warn!("audio stream failed to start: {e}"); + } + let buffer = match config.buffer_size { + cpal::BufferSize::Fixed(n) => Some(n), + cpal::BufferSize::Default => None, + }; + log::info!( + "audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}", + buffer.map_or("default".to_string(), |n| n.to_string()) + ); + return Player { + shared, + tx, + side: new_side(grx), + _stream: Some(stream), + device_name: name, + device_rate: rate, + device_channels: config.channels, + device_buffer: buffer, + silent: false, + }; } - log::info!("audio device: {name} @ {rate} Hz, {channels} ch"); - Player { - shared, - tx, - side, - _stream: Some(stream), - device_name: name, - device_rate: rate, - device_channels: config.channels, - silent: false, - } - } - Err(e) => { - log::warn!("cannot open audio stream: {e}; using a silent clock"); - let (tx2, rx2) = channel(); - let (gtx2, grx2) = channel(); - // The state (and its receivers) moved into the failed closure; rebuild. - let mut side = side; - side.get_mut().unwrap_or_else(PoisonError::into_inner).garbage = grx2; - Player::silent_clock(shared, tx2, rx2, want, side, gtx2) + Err(e) => log::warn!("cannot open audio stream ({:?}): {e}", config.buffer_size), } } + log::warn!("no usable audio stream; using a silent clock"); + let (tx, rx) = channel(); + let (gtx, grx) = channel(); + Player::silent_clock(shared, tx, rx, want, new_side(grx), gtx) } Err(e) => { log::warn!("no audio output: {e}; using a silent clock"); - let s2 = Arc::clone(&shared); - Player::silent_clock_with(s2, tx, rx, session, side, gtx) + let (tx, rx) = channel(); + let (gtx, grx) = channel(); + Player::silent_clock_with(shared, tx, rx, Arc::clone(&session), new_side(grx), gtx) } } } @@ -493,6 +509,7 @@ impl Player { device_name: "No audio device (silent)".into(), device_rate: rate, device_channels: 0, + device_buffer: None, silent: true, } } @@ -514,6 +531,7 @@ impl Player { device_name: "No audio device".into(), device_rate: session.sample_rate.hz(), device_channels: 0, + device_buffer: None, silent: true, } } @@ -709,41 +727,99 @@ fn fold_gains(s: &Session) -> [(f32, f32); MAX_CHANNELS] { g } -/// Open the default output device: f32 at the session rate if possible, with as many channels as -/// it offers up to `want_ch` (at least stereo when available). -fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::StreamConfig, String), String> { - use cpal::traits::{DeviceTrait, HostTrait}; - let host = cpal::default_host(); - let device = host.default_output_device().ok_or_else(|| "no default output device".to_string())?; +/// The output stream: the mix renders f32 into a scratch buffer allocated here (so the callback +/// never allocates), converted to the device's sample type. +fn build_output( + device: &cpal::Device, + config: &cpal::StreamConfig, + mut state: AudioState, + channels: usize, +) -> Result +where + T: cpal::SizedSample + cpal::FromSample, +{ + use cpal::traits::DeviceTrait; + let frame = channels.max(1); + let mut scratch = vec![0.0f32; 8192 * frame]; + let chunk = scratch.len(); + device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + mark_audio_thread(); + for out in data.chunks_mut(chunk) { + let Some(s) = scratch.get_mut(..out.len()) else { return }; + state.fill(s, channels); + for (o, x) in out.iter_mut().zip(s.iter()) { + *o = T::from_sample(*x); + } + } + }, + // cpal may call this on the audio thread (on ALSA in a loop while the device stays + // broken): marked, so the logger hands the record off instead of writing it. + |e: cpal::StreamError| { + mark_audio_thread(); + log::warn!("audio stream error: {e}"); + }, + None, + ) +} + +/// Open the output device `cfg` chooses (else the default) at the chosen rate (else the session +/// 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> { + 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())?; #[allow(deprecated)] let name = device.name().unwrap_or_else(|_| "Audio device".into()); - // Prefer an f32 config at the session rate; else the device default (we resample). + // Prefer a config at the chosen (else session) rate; else the device default (we resample). let want_ch = want_ch.max(2); - let mut chosen: Option = None; + let rates: Vec = cfg.sample_rate.into_iter().chain(Some(want_rate)).collect(); + let mut chosen: Option<(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)> = None; + // Better: a preferred format first, then more channels. + let better = |c: &cpal::SupportedStreamConfigRange, x: &(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)| { + let (rc, rx) = (devices::format_rank(c.sample_format()).unwrap_or(usize::MAX), devices::format_rank(x.2).unwrap_or(usize::MAX)); + rc < rx || (rc == rx && c.channels() > x.0.channels) + }; if let Ok(configs) = device.supported_output_configs() { - for c in configs { - if c.sample_format() == cpal::SampleFormat::F32 - && c.min_sample_rate().0 <= want_rate - && c.max_sample_rate().0 >= want_rate - && c.channels() >= 2 - && c.channels() <= want_ch - && chosen.as_ref().is_none_or(|x| c.channels() > x.channels) - { - chosen = Some(c.with_sample_rate(cpal::SampleRate(want_rate)).config()); + let configs: Vec = configs.collect(); + for rate in rates { + for c in &configs { + if devices::format_rank(c.sample_format()).is_some() + && c.min_sample_rate().0 <= rate + && c.max_sample_rate().0 >= rate + && c.channels() >= 2 + && c.channels() <= want_ch + && chosen.as_ref().is_none_or(|x| better(c, x)) + { + chosen = Some((c.with_sample_rate(cpal::SampleRate(rate)).config(), *c.buffer_size(), c.sample_format())); + } + } + if chosen.is_some() { + break; } } } - let config = match chosen { - Some(c) => c, + let (config, format) = match chosen { + Some((mut c, buffers, format)) => { + c.buffer_size = devices::buffer_size(cfg.buffer_size, &buffers); + (c, format) + } None => { let d = device.default_output_config().map_err(|e| e.to_string())?; - if d.sample_format() != cpal::SampleFormat::F32 { + if devices::format_rank(d.sample_format()).is_none() { return Err(format!("unsupported device sample format {:?}", d.sample_format())); } - d.config() + let buffers = *d.buffer_size(); + let format = d.sample_format(); + let mut c = d.config(); + c.buffer_size = devices::buffer_size(cfg.buffer_size, &buffers); + (c, format) } }; - Ok((device, config, name)) + Ok((device, config, format, name)) } #[cfg(test)] diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index ec784ac..4b9bcb3 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -87,53 +87,113 @@ pub struct Recorder { writer: Option>, } +/// The input stream: device samples are converted to f32 in a scratch buffer allocated here (so +/// the callback never allocates), then feed the monitor ring and, while armed, the capture. +#[cfg(not(target_os = "freebsd"))] +fn build_input( + device: &cpal::Device, + config: &cpal::StreamConfig, + shared: &Arc, + monitor: &Arc, +) -> Result +where + T: cpal::SizedSample, + f32: cpal::FromSample, +{ + use cpal::Sample; + use cpal::traits::DeviceTrait; + let sh = Arc::clone(shared); + let mon = Arc::clone(monitor); + let mut scratch = vec![0.0f32; 8192 * usize::from(config.channels).max(1)]; + let chunk = scratch.len(); + let mut owed = 0; + device.build_input_stream( + config, + move |data: &[T], _: &cpal::InputCallbackInfo| { + crate::mark_audio_thread(); + for part in data.chunks(chunk) { + let Some(s) = scratch.get_mut(..part.len()) else { return }; + for (o, x) in s.iter_mut().zip(part) { + *o = f32::from_sample(*x); + } + mon.push(s); + if sh.armed.load(Ordering::Acquire) { + sh.capture(s, &mut owed); + } else { + owed = 0; + } + } + }, + |e| { + // Possibly the audio thread (see `crate::mark_audio_thread`). + crate::mark_audio_thread(); + log::warn!("input stream error: {e}"); + }, + None, + ) +} + impl Recorder { /// Open the default input device. Errors when there is none (the caller shows a message). - #[cfg(not(target_os = "freebsd"))] pub fn open() -> Result { - use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; - let host = cpal::default_host(); - let device = host.default_input_device().ok_or("no audio input device")?; + Recorder::open_with(&crate::AudioConfig::default()) + } + + /// Open the input device `audio` chooses (else the default) at its chosen rate and buffer + /// size when the device offers them, else at the device's defaults. Integer devices (ASIO + /// drivers usually are) are converted to f32. + #[cfg(not(target_os = "freebsd"))] + pub fn open_with(audio: &crate::AudioConfig) -> Result { + 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")?; #[allow(deprecated)] let name = device.name().unwrap_or_else(|_| "Input".into()); - let cfg = device.default_input_config().map_err(|e| e.to_string())?; - if cfg.sample_format() != cpal::SampleFormat::F32 { - return Err(format!("unsupported input sample format {:?}", cfg.sample_format())); + // At the chosen rate when the device offers it, so input and output run together: the + // best format first, then the most channels. + let at_rate = audio.sample_rate.and_then(|r| { + device + .supported_input_configs() + .ok()? + .filter(|c| crate::devices::format_rank(c.sample_format()).is_some() && c.min_sample_rate().0 <= r && c.max_sample_rate().0 >= r) + .min_by_key(|c| (crate::devices::format_rank(c.sample_format()), std::cmp::Reverse(c.channels()))) + .map(|c| c.with_sample_rate(cpal::SampleRate(r))) + }); + let cfg = match at_rate { + Some(c) => c, + None => device.default_input_config().map_err(|e| e.to_string())?, + }; + let format = cfg.sample_format(); + if crate::devices::format_rank(format).is_none() { + return Err(format!("unsupported input sample format {format:?}")); } - let config = cfg.config(); + let mut config = cfg.config(); + config.buffer_size = crate::devices::buffer_size(audio.buffer_size, cfg.buffer_size()); let channels = usize::from(config.channels).max(1); let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) }); - let sh = Arc::clone(&shared); let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); - let mon = Arc::clone(&monitor); - let mut owed = 0; - let stream = device - .build_input_stream( - &config, - move |data: &[f32], _: &cpal::InputCallbackInfo| { - crate::mark_audio_thread(); - mon.push(data); - if sh.armed.load(Ordering::Acquire) { - sh.capture(data, &mut owed); - } else { - owed = 0; - } - }, - |e| { - // Possibly the audio thread (see `crate::mark_audio_thread`). - crate::mark_audio_thread(); - log::warn!("input stream error: {e}"); - }, - None, - ) - .map_err(|e| e.to_string())?; + let build = |config: &cpal::StreamConfig| match format { + cpal::SampleFormat::I32 => build_input::(&device, config, &shared, &monitor), + cpal::SampleFormat::I16 => build_input::(&device, config, &shared, &monitor), + _ => build_input::(&device, config, &shared, &monitor), + }; + // The chosen buffer size first; if the device refuses it, its default. + let stream = match build(&config) { + Ok(s) => s, + Err(e) if config.buffer_size != cpal::BufferSize::Default => { + log::warn!("cannot open the input with buffer {:?}: {e}; using the device default", config.buffer_size); + config.buffer_size = cpal::BufferSize::Default; + build(&config).map_err(|e| e.to_string())? + } + Err(e) => return Err(e.to_string()), + }; stream.play().map_err(|e| e.to_string())?; Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None }) } #[cfg(target_os = "freebsd")] - pub fn open() -> Result { + pub fn open_with(_audio: &crate::AudioConfig) -> Result { Err("recording is not supported on this platform yet".into()) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..a50f36a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -83,6 +83,8 @@ pub struct UiState { pub show_workspace: bool, pub show_configurations: bool, pub show_playback_engine: bool, + /// The audio device choice (Setup › Playback Engine), kept with the preferences: per machine. + pub audio: soundcraft_playback::AudioConfig, pub show_io_setup: bool, pub show_shortcuts: bool, pub show_clip_effects: bool, @@ -142,6 +144,7 @@ impl Default for UiState { show_workspace: false, show_configurations: false, show_playback_engine: false, + audio: soundcraft_playback::AudioConfig::default(), show_io_setup: false, show_shortcuts: false, show_clip_effects: false, @@ -555,12 +558,32 @@ impl SoundApp { || (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)) } + /// Reopens the audio devices with `ui.audio` (Setup › Playback Engine › Apply), keeping the + /// transport position. Plugin instances and their editors are recreated with the new player. + /// Refused while recording, which would lose the take. + pub fn apply_audio_config(&mut self) -> Result<(), String> { + if self.engine.transport.recording { + return Err("stop recording before changing the audio device".into()); + } + let at = self.position(); + if let Some(p) = &self.player { + p.stop(); + } + // The input reopens on the next record or monitor with the new choice. + self.recorder = None; + self.recorder_failed = false; + let player = soundcraft_playback::Player::with_config(self.engine.session_arc(), &self.ui.audio); + player.locate(at); + self.player = Some(player); + Ok(()) + } + /// Open the input device once (for recording and input monitoring) and feed the player. fn ensure_input(&mut self) { if self.recorder.is_some() || self.recorder_failed || self.player.is_none() { return; } - match soundcraft_playback::record::Recorder::open() { + match soundcraft_playback::record::Recorder::open_with(&self.ui.audio) { Ok(r) => { if let Some(p) = &self.player { p.set_input(std::sync::Arc::clone(&r.monitor)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..fb1520a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -64,6 +64,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.midi_ops", "MIDI Operations", "", None), ("window.rtp", "MIDI Real-Time Properties", "", None), ("window.playback_engine", "Playback Engine", "", None), + ("ui.audio_device", "Audio Device", "", None), ("window.io_setup", "I/O Setup", "", None), ("window.shortcuts", "Keyboard Shortcuts", "", None), ("view.mix_section", "Mix Window View", "", None), @@ -460,6 +461,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option Arc> { + let id = egui::Id::new("audio_systems"); + if !refresh && let Some(s) = ctx.data(|d| d.get_temp::>>(id)) { + return s; + } + let s = Arc::new(soundcraft_playback::devices::audio_systems()); + ctx.data_mut(|d| d.insert_temp(id, Arc::clone(&s))); + s +} + +/// The system a config means (its own, else the default one) in an enumeration. +fn chosen_system<'a>(systems: &'a [AudioSystem], cfg: &AudioConfig) -> Option<&'a AudioSystem> { + cfg.system + .as_deref() + .and_then(|n| systems.iter().find(|s| s.name.eq_ignore_ascii_case(n))) + .or_else(|| systems.iter().find(|s| s.is_default)) + .or(systems.first()) +} + +/// The device a config means (by name, else the system default). +fn chosen_device<'a>(devices: &'a [DeviceInfo], name: Option<&str>) -> Option<&'a DeviceInfo> { + name.and_then(|n| devices.iter().find(|d| d.name == n)).or_else(|| devices.iter().find(|d| d.is_default)) +} + +fn device_combo(ui: &mut egui::Ui, id: &str, value: &mut Option, devices: &[DeviceInfo]) { + let default = devices.iter().find(|d| d.is_default).map_or("none".to_string(), |d| d.name.clone()); + let shown = value.clone().unwrap_or_else(|| format!("Default ({default})")); + egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| { + ui.selectable_value(value, None, format!("Default ({default})")); + for d in devices { + ui.selectable_value(value, Some(d.name.clone()), format!("{} ({} ch)", d.name, d.max_channels)); + } + }); +} + +fn number_combo(ui: &mut egui::Ui, id: &str, value: &mut Option, options: &[u32], unit: &str) { + let shown = value.map_or("Device default".to_string(), |v| format!("{v} {unit}")); + egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| { + ui.selectable_value(value, None, "Device default"); + for v in options { + ui.selectable_value(value, Some(*v), format!("{v} {unit}")); + } + }); +} + +/// Setup › Playback Engine / Hardware: choose the audio system, devices, rate and buffer size. pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_playback_engine; - win(ctx, &mut open, "Playback Engine", vec2(380.0, 220.0), |ui| { - egui::Grid::new("pe").num_columns(2).show(ui, |ui| { + if !open { + return; + } + let draft_id = egui::Id::new("audio_draft"); + let mut draft = ctx.data(|d| d.get_temp::(draft_id)).unwrap_or_else(|| app.ui.audio.clone()); + let mut refresh = false; + let mut apply = false; + let systems = audio_systems(ctx, false); + win(ctx, &mut open, "Playback Engine", vec2(460.0, 360.0), |ui| { + let t = Tokens::current(); + egui::Grid::new("pe").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + ui.label("Audio system"); + let def = systems.iter().find(|s| s.is_default).map_or("none".to_string(), |s| s.name.clone()); + let shown = draft.system.clone().unwrap_or_else(|| format!("Default ({def})")); + egui::ComboBox::from_id_salt("pe-system").width(300.0).selected_text(shown).show_ui(ui, |ui| { + let before = draft.system.clone(); + ui.selectable_value(&mut draft.system, None, format!("Default ({def})")); + for s in systems.iter() { + ui.selectable_value(&mut draft.system, Some(s.name.clone()), &s.name); + } + if draft.system != before { + // Device names belong to a system. + draft.output = None; + draft.input = None; + } + }); + ui.end_row(); + let sys = chosen_system(&systems, &draft); ui.label("Output device"); - ui.label(app.player.as_ref().map_or("none".to_string(), |p| p.device_name.clone())); - ui.end_row(); - ui.label("Device rate"); - ui.label(app.player.as_ref().map_or("-".to_string(), |p| format!("{} Hz", p.device_rate))); - ui.end_row(); - ui.label("Session rate"); - ui.label(format!("{} Hz", app.engine.session().sample_rate.hz())); + device_combo(ui, "pe-out", &mut draft.output, sys.map_or(&[][..], |s| &s.outputs)); ui.end_row(); ui.label("Input device"); - ui.label( - app.recorder - .as_ref() - .map_or("opened on first record".to_string(), |r| format!("{} ({} ch @ {} Hz)", r.device_name, r.channels, r.sample_rate)), - ); + device_combo(ui, "pe-in", &mut draft.input, sys.map_or(&[][..], |s| &s.inputs)); ui.end_row(); - ui.label("Mix block size"); - ui.label("512 samples (resampled to the device rate when they differ)"); + let out = sys.and_then(|s| chosen_device(&s.outputs, draft.output.as_deref())); + ui.label("Sample rate"); + number_combo(ui, "pe-rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz"); + ui.end_row(); + ui.label("Buffer size"); + let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec()); + number_combo(ui, "pe-buffer", &mut draft.buffer_size, &buffers, "samples"); ui.end_row(); }); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Refresh devices").clicked() { + refresh = true; + } + let changed = draft != app.ui.audio; + if ui.add_enabled(changed, egui::Button::new("Apply")).on_hover_text("Reopen the audio devices with these settings").clicked() { + apply = true; + } + if changed && ui.button("Revert").clicked() { + draft = app.ui.audio.clone(); + } + }); + ui.separator(); + egui::Grid::new("pe-state").num_columns(2).show(ui, |ui| { + ui.label("Output"); + ui.label(app.player.as_ref().map_or("none".to_string(), |p| { + if p.silent { + "no device (silent clock)".to_string() + } else { + let buf = p.device_buffer.map_or("default buffer".to_string(), |b| format!("{b} samples")); + format!("{} · {} Hz · {} ch · {buf}", p.device_name, p.device_rate, p.device_channels) + } + })); + ui.end_row(); + ui.label("Input"); + ui.label(app.recorder.as_ref().map_or("opened on first record or monitor".to_string(), |r| { + format!("{} · {} Hz · {} ch", r.device_name, r.sample_rate, r.channels) + })); + ui.end_row(); + ui.label("Session rate"); + let sr = app.engine.session().sample_rate.hz(); + let note = app + .player + .as_ref() + .filter(|p| !p.silent && p.device_rate != sr) + .map_or(String::new(), |_| " (resampled to the device rate)".to_string()); + ui.label(format!("{sr} Hz{note}")); + ui.end_row(); + }); + ui.label(egui::RichText::new("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.").small().color(t.text_dim)); let mut dc = app.engine.session().edit.delay_compensation; if ui.checkbox(&mut dc, "Delay compensation").changed() { let _ = app.run("options.delay_compensation", json!({"value": dc})); } - if ui.button("Reconnect audio device").clicked() { - app.player = Some(soundcraft_playback::Player::new(app.engine.session_arc())); - } }); + if refresh { + audio_systems(ctx, true); + } + if apply { + app.ui.audio = draft.clone(); + if let Err(e) = app.apply_audio_config() { + app.ui.status = e; + } + } + if open { + ctx.data_mut(|d| d.insert_temp(draft_id, draft)); + } else { + // Closing discards unapplied edits and the device list (re-read on the next open). + ctx.data_mut(|d| { + d.remove_temp::(draft_id); + d.remove_temp::>>(egui::Id::new("audio_systems")); + }); + } app.ui.show_playback_engine = open; } +/// `ui.audio_device`: `{list?: bool}` returns the audio systems and their devices; +/// `{system?, output?, input?, sample_rate?, buffer_size?}` (a `null` value means "default") sets +/// the choice and reopens the devices. Always returns the choice and the open device. +pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result { + use serde_json::Value; + let mut changed = false; + let mut cfg = app.ui.audio.clone(); + for (key, slot) in [("system", &mut cfg.system), ("output", &mut cfg.output), ("input", &mut cfg.input)] { + if let Some(v) = p.get(key) { + *slot = v.as_str().map(str::to_string).filter(|s| !s.is_empty()); + changed = true; + } + } + for (key, slot, range) in [("sample_rate", &mut cfg.sample_rate, 8_000..=768_000u64), ("buffer_size", &mut cfg.buffer_size, 16..=8192u64)] { + if let Some(v) = p.get(key) { + *slot = match v { + Value::Null => None, + v => Some(v.as_u64().filter(|n| range.contains(n)).ok_or_else(|| format!("`{key}` must be a number in {range:?} or null"))? as u32), + }; + changed = true; + } + } + if changed { + app.ui.audio = cfg; + app.apply_audio_config()?; + } + let device = app.player.as_ref().map(|pl| { + json!({"name": pl.device_name, "sample_rate": pl.device_rate, "channels": pl.device_channels, "buffer_size": pl.device_buffer, "silent": pl.silent}) + }); + let mut out = json!({"config": app.ui.audio, "device": device}); + if p.get("list").and_then(Value::as_bool).unwrap_or(false) { + out["systems"] = json!(soundcraft_playback::devices::audio_systems()); + } + Ok(out) +} + /// Setup › I/O: busses and output paths. pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_io_setup; diff --git a/docs/gaps.md b/docs/gaps.md index cf3ea5b..f8c39b8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha blockers marked after the core-workflow gate) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G-01: audio device settings and opt-in ASIO) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker** @@ -15,7 +15,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t | # | Gap | Dimension | Doc | Est. (h) | |---|---|---|---|---:| -| G-01 | **Alpha blocker.** No audio device, channel or buffer-size selection; no duplex low-latency path | Hardware | [hardware-parity.md](hardware-parity.md) | 25–40 | +| G-01 | **Alpha blocker.** No channel routing or duplex low-latency path (device, rate and buffer choice done; ASIO opt-in) | Hardware | [hardware-parity.md](hardware-parity.md) | 15–25 | | G-02 | **Alpha blocker.** No MIDI device input or output | Hardware / MIDI | [hardware-parity.md](hardware-parity.md) | 20–35 | | G-03 | **Alpha blocker.** Cannot exchange sessions with Pro Tools (no AAF/OMF, no `.ptx`) | File formats | [file-format-parity.md](file-format-parity.md) | 50–90 | | G-04 | Open correctness bugs in features counted as done (~25 issues) | Stability | this file | 25–40 | @@ -42,12 +42,15 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t ### G-01. Audio interface support -- **Missing:** choosing the output or input device, the device's channel count and routing to - physical channels, the buffer size, and the sample rate from the hardware side. Input and - output are separate cpal streams on the *default* devices (`crates/playback/src/lib.rs` - `open_device`, `record.rs`); the mix runs in fixed 512-sample blocks and resamples when rates - differ. `setup.playback_engine` stores `device`/`buffer_size` params, but playback ignores them. - No ASIO on Windows (WASAPI shared only), so latency there is high. +- **Done (2026-10-12):** Setup › Playback Engine chooses the audio system, output and input + device, sample rate and device buffer size (kept with the preferences, per machine; `ui.audio_device` + for agents); f32 and 16/32-bit integer devices; ASIO behind the opt-in `asio` build feature + (verified on Windows with an SSL 2+ at 128 samples). +- **Missing:** routing to physical channels (I/O Setup), one duplex stream on one clock (input and + output are still separate cpal streams), latency reporting and record-offset compensation, ASIO + in official builds (owner decision: Steinberg's SDK is proprietary or GPLv3), JACK, and + verification on macOS and Linux. The mix still runs in fixed 512-sample blocks. + `setup.playback_engine` still stores `device`/`buffer_size` in the session, unused. - **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42 (no input device selection, forced monitoring feedback with virtual routing). - **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard. @@ -196,5 +199,6 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G-01 shrunk: audio device, rate and buffer choice; opt-in ASIO | | 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate | | 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 2b3eadc..1e234d9 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware audit against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (audio device, rate and buffer choice; ASIO feature) · **Target:** Avid Pro Tools Ultimate 2026.4.1 A DAW is judged by how it drives audio interfaces, MIDI gear, control surfaces and sync. This is SoundCraft's weakest dimension. Ours is measured from `crates/playback` (cpal) and the absence of @@ -15,10 +15,10 @@ Legend: ✅ works · 🟡 partial · ❌ missing. | Feature | Pro Tools (macOS / Windows) | SoundCraft macOS | Windows | Linux | FreeBSD | Web | |---|---|---|---|---|---|---| -| Driver API | Core Audio / ASIO | 🟡 Core Audio via cpal | 🟡 WASAPI shared via cpal; no ASIO | 🟡 ALSA/Pulse via cpal; no JACK | 🟡 cpal (ALSA layer) | 🟡 Web Audio | -| Choose playback device | ✅ Playback Engine | ❌ default output only | ❌ | ❌ | ❌ | ❌ | -| Choose input device | ✅ | ❌ default input only (#42) | ❌ | ❌ | ❌ | ❌ | -| Buffer size (32–2048) | ✅ | ❌ fixed 512-sample mix block | ❌ | ❌ | ❌ | ❌ | +| Driver API | Core Audio / ASIO | 🟡 Core Audio via cpal | 🟡 WASAPI shared via cpal; ASIO with the opt-in `asio` build feature (verified with an SSL 2+) | 🟡 ALSA/Pulse via cpal; no JACK | 🟡 cpal (ALSA layer) | 🟡 Web Audio | +| Choose playback device | ✅ Playback Engine | 🟡 Setup › Playback Engine (not yet verified on macOS) | ✅ Setup › Playback Engine (WASAPI, ASIO) | 🟡 (not yet verified) | 🟡 (not yet verified) | ❌ | +| Choose input device | ✅ | 🟡 Setup › Playback Engine (not yet verified on macOS) | ✅ (WASAPI, ASIO) | 🟡 (not yet verified) | ❌ no recording | ❌ | +| Buffer size (32–2048) | ✅ | 🟡 device buffer when the driver allows it (not yet verified); mix in 512-sample blocks | 🟡 device buffer 16–8192 (verified 128 on ASIO, 256 on WASAPI); mix in 512-sample blocks | 🟡 (not yet verified) | 🟡 | ❌ | | Duplex stream on one clock | ✅ | ❌ separate input/output streams | ❌ | ❌ | ❌ | ❌ | | Hardware I/O mapping (I/O Setup to physical channels) | ✅ | 🟡 multichannel output in order; no per-channel map | 🟡 | 🟡 | 🟡 | ❌ | | Low-latency monitoring | ✅ | ❌ (`options.low_latency_monitoring` is a stored flag) | ❌ | ❌ | ❌ | ❌ | @@ -72,4 +72,5 @@ Legend: ✅ works · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Audio device settings: system, output, input, sample rate and buffer size; opt-in ASIO | | 2026-10-10 | major | Created: audit of audio interface, MIDI, control-surface, sync and performance support against Pro Tools 2026.4.1 |