mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-14 14:26:46 -04:00
Merge pull request #319 from Limestudio90/audio-device-settings
Audio device settings (system, devices, rate, buffer) and opt-in ASIO
This commit is contained in:
14 files changed
+894
-154
No files matched your search
Generated
+147
-2
@@ -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"
|
||||
@@ -3574,6 +3717,8 @@ version = "0.6.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-mix",
|
||||
|
||||
@@ -122,6 +122,7 @@ 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-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
|
||||
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
|
||||
- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256)
|
||||
@@ -129,6 +130,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
- **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249)
|
||||
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
|
||||
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
|
||||
- **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
|
||||
|
||||
@@ -267,7 +267,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.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+166
-82
@@ -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, PluginStateReader};
|
||||
use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels};
|
||||
use soundcraft_model::{Session, TrackId};
|
||||
@@ -459,6 +462,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,
|
||||
}
|
||||
@@ -466,7 +471,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);
|
||||
p.side().return_old_sessions(&p.tx);
|
||||
// Hand the initial session's third-party plugins over.
|
||||
p.update_session(session);
|
||||
@@ -490,79 +501,78 @@ 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::new(session.sample_rate.as_f64() as f32, 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);
|
||||
let fault = Arc::new(Fault::default());
|
||||
let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared));
|
||||
// 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 gone = Arc::clone(&fault);
|
||||
let err_fn = move |e: cpal::StreamError| {
|
||||
mark_audio_thread();
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||||
gone.gone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
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();
|
||||
guarded_block(&failed, &guard, data, |d| state.fill(d, channels));
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
) {
|
||||
Ok(stream) => {
|
||||
if let Err(e) = stream.play() {
|
||||
log::warn!("audio stream failed to start: {e}");
|
||||
let side_rate = session.sample_rate.as_f64() as f32;
|
||||
let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| Mutex::new(Side::new(side_rate, garbage));
|
||||
match open_device(want, want_ch, cfg) {
|
||||
Ok((device, config, format, name)) => {
|
||||
// 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);
|
||||
let fault = Arc::new(Fault::default());
|
||||
use cpal::traits::StreamTrait;
|
||||
let built = match format {
|
||||
cpal::SampleFormat::I32 => build_output::<i32>(&device, &config, state, channels, &fault, &shared),
|
||||
cpal::SampleFormat::I16 => build_output::<i16>(&device, &config, state, channels, &fault, &shared),
|
||||
_ => build_output::<f32>(&device, &config, state, channels, &fault, &shared),
|
||||
};
|
||||
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,
|
||||
fault,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
watch: Mutex::new(None),
|
||||
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,
|
||||
fault,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
watch: Mutex::new(None),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,6 +633,7 @@ impl Player {
|
||||
device_name: "No audio device (silent)".into(),
|
||||
device_rate: rate,
|
||||
device_channels: 0,
|
||||
device_buffer: None,
|
||||
silent: true,
|
||||
}
|
||||
}
|
||||
@@ -645,6 +656,7 @@ impl Player {
|
||||
device_name: "No audio device".into(),
|
||||
device_rate: session.sample_rate.hz(),
|
||||
device_channels: 0,
|
||||
device_buffer: None,
|
||||
silent: true,
|
||||
}
|
||||
}
|
||||
@@ -691,6 +703,14 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes every plugin editor window (main thread). Call before replacing the player: its
|
||||
/// instances may be dropped on the audio thread, where editor windows cannot be closed.
|
||||
pub fn close_editors(&self) {
|
||||
for e in self.side().editors.values_mut() {
|
||||
e.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_open(&self, track: TrackId, slot: usize) -> bool {
|
||||
self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open())
|
||||
}
|
||||
@@ -842,41 +862,105 @@ 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,
|
||||
fault: &Arc<Fault>,
|
||||
shared: &Arc<Shared>,
|
||||
) -> 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();
|
||||
let (failed, guard, gone) = (Arc::clone(fault), Arc::clone(shared), Arc::clone(fault));
|
||||
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 };
|
||||
guarded_block(&failed, &guard, s, |d| state.fill(d, 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.
|
||||
move |e: cpal::StreamError| {
|
||||
mark_audio_thread();
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||||
gone.gone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
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)]
|
||||
|
||||
@@ -137,59 +137,119 @@ pub struct Recorder {
|
||||
seen: (u64, Instant),
|
||||
}
|
||||
|
||||
/// 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, errors) = (Arc::clone(shared), 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();
|
||||
sh.guarded(|| {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
move |e| {
|
||||
// Possibly the audio thread (see `crate::mark_audio_thread`).
|
||||
crate::mark_audio_thread();
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||||
let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
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::new(ring));
|
||||
let (sh, errors) = (Arc::clone(&shared), 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();
|
||||
sh.guarded(|| {
|
||||
mon.push(data);
|
||||
if sh.armed.load(Ordering::Acquire) {
|
||||
sh.capture(data, &mut owed);
|
||||
} else {
|
||||
owed = 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
move |e| {
|
||||
// Possibly the audio thread (see `crate::mark_audio_thread`).
|
||||
crate::mark_audio_thread();
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||||
let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
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())?;
|
||||
let seen = (0, Instant::now());
|
||||
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen })
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,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,
|
||||
/// The Mix window lives in its own native window (Window › Mix in Separate Window); the main
|
||||
/// window then always shows Edit.
|
||||
pub mix_separate: bool,
|
||||
@@ -162,6 +164,7 @@ impl Default for UiState {
|
||||
show_workspace: false,
|
||||
show_configurations: false,
|
||||
show_playback_engine: false,
|
||||
audio: soundcraft_playback::AudioConfig::default(),
|
||||
mix_separate: false,
|
||||
show_io_setup: false,
|
||||
show_shortcuts: false,
|
||||
@@ -765,12 +768,37 @@ 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 `cfg` (Setup › Playback Engine › Apply), keeping the
|
||||
/// transport position, and keeps it as `ui.audio`. Plugin instances and their editors are
|
||||
/// recreated with the new player. Refused while recording, which would lose the take; `ui.audio`
|
||||
/// then stays the configuration of the open device.
|
||||
pub fn apply_audio_config(&mut self, cfg: soundcraft_playback::AudioConfig) -> Result<(), String> {
|
||||
if self.engine.transport.recording {
|
||||
return Err("stop recording before changing the audio device".into());
|
||||
}
|
||||
self.ui.audio = cfg;
|
||||
let at = self.position();
|
||||
if let Some(p) = &self.player {
|
||||
p.stop();
|
||||
// The old player's plugin instances go away with it; their editor windows must be
|
||||
// closed here, on the main thread, or they would stay on screen orphaned.
|
||||
p.close_editors();
|
||||
}
|
||||
// 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));
|
||||
@@ -848,9 +876,13 @@ impl SoundApp {
|
||||
if self.is_playing() || self.engine.transport.recording {
|
||||
self.stop_play();
|
||||
}
|
||||
if let Some(p) = &self.player {
|
||||
// Its plugin editors go with it; closed here, on the main thread (see `apply_audio_config`).
|
||||
p.close_editors();
|
||||
}
|
||||
self.recorder = None;
|
||||
self.recorder_failed = false;
|
||||
self.player = Some(Player::new(self.engine.session_arc()));
|
||||
self.player = Some(Player::with_config(self.engine.session_arc(), &self.ui.audio));
|
||||
}
|
||||
|
||||
fn finish_recording(&mut self) {
|
||||
|
||||
@@ -65,6 +65,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),
|
||||
@@ -482,6 +483,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})
|
||||
|
||||
+179
-17
@@ -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);
|
||||
@@ -407,31 +410,140 @@ 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}));
|
||||
@@ -440,9 +552,59 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.reconnect_audio();
|
||||
}
|
||||
});
|
||||
if refresh {
|
||||
audio_systems(ctx, true);
|
||||
}
|
||||
if apply && let Err(e) = app.apply_audio_config(draft.clone()) {
|
||||
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.apply_audio_config(cfg)?;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! `ui.audio_device`: the audio device choice is kept only once the devices reopen with it.
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
#[test]
|
||||
fn the_audio_device_choice_is_kept_only_when_it_is_applied() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let before = app.ui.audio.clone();
|
||||
|
||||
let r = app.run("ui.audio_device", json!({})).unwrap();
|
||||
assert_eq!(r["config"], json!(before), "no change: just the choice and the device");
|
||||
|
||||
assert!(app.run("ui.audio_device", json!({"sample_rate": 12})).is_err());
|
||||
assert_eq!(app.ui.audio, before, "an out-of-range rate changes nothing");
|
||||
|
||||
app.engine.transport.recording = true;
|
||||
let err = app.run("ui.audio_device", json!({"buffer_size": 256, "output": "Elsewhere"})).unwrap_err();
|
||||
assert!(err.contains("stop recording"), "{err}");
|
||||
assert_eq!(app.ui.audio, before, "refused while recording: the saved choice stays the open device's");
|
||||
app.engine.transport.recording = false;
|
||||
|
||||
// Opens the chosen device, or the default, or a silent clock without one.
|
||||
let r = app.run("ui.audio_device", json!({"buffer_size": 256})).unwrap();
|
||||
assert_eq!(app.ui.audio.buffer_size, Some(256));
|
||||
assert_eq!(r["config"]["buffer_size"], 256);
|
||||
assert!(r["device"].is_object(), "{r}");
|
||||
}
|
||||
+12
-8
@@ -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 |
|
||||
Reference in new issue
Block a user