Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
4ee3093
fix: audio test hardening (#52)
Horuse Sep 27, 2026
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
8d61bd1
fix(audio): a closing speaker plays out a tail of silence, so built-i…
Horuse Oct 14, 2026
7bf4cb3
test: a rig sets a file's loop as the editor does, so a file plays pa…
Horuse Oct 14, 2026
a5be365
test: drive the close-tail test without pacing, so a slow machine doe…
Horuse Oct 14, 2026
d4b6031
test: check a speaker's close tail directly, without racing the retir…
Horuse Oct 14, 2026
1b8fb01
Merge remote-tracking branch 'origin/main' into feat/low-latency
Horuse Oct 17, 2026
dac2b9e
fix(audio-file): read ahead past a file source's start lead, so a 204…
Horuse Oct 20, 2026
aa99510
test: an offloaded node may lose one block to a loaded runner
Horuse Oct 21, 2026
89544f6
fix(limiter): evict the expired peak before pushing, so a full window…
Horuse Oct 22, 2026
85d8508
fix(adaptive-depth): carry the envelope sink across blocks, so 32-fra…
Horuse Oct 22, 2026
3ff1752
fix(pipeline): fade dropped speakers out together on reconcile
Horuse Oct 22, 2026
679d956
fix(recorder): stop the dsp thread when the writer fails to spawn
Horuse Oct 22, 2026
f486ffc
perf(effects): skip the dry crossfade at full wet; settle compressor …
Horuse Oct 22, 2026
a7a5c63
fix(netaudio): re-attach a channel on a rate change, so taps never bu…
Horuse Oct 23, 2026
0187891
fix(worker): hold a swapped-in graph silent only for the delay lines …
Horuse Oct 23, 2026
0dee163
fix(speaker): rebuild a speaker's graph for its real callbacks; retir…
Horuse Oct 23, 2026
0f028c8
test(render): restore the offload dropout helper
Horuse Oct 23, 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
6 changes: 3 additions & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
- Read docs/CONTRIBUTING.md and docs/RULES.md.
- Keep the diff to the change. No drive-by renames, reformatting, or
refactors of code the change does not touch.
- Run `bun run check`, `cargo check --manifest-path src-tauri/Cargo.toml`,
and `bun run format` — all must pass and leave the tree clean.
- Run `bun run test`, `bun run check`, and `bun run format` —
all must pass and leave the tree clean.
- If you changed a `#[derive(TS)]` type in Rust, run `bun run generate` and
commit the regenerated files in src/lib/modules/pipeline/generated/.
-->
Expand All @@ -21,8 +21,8 @@ _Explain the design choice. What alternatives were considered and rejected? If t
## Checklist

- [ ] Diff is limited to the change — no unrelated edits
- [ ] `bun run test` passes
- [ ] `bun run check` passes
- [ ] `cargo check --manifest-path src-tauri/Cargo.toml` passes
- [ ] `bun run format` leaves the tree clean
- [ ] Generated TS types are committed with the Rust change (if any)
- [ ] No new dependency without a reason in the PR description
Expand Down
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ jobs:
- name: Install JS deps
run: bun install --frozen-lockfile

- name: Frontend tests
run: bun run test:frontend

# Each OS backend needs its native headers to compile. Linux installs
# PipeWire and the GTK stack explicitly; macOS and Windows runners ship
# their SDKs (CoreAudio / WASAPI + MSVC) preinstalled.
Expand Down Expand Up @@ -93,12 +96,24 @@ jobs:
fi
if [ "$RUNNER_OS" = "Windows" ]; then
cargo test --no-run
elif [ "$RUNNER_OS" = "Linux" ]; then
cargo test -- --skip audio::playback::tests::playback_pulls_samples
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Without these, one chunk takes ~16 ms and the worker stalls.
## When in doubt

- Read the current code, not earlier explanations.
- RT path change → `cargo check`.
- RT path change → `bun run check`.
- Svelte change → `bun run check`.
- New node or shared UI component → reuse existing Storybook components first, add its stories under `src/stories/` in the same commit (see [docs/UI.md](docs/UI.md)).
- Rust `#[derive(TS)]` change → `bun run generate`, commit the generated files with the Rust change.
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ If a platform genuinely cannot support what you are adding, return a real error
## Before you open the PR

```bash
bun run check # svelte-check + tsc
cargo check --manifest-path src-tauri/Cargo.toml
bun run test # frontend + Rust tests in parallel
bun run check # frontend + Rust checks in parallel
bun run format # Prettier + rustfmt
```

Both checks must pass, and `format` must leave the tree clean. A new node or shared component ships with its Storybook stories in the same PR; see [docs/UI.md](docs/UI.md). If you changed a `#[derive(TS)]` type in Rust, run `bun run generate` and commit the regenerated files in `src/lib/modules/pipeline/generated/` together with the Rust change — never by hand, and never on their own.
All checks must pass, and `format` must leave the tree clean. A new node or shared component ships with its Storybook stories in the same PR; see [docs/UI.md](docs/UI.md). If you changed a `#[derive(TS)]` type in Rust, run `bun run generate` and commit the regenerated files in `src/lib/modules/pipeline/generated/` together with the Rust change — never by hand, and never on their own.

Anything persisted to disk — pipeline JSON, `virtual-devices.json`, the macOS driver plist — has to keep loading for people upgrading. Add a `#[serde(default)]` or a versioned migration; do not silently change the meaning of an existing field.

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
4 changes: 2 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ bun run tauri dev
## Useful commands

```bash
bun run check # svelte-check + tsc
bun run test # frontend + Rust tests in parallel
bun run check # frontend + Rust checks in parallel
bun run generate # regenerate TypeScript types from Rust (ts-rs)
bun run storybook # Storybook on :6006, nodes and shared components with mocked engine
cargo check --manifest-path src-tauri/Cargo.toml
bun run tauri build --bundles app # local .app build (macOS)
bun run tauri build --bundles appimage # local AppImage build (Linux)
bun run tauri build --bundles nsis # local installer build (Windows)
Expand Down
2 changes: 1 addition & 1 deletion docs/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type(scope): subject
## When in doubt

- Read the current code, not earlier explanations.
- RT path change → `cargo check`.
- RT path change → `bun run check`.
- Svelte change → `bun run check`.
- New node or shared component → add its stories in the same commit, see [UI.md](UI.md).
- Rust `#[derive(TS)]` change → `bun run generate`, commit the generated files with the Rust change.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "bun scripts/test-all.ts",
"test:frontend": "bun test tests",
"check": "bun scripts/check-all.ts",
"check:frontend": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri",
"format": "prettier --write \"**/*.{ts,tsx,md,svelte,json}\" && cargo fmt --manifest-path src-tauri/Cargo.toml",
Expand Down
29 changes: 29 additions & 0 deletions scripts/check-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const tasks = [
{
name: 'frontend',
command: ['bun', 'run', 'check:frontend']
},
{
name: 'backend',
command: ['cargo', 'check', '--manifest-path', 'src-tauri/Cargo.toml']
}
] as const;

const children = tasks.map((task) => ({
...task,
process: Bun.spawn(task.command, {
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit'
})
}));

const results = await Promise.all(children.map(async ({ name, process }) => ({ name, exitCode: await process.exited })));
const failed = results.filter(({ exitCode }) => exitCode !== 0);

if (failed.length > 0) {
for (const { name, exitCode } of failed) {
console.error(`${name} check failed with exit code ${exitCode}`);
}
process.exit(1);
}
29 changes: 29 additions & 0 deletions scripts/test-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const tasks = [
{
name: 'frontend',
command: ['bun', 'run', 'test:frontend']
},
{
name: 'backend',
command: ['cargo', 'test', '--manifest-path', 'src-tauri/Cargo.toml']
}
] as const;

const children = tasks.map((task) => ({
...task,
process: Bun.spawn(task.command, {
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit'
})
}));

const results = await Promise.all(children.map(async ({ name, process }) => ({ name, exitCode: await process.exited })));
const failed = results.filter(({ exitCode }) => exitCode !== 0);

if (failed.length > 0) {
for (const { name, exitCode } of failed) {
console.error(`${name} tests failed with exit code ${exitCode}`);
}
process.exit(1);
}
Loading
Loading