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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **iOS**: Timestamps now include hardware latency and update when the audio route changes.
- **JACK**: Timestamps now include port latency.
- **WASAPI**: The `windows` and `windows-core` dependencies are now both pinned to 0.62.

### Fixed

- **WASAPI**: Fix output `playback` timestamps occasionally stepping backwards.
- 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.
- **WASAPI**: Reported buffer sizes are no longer off by one frame.

## [0.18.1] - 2026-06-07

Expand Down
6 changes: 6 additions & 0 deletions src/host/aaudio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,9 @@ impl DeviceTrait for Device {
.channel_count(config.channels as i32)
.format(format);

// Keep `capture` monotonic: a transient getTimestamp() failure falls back to `now()`
// (no latency offset), so the next successful read can pull `capture` backward.
let data_callback = crate::host::monotonic_input_callback(data_callback);
build_input_stream(
self,
config,
Expand Down Expand Up @@ -721,6 +724,9 @@ impl DeviceTrait for Device {
.channel_count(config.channels as i32)
.format(format);

// Keep `playback` monotonic: a transient getTimestamp() failure falls back to `now()`
// (no latency offset), pulling `playback` backward.
let data_callback = crate::host::monotonic_output_callback(data_callback);
build_output_stream(
self,
config,
Expand Down
10 changes: 9 additions & 1 deletion src/host/alsa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ impl DeviceTrait for Device {
D: FnMut(&Data, &InputCallbackInfo) + Send + 'static,
E: FnMut(Error) + Send + 'static,
{
// Keep `capture` monotonic: avail_delay() varies between cycles, and a capture overrun
// can make it jump up enough to pull `capture` backward.
let data_callback = crate::host::monotonic_input_callback(data_callback);
let stream_inner =
self.build_stream_inner(conf, sample_format, alsa::Direction::Capture)?;
let stream = Self::Stream::new_input(
Expand All @@ -288,6 +291,9 @@ impl DeviceTrait for Device {
D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static,
E: FnMut(Error) + Send + 'static,
{
// Keep `playback` monotonic: avail_delay() varies between cycles, and a playback
// underrun can drain the buffer enough to pull `playback` backward.
let data_callback = crate::host::monotonic_output_callback(data_callback);
let stream_inner =
self.build_stream_inner(conf, sample_format, alsa::Direction::Playback)?;
let stream = Self::Stream::new_output(
Expand Down Expand Up @@ -856,7 +862,9 @@ struct StreamWorkerContext {
impl StreamWorkerContext {
fn new(poll_timeout: &Option<Duration>, stream: &StreamInner, rx: &TriggerReceiver) -> Self {
let poll_timeout: i32 = if let Some(d) = poll_timeout {
d.as_millis().min(i32::MAX as u128) as i32
// Round up: a nonzero sub-millisecond timeout must not floor to 0 (a non-blocking poll),
// but an explicit Duration::ZERO stays 0 so a non-blocking poll can still be requested.
d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32
} else {
-1 // Don't timeout, wait forever.
};
Expand Down
12 changes: 10 additions & 2 deletions src/host/asio/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ impl Device {
&self,
config: StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Stream, Error>
Expand All @@ -134,6 +134,10 @@ impl Device {
E: FnMut(Error) + Send + 'static,
{
crate::validate_stream_config(&config)?;
// Keep `capture` monotonic: kAsioLatenciesChanged (e.g. the user changes the buffer
// size in the ASIO driver's control panel) can raise inputLatency, pulling `capture`
// backward.
let mut data_callback = crate::host::monotonic_input_callback(data_callback);
com::com_initialized();
let description = self.description()?;
let driver = super::GLOBAL_ASIO
Expand Down Expand Up @@ -458,7 +462,7 @@ impl Device {
&self,
config: StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
data_callback: D,
error_callback: E,
_timeout: Option<Duration>,
) -> Result<Stream, Error>
Expand All @@ -467,6 +471,10 @@ impl Device {
E: FnMut(Error) + Send + 'static,
{
crate::validate_stream_config(&config)?;
// Keep `playback` monotonic: kAsioLatenciesChanged (e.g. the user changes the buffer
// size in the ASIO driver's control panel) can lower outputLatency, pulling `playback`
// backward.
let mut data_callback = crate::host::monotonic_output_callback(data_callback);
com::com_initialized();
let description = self.description()?;
let driver = super::GLOBAL_ASIO
Expand Down
94 changes: 67 additions & 27 deletions src/host/audioworklet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ pub struct Host;
pub struct Stream {
audio_context: web_sys::AudioContext,
buffer_size_frames: Arc<AtomicU64>,
_latency_poller: Option<LatencyPoller>,
}

/// How often the main thread re-reads `outputLatency` to publish it to the worklet.
const LATENCY_POLL_INTERVAL: Duration = Duration::from_millis(500);

pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs};

// https://webaudio.github.io/web-audio-api/#dom-audioworkletnode-audioworkletnode
Expand Down Expand Up @@ -250,7 +254,7 @@ impl DeviceTrait for Device {
&self,
config: StreamConfig,
sample_format: SampleFormat,
mut data_callback: D,
data_callback: D,
mut error_callback: E,
_timeout: Option<Duration>,
) -> Result<Self::Stream, Error>
Expand All @@ -259,6 +263,9 @@ impl DeviceTrait for Device {
E: FnMut(Error) + Send + 'static,
{
crate::validate_stream_config(&config)?;
// Keep `playback` monotonic: the polled outputLatency can drop when the
// page calls `setSinkId()` to switch output devices, pulling `playback` backward.
let mut data_callback = crate::host::monotonic_output_callback(data_callback);
if config.channels > MAX_CHANNELS {
return Err(Error::with_message(
ErrorKind::UnsupportedConfig,
Expand Down Expand Up @@ -346,6 +353,9 @@ impl DeviceTrait for Device {
});
let buffer_size_frames = Arc::new(AtomicU64::new(initial_quantum));
let buffer_size_frames_cb = buffer_size_frames.clone();
// 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();
let ctx = audio_context.clone();
wasm_bindgen_futures::spawn_local(async move {
let result: Result<(), JsValue> = async move {
Expand All @@ -361,28 +371,6 @@ impl DeviceTrait for Device {
options.set_output_channel_count(&js_array);
options.set_number_of_inputs(0);

// Capture audio output latency here: the closure runs in a separate worker and cannot access AudioContext properties directly.
// While baseLatency is fixed for the context lifetime, outputLatency can change but not be re-read from inside the worklet;
// we snapshot it here.
let base_latency_secs =
js_sys::Reflect::get(ctx.as_ref(), &JsValue::from("baseLatency"))
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let output_latency_secs =
js_sys::Reflect::get(ctx.as_ref(), &JsValue::from("outputLatency"))
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let total_output_latency_secs = {
let sum = base_latency_secs + output_latency_secs;
if sum.is_finite() {
sum.max(0.0)
} else {
0.0
}
};

options.set_processor_options(Some(&js_sys::Array::of3(
&wasm_bindgen::module(),
&wasm_bindgen::memory(),
Expand All @@ -397,9 +385,9 @@ impl DeviceTrait for Device {
let callback = StreamInstant::from_secs_f64(now);
let buffer_duration =
frames_to_duration(frame_size as FrameCount, sample_rate);
let playback = callback
+ (buffer_duration
+ Duration::from_secs_f64(total_output_latency_secs));
let latency =
Duration::from_nanos(latency_nanos_cb.load(Ordering::Relaxed));
let playback = callback + (buffer_duration + latency);
let timestamp = OutputStreamTimestamp { callback, playback };
let info = OutputCallbackInfo { timestamp };
(data_callback)(&mut data, &info);
Expand Down Expand Up @@ -428,9 +416,31 @@ impl DeviceTrait for Device {
}
});

// 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,
})
});

Ok(Self::Stream {
audio_context,
buffer_size_frames,
_latency_poller: latency_poller,
})
}
}
Expand Down Expand Up @@ -486,7 +496,7 @@ impl Iterator for Devices {

type AudioProcessorCallback = Box<dyn FnMut(&mut [f32], u32, u32, f64)>;

/// WasmAudioProcessor provides an interface for the Javascript code
/// WasmAudioProcessor provides an interface for the JavaScript code
/// running in the AudioWorklet to interact with Rust.
#[wasm_bindgen]
pub struct WasmAudioProcessor {
Expand Down Expand Up @@ -558,3 +568,33 @@ impl WasmAudioProcessor {
*Box::from_raw(val as *mut _)
}
}

/// Drives a `setInterval` that refreshes the shared output-latency value.
struct LatencyPoller {
window: web_sys::Window,
interval_id: i32,
_closure: Closure<dyn FnMut()>,
}

impl Drop for LatencyPoller {
fn drop(&mut self) {
self.window.clear_interval_with_handle(self.interval_id);
}
}
Comment thread
Copilot marked this conversation as resolved.

/// Reads the playback buffer depth from a context.
fn total_latency_nanos(ctx: &web_sys::AudioContext) -> u64 {
let read = |key: &str| {
js_sys::Reflect::get(ctx.as_ref(), &JsValue::from(key))
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
};
// `baseLatency` is fixed for the context lifetime; `outputLatency` can change.
let secs = read("baseLatency") + read("outputLatency");
if secs.is_finite() && secs > 0.0 {
(secs * 1_000_000_000.0).round() as u64
} else {
0
}
Comment thread
roderickvd marked this conversation as resolved.
}
Loading
Loading