Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/actions/determine-msrv/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ inputs:
description: The MSRV for Pipewire backend (optional, Linux only)
required: false
default: ''
asio-new-msrv:
description: The MSRV for the asio-new feature (optional, Windows only)
required: false
default: ''

outputs:
all-features:
Expand All @@ -33,6 +37,7 @@ runs:
JACK_MSRV="${{ inputs.jack-msrv }}"
PULSEAUDIO_MSRV="${{ inputs.pulseaudio-msrv }}"
PIPEWIRE_MSRV="${{ inputs.pipewire-msrv }}"
ASIO_NEW_MSRV="${{ inputs.asio-new-msrv }}"
# Use sort -V to find the maximum version
VERSIONS="$PLATFORM_MSRV $JACK_MSRV"
if [ -n "$PULSEAUDIO_MSRV" ]; then
Expand All @@ -41,6 +46,9 @@ runs:
if [ -n "$PIPEWIRE_MSRV" ]; then
VERSIONS="$VERSIONS $PIPEWIRE_MSRV"
fi
if [ -n "$ASIO_NEW_MSRV" ]; then
VERSIONS="$VERSIONS $ASIO_NEW_MSRV"
fi
MAX_MSRV=$(printf '%s\n' $VERSIONS | sort -V | tail -n1)
echo "all-features=$MAX_MSRV" >> $GITHUB_OUTPUT
echo "Platform MSRV: $PLATFORM_MSRV, JACK MSRV: $JACK_MSRV, PulseAudio MSRV: $PULSEAUDIO_MSRV, PIPEWIRE_MSRV: $PIPEWIRE_MSRV, Using for --all-features: $MAX_MSRV"
echo "Platform MSRV: $PLATFORM_MSRV, JACK MSRV: $JACK_MSRV, PulseAudio MSRV: $PULSEAUDIO_MSRV, PIPEWIRE_MSRV: $PIPEWIRE_MSRV, ASIO-new MSRV: $ASIO_NEW_MSRV, Using for --all-features: $MAX_MSRV"
3 changes: 3 additions & 0 deletions .github/workflows/platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ env:
MSRV_WASIP1: "1.85"
MSRV_WASM: "1.85"
MSRV_WINDOWS: "1.85"
MSRV_ASIO_NEW: "1.95" # required by `windows-bindgen`

PACKAGES_LINUX: libasound2-dev libdbus-1-dev libjack-jackd2-dev libpipewire-0.3-dev

Expand Down Expand Up @@ -246,6 +247,7 @@ jobs:
with:
platform-msrv: ${{ env.MSRV_WINDOWS }}
jack-msrv: ${{ env.MSRV_JACK }}
asio-new-msrv: ${{ env.MSRV_ASIO_NEW }}

- name: Install Rust MSRV (${{ env.MSRV_WINDOWS }})
uses: dtolnay/rust-toolchain@master
Expand All @@ -258,6 +260,7 @@ jobs:
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ steps.msrv.outputs.all-features }}
components: rustfmt # required by windows-bindgen
targets: ${{ env.TARGET }}

- name: Rust Cache
Expand Down
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ realtime-dbus = ["realtime", "audio_thread_priority/with_dbus"]
# ASIO backend for Windows
# Provides low-latency audio I/O by bypassing the Windows audio stack
# Requires: ASIO drivers and LLVM/Clang for build-time bindings
# Platform: Windows
# See README for detailed setup instructions
asio = ["dep:asio-sys", "dep:num-traits"]

# Experimental ASIO implementation with multi-driver support and no external build requirements.
# Requires: ASIO drivers
# Platform: Windows
asio-new = ["dep:azo", "dep:closure-ffi", "dep:parking_lot", "dep:tap"]

# Audio Worklet backend for WebAssembly
# Provides lower-latency web audio processing compared to default Web Audio API
# Requires: Build with atomics support and Cross-Origin headers for SharedArrayBuffer
Expand Down Expand Up @@ -131,7 +137,11 @@ windows = { version = "0.62", features = [
] }
audio_thread_priority = { version = "0.36", optional = true, default-features = false }
asio-sys = { version = "0.5.0", path = "asio-sys", optional = true }
azo = { version = "0.5.2", features = ["host"], optional = true }
closure-ffi = { version = "5.1.2", optional = true }
num-traits = { version = "0.2", optional = true }
parking_lot = { version = "0.12.5", optional = true }
tap = { version = "1.0.1", optional = true }
jack = { version = "0.13.5", optional = true }

[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd"))'.dependencies]
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ The minimum Rust version (MSRV) and minimum operating system / runtime version b
| PipeWire | Linux, BSD | 1.85 | PipeWire 0.3.53 |
| PulseAudio | Linux, BSD | 1.88 | — |
| WASAPI / ASIO | Windows | 1.85 | Windows 10 |
| New ASIO (`-F asio-new`) | Windows | 1.95 | Windows 10 |
| WASM (`wasm32-unknown-unknown`) | WebAssembly | 1.85 | — |
| WASM (`wasm32-unknown-emscripten`) | WebAssembly | 1.85 | Emscripten 6.0.3, wasm-bindgen 0.2.127 |
| WASM (`wasm32-wasip1`) | WebAssembly | 1.85 | — |
Expand All @@ -64,6 +65,7 @@ The `audioworklet` backend additionally requires `-Zbuild-std` with atomics supp
| Feature | Platform | Description |
| ------- | -------- | ----------- |
| `asio` | Windows | ASIO backend for low-latency audio, bypassing the Windows audio stack. Requires ASIO drivers and LLVM/Clang. See the [ASIO setup guide](#compiling-for-asio). |
| `asio-new` | Windows | Experimental pure-Rust ASIO implementation with multi-driver support and no external build requirements. |
| `audioworklet` | WebAssembly (`wasm32-unknown-unknown`) | Audio Worklet backend for lower-latency web audio than the default Web Audio API, running audio on a dedicated thread. Requires atomics support (`RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals"`) and `Cross-Origin` headers for `SharedArrayBuffer`. See the `audioworklet` example. |
| `custom` | All | User-defined backend implementations for audio systems not natively supported by CPAL. See `examples/custom.rs`. |
| `jack` | Linux, BSD, macOS, Windows | JACK Audio Connection Kit backend for pro-audio routing and inter-application connectivity. Requires `libjack-jackd2-dev` (Debian/Ubuntu) or `jack-devel` (Fedora). |
Expand Down
203 changes: 203 additions & 0 deletions src/host/asio_new/buffer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use super::utils::{CpalResult, Decorate, DoubleBuffer, err, sample_format_asio2cpal};
use crate::ErrorKind::UnsupportedConfig;
use crate::host::equilibrium::fill_equilibrium;
use crate::{Data, FrameCount, SampleFormat};
use azo::driver::Driver;
use azo::dto::ChannelId;
use std::fmt::Debug;
use std::hash::Hash;
use std::{ptr, slice};
use tap::Pipe;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Config {
pub format : SampleFormat,
pub channels: u16,
pub input : bool,
}

impl Config {
pub fn validate(self, driver: &impl Driver) -> impl Iterator<Item = CpalResult<ChannelId>> {
(0..self.channels)
.map(move |i| {
let id = ChannelId {
input: self.input,
index: i as _
};

let actual_format = driver
.channel_info(id)
.decorate(driver, stringify!(Driver::channel_info))?
.sample_type
.pipe(sample_format_asio2cpal);

if actual_format != Some(self.format) {
return err(UnsupportedConfig, "Sample format mismatch");
}
Ok(id)
})
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Head {
format : SampleFormat,
frame_count: FrameCount,
buf_ptrs : Vec<DoubleBuffer>,
}

impl Head {
const fn frame_count(&self) -> usize {
self.frame_count as usize
}

#[expect(clippy::incompatible_msrv, reason = "MSRV for `asio-new` is 1.95")]
const fn channel_count(&self) -> usize {
self.buf_ptrs.len()
}

const fn sample_size(&self) -> usize {
self.format.sample_size()
}

const fn sample_count(&self) -> usize {
self.frame_count() * self.channel_count()
}

const fn bytes_per_channel(&self) -> usize {
self.frame_count() * self.sample_size()
}

const fn _frame_size(&self) -> usize {
self.channel_count() * self.sample_size()
}

const fn total_buffer_space(&self) -> usize {
self.frame_count() * self.channel_count() * self.sample_size()
}

fn get_buf_ptr(&self, channel: usize, side: usize) -> *mut u8 {
self.buf_ptrs[channel]
.0[side]
.cast()
}

fn get_buf<'buf>(&self, channel: usize, side: usize) -> &'buf [u8] {
let ptr = self.get_buf_ptr(channel, side);
let len = self.bytes_per_channel();
unsafe { slice::from_raw_parts(ptr, len) }
}

#[expect(
clippy::needless_pass_by_ref_mut,
reason = "match mutability of the returned slice"
)]
fn get_buf_mut<'buf>(&mut self, channel: usize, side: usize) -> &'buf mut [u8] {
let ptr = self.get_buf_ptr(channel, side);
let len = self.bytes_per_channel();
unsafe { slice::from_raw_parts_mut(ptr, len) }
}
}

pub trait DirectionMarker: Debug + Clone + Copy + Default + PartialEq + Eq + Hash {}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct In;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Out;

impl DirectionMarker for In {}
impl DirectionMarker for Out {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Buffer<Marker: DirectionMarker> {
head : Head,
scratch: Box<[u8]>,
_marker: Marker,
}

impl<Marker: DirectionMarker> Buffer<Marker> {
pub fn new(format: SampleFormat, frame_count: FrameCount, buf_ptrs: Vec<DoubleBuffer>) -> Self {
let head = Head { format, frame_count, buf_ptrs };

// when the stream is mono, the ASIO buffer can be exposed to the user callback directly
let scratch_len = if head.channel_count() == 1 { 0 } else { head.total_buffer_space() };
let scratch = vec![0; scratch_len].into_boxed_slice();
Self { head, scratch, _marker: Marker::default() }
}

pub fn data(&mut self, side: usize) -> Data {
let ptr = match self.head.channel_count() {
0 => ptr::null_mut(),
1 => self.head.get_buf_ptr(0, side).cast(),
2.. => self.scratch.as_mut_ptr().cast(),
};
unsafe {
Data::from_parts(
ptr,
self.head.sample_count(),
self.head.format
)
}
}
}

impl Buffer<In> {
/// copies channel data to the scratch buffer, interleaving it in the process.
pub fn interleave(&mut self, side: usize) {
if self.head.channel_count() < 2 {
// channel_count == 1 -> the data is already mono. ASIO buffers will be exposed to the user callback directly.
// channel_count == 0 -> there is no data to process.
return;
}

let stride = self.head.sample_size();
let scratch_frames = self
.scratch
.chunks_exact_mut(self.head.channel_count() * stride);

for (i_frame, scratch_frame) in scratch_frames.enumerate() {
for (i_channel, scratch_sample) in scratch_frame.chunks_exact_mut(stride).enumerate() {
let pos = i_frame * stride;
self.head
.get_buf(i_channel, side)
[pos..][..stride]
.pipe_ref(|slice| scratch_sample.copy_from_slice(slice));
}
}
}
}

impl Buffer<Out> {
/// copies scratch data to the channels, deinterleaving it in the process.
///
/// This is mostly a copy-paste of [`Buffer<In>::interleave`], but the differences in mutability
/// cannot be generalized in today's Rust. (`#![feature(keyword_generics)]` when?)
pub fn deinterleave(&mut self, side: usize) {
if self.head.channel_count() < 2 {
// channel_count == 1 -> the data is already mono. ASIO buffers were exposed to the user callback directly.
// channel_count == 0 -> there is no data to process.
return;
}

let stride = self.head.sample_size();
let scratch_frames = self
.scratch
.chunks_exact(self.head.channel_count() * stride);

for (i_frame, scratch_frame) in scratch_frames.enumerate() {
for (i_channel, scratch_sample) in scratch_frame.chunks_exact(stride).enumerate() {
let pos = i_frame * stride;
self.head
.get_buf_mut(i_channel, side)
[pos..][..stride]
.copy_from_slice(scratch_sample);
}
}
}

pub fn fill_equillirium(&mut self, side: usize) {
for i_channel in 0..self.head.channel_count() {
fill_equilibrium(self.head.get_buf_mut(i_channel, side), self.head.format);
}
}
}
Loading