Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Timestamps now stay monotonic across device and graph changes.
- **ALSA**: A nonzero but sub-millisecond stream timeout is no longer treated as a non-blocking poll.
- **AudioWorklet**: Fix `Stream` operations to work when called from any thread.
- **AudioWorklet**: Fix stale audio output when a data callback wrote a partial buffer.
- **CoreAudio**: Default-output streams now report xrun status.
- **CoreAudio**: Fix stale audio output when a data callback wrote a partial buffer.
- **JACK**: Streams with more channels than physical system ports no longer fail to build.
- **JACK**: Channel enumeration is no longer capped at the physical system port count.
- **PipeWire**: Fix streams starting audio before `play()` is called.
- **PipeWire**: Streams for a specific device no longer auto-reroute if it disappears.
- **PulseAudio**: `NoData` errors are no longer misreported as buffer xruns.
- **visionOS**: The CoreAudio backend now builds.
- **WASAPI**: Default device changes no longer report `DeviceChanged`, which wrongly implied the stream had rerouted automatically.
- **WASAPI**: Reported buffer sizes are no longer off by one frame.
- **WASAPI**: `Stream::drop`, `play`, and `pause` no longer panic when the device is lost.
- **WebAudio**: Fix stale audio output when a data callback wrote a partial buffer.
- **WebAudio**: Fix unsound `Send + Sync` on `Stream` when compiled with `+atomics`.
- **WebAudio**: Fix `Host::is_available()` always returning `true`, even in non-window contexts.

## [0.18.1] - 2026-06-07

Expand Down
17 changes: 16 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ asio = [
# Requires: Build with atomics support and Cross-Origin headers for SharedArrayBuffer
# Platform: WebAssembly (wasm32-unknown-unknown)
audioworklet = [
"dep:futures-channel",
"dep:futures-util",
"wasm-bindgen",
"web-sys/Blob",
"web-sys/BlobPropertyBag",
Expand Down Expand Up @@ -79,7 +81,12 @@ pulseaudio = ["dep:pulseaudio", "dep:futures"]
# Required for any WebAssembly audio support
# Platform: WebAssembly (wasm32-unknown-unknown)
# Note: This is typically enabled automatically when targeting wasm32
wasm-bindgen = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures"]
wasm-bindgen = [
"dep:wasm-bindgen",
"dep:wasm-bindgen-futures",
"dep:futures-channel",
"dep:futures-util",
]

[dependencies]
dasp_sample = "0.11"
Expand Down Expand Up @@ -174,6 +181,10 @@ objc2-avf-audio = { version = "0.3", default-features = false, features = [
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
wasm-bindgen = { version = "0.2", optional = true }
wasm-bindgen-futures = { version = "0.4", optional = true }
futures-channel = { version = "0.3", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true, features = [
"alloc",
] }
js-sys = { version = "0.3" }
web-sys = { version = "0.3", features = [
"AudioContext",
Expand Down Expand Up @@ -213,6 +224,10 @@ name = "record_wav"
[[example]]
name = "synth_tones"

[[example]]
name = "custom"
required-features = ["custom"]

[package.metadata.docs.rs]
# The docs.rs build environment does not have all the dependencies for all features
features = [
Expand Down
1 change: 0 additions & 1 deletion examples/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ use cpal::{
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
};

#[allow(dead_code)]
#[derive(Clone)] // Clone, Send+Sync are required
struct MyHost;

Expand Down
3 changes: 2 additions & 1 deletion src/host/asio/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use super::Device;
use crate::{
host::{
com,
equilibrium::fill_equilibrium,
error_emit::{emit_error, try_emit_error},
frames_to_duration,
},
Expand Down Expand Up @@ -644,7 +645,7 @@ impl Device {
}
}

interleaved.fill(0);
fill_equilibrium(&mut interleaved, sample_format);
match (sample_format, &stream_type) {
(SampleFormat::I16, &sys::AsioSampleType::ASIOSTInt16LSB) => {
process_output_callback::<i16, _, _>(
Expand Down
143 changes: 97 additions & 46 deletions src/host/audioworklet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use std::{
time::Duration,
};

use futures_channel::mpsc;
use futures_util::StreamExt as _;
use js_sys::wasm_bindgen;
use wasm_bindgen::prelude::*;

Expand All @@ -20,7 +22,7 @@ use crate::{
traits::{DeviceTrait, HostTrait, StreamTrait},
BufferSize, ChannelCount, Data, DeviceDescription, DeviceDescriptionBuilder, DeviceDirection,
DeviceId, Error, ErrorKind, FrameCount, InputCallbackInfo, OutputCallbackInfo,
OutputStreamTimestamp, SampleFormat, SampleRate, StreamConfig, StreamInstant,
OutputStreamTimestamp, Sample, SampleFormat, SampleRate, StreamConfig, StreamInstant,
SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange,
};

Expand All @@ -42,10 +44,21 @@ impl fmt::Display for Device {

pub struct Host;

// `Stream`'s fields are all Send+Sync (a channel sender plus atomics).
// Any JS-backed resource (e.g. `web_sys::Window`, `Closure`) must live as a local
// inside the `spawn_local` task in `build_*_stream_raw`.
pub struct Stream {
audio_context: web_sys::AudioContext,
command_tx: mpsc::UnboundedSender<Command>,
current_time_bits: Arc<AtomicU64>,
buffer_size_frames: Arc<AtomicU64>,
_latency_poller: Option<LatencyPoller>,
}

crate::assert_stream_send!(Stream);
crate::assert_stream_sync!(Stream);

enum Command {
Play,
Pause,
}

/// How often the main thread re-reads `outputLatency` to publish it to the worklet.
Expand Down Expand Up @@ -353,6 +366,13 @@ impl DeviceTrait for Device {
});
let buffer_size_frames = Arc::new(AtomicU64::new(initial_quantum));
let buffer_size_frames_cb = buffer_size_frames.clone();

let current_time_bits = Arc::new(AtomicU64::new(audio_context.current_time().to_bits()));
let current_time_bits_cb = current_time_bits.clone();
let current_time_bits_init = current_time_bits.clone();

let (command_tx, mut command_rx) = mpsc::unbounded::<Command>();

// The worklet realm cannot read AudioContext properties, so share the value via an atomic.
let latency_nanos = Arc::new(AtomicU64::new(total_latency_nanos(&audio_context)));
let latency_nanos_cb = latency_nanos.clone();
Expand All @@ -377,6 +397,7 @@ impl DeviceTrait for Device {
&WasmAudioProcessor::new(Box::new(
move |interleaved_data, frame_size, sample_rate, now| {
buffer_size_frames_cb.store(frame_size as u64, Ordering::Relaxed);
current_time_bits_cb.store(now.to_bits(), Ordering::Relaxed);
let data = interleaved_data.as_mut_ptr() as *mut ();
let mut data = unsafe {
Data::from_parts(data, interleaved_data.len(), sample_format)
Expand Down Expand Up @@ -412,35 +433,69 @@ impl DeviceTrait for Device {
error_callback(Error::with_message(
ErrorKind::UnsupportedOperation,
message,
))
));

// Close AudioContext and exit; dropping command_rx closes the channel,
// so subsequent play()/pause() calls return HostUnavailable.
let _ = audio_context.close();
return;
}
});

// outputLatency can change at runtime (e.g. an output-device switch) but is only readable
// on the main thread, so poll it here and publish it to the worklet via the shared atomic.
let latency_poller = web_sys::window().and_then(|window| {
let poll_ctx = audio_context.clone();
let poll_latency = latency_nanos.clone();
let closure = Closure::<dyn FnMut()>::new(move || {
poll_latency.store(total_latency_nanos(&poll_ctx), Ordering::Relaxed);
current_time_bits_init.store(audio_context.current_time().to_bits(), Ordering::Relaxed);

// outputLatency can change at runtime (e.g. an output-device switch) but is only
// readable on the main thread, so poll it here and publish it to the worklet via the
// shared atomic.
let _latency_poller = web_sys::window().and_then(|window| {
let poll_ctx = audio_context.clone();
let poll_latency = latency_nanos.clone();
let closure = Closure::<dyn FnMut()>::new(move || {
poll_latency.store(total_latency_nanos(&poll_ctx), Ordering::Relaxed);
});
window
.set_interval_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
LATENCY_POLL_INTERVAL.as_millis() as i32,
)
.ok()
.map(|interval_id| LatencyPoller {
window,
interval_id,
_closure: closure,
})
});
window
.set_interval_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
LATENCY_POLL_INTERVAL.as_millis() as i32,
)
.ok()
.map(|interval_id| LatencyPoller {
window,
interval_id,
_closure: closure,
})

// Process play/pause commands from any thread until Stream is dropped.
// Dropping Stream closes command_tx, which terminates this loop.
while let Some(cmd) = command_rx.next().await {
match cmd {
Command::Play => {
if audio_context.resume().is_err() {
error_callback(Error::with_message(
ErrorKind::DeviceNotAvailable,
"Failed to resume audio context",
));
}
}
Command::Pause => {
if audio_context.suspend().is_err() {
error_callback(Error::with_message(
ErrorKind::DeviceNotAvailable,
"Failed to suspend audio context",
));
}
}
}
}

// Stream dropped: close the AudioContext on the main thread.
let _ = audio_context.close();
});

Ok(Self::Stream {
audio_context,
command_tx,
current_time_bits,
buffer_size_frames,
_latency_poller: latency_poller,
})
}
}
Expand All @@ -451,33 +506,27 @@ impl StreamTrait for Stream {
}

fn play(&self) -> Result<(), Error> {
match self.audio_context.resume() {
Ok(_) => Ok(()),
Err(_) => Err(Error::with_message(
ErrorKind::DeviceNotAvailable,
"Failed to resume audio context",
)),
}
self.command_tx.unbounded_send(Command::Play).map_err(|_| {
Error::with_message(
ErrorKind::HostUnavailable,
"audio worklet initialization failed",
)
})
}

fn pause(&self) -> Result<(), Error> {
match self.audio_context.suspend() {
Ok(_) => Ok(()),
Err(_) => Err(Error::with_message(
ErrorKind::DeviceNotAvailable,
"Failed to suspend audio context",
)),
}
self.command_tx.unbounded_send(Command::Pause).map_err(|_| {
Error::with_message(
ErrorKind::HostUnavailable,
"audio worklet initialization failed",
)
})
}

fn now(&self) -> StreamInstant {
StreamInstant::from_secs_f64(self.audio_context.current_time())
}
}

impl Drop for Stream {
fn drop(&mut self) {
let _ = self.audio_context.close();
StreamInstant::from_secs_f64(f64::from_bits(
self.current_time_bits.load(Ordering::Relaxed),
))
}
}

Expand Down Expand Up @@ -533,6 +582,8 @@ impl WasmAudioProcessor {
0.0,
);

self.interleaved_buffer[..interleaved_buffer_size].fill(f32::EQUILIBRIUM);

(self.callback)(
&mut self.interleaved_buffer[..interleaved_buffer_size],
frame_size as u32,
Expand Down
15 changes: 14 additions & 1 deletion src/host/coreaudio/ios/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ use self::enumerate::{
};
use super::{asbd_from_config, host_time_to_stream_instant};
use crate::{
host::{frames_to_duration, latch::Latch, try_emit_error, ErrorCallbackArc},
host::{
equilibrium::fill_equilibrium, frames_to_duration, latch::Latch, try_emit_error,
ErrorCallbackArc,
},
traits::{DeviceTrait, HostTrait, StreamTrait},
BufferSize, ChannelCount, Data, DeviceDescription, DeviceDescriptionBuilder, DeviceId, Error,
ErrorKind, FrameCount, InputCallbackInfo, InputStreamTimestamp, OutputCallbackInfo,
Expand Down Expand Up @@ -624,6 +627,16 @@ where
let (buffer, mut data) =
unsafe { extract_audio_buffer(&args, bytes_per_channel, sample_format, false) };

if !buffer.mData.is_null() {
let bytes = unsafe {
std::slice::from_raw_parts_mut(
buffer.mData as *mut u8,
buffer.mDataByteSize as usize,
)
};
fill_equilibrium(bytes, sample_format);
}

let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Err(err) => {
error_callback(err);
Expand Down
5 changes: 5 additions & 0 deletions src/host/coreaudio/macos/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ use super::{
use crate::{
host::{
coreaudio::macos::{loopback::LoopbackDevice, StreamInner},
equilibrium::fill_equilibrium,
frames_to_duration, try_emit_error, ErrorCallbackArc,
},
traits::DeviceTrait,
Expand Down Expand Up @@ -896,6 +897,10 @@ impl Device {

let data = data as *mut ();
let len = data_byte_size as usize / bytes_per_channel;

let bytes = std::slice::from_raw_parts_mut(data as *mut u8, data_byte_size as usize);
fill_equilibrium(bytes, sample_format);

let mut data = Data::from_parts(data, len, sample_format);

let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Expand Down
Loading
Loading