Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
2ebd1e6
feat(audio): adaptive queue depth estimator and RT allocation guard
Horuse Sep 27, 2026
11c3054
feat(audio): render speakers in the device callback at a configurable…
Horuse Sep 27, 2026
35e3473
feat(ui): buffer size setting, round-trip latency readout and working…
Horuse Sep 27, 2026
dd6e358
fix(audio): silence from a quiet source no longer deepens any buffer
Horuse Sep 28, 2026
75dfbbf
fix(audio): lock same-clock sources, size capture buffers to the engi…
Horuse Sep 28, 2026
a937548
feat(audio): absorb clock drift with a steered resampler instead of s…
Horuse Sep 28, 2026
883da95
feat(net): steer the receive buffer with the shared drift loop, corre…
Horuse Sep 28, 2026
42fa839
chore(deps): cpal 0.18.2, device xruns no longer end a stream
Horuse Sep 28, 2026
83bb659
fix(audio): offload gathers an effect's own blocks, noise suppressor …
Horuse Sep 28, 2026
9b7dbd9
fix(audio): fade speakers in and out, crossfade graph swaps, no false…
Horuse Sep 28, 2026
43051de
feat(audio): carry unchanged nodes across graph swaps instead of rebu…
Horuse Sep 29, 2026
8332a40
test(audio): commit the recorded tap timing fixtures the cushion test…
Horuse Sep 29, 2026
e054a84
fix(audio): dip instead of stepping when a swap changes what is heard
Horuse Sep 29, 2026
eb61bf9
fix(audio): offload worker blocks when overloaded, speaker renderer c…
Horuse Sep 29, 2026
722c3a8
fix(latency): count network receive buffers and fan-out paths, apply …
Horuse Sep 29, 2026
7029488
feat(macos): log when opening a stream changed a device's physical fo…
Horuse Sep 29, 2026
7c519e3
perf(net): run wire senders at the engine block and send each block a…
Horuse Sep 29, 2026
f1ddbd1
fix(audio): follow the engine block and source rate everywhere, share…
Horuse Sep 30, 2026
cedc931
fix(delay): a time change glides like tape instead of stepping once a…
Horuse Oct 1, 2026
96c40aa
fix(spectrum): same frequency resolution at any sample rate
Horuse Oct 1, 2026
9e53d80
fix(audio-file): sample-accurate seeks that never play the old positi…
Horuse Oct 1, 2026
8bfe91f
fix(plugins): a slow plugin loads instead of timing out, a call that …
Horuse Oct 1, 2026
f90cf6f
fix(webrtc): joining waits on connection events and gives up on a sil…
Horuse Oct 1, 2026
f4c3b6e
fix(monitor): tick the monitor at its own block, file read-ahead is n…
Horuse Oct 1, 2026
42e4270
refactor(pipeline): run on a host, so whole pipelines can run on virt…
Horuse Oct 1, 2026
22e9676
test: 99 whole-pipeline scenarios on virtual devices
Horuse Oct 1, 2026
f8125c5
fix(audio): a speaker that stalls does not keep what piled up meanwhile
Horuse Oct 1, 2026
6d09ead
fix(audio): resume a paused file at once, keep a late scope tick at a…
Horuse Oct 1, 2026
a6c50bb
style: prettier on the files this branch changes
Horuse Oct 2, 2026
cd7d892
fix(plugins): a plugin no output hears still loads and runs
Horuse Oct 2, 2026
d108840
fix(editor): send node settings once a drag rests, wiring at once
Horuse Oct 2, 2026
bceb036
fix(audio): a reopened speaker reports its overloads from zero
Horuse Oct 2, 2026
9d88de4
fix(audio): count capture resampling latency at the pipeline rate
Horuse Oct 2, 2026
cbf8863
fix(audio): resample odd device rates with sinc instead of a second-l…
Horuse Oct 2, 2026
2b7fff6
fix(audio): level meter takes squared sum and frames in one atomic
Horuse Oct 2, 2026
79b67ec
fix(latency): node timing carries the rate of the graph it runs in
Horuse Oct 2, 2026
9f9b13c
fix(macos): app tap follows the default output device
Horuse Oct 2, 2026
c4d05a8
fix(audio): a doorbell keeps a ring that comes before anyone answers
Horuse Oct 2, 2026
d2e69f6
fix(audio): a swapped-in graph fades in once its new delay lines have…
Horuse Oct 2, 2026
f229788
fix(audio): rebuilt sources and receivers start at their measured dep…
Horuse Oct 2, 2026
b1c0ebb
test: edits heard through a speaker, on a microphone with its own clock
Horuse Oct 2, 2026
74ce9ab
fix(audio): a stopping speaker waits for its fade to leave the device
Horuse Oct 2, 2026
e5722db
fix(net): send and receive threads run at real-time priority
Horuse Oct 2, 2026
2a29344
fix(audio): timer-paced graphs make up late ticks, and a recorder wri…
Horuse Oct 12, 2026
4ae1eee
fix(limiter): ramp the gain down across the lookahead instead of step…
Horuse Oct 12, 2026
18d5688
fix(audio): settings glide, a bypass keeps its effect in time and cro…
Horuse Oct 12, 2026
5d56590
fix(audio): an offloaded effect's pad covers the blocks a resampled s…
Horuse Oct 12, 2026
9b1eb21
test: re-measure a window in which the host held the virtual devices up
Horuse Oct 12, 2026
60af706
build: optimize the symphonia decoders in dev, so a file input keeps …
Horuse Oct 12, 2026
3b66699
fix(audio): a file source joined mid-play starts with a block and a p…
Horuse Oct 12, 2026
96b376d
test: hold a run to 1 ms of host lateness, and make the real-time sce…
Horuse Oct 12, 2026
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,24 @@ jobs:
fi
if [ "$RUNNER_OS" = "Windows" ]; then
cargo test --no-run
elif [ "$RUNNER_OS" = "macOS" ]; then
# The real-time scenarios run in their own advisory step below.
cargo test --lib --bins --test updater
cargo test --doc
else
cargo test
fi

# The macOS runner is a virtual machine that pauses threads for
# milliseconds at a time, longer than a small engine buffer lasts, so the
# real-time scenarios report there without failing the build. Linux runs
# them as a gate.
- name: Real-time scenarios (advisory on macOS)
if: runner.os == 'macOS'
continue-on-error: true
working-directory: src-tauri
run: cargo test --test pipelines --test transitions

- name: TS-RS bindings up to date
run: git diff --exit-code -- src/lib/modules/pipeline/generated

Expand Down
12 changes: 10 additions & 2 deletions docs/CONCEPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Inside cpal / SCK callbacks and `DspWorker::run`:
- Syscalls (I/O, logging, IPC)
- Non-deterministic latency ops

One exception: waking a sleeping helper thread with `Thread::unpark` (an offload worker after its input was pushed, a network send thread after a block went into its rings; `audio/wake.rs`). It never blocks; when the thread is awake it is a single atomic swap, and otherwise one futex, `dispatch_semaphore_signal` or `WakeByAddressSingle` wake. Polling with `sleep` instead costs the worker up to a poll period of its deadline.

Shared RT↔UI state: `Arc<AtomicU32>` (f32-as-bits, `Ordering::Relaxed`). See `MeterHandle` / `EffectControl` in `audio/effects.rs`.

Ring buffers: `rtrb` SPSC. Use `bulk_pop` / `bulk_push`, never per-sample loops.
Expand All @@ -40,8 +42,13 @@ Without these, one chunk takes ~16 ms and the worker stalls.

## DspWorker pacing

- `Clock` — the single transport cadence for every worker (speaker, monitoring, wire sender, recording). Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source — a file source decodes faster than real time and would otherwise over-run the encoder.
- Stall: per-source `last_pop_at`; >150 ms silence → zero-fill and proceed.
- Speakers render inside the device callback (`pipeline/output/render.rs`): the device is the clock, one engine block per callback, no output ring. The engine block is the user's buffer setting (32-2048 frames); the device IO buffer is requested at the same size. The graph reaches the callback through an SPSC handoff and is handed back on teardown, so it never drops on the audio thread.
- `Clock` paces the timer workers: monitoring and recording at `TIMER_BLOCK_FRAMES`, a wire sender at the engine block, since its latency is heard at the far end. Sleeps to a per-block deadline; a missed deadline produces silence, never a rate error. Recording must follow the wall clock, not the source: a file source decodes faster than real time and would otherwise over-run the encoder.
- Live sources hold their queue at a measured headroom (`pipeline/cushion.rs`), sized by `audio/adaptive_depth.rs`, which also sizes the network jitter buffer. A source on another clock reads through an asynchronous resampler (`pipeline/asrc.rs`) whose ratio a slow PI loop steers (`audio/drift_loop.rs`, zita-a2j style, 0.05 Hz, at most 1000 ppm), fed the queue plus what the producer has made but not yet written (`input_bridge::WriteClock`, lowpassed by `ArrivalClock`): drift is absorbed by the ratio, never by splices. The network receiver (`stream_recv.rs`) runs the same loop against its packets' write timing, holding the fill right after a packet at its start level. A source on the speaker's own clock (a process tap on the default output, a mic on the same device or clock domain) reads straight. Either way the queue is spliced only once, when the start depth guessed before measuring is corrected (the loop holds still meanwhile), and after running dry it is realigned only under the silence already played, with fades at every stop and start. Running dry deepens a queue only by audio that later arrives late (`OutageJudge`), and only when that happens again within a minute; a source that goes quiet (paused app, DTX sender) never does. Capture buffers (mic, tap aggregate) are requested at the engine block.
- An edit rebuilds only what it changed. A node whose build inputs are unchanged (same id, structural spec, width, rate, block) is built as an empty placeholder, and when the new graph swaps in on the running worker it takes over the old node's live state (`OutputGraph::adopt_from`): a source its ring, queue and resamplers, with the bridge feeding that ring kept; an effect its instances, controls and handles; an edge its delay line when the delay is unchanged. Only a graph that is not swapped in place is built from scratch.
- Nothing steps the output: a speaker fades in over 10 ms when it starts and out over 10 ms before it retires. A swap whose heard part is unchanged (every node reaching the output carried over, fed by the same edges at the same delays; `OutputGraph::plays_like`) goes in at once; any other fades the running graph out over 10 ms, swaps, carrying over what it can, and fades the new one in (`worker.rs`).
- An effect that cannot answer within the engine block (offloaded plugins, the noise suppressor) runs on an offload thread (`effects/offload.rs`). One that can only step in whole blocks of its own (a model hop) names that block, and the offload gathers exactly those before calling it; the effect never pads or buffers for it itself. The offload's pad is the least that never starves at the worst alignment of those blocks against the engine's, plus a 5 ms turnaround. The effect declares only its own latency (a model's algorithmic delay, a resampler's queues), the offload adds its pad, PDC aligns against the sum, and the node shows it.
- Stall: per-source `last_pop_at`; >150 ms silence: zero-fill and proceed.
- RT promotion happens after speaker startup prefill. Every overdue Linux tick performs a real blocking sleep before more DSP work; an unbounded catch-up loop is forbidden even when the ring can absorb it.

## Effects
Expand Down Expand Up @@ -89,6 +96,7 @@ A backend that cannot support the feature returns an error; it does not substitu
- PipeWire process callbacks and promoted DSP workers are RT code. They may touch only preallocated buffers, SPSC rings and relaxed atomics.
- `audio_thread_priority` obtains real-time scheduling through RTKit. Its frame argument is the maximum uninterrupted render quantum, not a latency target. Pass the actual known block size; pass `0` when PipeWire owns an unknown callback quantum.
- Linux `RLIMIT_RTTIME` measures CPU time spent under real-time scheduling without a blocking syscall. Crossing the soft limit sends `SIGXCPU`; crossing the hard limit sends `SIGKILL`. Preemption and `sched_yield` do not reset it. Startup prefill runs before promotion, and deadline catch-up must block between blocks. Never raise or disable the OS limit to hide an overload.
- An offload worker runs promoted but parks only when its input is empty; one whose effect cannot keep up would never park, so after 20 ms of uninterrupted work it sleeps 1 ms (`effects/offload.rs`).
- A `SIGXCPU` followed by `SIGKILL` from the audio thread with `si_code=SI_KERNEL` is an RT-budget failure. A Rust panic hook and in-process crash modal cannot observe `SIGKILL`; preserve the previous-run unexpected-exit report.

### macOS: CoreAudio
Expand Down
Loading
Loading