Audio device settings: system, devices, sample rate, buffer size; opt-in ASIO
Setup > Playback Engine now chooses the audio system (cpal host), output and input device, device sample rate and buffer size, kept per machine with the UI preferences and applied by reopening the devices (refused while recording; the transport position is kept). ui.audio_device does the same for agents and lists every system with its devices, channels, rates and buffer range. Player and recorder open the chosen device, fall back to the defaults for anything unavailable (unplugged device, unsupported rate), retry with the device's default buffer when the chosen one is refused, and convert 32/16-bit integer devices from and to the f32 mix through scratch buffers allocated up front (no allocation in the callbacks). ASIO is behind an opt-in 'asio' feature (playback and app): building it downloads Steinberg's ASIO SDK (proprietary licence or GPLv3) and needs LLVM, so it stays off by default. Verified on Windows with an SSL 2+: WASAPI output/input switching and buffer 256, ASIO duplex at 128. Docs: G-01 shrunk, hardware-parity rows, roadmap log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6cf896e76c
commit
a1e9660e60
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
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<String>,
|
||||
pub output: Option<String>,
|
||||
pub input: Option<String>,
|
||||
/// Device sample rate; the mix resamples when it differs from the session's.
|
||||
pub sample_rate: Option<u32>,
|
||||
/// Device buffer in frames.
|
||||
pub buffer_size: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
/// Buffer range the device reports (`None` when it does not say).
|
||||
pub buffer_min: Option<u32>,
|
||||
pub buffer_max: Option<u32>,
|
||||
}
|
||||
|
||||
impl DeviceInfo {
|
||||
/// Standard buffer sizes inside the device's range (all of them when the range is unknown).
|
||||
pub fn buffer_sizes(&self) -> Vec<u32> {
|
||||
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<DeviceInfo>,
|
||||
pub inputs: Vec<DeviceInfo>,
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn device_name(d: &cpal::Device) -> Option<String> {
|
||||
d.name().ok()
|
||||
}
|
||||
|
||||
fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Option<DeviceInfo> {
|
||||
let name = device_name(d)?;
|
||||
let ranges: Vec<cpal::SupportedStreamConfigRange> = 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::<u32>, None::<u32>);
|
||||
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<AudioSystem> {
|
||||
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<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)));
|
||||
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<u32>, 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::<AudioConfig>(&s).unwrap(), c);
|
||||
assert_eq!(serde_json::from_str::<AudioConfig>("{}").unwrap(), AudioConfig::default());
|
||||
assert_eq!(serde_json::from_str::<AudioConfig>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u32>,
|
||||
/// 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<Session>) -> 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<Session>, 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<Session>) -> 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<Session>, 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<Vec<Box<dyn Plugin>>>| {
|
||||
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::<i32>(&device, &config, state, channels),
|
||||
cpal::SampleFormat::I16 => build_output::<i16>(&device, &config, state, channels),
|
||||
_ => build_output::<f32>(&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<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
mut state: AudioState,
|
||||
channels: usize,
|
||||
) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
T: cpal::SizedSample + cpal::FromSample<f32>,
|
||||
{
|
||||
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<cpal::StreamConfig> = None;
|
||||
let rates: Vec<u32> = 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<cpal::SupportedStreamConfigRange> = 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)]
|
||||
|
|
|
|||
|
|
@ -87,53 +87,113 @@ pub struct Recorder {
|
|||
writer: Option<std::thread::JoinHandle<Takes>>,
|
||||
}
|
||||
|
||||
/// 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<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
shared: &Arc<Shared>,
|
||||
monitor: &Arc<InputRing>,
|
||||
) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
T: cpal::SizedSample,
|
||||
f32: cpal::FromSample<T>,
|
||||
{
|
||||
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<Recorder, String> {
|
||||
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<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")?;
|
||||
#[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::<i32>(&device, config, &shared, &monitor),
|
||||
cpal::SampleFormat::I16 => build_input::<i16>(&device, config, &shared, &monitor),
|
||||
_ => build_input::<f32>(&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<Recorder, String> {
|
||||
pub fn open_with(_audio: &crate::AudioConfig) -> Result<Recorder, String> {
|
||||
Err("recording is not supported on this platform yet".into())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<Result<
|
|||
if let Some(v) = crate::extra_windows::run(app, id, p) {
|
||||
return Some(Ok(v));
|
||||
}
|
||||
if id == "ui.audio_device" {
|
||||
return Some(crate::windows::audio_device_command(app, p));
|
||||
}
|
||||
let toggle = |v: &mut bool| {
|
||||
*v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v);
|
||||
json!({"value": *v})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ use crate::theme::{Tokens, mono, rgb};
|
|||
use egui::{Color32, Sense, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{AutomationMode, ClipContent};
|
||||
use soundcraft_playback::AudioConfig;
|
||||
use soundcraft_playback::devices::{AudioSystem, DeviceInfo};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
audio_health(app, ctx);
|
||||
|
|
@ -402,42 +405,202 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
app.ui.show_configurations = open;
|
||||
}
|
||||
|
||||
/// Setup › Playback Engine / Hardware.
|
||||
/// The audio systems as last enumerated (enumerating probes devices: done when the window opens
|
||||
/// and on Refresh, not every frame).
|
||||
fn audio_systems(ctx: &egui::Context, refresh: bool) -> Arc<Vec<AudioSystem>> {
|
||||
let id = egui::Id::new("audio_systems");
|
||||
if !refresh && let Some(s) = ctx.data(|d| d.get_temp::<Arc<Vec<AudioSystem>>>(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<String>, 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<u32>, 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::<AudioConfig>(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::<AudioConfig>(draft_id);
|
||||
d.remove_temp::<Arc<Vec<AudioSystem>>>(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<serde_json::Value, String> {
|
||||
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;
|
||||
|
|
|
|||
20
docs/gaps.md
20
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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue