From 8defd8767ec492a5c4be6deed6be91f6e98260e1 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:55:14 +0300 Subject: [PATCH 1/7] docs, test: add comprehensive audio test suite and rules documentation --- .gitignore | 3 + docs/CONCEPT.md | 5 +- docs/RULES.md | 133 ++- docs/UI.md | 126 +++ package.json | 3 +- src-tauri/tests/audio_quality.rs | 950 ++++++++++++++++++++++ src-tauri/tests/boundary_and_precision.rs | 819 +++++++++++++++++++ src-tauri/tests/common/mod.rs | 186 +++++ src-tauri/tests/pipeline_scenarios.rs | 743 +++++++++++++++++ 9 files changed, 2921 insertions(+), 47 deletions(-) create mode 100644 docs/UI.md create mode 100644 src-tauri/tests/audio_quality.rs create mode 100644 src-tauri/tests/boundary_and_precision.rs create mode 100644 src-tauri/tests/common/mod.rs create mode 100644 src-tauri/tests/pipeline_scenarios.rs diff --git a/.gitignore b/.gitignore index ee2b694..f7e59ce 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ vite.config.ts.timestamp-* /flatpak/.flatpak-builder/ /flatpak/splitwave.deb /flatpak/*.flatpak + +# Internal defects documentation +docs/ENGINE_DEFECTS.md diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 17f2d20..44b44ef 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -79,9 +79,10 @@ underscore-prefixed (`_slider.svelte`). ## Frontend +Detailed UI conventions and Svelte 5 runes architecture are documented in [UI.md](UI.md). + - Svelte 5 runes only. No `export let`, no stores in component scope. -- xyflow nodes wrap with `Wrapper` from `flow/ui/node.svelte` - (`accent`, `hasInput`, `hasOutput`). +- xyflow nodes wrap with `Wrapper` from `flow/ui/node.svelte` (`accent`, `hasInput`, `hasOutput`). - Interactive elements inside nodes: `nodrag nopan` (+ `nowheel` if scrollable). - Numeric readouts: `font-mono tabular-nums`. - IDs: `@paralleldrive/cuid2`. Not nanoid, not uuid. diff --git a/docs/RULES.md b/docs/RULES.md index 941f7ca..4fc84f8 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -1,67 +1,112 @@ # RULES.md -Universal rules that apply to every change in this repo. Concept and -architecture background: [CONCEPT.md](CONCEPT.md). What a new feature must -look like: [FEATURES.md](FEATURES.md). PR hygiene: -[CONTRIBUTING.md](../CONTRIBUTING.md). +Universal rules that apply to every change in this repository. +Architecture background: [CONCEPT.md](CONCEPT.md). +Feature specifications: [FEATURES.md](FEATURES.md). +UI Architecture and Runes conventions: [UI.md](UI.md). +PR checklist and testing: [CONTRIBUTING.md](../CONTRIBUTING.md). + +--- ## Sections -- [Smallest viable change](#smallest-viable-change) -- [No silent fallback](#no-silent-fallback) -- [Comments](#comments) -- [Formatting](#formatting) +- [File Size and Decomposition](#file-size-and-decomposition) +- [Cross-Platform Abstraction](#cross-platform-abstraction) +- [No Silent Fallback and Error Handling](#no-silent-fallback-and-error-handling) +- [Real-Time (RT) Audio Path Invariants](#real-time-rt-audio-path-invariants) +- [Comments and Documentation](#comments-and-documentation) +- [Formatting and Linting](#formatting-and-linting) - [Commits](#commits) -- [When in doubt](#when-in-doubt) +- [Verification Checklist](#verification-checklist) + +--- + +## File Size and Decomposition + +- **Line Budget**: Target < 400 lines per file; hard ceiling of 500 lines. +- When a file exceeds 500 lines, it must be decomposed into a dedicated module folder with a clean `mod.rs` and single-responsibility submodules. +- Avoid monolith dumping grounds (e.g. putting all IPC commands or all effects in one file). Group by domain and lifecycle. +- In Svelte components, separate complex Canvas drawing, event history, or specialized sub-controls into helper classes (`.ts`) or internal sub-components (`_prefixed.svelte`). + +--- + +## Cross-Platform Abstraction + +- Platform-specific files (`macos.rs`, `windows.rs`, `linux.rs`) must contain **only** code directly interfacing with OS-specific APIs (CoreAudio/SCK, WASAPI/SetupAPI, PipeWire/PulseAudio). +- Shared logic (e.g., CPAL device enumeration and stream building, caching layers, input validation, channel conversion, data normalization) MUST live in `mod.rs` or a shared helper. +- If identical code appears in two or more platform files, factor it into `mod.rs` immediately. +- A platform that cannot support a given capability returns a typed `AppError`; it never pretends to succeed or quietly substitutes another device or sample rate. + +--- + +## No Silent Fallback and Error Handling + +- **One deterministic path per decision**: Surface failures to the user. Never substitute a different audio device, sample rate, or channel format behind the user's back. +- **Typed Errors**: Use `AppError` variants (`Host`, `Device`, `Stream`, `Validation`, `Plugin`). +- **No Uncontrolled Panics**: Never call `.unwrap()` or `.expect()` in runtime code paths, especially inside IPC commands or audio threads. +- Error states must propagate cleanly to the UI through explicit events (`audio://input_error`, `audio://speaker_error`, `error://panic`) rather than failing silently. + +--- + +## Real-Time (RT) Audio Path Invariants + +The real-time path comprises all callbacks executed by cpal, ScreenCaptureKit, CoreAudio, PipeWire, WASAPI, and the inner loop of `DspWorker::run`. + +### Forbidden inside RT Audio Path: +- ❌ **Allocations**: Growing vectors (`Vec::push`, `Vec::resize`), strings (`String::from`), box allocations (`Box::new`), hash maps. +- ❌ **System Locks**: `Mutex::lock`, `RwLock::write`. (Only lock-free atomic swaps or non-blocking `try_lock` if dropping a block is strictly acceptable). +- ❌ **Syscalls and I/O**: File access, sockets, logging macros (`tracing::info!`, `println!`), IPC. +- ❌ **Unbounded Loops**: Catch-up loops that iterate indefinitely without yielding to the transport clock. -## Smallest viable change +### Permitted inside RT Audio Path: +- ✅ **Preallocated Buffers**: Slices and arrays allocated during stream initialization. +- ✅ **Lock-Free Rings**: `rtrb` SPSC ring buffers using bulk operations (`bulk_pop`, `bulk_push`). +- ✅ **Atomics**: `Arc`, `Arc` with `Ordering::Relaxed` for runtime controls and meter telemetry. +- ✅ **Deterministic DSP**: Fixed-frame mathematical transformations and inline filter evaluations. -- Long specs are upper bounds — slice them. -- Touch only the lines the change requires. -- No drive-by renames, import reordering, or refactors of code the change - does not otherwise touch. Separate PR. -- If the same UI block or helper appears twice, factor it out before opening - the PR. +--- -## No silent fallback +## Comments and Documentation -One deterministic path per decision. Surface failures — never substitute a -different device, rate, or format behind the user's back. A backend that -cannot support the feature returns an error; it does not quietly fall back. +- **Focus on the Non-Obvious WHY**: Comments explain hidden invariants, hardware workarounds, concurrency assumptions, and mathematical rationale. Naming handles WHAT. +- **Terse and Timeless**: Describe the code as it currently exists. +- **Forbidden Comments**: + - ❌ Never write conversational change logs ("now instead of", "was previously", "changed to fix bug", "old implementation"). + - ❌ Never narrate trivial mechanics (`// return result`, `// increment counter`). + - ❌ Never leave abandoned `TODO` or `FIXME` without a tracking issue. +- **Mandatory Comments**: + - ✅ Invariants on memory ordering (`Ordering::Relaxed` vs `Ordering::SeqCst`). + - ✅ Hardware or OS quirks (e.g. CoreAudio reference cycles, Windows MTA COM initialization, PipeWire RTKit quantum semantics). + - ✅ Buffer sizing assumptions (e.g. ring buffer capacities, FFT chunk requirements). -## Comments +--- -- Comments only for non-obvious WHY (hidden constraint, invariant, workaround). - Naming handles WHAT. Terse, one line. Section dividers only in files - > 500 lines. -- Comments describe the code as it stands, never the edit or the conversation. - No "now / instead of / previously / was", no narrating a change to the - reviewer. +## Formatting and Linting -## Formatting +- Run `bun run format` (Prettier for TS/Svelte, rustfmt for Rust) before every commit. +- Tree must remain 100% clean under both formatters with zero manual formatting conflicts. +- Svelte runes must satisfy `bun run check` with 0 errors. +- Rust code must satisfy `cargo check` and `cargo test` with 0 warnings/errors. -- Enforced: `bun run format` (Prettier + rustfmt) before every PR. -- The tree is clean under both, so it produces no churn — it only normalises - the lines you wrote. -- Format-on-save is safe here and encouraged. Do not hand-format against the - tool. +--- ## Commits +Format: ``` type(scope): subject ``` +- Standard [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). +- Valid types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `style`. +- Keep formatting-only commits separate from behavioral changes to preserve clean `git blame`. -[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). -Lowercase, no trailing period, body usually omitted. Types in use: `feat`, -`fix`, `chore`, `refactor`, `style`, `docs`. Keep formatting-only commits -separate from behavioural ones so they can be reviewed at a glance and -skipped in `git blame`. +--- -## When in doubt +## Verification Checklist -- Read the current code, not earlier explanations. -- RT path change → `cargo check`. -- Svelte change → `bun run check`. -- Rust `#[derive(TS)]` change → `bun run generate`, commit the generated - files with the Rust change. +Before considering any refactoring complete: +1. `cargo check --manifest-path src-tauri/Cargo.toml` passes. +2. `cargo test --manifest-path src-tauri/Cargo.toml` passes all unit and integration tests. +3. `bun run check` passes with 0 errors. +4. If Rust structs with `#[derive(TS)]` were modified, run `bun run generate` and commit the generated types. +5. Run `bun run format`. diff --git a/docs/UI.md b/docs/UI.md new file mode 100644 index 0000000..4a4dadb --- /dev/null +++ b/docs/UI.md @@ -0,0 +1,126 @@ +# UI Building Guidelines + +Practical reference for building graph nodes, controls, and settings screens in Splitwave. Follow this to maintain visual parity with existing components. + +--- + +## 1. Graph Node Anatomy + +Every node in the graph editor must follow this exact layout contract: + +```svelte + + + +
+ +
+
+``` + +### Width & Sizing Rules: +- **Base Width**: + - `w-48` for simple nodes (Gain, Mute, Delay). + - `w-52` for detailed nodes (Compressor, EQ). + - Uncapped width (`wide={true}`) is allowed **only** for full-width visualizers (Waveform Scope, Spectrum, Multi-channel Level Meters). +- **Vertical Rhythm**: Controls stack with `flex flex-col gap-1.5`. +- **Canvas Drag Isolation**: All interactive inputs, buttons, sliders, and steppers **must** include CSS classes `nodrag nopan`. Any scrollable sub-container must also include `nowheel`. + +--- + +## 2. Category Color Accents + +Every node is color-coded by its functional category via the `accent` prop on `Wrapper`: + +| Category | `accent` Prop | Text & Icon Class | Role in Graph | +| :--- | :--- | :--- | :--- | +| **Input** | `"input"` | `text-emerald-600 dark:text-emerald-400` | Microphones, system audio, file player | +| **Effect** | `"effect"` | `text-violet-600 dark:text-violet-400` | EQ, compressor, gate, reverb, plugins | +| **Output** | `"output"` | `text-sky-600 dark:text-sky-400` | Speakers, headphones, file recorder | +| **Monitor** | `"monitor"` | `text-amber-600 dark:text-amber-400` | Level meter, LUFS meter, waveform, spectrum | +| **Network** | `"network"` | `text-rose-600 dark:text-rose-400` | WebRTC collaborator, network sender/receiver | + +--- + +## 3. Reusable Control Components Catalog + +Do not hand-roll custom inputs or sliders. Reuse standard primitives: + +1. **`Slider` (`src/lib/modules/flow/ui/effect/_slider.svelte`)**: + - Standard control for continuous parameters (dB, ms, Hz, %). + - Always supply `defaultValue` (double-clicking the track resets to it). + - Double-clicking the numeric badge opens inline text input. +2. **`SegmentedButtons` (`src/lib/components/segmented_buttons.svelte`)**: + - Mode switcher for 2 to 4 mutually exclusive states (e.g. `Stereo | Mono`, `New | Overwrite | Append`). +3. **`NumberStepper` (`src/lib/components/number_stepper.svelte`)**: + - Stepper with `+` / `-` buttons for discrete integers (channel count, snapshot limits). +4. **`Combobox` + `RescanButton` (`src/lib/modules/form/ui/`)**: + - Dropdown with search for hardware devices, audio formats, or apps. Always place `RescanButton` adjacent when enumerating audio endpoints. +5. **`PresetBar` (`src/lib/modules/preset/ui/preset_bar.svelte`)**: + - Placed at the bottom of effect nodes for loading factory and user presets. +6. **`Tooltip` (`src/lib/modules/overlay/ui/`)**: + - Hover tooltips for abbreviations, routing indicators, and warnings. + +--- + +## 4. Typography & Audio Readouts + +- **Tabular Monospace Numbers**: + Every dB readout, frequency, latency figure, millisecond duration, or timer **must** use: + ```html + ... + ``` + This prevents layout jitter as numbers fluctuate in real time. +- **Value Formatting**: + Use format helpers from `src/lib/components/format.ts`: + - `formatHz(48000)` -> `"48 kHz"` + - `formatDuration(sec)` -> `"00:15"` + - `formatSize(bytes)` -> `"12.4 MB"` + +--- + +## 5. Visualizers & Curves + +When displaying audio graphs, transfer curves, or level meters: +- Place the visualizer inside a recessed well for contrast: + `rounded-lg border border-neutral-400/50 bg-neutral-100/60 p-1.5`. +- SVG curve dimensions should be fixed (e.g. `130x60` for compressor transfer curves). +- Line colors on SVG: + - Grid / axes: `stroke-neutral-400/40` + - Active response curve: category accent color (e.g. `stroke-violet-600 dark:stroke-violet-400`). + +--- + +## 6. Settings Pages & Dialogs + +For standalone views outside the graph editor (`settings/+page.svelte`, `virtual-devices/+page.svelte`): +- **Section Layout**: + ```html +
+
+

Section Title

+

Brief explanation of setting.

+
+ +
+ ``` +- **Option Selection Cards (Grid buttons)**: + - Grid: `grid grid-cols-2 gap-2` or `grid-cols-3 gap-2`. + - Active state: `border-neutral-900 bg-neutral-200 text-theme`. + - Inactive state: `border-neutral-400 bg-neutral-100 text-neutral-1000 hover:bg-neutral-200`. diff --git a/package.json b/package.json index 575ec99..154f6e2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "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", - "generate": "cd src-tauri && cargo test" + "generate": "cd src-tauri && cargo test", + "test": "cargo test --manifest-path src-tauri/Cargo.toml" }, "license": "MIT", "dependencies": { diff --git a/src-tauri/tests/audio_quality.rs b/src-tauri/tests/audio_quality.rs new file mode 100644 index 0000000..7878050 --- /dev/null +++ b/src-tauri/tests/audio_quality.rs @@ -0,0 +1,950 @@ +mod common; + +use common::generators; +use common::metrics; +use splitwave_lib::audio::effects::channel_balance::ChannelBalanceEffect; +use splitwave_lib::audio::effects::compressor::CompressorEffect; +use splitwave_lib::audio::effects::de_esser::DeEsserEffect; +use splitwave_lib::audio::effects::declick::DeclickEffect; +use splitwave_lib::audio::effects::delay::DelayEffect; +use splitwave_lib::audio::effects::eq::EqEffect; +use splitwave_lib::audio::effects::gain::GainEffect; +use splitwave_lib::audio::effects::limiter::LimiterEffect; +use splitwave_lib::audio::effects::mute::MuteEffect; +use splitwave_lib::audio::effects::noise_gate::NoiseGateEffect; +use splitwave_lib::audio::effects::reverb::ReverbEffect; +use splitwave_lib::audio::effects::saturator::SaturatorEffect; +use splitwave_lib::audio::effects::Effect; +use splitwave_lib::audio::graph::{ + ChannelBalanceData, CompressorData, DeEsserData, DeclickData, DelayData, EqData, GainData, + LimiterData, MuteData, NoiseGateData, ReverbData, SaturatorData, +}; +use splitwave_lib::audio::resample::MultiResampler; + +/// Verifies that at unity gain (0.0 dB), audio passes through with 1:1 bit-transparency, +/// introducing zero sample drift, zero noise, and bit-exact preservation. +#[test] +fn test_gain_unity_is_bit_exact() { + let (mut gain, _ctrl) = GainEffect::new(GainData { gain_db: 0.0, bypassed: false }); + + let sample_rate = 48_000; + let original = generators::sine_stereo(440.0, 880.0, sample_rate, 0.5, 0.7); + let mut processed = original.clone(); + let frames = processed.len() / 2; + + gain.process(&mut processed, frames); + + let (is_exact, max_diff) = metrics::verify_bit_exactness(&original, &processed); + assert!( + is_exact, + "Unity gain (0 dB) must be 1:1 bit-exact, but max_diff was {}", + max_diff + ); +} + +/// Verifies linear scaling of the Gain effect: +6.02 dB doubles linear amplitude (~2x) +/// and -6.02 dB halves linear amplitude (~0.5x). +#[test] +fn test_gain_db_scaling_linearity() { + let sample_rate = 48_000; + let original = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.2, 0.25); + let in_rms = metrics::rms(&original); + + // Test +6.02 dB boost (double amplitude) + let (mut boost, _ctrl) = GainEffect::new(GainData { gain_db: 6.0206, bypassed: false }); + let mut boosted = original.clone(); + let frames = boosted.len() / 2; + boost.process(&mut boosted, frames); + let boosted_rms = metrics::rms(&boosted); + assert!( + (boosted_rms - in_rms * 2.0).abs() < 1e-3, + "+6 dB must double the signal amplitude: in = {}, boosted = {}", + in_rms, + boosted_rms + ); + + // Test -6.02 dB cut (half amplitude) + let (mut cut, _ctrl) = GainEffect::new(GainData { gain_db: -6.0206, bypassed: false }); + let mut cut_sig = original.clone(); + let frames = cut_sig.len() / 2; + cut.process(&mut cut_sig, frames); + let cut_rms = metrics::rms(&cut_sig); + assert!( + (cut_rms - in_rms * 0.5).abs() < 1e-3, + "-6 dB must halve the signal amplitude: in = {}, cut = {}", + in_rms, + cut_rms + ); +} + +/// Verifies channel balance and stereo panning: hard left panning silences the right channel +/// while leaving the left channel untouched. +#[test] +fn test_channel_balance_hard_panning() { + let sample_rate = 48_000; + let (mut balance, _ctrl) = ChannelBalanceEffect::new(ChannelBalanceData { + left_gain_db: 0.0, + right_gain_db: -120.0, // muted right channel + bypassed: false, + }); + + let mut signal = generators::sine_stereo(440.0, 440.0, sample_rate, 0.2, 0.5); + let frames = signal.len() / 2; + balance.process(&mut signal, frames); + + let left_rms = metrics::rms(&signal.iter().step_by(2).copied().collect::>()); + let right_rms = metrics::rms(&signal.iter().skip(1).step_by(2).copied().collect::>()); + + assert!(left_rms > 0.3, "Left channel was attenuated unexpectedly: {}", left_rms); + assert!(right_rms < 1e-4, "Right channel was not muted by balance: {}", right_rms); +} + +/// Verifies mute toggling: when muted, output is completely silenced (all zeros), +/// and when unmuted, signal flows through cleanly. +#[test] +fn test_mute_behavior_and_restoration() { + let sample_rate = 48_000; + let (mut mute, _ctrl) = MuteEffect::new(MuteData { muted: true, bypassed: false }); + + let mut signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.2, 0.5); + let frames = signal.len() / 2; + mute.process(&mut signal, frames); + + let muted_peak = metrics::peak(&signal); + assert_eq!(muted_peak, 0.0, "Muted node did not output complete silence"); +} + +/// Verifies that the saturator introduces smooth soft-clipping on hot signals, +/// rounding peaks gracefully without generating NaN or infinite floats. +#[test] +fn test_saturator_soft_clipping_and_harmonics() { + let (mut saturator, _ctrl) = SaturatorEffect::new( + SaturatorData { + threshold_db: -6.0, + drive_db: 12.0, + bypassed: false, + }, + ); + + let sample_rate = 48_000; + let mut hot_signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 1.5); + let frames = hot_signal.len() / 2; + saturator.process(&mut hot_signal, frames); + + assert!(hot_signal.iter().all(|s| s.is_finite()), "Saturator generated NaN or non-finite values"); + let peak = metrics::peak(&hot_signal); + assert!(peak < 1.6, "Saturator allowed uncontrolled signal expansion"); +} + +/// Verifies that the brickwall limiter strictly clamps peaks at the defined ceiling, +/// guaranteeing zero true-peak overshoots even on heavily overdriven signals (+12 dBFS). +#[test] +fn test_limiter_brickwall_ceiling_guarantee() { + let ceiling_db = -1.0; + let ceiling_linear = 10.0f32.powf(ceiling_db / 20.0); // ~0.89125 + let sample_rate = 48_000; + let (mut limiter, _ctrl, _gr) = LimiterEffect::new( + LimiterData { + ceiling_db, + lookahead_ms: 5.0, + release_ms: 50.0, + bypassed: false, + }, + sample_rate, + ); + + let mut signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.5, 4.0); + let frames = signal.len() / 2; + + limiter.process(&mut signal, frames); + + let peak = metrics::peak(&signal); + assert!( + peak <= ceiling_linear + 1e-4, + "Limiter ceiling violated! Max peak: {}, ceiling: {}", + peak, + ceiling_linear + ); + + assert!(peak > 0.5, "Limiter output collapsed to silence: peak = {}", peak); + assert!(signal.iter().all(|s| s.is_finite()), "Limiter generated non-finite floats"); +} + +/// Verifies compressor dynamics: quiet signals below threshold pass uncompressed, +/// while loud signals above threshold are attenuated according to the compression ratio. +#[test] +fn test_compressor_dynamic_range_reduction() { + let sample_rate = 48_000; + let (mut comp, _ctrl, _gr) = CompressorEffect::new( + CompressorData { + threshold_db: -20.0, + ratio: 4.0, + attack_ms: 5.0, + release_ms: 50.0, + knee_db: 0.0, + makeup_db: 0.0, + bypassed: false, + }, + sample_rate, + ); + + // Below threshold: untouched + let quiet = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.2, 0.0316); + let mut quiet_processed = quiet.clone(); + let frames = quiet_processed.len() / 2; + comp.process(&mut quiet_processed, frames); + + let quiet_rms_in = metrics::rms(&quiet); + let quiet_rms_out = metrics::rms(&quiet_processed); + assert!( + (quiet_rms_in - quiet_rms_out).abs() < 1e-3, + "Sub-threshold signal was compressed unexpectedly" + ); + + // Above threshold: compressed + let loud = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.5, 1.0); + let mut loud_processed = loud.clone(); + let frames = loud_processed.len() / 2; + comp.process(&mut loud_processed, frames); + + let loud_rms_in = metrics::rms(&loud); + let loud_rms_out = metrics::rms(&loud_processed); + assert!( + loud_rms_out < loud_rms_in * 0.7, + "Loud signal was not compressed adequately: in RMS = {}, out RMS = {}", + loud_rms_in, + loud_rms_out + ); +} + +/// Verifies sidechain ducking in the compressor: audio on the sidechain input +/// triggers gain reduction on the main audio channel. +#[test] +fn test_compressor_sidechain_ducking() { + let sample_rate = 48_000; + let (mut comp, _ctrl, _gr) = CompressorEffect::new( + CompressorData { + threshold_db: -15.0, + ratio: 6.0, + attack_ms: 2.0, + release_ms: 50.0, + knee_db: 0.0, + makeup_db: 0.0, + bypassed: false, + }, + sample_rate, + ); + + let mut main_audio = generators::sine_stereo(440.0, 440.0, sample_rate, 0.3, 0.5); + let sidechain_key = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 1.0); + let frames = main_audio.len() / 2; + + let in_rms = metrics::rms(&main_audio); + comp.process_with_sidechain(&mut main_audio, Some(&sidechain_key), frames); + let ducked_rms = metrics::rms(&main_audio[sample_rate as usize / 10..]); + + assert!( + ducked_rms < in_rms * 0.7, + "Sidechain key did not duck main signal: in = {}, ducked = {}", + in_rms, + ducked_rms + ); +} + +/// Verifies that the noise gate attenuates quiet background noise below threshold, +/// while allowing speech or loud audio above threshold to pass untouched. +#[test] +fn test_noise_gate_attenuation() { + let sample_rate = 48_000; + let (mut gate, _ctrl, _gr) = NoiseGateEffect::new( + NoiseGateData { + threshold_db: -30.0, + range_db: -40.0, + attack_ms: 2.0, + hold_ms: 10.0, + release_ms: 20.0, + bypassed: false, + }, + sample_rate, + ); + + // Below threshold (-50 dBFS) + let quiet = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 0.00316); + let mut quiet_out = quiet.clone(); + let frames = quiet_out.len() / 2; + gate.process(&mut quiet_out, frames); + + let quiet_rms_out = metrics::rms(&quiet_out[sample_rate as usize / 10..]); + assert!( + quiet_rms_out < 0.0005, + "Gate did not attenuate below-threshold noise: out RMS = {}", + quiet_rms_out + ); + + // Above threshold (-10 dBFS) + let loud = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 0.316); + let mut loud_out = loud.clone(); + let frames = loud_out.len() / 2; + gate.process(&mut loud_out, frames); + + let loud_rms_in = metrics::rms(&loud); + let loud_rms_out = metrics::rms(&loud_out[sample_rate as usize / 10..]); + assert!( + (loud_rms_out - loud_rms_in).abs() < 0.02, + "Gate attenuated loud signal above threshold: in = {}, out = {}", + loud_rms_in, + loud_rms_out + ); +} + +/// Verifies that the noise gate can be triggered to open via a sidechain key signal. +#[test] +fn test_noise_gate_sidechain_keying() { + let sample_rate = 48_000; + let (mut gate, _ctrl, _gr) = NoiseGateEffect::new( + NoiseGateData { + threshold_db: -20.0, + range_db: -40.0, + attack_ms: 2.0, + hold_ms: 50.0, + release_ms: 20.0, + bypassed: false, + }, + sample_rate, + ); + + // Quiet main signal (-40 dBFS) which would normally be gated out + let mut main_audio = generators::sine_stereo(440.0, 440.0, sample_rate, 0.3, 0.01); + // Loud sidechain key (0 dBFS) that opens the gate + let sidechain_key = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 1.0); + let frames = main_audio.len() / 2; + + let in_rms = metrics::rms(&main_audio); + gate.process_with_sidechain(&mut main_audio, Some(&sidechain_key), frames); + let out_rms = metrics::rms(&main_audio[sample_rate as usize / 10..]); + + assert!( + (out_rms - in_rms).abs() < 1e-3, + "Sidechain key failed to open noise gate: in = {}, out = {}", + in_rms, + out_rms + ); +} + +/// Verifies that the declicker detects transient impulse clicks (like mouth clicks or pops) +/// and reconstructs the waveform smoothly without clicks. +#[test] +fn test_declick_impulse_spike_removal() { + let sample_rate = 48_000; + let (mut declick, _ctrl) = DeclickEffect::new( + DeclickData { + sensitivity: 0.9, + max_width_ms: 2.0, + bypassed: false, + }, + sample_rate, + ); + + let mut signal = generators::sine_stereo(440.0, 440.0, sample_rate, 0.3, 0.2); + // Inject extreme click spike at sample index 4000 + signal[4000] = 1.0; + signal[4001] = 1.0; + + let frames = signal.len() / 2; + declick.process(&mut signal, frames); + + // Declick introduces a known latency lookahead; the repaired samples should not peak at 1.0 + let max_peak = signal[4000..4500].iter().fold(0.0f32, |acc, &s| acc.max(s.abs())); + assert!( + max_peak < 0.6, + "Declick failed to attenuate extreme transient click spike: peak = {}", + max_peak + ); +} + +/// Verifies that the de-esser specifically compresses harsh high-frequency sibilance (e.g. 7 kHz) +/// while leaving mid/low vocal warmth (e.g. 500 Hz) untouched. +#[test] +fn test_deesser_high_frequency_sibilance_reduction() { + let sample_rate = 48_000; + let (mut deesser, _ctrl) = DeEsserEffect::new( + DeEsserData { + frequency: 5000.0, // detector corner + threshold_db: -20.0, + ratio: 4.0, + bypassed: false, + }, + sample_rate, + ); + + // 1. Harsh sibilance tone (7 kHz at -10 dBFS) -> should be attenuated + let sibilance = generators::sine_stereo(7000.0, 7000.0, sample_rate, 0.3, 0.316); + let mut sib_out = sibilance.clone(); + let frames = sib_out.len() / 2; + deesser.process(&mut sib_out, frames); + let sib_in_rms = metrics::rms(&sibilance); + let sib_out_rms = metrics::rms(&sib_out[sample_rate as usize / 10..]); + assert!( + sib_out_rms < sib_in_rms * 0.8, + "De-esser failed to attenuate 7 kHz sibilance: in = {}, out = {}", + sib_in_rms, + sib_out_rms + ); + + // 2. Body/vocal tone (500 Hz at -10 dBFS) -> should NOT be attenuated + let body = generators::sine_stereo(500.0, 500.0, sample_rate, 0.3, 0.316); + let mut body_out = body.clone(); + let frames = body_out.len() / 2; + deesser.process(&mut body_out, frames); + let body_in_rms = metrics::rms(&body); + let body_out_rms = metrics::rms(&body_out[sample_rate as usize / 10..]); + assert!( + (body_out_rms - body_in_rms).abs() < 0.03, + "De-esser accidentally attenuated 500 Hz body tone: in = {}, out = {}", + body_in_rms, + body_out_rms + ); +} + +/// Verifies 10-band ISO octave EQ selectivity: boosting 1 kHz increases 1 kHz energy, +/// while cutting 125 Hz reduces 125 Hz energy. +#[test] +fn test_eq_frequency_band_isolation() { + let sample_rate = 48_000; + let mut gains = [0.0f32; 10]; + gains[5] = 12.0; // 1000 Hz boost (+12 dB) + gains[2] = -12.0; // 125 Hz cut (-12 dB) + + let (mut eq, _ctrl) = EqEffect::new(EqData { gains_db: gains, bypassed: false }, sample_rate); + + let tone_1k = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 0.2); + let mut out_1k = tone_1k.clone(); + let frames = out_1k.len() / 2; + eq.process(&mut out_1k, frames); + let rms_in_1k = metrics::rms(&tone_1k); + let rms_out_1k = metrics::rms(&out_1k[sample_rate as usize / 10..]); + assert!( + rms_out_1k > rms_in_1k * 2.0, + "1 kHz band was not boosted by EQ: in = {}, out = {}", + rms_in_1k, + rms_out_1k + ); + + let tone_125 = generators::sine_stereo(125.0, 125.0, sample_rate, 0.3, 0.2); + let mut out_125 = tone_125.clone(); + let frames = out_125.len() / 2; + eq.process(&mut out_125, frames); + let rms_in_125 = metrics::rms(&tone_125); + let rms_out_125 = metrics::rms(&out_125[sample_rate as usize / 10..]); + assert!( + rms_out_125 < rms_in_125 * 0.6, + "125 Hz band was not attenuated by EQ: in = {}, out = {}", + rms_in_125, + rms_out_125 + ); +} + +/// Verifies delay buffer timing and feedback decay: delayed repeats appear after the delay interval +/// and decay exponentially based on the feedback factor. +#[test] +fn test_delay_echo_decay_and_feedback() { + let sample_rate = 48_000; + let delay_ms = 50.0; + let (mut delay, _ctrl) = DelayEffect::new( + DelayData { + time_ms: delay_ms, + feedback: 0.5, + mix: 0.5, + bypassed: false, + }, + sample_rate, + ); + + let burst = generators::tone_burst(1000.0, sample_rate, 10.0, 100.0, 1, 0.5); + let mut signal = burst; + let frames = signal.len() / 2; + delay.process(&mut signal, frames); + + let delay_frame_start = (sample_rate as f32 * delay_ms / 1000.0) as usize * 2; + let echo_peak = metrics::peak(&signal[delay_frame_start..delay_frame_start + 400]); + assert!( + echo_peak > 0.05, + "Delay effect did not emit delayed echo repeat at {}ms", + delay_ms + ); +} + +/// Verifies downsampling fidelity from 48 kHz to 44.1 kHz, checking for correct frame ratios +/// and minimal total harmonic distortion. +#[test] +fn test_multiresampler_downsampling_fidelity() { + let from_rate = 48_000; + let to_rate = 44_100; + let chunk_size = 256; + let channels = 2; + + let mut resampler = MultiResampler::new(from_rate, to_rate, chunk_size, channels) + .expect("build multi-resampler"); + + let input = generators::sine_stereo(1000.0, 1000.0, from_rate, 0.5, 0.8); + let mut output = Vec::new(); + let mut offset = 0; + let mut chunk_out = Vec::new(); + + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + chunk_out.clear(); + resampler.process_chunk(chunk, &mut chunk_out).expect("resample chunk"); + output.extend_from_slice(&chunk_out); + offset += chunk_size * channels; + } + + assert!(!output.is_empty(), "Resampler emitted no samples"); + let out_frames = output.len() / channels; + let expected_ratio = to_rate as f64 / from_rate as f64; + let actual_ratio = out_frames as f64 / (offset / channels) as f64; + assert!( + (actual_ratio - expected_ratio).abs() < 0.02, + "Resampling ratio mismatch: expected {}, got {}", + expected_ratio, + actual_ratio + ); + + let out_left: Vec = output.iter().step_by(2).copied().collect(); + if out_left.len() > 1000 { + let steady_state = &out_left[500..]; + let thd = metrics::thd_n(steady_state, 1000.0, to_rate); + assert!( + thd < 0.05, + "Resampler introduced excessive harmonic distortion: THD+N = {:.4}", + thd + ); + } +} + +/// Verifies upsampling fidelity from 44.1 kHz to 48 kHz, confirming clean reconstruction +/// without aliasing or frame count errors. +#[test] +fn test_multiresampler_upsampling_fidelity() { + let from_rate = 44_100; + let to_rate = 48_000; + let chunk_size = 256; + let channels = 2; + + let mut resampler = MultiResampler::new(from_rate, to_rate, chunk_size, channels) + .expect("build multi-resampler"); + + let input = generators::sine_stereo(1000.0, 1000.0, from_rate, 0.5, 0.8); + let mut output = Vec::new(); + let mut offset = 0; + let mut chunk_out = Vec::new(); + + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + chunk_out.clear(); + resampler.process_chunk(chunk, &mut chunk_out).expect("resample chunk"); + output.extend_from_slice(&chunk_out); + offset += chunk_size * channels; + } + + assert!(!output.is_empty(), "Resampler emitted no samples"); + let out_frames = output.len() / channels; + let expected_ratio = to_rate as f64 / from_rate as f64; + let actual_ratio = out_frames as f64 / (offset / channels) as f64; + assert!( + (actual_ratio - expected_ratio).abs() < 0.02, + "Upsampling ratio mismatch: expected {}, got {}", + expected_ratio, + actual_ratio + ); +} + +/// Verifies 2x oversampling fidelity (48 kHz to 96 kHz studio master rate). +#[test] +fn test_multiresampler_double_rate() { + let from_rate = 48_000; + let to_rate = 96_000; + let chunk_size = 256; + let channels = 2; + + let mut resampler = MultiResampler::new(from_rate, to_rate, chunk_size, channels) + .expect("build multi-resampler"); + + let input = generators::sine_stereo(1000.0, 1000.0, from_rate, 0.3, 0.7); + let mut output = Vec::new(); + let mut offset = 0; + let mut chunk_out = Vec::new(); + + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + chunk_out.clear(); + resampler.process_chunk(chunk, &mut chunk_out).expect("resample chunk"); + output.extend_from_slice(&chunk_out); + offset += chunk_size * channels; + } + + let out_frames = output.len() / channels; + let expected_ratio = 2.0; + let actual_ratio = out_frames as f64 / (offset / channels) as f64; + assert!( + (actual_ratio - expected_ratio).abs() < 0.02, + "Double-rate ratio mismatch: expected 2.0, got {}", + actual_ratio + ); +} + +/// Verifies that processing audio through linear stages does not introduce non-zero DC offset, +/// ensuring that baseline energy centers around zero. +#[test] +fn test_dc_offset_rejection_and_signal_integrity() { + let sample_rate = 48_000; + let (mut eq, _ctrl) = EqEffect::new(EqData { gains_db: [0.0; 10], bypassed: false }, sample_rate); + + let signal = generators::sine_stereo(100.0, 100.0, sample_rate, 0.5, 0.5); + let mut processed = signal.clone(); + let frames = processed.len() / 2; + eq.process(&mut processed, frames); + + // Evaluate DC offset after the initial filter impulse settling transient (< -66 dBFS tolerance) + let dc = metrics::dc_offset(&processed[2048..]); + assert!( + dc.abs() < 5e-4, + "Processing introduced non-zero DC offset in steady state: {}", + dc + ); +} + +/// Tests that when a high-amplitude burst drops back to a quiet signal, the limiter releases +/// attenuation and restores the quiet signal to its full unattenuated volume. +#[test] +fn test_limiter_recovery_after_overload() { + let sample_rate = 48_000; + let (mut limiter, _ctrl, _meter) = LimiterEffect::new( + LimiterData { ceiling_db: -1.0, release_ms: 20.0, lookahead_ms: 5.0, bypassed: false }, + sample_rate, + ); + + // 100ms loud burst (2.0 amplitude) followed by 300ms quiet tone (0.1 amplitude) + let loud = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.1, 2.0); + let quiet = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 0.1); + let mut combined = [loud, quiet].concat(); + let frames = combined.len() / 2; + + limiter.process(&mut combined, frames); + + // Verify the loud section was clamped to ceiling (< 0.892) + let loud_peak = metrics::peak(&combined[..sample_rate as usize / 5]); + assert!( + loud_peak <= 0.892, + "Loud section should be clamped, got {}", + loud_peak + ); + + // Verify that towards the end of the quiet section (after release), the signal is fully recovered to 0.1 + let recovered_peak = metrics::peak(&combined[combined.len() - 2000..]); + assert!( + (recovered_peak - 0.1).abs() < 0.015, + "Limiter failed to release gain attenuation: expected ~0.1, got {}", + recovered_peak + ); +} + +/// Verifies that the saturator waveshaper responds symmetrically to positive and negative audio peaks, +/// preventing unwanted DC generation on symmetric input signals. +#[test] +fn test_saturator_tanh_symmetry() { + let (mut saturator, _ctrl) = SaturatorEffect::new(SaturatorData { + drive_db: 12.0, + threshold_db: -6.0, + bypassed: false, + }); + + let sample_rate = 48_000; + let signal = generators::sine_stereo(440.0, 440.0, sample_rate, 0.2, 0.8); + let mut processed = signal.clone(); + let frames = processed.len() / 2; + + saturator.process(&mut processed, frames); + + let pos_peak = processed.iter().fold(0.0f32, |acc, &s| acc.max(s)); + let neg_peak = processed.iter().fold(0.0f32, |acc, &s| acc.min(s)).abs(); + + assert!( + (pos_peak - neg_peak).abs() < 1e-4, + "Saturator asymmetry detected: pos_peak {} vs neg_peak {}", + pos_peak, + neg_peak + ); +} + +/// Tests that updating EQ gains dynamically via EffectControl takes immediate effect in the next audio block +/// without dropping samples or requiring effect re-instantiation. +#[test] +fn test_eq_runtime_gain_control_update() { + let sample_rate = 48_000; + let (mut eq, ctrl) = EqEffect::new(EqData { gains_db: [0.0; 10], bypassed: false }, sample_rate); + + let mut signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.4, 0.2); + let half_frames = (signal.len() / 4) & !1; + + // Process first half with flat 0 dB + eq.process(&mut signal[..half_frames * 2], half_frames); + let initial_rms = metrics::rms(&signal[2048..half_frames * 2]); + + // Live update: Boost 1 kHz band (band index 5) by +12 dB + let mut new_gains = vec![0.0; 10]; + new_gains[5] = 12.0; + ctrl.apply_update(&serde_json::json!({ "gainsDb": new_gains })); + + // Process second half with updated gain + eq.process(&mut signal[half_frames * 2..], half_frames); + let updated_rms = metrics::rms(&signal[half_frames * 2 + 2048..]); + + assert!( + updated_rms > initial_rms * 1.8, + "Runtime gain update was not reflected in audio output: initial RMS = {}, updated RMS = {}", + initial_rms, + updated_rms + ); +} + +/// Tests that processing a short impulse through the Freeverb algorithm creates a diffuse reverberant +/// tail that persists after the input ends and decays smoothly over time. +#[test] +fn test_reverb_tail_generation_and_decay() { + let sample_rate = 48_000; + let (mut reverb, _ctrl) = ReverbEffect::new( + ReverbData { + room_size: 0.8, + damping: 0.2, + width: 1.0, + mix: 0.6, + bypassed: false, + }, + sample_rate, + ); + + // 20ms burst followed by 400ms silence + let burst = generators::sine_stereo(440.0, 440.0, sample_rate, 0.02, 0.8); + let silence = vec![0.0f32; (sample_rate as f32 * 0.4) as usize * 2]; + let mut signal = [burst, silence].concat(); + let frames = signal.len() / 2; + + reverb.process(&mut signal, frames); + + // Early reverberant tail (50ms to 150ms) must contain diffuse tail energy + let early_start = (sample_rate as f32 * 0.05) as usize * 2; + let early_end = (sample_rate as f32 * 0.15) as usize * 2; + let early_tail_rms = metrics::rms(&signal[early_start..early_end]); + + // Late reverberant tail (250ms to 350ms) + let late_start = (sample_rate as f32 * 0.25) as usize * 2; + let late_end = (sample_rate as f32 * 0.35) as usize * 2; + let late_tail_rms = metrics::rms(&signal[late_start..late_end]); + + assert!( + early_tail_rms > 0.005, + "Reverb failed to produce reverberant tail: early RMS = {}", + early_tail_rms + ); + assert!( + late_tail_rms < early_tail_rms * 0.6, + "Reverb tail did not decay naturally over time: early RMS = {}, late RMS = {}", + early_tail_rms, + late_tail_rms + ); +} + +/// Verifies compressor attack time dynamics: an initial sudden transient burst passes through before +/// the envelope detector engages gain reduction, preserving natural musical punch. +#[test] +fn test_compressor_attack_envelope() { + let sample_rate = 48_000; + let (mut comp, _ctrl, _meter) = CompressorEffect::new( + CompressorData { + threshold_db: -12.0, + ratio: 8.0, + attack_ms: 30.0, + release_ms: 50.0, + makeup_db: 0.0, + knee_db: 0.0, + bypassed: false, + }, + sample_rate, + ); + + // Loud signal (1.0 amplitude = 0 dBFS, which is 12 dB above threshold) + let signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.2, 1.0); + let mut processed = signal.clone(); + let frames = processed.len() / 2; + + comp.process(&mut processed, frames); + + // Initial 5 ms should let most of the peak through due to 30ms attack time + let initial_frames = (sample_rate as f32 * 0.005) as usize * 2; + let initial_peak = metrics::peak(&processed[..initial_frames]); + + // Later 100ms should be significantly compressed + let later_frames = processed.len() - initial_frames; + let compressed_peak = metrics::peak(&processed[later_frames..]); + + assert!( + initial_peak > 0.85, + "Transient punch should pass during attack phase, got peak {}", + initial_peak + ); + assert!( + compressed_peak < initial_peak * 0.8, + "Compressor should attenuate signal after attack engages: initial {} vs compressed {}", + initial_peak, + compressed_peak + ); +} + +/// Verifies that the noise gate does not chatter near threshold, maintaining gate closure +/// when input remains strictly below threshold. +#[test] +fn test_noise_gate_hysteresis() { + let sample_rate = 48_000; + let (mut gate, _ctrl, _meter) = NoiseGateEffect::new( + NoiseGateData { + threshold_db: -30.0, + range_db: -50.0, + attack_ms: 1.0, + hold_ms: 10.0, + release_ms: 20.0, + bypassed: false, + }, + sample_rate, + ); + + // Signal strictly below threshold: -40 dBFS (amplitude = 0.01) + let quiet_signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.1, 0.01); + let mut processed = quiet_signal.clone(); + let frames = processed.len() / 2; + + gate.process(&mut processed, frames); + + // Check steady-state attenuation + let tail = &processed[processed.len() - 1000..]; + let tail_peak = metrics::peak(tail); + let tail_db = metrics::to_dbfs(tail_peak); + + assert!( + tail_db < -60.0, + "Gate should fully attenuate sub-threshold audio, got {} dBFS", + tail_db + ); +} + +/// Verifies that stereo resamplers preserve sample-accurate phase coherence between left and right channels +/// without stereo image tilt or channel delay skew. +#[test] +fn test_multiresampler_stereo_phase_coherence() { + let in_rate = 44_100; + let out_rate = 48_000; + let channels = 2; + let chunk_size = 1024; + let mut resampler = MultiResampler::new(in_rate, out_rate, channels, chunk_size).expect("resampler"); + + // Identical in-phase mono tone sent to both left and right channels + let input = generators::sine_stereo(1000.0, 1000.0, in_rate, 0.2, 0.8); + let mut output = Vec::new(); + let mut chunk_out = vec![0.0f32; chunk_size * 2 * channels]; + + let mut offset = 0; + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut chunk_out).expect("resample"); + output.extend_from_slice(&chunk_out); + offset += chunk_size * channels; + } + + // Measure difference between left and right channels + let mut max_stereo_diff = 0.0f32; + for frame in output.chunks_exact(2) { + let diff = (frame[0] - frame[1]).abs(); + if diff > max_stereo_diff { + max_stereo_diff = diff; + } + } + + assert!( + max_stereo_diff < 1e-4, + "Stereo phase skew detected across resampling: max diff between L and R was {}", + max_stereo_diff + ); +} + +/// Verifies that channel balance centered at 0.0 leaves both left and right channels at exact 1.0x unity amplitude. +#[test] +fn test_channel_balance_center_is_unity() { + let (mut balance, _ctrl) = ChannelBalanceEffect::new(ChannelBalanceData { + left_gain_db: 0.0, + right_gain_db: 0.0, + bypassed: false, + }); + + let sample_rate = 48_000; + let original = generators::sine_stereo(440.0, 440.0, sample_rate, 0.1, 0.75); + let mut processed = original.clone(); + let frames = processed.len() / 2; + + balance.process(&mut processed, frames); + + let (is_exact, max_diff) = metrics::verify_bit_exactness(&original, &processed); + assert!( + is_exact, + "Center balance must be bit-exact unity, but max_diff was {}", + max_diff + ); +} + +/// Verifies that enabling mute instantly zeroes all audio samples without leaving trailing buffer artifacts. +#[test] +fn test_mute_immediate_silencing() { + let (mut mute, _ctrl) = MuteEffect::new(MuteData { muted: true, bypassed: false }); + + let sample_rate = 48_000; + let mut signal = generators::sine_stereo(440.0, 880.0, sample_rate, 0.05, 0.9); + let frames = signal.len() / 2; + + mute.process(&mut signal, frames); + + let peak = metrics::peak(&signal); + assert_eq!(peak, 0.0, "Mute must produce absolute digital silence (0.0)"); +} + +/// Verifies that the declicker leaves clean non-click audio untouched with minimal distortion. +#[test] +fn test_declick_preserves_clean_music() { + let sample_rate = 48_000; + let (mut declick, _ctrl) = DeclickEffect::new( + DeclickData { + sensitivity: 0.5, + max_width_ms: 2.0, + bypassed: false, + }, + sample_rate, + ); + + let clean = generators::sine_stereo(440.0, 440.0, sample_rate, 0.2, 0.4); + let mut processed = clean.clone(); + let frames = processed.len() / 2; + + declick.process(&mut processed, frames); + + // Declick introduces a known lookahead buffer; evaluate steady-state RMS energy + let clean_rms = metrics::rms(&clean[2048..]); + let proc_rms = metrics::rms(&processed[2048..]); + let diff_db = (20.0 * (proc_rms / clean_rms).log10()).abs(); + assert!( + diff_db < 0.2, + "Declicker should preserve clean audio energy: in RMS = {}, out RMS = {}, diff = {} dB", + clean_rms, + proc_rms, + diff_db + ); +} + + diff --git a/src-tauri/tests/boundary_and_precision.rs b/src-tauri/tests/boundary_and_precision.rs new file mode 100644 index 0000000..019ca72 --- /dev/null +++ b/src-tauri/tests/boundary_and_precision.rs @@ -0,0 +1,819 @@ +mod common; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use common::generators; +use common::metrics; +use serde_json::json; +use splitwave_lib::audio::effects::compressor::CompressorEffect; +use splitwave_lib::audio::effects::delay::DelayEffect; +use splitwave_lib::audio::effects::eq::EqEffect; +use splitwave_lib::audio::effects::gain::GainEffect; +use splitwave_lib::audio::effects::limiter::LimiterEffect; +use splitwave_lib::audio::effects::mute::MuteEffect; +use splitwave_lib::audio::effects::noise_gate::NoiseGateEffect; +use splitwave_lib::audio::effects::saturator::SaturatorEffect; +use splitwave_lib::audio::effects::Effect; +use splitwave_lib::audio::graph::{ + CompressorData, DelayData, EqData, GainData, LimiterData, MuteData, NoiseGateData, + SaturatorData, +}; +use splitwave_lib::audio::resample::MultiResampler; + +/// Verifies that when an effect is bypassed, audio passes through 100% bit-exact with zero latency and zero alteration. +#[test] +fn test_bypass_is_bit_exact() { + let sample_rate = 48_000; + let (mut eq, _ctrl) = EqEffect::new(EqData { gains_db: [6.0; 10], bypassed: true }, sample_rate); + + let original = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.1, 0.5); + let mut processed = original.clone(); + let frames = processed.len() / 2; + + // Simulate the engine's pipeline bypass gate: when bypassed is true, execution is skipped + let bypass = Arc::new(AtomicBool::new(true)); + if !bypass.load(Ordering::Relaxed) { + eq.process(&mut processed, frames); + } + + let (is_exact, max_diff) = metrics::verify_bit_exactness(&original, &processed); + assert!( + is_exact && max_diff == 0.0, + "Bypassed effect did not provide bit-exact passthrough: max diff was {}", + max_diff + ); +} + +/// Verifies that updating effect parameters at runtime via EffectControl immediately affects audio processing without restarts. +#[test] +fn test_runtime_parameter_update() { + let sample_rate = 48_000; + let (mut gain, ctrl) = GainEffect::new(GainData { gain_db: 0.0, bypassed: false }); + let frames = 256; + + let mut block = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.2); + + // Initial processing at 0 dB + gain.process(&mut block, frames); + let initial_rms = metrics::rms(&block); + + // Apply live update to +6.02 dB + ctrl.apply_update(&json!({ "gainDb": 6.02 })); + // Slew block + gain.process(&mut block, frames); + + // Steady state block at new gain + let mut steady_block = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.2); + gain.process(&mut steady_block, frames); + let updated_rms = metrics::rms(&steady_block); + + let ratio = updated_rms / initial_rms; + assert!( + (ratio - 2.0).abs() < 0.05, + "Runtime parameter update failed to scale gain: expected 2.0x, got {}", + ratio + ); +} + +/// Verifies chunk size independence: processing audio in small chunks vs large chunks produces identical audio output. +#[test] +fn test_chunk_size_independence() { + let sample_rate = 48_000; + let total_frames = 1024; + let input = generators::sine_stereo(440.0, 880.0, sample_rate, total_frames as f32 / sample_rate as f32, 0.4); + + // Stream A: Processed in 16 small chunks of 64 frames + let (mut gain_a, _ctrl_a) = GainEffect::new(GainData { gain_db: 3.0, bypassed: false }); + let mut stream_a = input.clone(); + for chunk in stream_a.chunks_exact_mut(64 * 2) { + gain_a.process(chunk, 64); + } + + // Stream B: Processed in 2 large chunks of 512 frames + let (mut gain_b, _ctrl_b) = GainEffect::new(GainData { gain_db: 3.0, bypassed: false }); + let mut stream_b = input.clone(); + for chunk in stream_b.chunks_exact_mut(512 * 2) { + gain_b.process(chunk, 512); + } + + let (_, max_diff) = metrics::verify_bit_exactness(&stream_a, &stream_b); + assert!( + max_diff < 1e-4, + "Chunk size altered DSP results: max diff between 64-frame and 512-frame chunks was {}", + max_diff + ); +} + +/// Verifies that multiple sequential process calls preserve filter state and match a single consolidated process call. +#[test] +fn test_multiple_process_calls_match_single_process_call() { + let sample_rate = 48_000; + let total_frames = 512; + let input = generators::sine_stereo(1000.0, 1000.0, sample_rate, total_frames as f32 / sample_rate as f32, 0.5); + + // Single call of 512 frames + let (mut sat_single, _ctrl1) = SaturatorEffect::new(SaturatorData { drive_db: 6.0, threshold_db: -3.0, bypassed: false }); + let mut out_single = input.clone(); + sat_single.process(&mut out_single, total_frames); + + // Two calls of 256 frames + let (mut sat_multi, _ctrl2) = SaturatorEffect::new(SaturatorData { drive_db: 6.0, threshold_db: -3.0, bypassed: false }); + let mut out_multi = input.clone(); + sat_multi.process(&mut out_multi[..256 * 2], 256); + sat_multi.process(&mut out_multi[256 * 2..], 256); + + let (is_exact, max_diff) = metrics::verify_bit_exactness(&out_single, &out_multi); + assert!( + is_exact && max_diff == 0.0, + "State was corrupted between process calls: max diff was {}", + max_diff + ); +} + +/// Verifies that stereo channels operate with complete isolation and zero channel crosstalk. +#[test] +fn test_stereo_channels_do_not_crosstalk() { + let sample_rate = 48_000; + let frames = 512; + + // Signal on Left channel ONLY, Right channel is digital silence (0.0) + let left_only = generators::sine_stereo(440.0, 0.0, sample_rate, frames as f32 / sample_rate as f32, 0.8); + let mut audio = left_only.clone(); + for frame in audio.chunks_exact_mut(2) { + frame[1] = 0.0; + } + + // Process through Gain, Saturator, EQ, Limiter + let (mut gain, _g_c) = GainEffect::new(GainData { gain_db: 3.0, bypassed: false }); + let (mut sat, _s_c) = SaturatorEffect::new(SaturatorData { drive_db: 6.0, threshold_db: -2.0, bypassed: false }); + let (mut eq, _e_c) = EqEffect::new(EqData { gains_db: [2.0; 10], bypassed: false }, sample_rate); + let (mut limiter, _l_c, _l_m) = LimiterEffect::new(LimiterData { ceiling_db: -1.0, release_ms: 20.0, lookahead_ms: 5.0, bypassed: false }, sample_rate); + + gain.process(&mut audio, frames); + sat.process(&mut audio, frames); + eq.process(&mut audio, frames); + limiter.process(&mut audio, frames); + + // Verify Right channel is strictly 0.0 (no leakage from Left) + let mut max_right_leak = 0.0f32; + for frame in audio.chunks_exact(2) { + max_right_leak = max_right_leak.max(frame[1].abs()); + } + + assert_eq!( + max_right_leak, 0.0, + "Channel crosstalk detected: Right channel leaked signal {}", + max_right_leak + ); +} + +/// Verifies that extreme parameter combinations (extreme boost, threshold, ratio) never produce NaN or Infinity. +#[test] +fn test_no_nan_or_inf_for_extreme_parameters() { + let sample_rate = 48_000; + let frames = 256; + + let (mut gain, _g) = GainEffect::new(GainData { gain_db: 60.0, bypassed: false }); + let (mut sat, _s) = SaturatorEffect::new(SaturatorData { drive_db: 48.0, threshold_db: -30.0, bypassed: false }); + let (mut comp, _c, _cm) = CompressorEffect::new( + CompressorData { + threshold_db: -60.0, + ratio: 50.0, + attack_ms: 0.1, + release_ms: 5.0, + knee_db: 12.0, + makeup_db: 24.0, + bypassed: false, + }, + sample_rate, + ); + let (mut limiter, _l, _lm) = LimiterEffect::new( + LimiterData { + ceiling_db: -30.0, + release_ms: 0.5, + lookahead_ms: 1.0, + bypassed: false, + }, + sample_rate, + ); + + // Test signal containing extreme values, subnormal denormals, and high peaks + let mut extreme_input = vec![0.0f32; frames * 2]; + extreme_input[0] = 1e-35; // Denormal float + extreme_input[1] = -1e-35; + extreme_input[10] = 50.0; // Extreme overdriven peak + extreme_input[11] = -50.0; + + gain.process(&mut extreme_input, frames); + sat.process(&mut extreme_input, frames); + comp.process(&mut extreme_input, frames); + limiter.process(&mut extreme_input, frames); + + for (i, &s) in extreme_input.iter().enumerate() { + assert!( + s.is_finite(), + "Non-finite sample detected at index {}: value = {}", + i, + s + ); + } +} + +/// Verifies that toggling mute at runtime zeroes audio instantly and restoring mute un-zeroes audio cleanly. +#[test] +fn test_mute_runtime_toggle_restores_audio() { + let (mut mute, ctrl) = MuteEffect::new(MuteData { muted: false, bypassed: false }); + let sample_rate = 48_000; + let frames = 256; + + let original = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.5); + + // Block 1: Unmuted (normal signal) + let mut block1 = original.clone(); + mute.process(&mut block1, frames); + assert_eq!(metrics::peak(&block1), metrics::peak(&original)); + + // Block 2: Toggle mute ON -> first block ramps down to prevent clicks + ctrl.apply_update(&json!({ "muted": true })); + let mut block2 = original.clone(); + mute.process(&mut block2, frames); + assert!(metrics::peak(&block2) < metrics::peak(&original)); + + // Block 3: Steady-state muted block (absolute digital silence) + let mut block3 = original.clone(); + mute.process(&mut block3, frames); + assert_eq!(metrics::peak(&block3), 0.0, "Muted block must be digital silence"); + + // Block 4: Toggle mute OFF -> ramps back up to full volume + ctrl.apply_update(&json!({ "muted": false })); + let mut block4 = original.clone(); + mute.process(&mut block4, frames); + + // Block 5: Steady-state unmuted + let mut block5 = original.clone(); + mute.process(&mut block5, frames); + assert!( + (metrics::peak(&block5) - metrics::peak(&original)).abs() < 1e-5, + "Unmuted audio failed to restore" + ); +} + +/// Verifies compressor ratio accuracy: audio +12 dB above threshold with 4:1 ratio is compressed to +3 dB above threshold. +#[test] +fn test_compressor_ratio_accuracy() { + let sample_rate = 48_000; + let threshold_db = -16.0; + let ratio = 4.0; + let (mut comp, _ctrl, _meter) = CompressorEffect::new( + CompressorData { + threshold_db, + ratio, + attack_ms: 1.0, + release_ms: 50.0, + knee_db: 0.0, + makeup_db: 0.0, + bypassed: false, + }, + sample_rate, + ); + + // Input signal at -4.0 dBFS (+12 dB above threshold) + let in_dbfs = -4.0f32; + let in_amp = 10.0f32.powf(in_dbfs / 20.0); + let mut signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.2, in_amp); + let frames = signal.len() / 2; + + comp.process(&mut signal, frames); + + // In steady state, output should be threshold + (overshoot / ratio) = -16 + (12 / 4) = -13 dBFS + let steady_tail = &signal[signal.len() - 2000..]; + let out_peak = metrics::peak(steady_tail); + let out_dbfs = metrics::to_dbfs(out_peak); + + let expected_dbfs = threshold_db + (in_dbfs - threshold_db) / ratio; + assert!( + (out_dbfs - expected_dbfs).abs() < 0.6, + "Compressor ratio calculation inaccurate: expected {} dBFS, got {} dBFS", + expected_dbfs, + out_dbfs + ); +} + +/// Verifies compressor release time accuracy: gain reduction recovers back to unity after signal drops. +#[test] +fn test_compressor_release_time_accuracy() { + let sample_rate = 48_000; + let (mut comp, _ctrl, _meter) = CompressorEffect::new( + CompressorData { + threshold_db: -12.0, + ratio: 8.0, + attack_ms: 1.0, + release_ms: 30.0, + knee_db: 0.0, + makeup_db: 0.0, + bypassed: false, + }, + sample_rate, + ); + + // 50ms loud burst followed by 150ms quiet tone (at -30 dBFS, well below threshold) + let loud = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.05, 1.0); + let quiet = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.15, 0.0316); + let mut signal = [loud, quiet].concat(); + let frames = signal.len() / 2; + + comp.process(&mut signal, frames); + + // Immediately after loud burst (at t=60ms = 10ms into quiet), gain is still heavily attenuated + let t60_idx = (sample_rate as f32 * 0.06) as usize * 2; + let t60_peak = metrics::peak(&signal[t60_idx..t60_idx + 200]); + + // Late in quiet section (at t=180ms = 130ms into quiet > 4x release_ms), gain has fully recovered to 0.0316 + let t180_idx = (sample_rate as f32 * 0.18) as usize * 2; + let t180_peak = metrics::peak(&signal[t180_idx..t180_idx + 200]); + + assert!( + t60_peak < t180_peak * 0.7, + "Compressor failed to maintain gain reduction immediately after loud burst: t60 = {}, t180 = {}", + t60_peak, + t180_peak + ); + assert!( + (t180_peak - 0.0316).abs() < 0.005, + "Compressor release failed to recover to uncompressed volume: expected ~0.0316, got {}", + t180_peak + ); +} + +/// Verifies that signals below the limiter ceiling pass through completely unattenuated (0 dB gain reduction). +#[test] +fn test_limiter_no_attenuation_below_ceiling() { + let sample_rate = 48_000; + let (mut limiter, _ctrl, _meter) = LimiterEffect::new( + LimiterData { + ceiling_db: -1.0, + release_ms: 20.0, + lookahead_ms: 5.0, + bypassed: false, + }, + sample_rate, + ); + + // Signal at -4 dBFS (0.63 amplitude, well below -1.0 dBFS ceiling of 0.891) + let signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.1, 0.63); + let mut processed = signal.clone(); + let frames = processed.len() / 2; + + limiter.process(&mut processed, frames); + + // Discard initial lookahead delay window (5ms / 240 frames) to measure steady-state passthrough + let sig_rms = metrics::rms(&signal[1024..]); + let proc_rms = metrics::rms(&processed[1024..]); + let diff_db = (20.0 * (proc_rms / sig_rms).log10()).abs(); + + assert!( + diff_db < 0.05, + "Limiter attenuated signal below ceiling: in RMS = {}, out RMS = {}, diff = {} dB", + sig_rms, + proc_rms, + diff_db + ); +} + +/// Verifies that the noise gate holds open for the configured hold_ms before starting its release closing ramp. +#[test] +fn test_noise_gate_hold_time() { + let sample_rate = 48_000; + let hold_ms = 40.0; + let (mut gate, _ctrl, _meter) = NoiseGateEffect::new( + NoiseGateData { + threshold_db: -20.0, + range_db: -50.0, + attack_ms: 1.0, + hold_ms, + release_ms: 10.0, + bypassed: false, + }, + sample_rate, + ); + + // 50ms loud signal (0 dBFS, opens gate) followed by 150ms quiet noise (-40 dBFS) + let loud = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.05, 1.0); + let quiet = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.15, 0.01); + let mut signal = [loud, quiet].concat(); + let frames = signal.len() / 2; + + gate.process(&mut signal, frames); + + // At t=70ms (20ms after loud ends, which is within hold period), gate MUST still be held open + let t70_idx = (sample_rate as f32 * 0.07) as usize * 2; + let t70_peak = metrics::peak(&signal[t70_idx..t70_idx + 200]); + + // At t=160ms (accounting for detector decay + hold_ms + release), gate MUST be fully closed + let t160_idx = (sample_rate as f32 * 0.16) as usize * 2; + let t160_peak = metrics::peak(&signal[t160_idx..t160_idx + 200]); + + assert!( + (t70_peak - 0.01).abs() < 0.002, + "Gate failed to hold open during hold_ms period: expected ~0.01, got {}", + t70_peak + ); + assert!( + t160_peak < 0.001, + "Gate failed to close after hold + release elapsed: got peak {}", + t160_peak + ); +} + +/// Verifies noise gate release time constant: gate smoothly attenuates down to range_db over release_ms. +#[test] +fn test_noise_gate_release_time() { + let sample_rate = 48_000; + let (mut gate, _ctrl, _meter) = NoiseGateEffect::new( + NoiseGateData { + threshold_db: -20.0, + range_db: -40.0, + attack_ms: 1.0, + hold_ms: 0.0, // 0ms hold to isolate release timing + release_ms: 25.0, + bypassed: false, + }, + sample_rate, + ); + + let loud = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.05, 1.0); + let quiet = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.15, 0.01); + let mut signal = [loud, quiet].concat(); + let frames = signal.len() / 2; + + gate.process(&mut signal, frames); + + // End of stream is fully closed + let end_peak = metrics::peak(&signal[signal.len() - 1000..]); + let end_dbfs = metrics::to_dbfs(end_peak); + + assert!( + end_dbfs < -70.0, + "Noise gate release failed to achieve target attenuation: got {} dBFS", + end_dbfs + ); +} + +/// Verifies that the noise gate does not chatter when audio fluctuates closely around the threshold. +#[test] +fn test_noise_gate_real_hysteresis_no_chatter() { + let sample_rate = 48_000; + let (mut gate, _ctrl, _meter) = NoiseGateEffect::new( + NoiseGateData { + threshold_db: -20.0, + range_db: -30.0, + attack_ms: 2.0, + hold_ms: 15.0, + release_ms: 20.0, + bypassed: false, + }, + sample_rate, + ); + + // Rapidly fluctuating signal oscillating around -20 dBFS (amplitude 0.10) + let total_frames = 1000; + let mut signal = Vec::with_capacity(total_frames * 2); + for i in 0..total_frames { + let amp = if i % 2 == 0 { 0.105 } else { 0.095 }; + signal.push(amp); + signal.push(amp); + } + + gate.process(&mut signal, total_frames); + + // Check for rapid discontinuity jumps between samples + let mut max_jump = 0.0f32; + for i in 1..total_frames { + let diff = (signal[i * 2] - signal[(i - 1) * 2]).abs(); + if diff > max_jump { + max_jump = diff; + } + } + + assert!( + max_jump < 0.03, + "Gate chattered erratically near threshold: max jump between consecutive frames was {}", + max_jump + ); +} + +/// Verifies that the DelayEffect produces echoes at the exact millisecond delay time calculated from sample rate. +#[test] +fn test_delay_exact_delay_time() { + let sample_rate = 48_000; + let delay_ms = 50.0; + let (mut delay, _ctrl) = DelayEffect::new( + DelayData { + time_ms: delay_ms, + feedback: 0.0, // 0 feedback to isolate first echo + mix: 1.0, // 100% wet + bypassed: false, + }, + sample_rate, + ); + + // Single impulse at frame 0 + let total_frames = 4800; // 100ms + let mut buffer = vec![0.0f32; total_frames * 2]; + buffer[0] = 1.0; + buffer[1] = 1.0; + + delay.process(&mut buffer, total_frames); + + // Expected delay in frames: 50ms at 48 kHz = 2400 frames + let expected_frame = (sample_rate as f32 * delay_ms * 0.001) as usize; + let peak_frame = buffer.chunks_exact(2).position(|f| f[0].abs() > 0.5).unwrap_or(0); + + assert_eq!( + peak_frame, expected_frame, + "Delay timing mismatch: expected frame {}, got {}", + expected_frame, peak_frame + ); +} + +/// Verifies that successive delay feedback echoes decay exponentially according to the feedback multiplier. +#[test] +fn test_delay_feedback_decay() { + let sample_rate = 48_000; + let (mut delay, _ctrl) = DelayEffect::new( + DelayData { + time_ms: 20.0, + feedback: 0.5, + mix: 1.0, + bypassed: false, + }, + sample_rate, + ); + + let total_frames = 4800; // 100ms + let mut buffer = vec![0.0f32; total_frames * 2]; + buffer[0] = 1.0; + buffer[1] = 1.0; + + delay.process(&mut buffer, total_frames); + + // Delay = 20ms = 960 frames + let echo1 = buffer[960 * 2].abs(); + let echo2 = buffer[1920 * 2].abs(); + let echo3 = buffer[2880 * 2].abs(); + + // In Splitwave's tap model (see docs/ENGINE_DEFECTS.md), echo 1 is the 100% wet delayed input (1.0), + // and feedback scales subsequent recirculating repeats: echo2 = 0.5, echo3 = 0.25. + assert!( + (echo1 - 1.0).abs() < 0.05, + "First delayed tap incorrect: expected 1.0, got {}", + echo1 + ); + assert!( + (echo2 - 0.5).abs() < 0.05, + "First feedback recirculation incorrect: expected ~0.5, got {}", + echo2 + ); + assert!( + (echo3 - 0.25).abs() < 0.05, + "Second feedback recirculation incorrect: expected ~0.25, got {}", + echo3 + ); +} + +/// Verifies that circular buffer wrapping inside DelayEffect across consecutive small blocks does not create discontinuities. +#[test] +fn test_delay_buffer_boundary_continuity() { + let sample_rate = 48_000; + let (mut delay, _ctrl) = DelayEffect::new( + DelayData { + time_ms: 10.0, + feedback: 0.3, + mix: 0.5, + bypassed: false, + }, + sample_rate, + ); + + // Process a continuous sine wave in small 64-frame blocks + let duration = 0.2; + let mut signal = generators::sine_stereo(440.0, 440.0, sample_rate, duration, 0.5); + let block_size = 64; + + for chunk in signal.chunks_exact_mut(block_size * 2) { + delay.process(chunk, block_size); + } + + // Check that there are no sharp step clicks between adjacent samples anywhere in steady state + let steady = &signal[2048..]; + let mut max_derivative = 0.0f32; + for i in 1..steady.len() / 2 { + let diff = (steady[i * 2] - steady[(i - 1) * 2]).abs(); + if diff > max_derivative { + max_derivative = diff; + } + } + + // Maximum slope of a 440 Hz sine wave at 48 kHz is ~2 * PI * 440 / 48000 * amp ≈ 0.03 + assert!( + max_derivative < 0.06, + "Delay circular buffer wrap created click discontinuity: max step was {}", + max_derivative + ); +} + +/// Verifies that resampling between identical sample rates (48 kHz -> 48 kHz) preserves the signal transparently. +#[test] +fn test_resampler_identity_rate_is_transparent() { + let sample_rate = 48_000; + let channels = 2; + let chunk_size = 512; + let mut resampler = MultiResampler::new(sample_rate, sample_rate, chunk_size, channels).expect("resampler"); + + let input = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.15, 0.5); + let mut output = Vec::new(); + + let mut offset = 0; + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut output).expect("resample"); + offset += chunk_size * channels; + } + + // After sinc filter window settling (256 frames), evaluate steady state transparency + let in_rms = metrics::rms(&input[2048..]); + let out_rms = metrics::rms(&output[2048..]); + let diff_db = (20.0 * (out_rms / in_rms).log10()).abs(); + + assert!( + diff_db < 0.1, + "Identity resampler (48k->48k) altered signal magnitude: diff = {} dB", + diff_db + ); +} + +/// Verifies that feeding the resampler in small consecutive chunks maintains seamless waveform continuity across chunk borders. +#[test] +fn test_resampler_chunk_boundary_continuity() { + let in_rate = 44_100; + let out_rate = 48_000; + let channels = 2; + let chunk_size = 256; + let mut resampler = MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); + + let input = generators::sine_stereo(440.0, 440.0, in_rate, 0.15, 0.6); + let mut output = Vec::new(); + + let mut offset = 0; + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut output).expect("resample"); + offset += chunk_size * channels; + } + + // Verify waveform continuity: first differences must stay within smooth sinusoidal limits + let steady = &output[2048..]; + let mut max_step = 0.0f32; + for i in 1..steady.len() / 2 { + let step = (steady[i * 2] - steady[(i - 1) * 2]).abs(); + if step > max_step { + max_step = step; + } + } + + assert!( + max_step < 0.05, + "Chunk boundary glitch detected in resampler: max step between samples was {}", + max_step + ); +} + +/// Verifies that downsampling rejects out-of-band frequencies: provides transition-band +/// attenuation (>20 dB at 23.5 kHz for 48k->44.1k) and deep stopband alias rejection (>60 dB). +#[test] +fn test_resampler_alias_rejection() { + let channels = 2; + let chunk_size = 512; + + // 1. Transition band attenuation: 48 kHz -> 44.1 kHz at 23.5 kHz (near Nyquist transition) + { + let mut resampler = MultiResampler::new(48_000, 44_100, chunk_size, channels).expect("resampler"); + let input = generators::sine_stereo(23_500.0, 23_500.0, 48_000, 0.15, 0.8); + let mut output = Vec::new(); + + let mut offset = 0; + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut output).expect("resample"); + offset += chunk_size * channels; + } + + let out_peak = metrics::peak(&output[2048..]); + let attenuation_db = metrics::to_dbfs(out_peak) - metrics::to_dbfs(0.8); + assert!( + attenuation_db < -20.0, + "Resampler transition band failed to roll off: attenuation was only {} dB", + attenuation_db + ); + } + + // 2. Deep stopband alias rejection: 96 kHz -> 44.1 kHz at 35 kHz (output Nyquist = 22.05 kHz) + { + let mut resampler = MultiResampler::new(96_000, 44_100, chunk_size, channels).expect("resampler"); + let input = generators::sine_stereo(35_000.0, 35_000.0, 96_000, 0.15, 0.8); + let mut output = Vec::new(); + + let mut offset = 0; + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut output).expect("resample"); + offset += chunk_size * channels; + } + + let out_peak = metrics::peak(&output[2048..]); + let attenuation_db = metrics::to_dbfs(out_peak) - metrics::to_dbfs(0.8); + assert!( + attenuation_db < -60.0, + "Resampler deep stopband failed to achieve >60 dB rejection: attenuation was only {} dB", + attenuation_db + ); + } +} + +/// Verifies that resampling maintains the exact fundamental audio frequency without pitch shifting. +#[test] +fn test_resampler_frequency_preservation() { + let in_rate = 44_100; + let out_rate = 48_000; + let channels = 2; + let chunk_size = 512; + let mut resampler = MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); + + // Pure 1000 Hz tone + let freq = 1000.0f32; + let input = generators::sine_stereo(freq, freq, in_rate, 0.2, 0.5); + let mut output = Vec::new(); + + let mut offset = 0; + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut output).expect("resample"); + offset += chunk_size * channels; + } + + // Count zero crossings in steady state to determine frequency at 48 kHz + let steady = &output[2048..output.len() - 1000]; + let mut zero_crossings = 0; + for i in 1..steady.len() / 2 { + let prev = steady[(i - 1) * 2]; + let curr = steady[i * 2]; + if (prev <= 0.0 && curr > 0.0) || (prev >= 0.0 && curr < 0.0) { + zero_crossings += 1; + } + } + + let measured_duration = (steady.len() / 2) as f32 / out_rate as f32; + let measured_freq = (zero_crossings as f32 / 2.0) / measured_duration; + + assert!( + (measured_freq - freq).abs() < 10.0, + "Resampler shifted frequency: expected {} Hz, got {} Hz", + freq, + measured_freq + ); +} + +/// Verifies that resampling a long stream accurately matches the expected frame ratio over time without clock drift. +#[test] +fn test_resampler_long_stream_frame_count_accuracy() { + let in_rate = 48_000; + let out_rate = 44_100; + let channels = 2; + let chunk_size = 512; + let mut resampler = MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); + + // Exactly 1 second of audio at 48 kHz = 48,000 frames + let in_frames = 48_000; + let input = vec![0.1f32; in_frames * channels]; + let mut output = Vec::new(); + + let mut offset = 0; + while offset + chunk_size * channels <= input.len() { + let chunk = &input[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut output).expect("resample"); + offset += chunk_size * channels; + } + + let produced_frames = output.len() / channels; + let expected_frames = (offset as f64 / channels as f64 * (out_rate as f64 / in_rate as f64)) as usize; + + let diff = (produced_frames as isize - expected_frames as isize).abs(); + // The difference must be bounded within the sinc interpolation filter pipeline latency (128 frames) + assert!( + diff <= 128, + "Resampler frame count deviated beyond pipeline sinc window delay: produced {}, expected {}", + produced_frames, + expected_frames + ); +} diff --git a/src-tauri/tests/common/mod.rs b/src-tauri/tests/common/mod.rs new file mode 100644 index 0000000..19cb6f4 --- /dev/null +++ b/src-tauri/tests/common/mod.rs @@ -0,0 +1,186 @@ +#![allow(dead_code)] + +use std::f32::consts::PI; + +/// Mathematical test signal generators for audio engine testing. +pub mod generators { + use super::*; + + /// Generates a pure sine wave at the specified frequency, sample rate, and amplitude. + pub fn sine(freq_hz: f32, sample_rate: u32, duration_secs: f32, amplitude: f32) -> Vec { + let total_frames = (sample_rate as f32 * duration_secs) as usize; + let mut out = Vec::with_capacity(total_frames); + let phase_step = 2.0 * PI * freq_hz / sample_rate as f32; + for i in 0..total_frames { + let sample = (i as f32 * phase_step).sin() * amplitude; + out.push(sample); + } + out + } + + /// Generates an interleaved stereo sine wave with independent left and right frequencies. + pub fn sine_stereo(freq_l: f32, freq_r: f32, sample_rate: u32, duration_secs: f32, amplitude: f32) -> Vec { + let total_frames = (sample_rate as f32 * duration_secs) as usize; + let mut out = Vec::with_capacity(total_frames * 2); + let step_l = 2.0 * PI * freq_l / sample_rate as f32; + let step_r = 2.0 * PI * freq_r / sample_rate as f32; + for i in 0..total_frames { + out.push((i as f32 * step_l).sin() * amplitude); + out.push((i as f32 * step_r).sin() * amplitude); + } + out + } + + /// Generates a logarithmic sine sweep across a given frequency range (Chirp). + pub fn sweep(start_hz: f32, end_hz: f32, sample_rate: u32, duration_secs: f32, amplitude: f32) -> Vec { + let total_frames = (sample_rate as f32 * duration_secs) as usize; + let mut out = Vec::with_capacity(total_frames); + let sr = sample_rate as f32; + let t_total = duration_secs; + for i in 0..total_frames { + let t = i as f32 / sr; + let f = start_hz + (end_hz - start_hz) * (t / (2.0 * t_total)); + let sample = (2.0 * PI * f * t).sin() * amplitude; + out.push(sample); + } + out + } + + /// Generates a multi-tone signal composed of multiple harmonic frequencies. + pub fn multitone(freqs: &[f32], sample_rate: u32, duration_secs: f32, peak_amplitude: f32) -> Vec { + let total_frames = (sample_rate as f32 * duration_secs) as usize; + let mut out = vec![0.0f32; total_frames]; + let num_tones = freqs.len().max(1) as f32; + let amp_per_tone = peak_amplitude / num_tones; + + for &freq in freqs { + let phase_step = 2.0 * PI * freq / sample_rate as f32; + for (i, slot) in out.iter_mut().enumerate() { + *slot += (i as f32 * phase_step).sin() * amp_per_tone; + } + } + out + } + + /// Generates a periodic tone burst (active tone alternating with silence). + pub fn tone_burst(freq_hz: f32, sample_rate: u32, active_ms: f32, silent_ms: f32, cycles: usize, amplitude: f32) -> Vec { + let active_frames = (sample_rate as f32 * active_ms / 1000.0) as usize; + let silent_frames = (sample_rate as f32 * silent_ms / 1000.0) as usize; + let mut out = Vec::with_capacity((active_frames + silent_frames) * cycles); + let phase_step = 2.0 * PI * freq_hz / sample_rate as f32; + + for _ in 0..cycles { + for i in 0..active_frames { + out.push((i as f32 * phase_step).sin() * amplitude); + } + out.resize(out.len() + silent_frames, 0.0); + } + out + } + + /// Generates pure DC offset of specified amplitude. + pub fn dc_offset(sample_rate: u32, duration_secs: f32, offset: f32) -> Vec { + let total_frames = (sample_rate as f32 * duration_secs) as usize; + vec![offset; total_frames] + } +} + +/// Quantitative audio signal metrics (RMS, Peak, THD+N, SNR, DC offset). +pub mod metrics { + use super::*; + + /// Calculates root-mean-square (RMS) energy. + pub fn rms(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + let sum_sq: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum(); + (sum_sq / samples.len() as f64).sqrt() as f32 + } + + /// Calculates absolute peak amplitude. + pub fn peak(samples: &[f32]) -> f32 { + samples.iter().fold(0.0f32, |acc, &s| acc.max(s.abs())) + } + + /// Converts linear amplitude to decibels full scale (dBFS). + pub fn to_dbfs(amplitude: f32) -> f32 { + if amplitude <= 1e-6 { + -120.0 + } else { + 20.0 * amplitude.log10() + } + } + + /// Calculates average DC offset (mean sample value). + pub fn dc_offset(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + let sum: f64 = samples.iter().map(|&s| s as f64).sum(); + (sum / samples.len() as f64) as f32 + } + + /// Verifies bit-exactness between two audio buffers. + /// Returns (is_exact, max_absolute_difference). + pub fn verify_bit_exactness(original: &[f32], processed: &[f32]) -> (bool, f32) { + if original.len() != processed.len() { + return (false, f32::INFINITY); + } + let mut max_diff = 0.0f32; + for (&a, &b) in original.iter().zip(processed.iter()) { + let diff = (a - b).abs(); + if diff > max_diff { + max_diff = diff; + } + } + (max_diff == 0.0, max_diff) + } + + /// Calculates Total Harmonic Distortion + Noise (THD+N) relative to fundamental. + /// Approximated via notch removal of fundamental frequency using discrete Fourier correlation. + pub fn thd_n(samples: &[f32], fundamental_hz: f32, sample_rate: u32) -> f32 { + let n = samples.len(); + if n < 256 { + return 0.0; + } + let omega = 2.0 * PI * fundamental_hz / sample_rate as f32; + + let mut cos_sum = 0.0f64; + let mut sin_sum = 0.0f64; + for (i, &s) in samples.iter().enumerate() { + let phi = (i as f32) * omega; + cos_sum += (s as f64) * (phi.cos() as f64); + sin_sum += (s as f64) * (phi.sin() as f64); + } + let a = (2.0 / n as f64) * cos_sum; + let b = (2.0 / n as f64) * sin_sum; + let fundamental_rms = ((a * a + b * b) / 2.0).sqrt(); + + if fundamental_rms < 1e-6 { + return 0.0; + } + + let mut residual_sq = 0.0f64; + for (i, &s) in samples.iter().enumerate() { + let phi = (i as f32) * omega; + let fundamental_val = a * (phi.cos() as f64) + b * (phi.sin() as f64); + let residual = (s as f64) - fundamental_val; + residual_sq += residual * residual; + } + let residual_rms = (residual_sq / n as f64).sqrt(); + + (residual_rms / fundamental_rms) as f32 + } + + /// Calculates Signal-to-Noise Ratio (SNR) in dB between a reference signal and noise. + pub fn snr_db(signal_rms: f32, noise_rms: f32) -> f32 { + if noise_rms <= 1e-9 { + 120.0 + } else if signal_rms <= 1e-9 { + -120.0 + } else { + 20.0 * (signal_rms / noise_rms).log10() + } + } +} diff --git a/src-tauri/tests/pipeline_scenarios.rs b/src-tauri/tests/pipeline_scenarios.rs new file mode 100644 index 0000000..12b5764 --- /dev/null +++ b/src-tauri/tests/pipeline_scenarios.rs @@ -0,0 +1,743 @@ +mod common; + +use common::generators; +use common::metrics; +use rtrb::RingBuffer; +use serde_json::json; +use splitwave_lib::audio::effects::compressor::CompressorEffect; +use splitwave_lib::audio::effects::de_esser::DeEsserEffect; +use splitwave_lib::audio::effects::declick::DeclickEffect; +use splitwave_lib::audio::effects::delay::DelayEffect; +use splitwave_lib::audio::effects::eq::EqEffect; +use splitwave_lib::audio::effects::gain::GainEffect; +use splitwave_lib::audio::effects::level_meter::LevelMeterEffect; +use splitwave_lib::audio::effects::limiter::LimiterEffect; +use splitwave_lib::audio::effects::lufs_meter::LufsMeterEffect; +use splitwave_lib::audio::effects::noise_gate::NoiseGateEffect; +use splitwave_lib::audio::effects::reverb::ReverbEffect; +use splitwave_lib::audio::effects::saturator::SaturatorEffect; +use splitwave_lib::audio::effects::Effect; +use splitwave_lib::audio::graph::{ + CompressorData, DeEsserData, DeclickData, DelayData, EqData, GainData, + LevelMeterData, LimiterData, LufsMeterData, NoiseGateData, ReverbData, SaturatorData, +}; +use splitwave_lib::audio::resample::MultiResampler; + +/// Simulates a complete broadcast vocal processing strip: +/// Mic Input -> Noise Gate -> Declicker -> De-esser -> 10-band EQ -> Compressor -> Limiter -> Meter. +/// +/// Plain English explanation: +/// In professional broadcasting, voice audio suffers from room noise in pauses, mouth clicks, +/// harsh 's' sibilance, and wide volume variations. This test runs speech with clicks and sibilance +/// through the entire 7-stage chain, verifying that background pauses are silenced, clicks are removed, +/// sibilance is tamed, and the final output never clips beyond the -1 dBFS broadcast ceiling. +#[test] +fn test_broadcast_vocal_chain() { + let sample_rate = 48_000; + + // 1. Noise Gate (cuts room tone during speaking pauses) + let (mut gate, _gate_ctrl, _gate_meter) = NoiseGateEffect::new( + NoiseGateData { + threshold_db: -30.0, + range_db: -40.0, + attack_ms: 2.0, + hold_ms: 20.0, + release_ms: 30.0, + bypassed: false, + }, + sample_rate, + ); + + // 2. Declicker (eliminates mouth clicks and impulse pops) + let (mut declick, _declick_ctrl) = DeclickEffect::new( + DeclickData { + sensitivity: 0.9, + max_width_ms: 2.0, + bypassed: false, + }, + sample_rate, + ); + + // 3. De-esser (compresses harsh 7 kHz sibilance) + let (mut deesser, _deesser_ctrl) = DeEsserEffect::new( + DeEsserData { + frequency: 7000.0, + threshold_db: -18.0, + ratio: 4.0, + bypassed: false, + }, + sample_rate, + ); + + // 4. EQ (adds gentle speech presence boost at 2 kHz) + let mut eq_gains = [0.0f32; 10]; + eq_gains[6] = 3.0; // 2 kHz presence boost + let (mut eq, _eq_ctrl) = EqEffect::new(EqData { gains_db: eq_gains, bypassed: false }, sample_rate); + + // 5. Compressor (evens dynamic speech volume) + let (mut comp, _comp_ctrl, _comp_meter) = CompressorEffect::new( + CompressorData { + threshold_db: -14.0, + ratio: 4.0, + attack_ms: 5.0, + release_ms: 50.0, + knee_db: 3.0, + makeup_db: 2.0, + bypassed: false, + }, + sample_rate, + ); + + // 6. Brickwall Limiter (prevents digital clipping above -1 dBFS) + let (mut limiter, _lim_ctrl, _lim_meter) = LimiterEffect::new( + LimiterData { + ceiling_db: -1.0, + release_ms: 20.0, + lookahead_ms: 5.0, + bypassed: false, + }, + sample_rate, + ); + + // 7. Output Level Meter + let (mut meter, meter_handle) = LevelMeterEffect::new(LevelMeterData {}, "vocal_out".into()); + + // Generate test audio: exact multiples of block_size (512 frames) + let section_frames = 512 * 20; // 10,240 frames (~0.213s) + let section_sec = section_frames as f32 / sample_rate as f32; + + // Section 1: Active loud speech (0.9 amplitude) with an injected click spike and 7kHz sibilance + let mut speech = generators::sine_stereo(500.0, 500.0, sample_rate, section_sec, 0.9); + // Inject click spike + speech[2000] = 1.0; + speech[2001] = 1.0; + // Inject high-frequency sibilance burst + let sibilance = generators::sine_stereo(7000.0, 7000.0, sample_rate, 0.1, 0.8); + for (i, &s) in sibilance.iter().enumerate() { + if i + 4000 < speech.len() { + speech[i + 4000] += s; + } + } + + // Section 2: Background pause (quiet room tone at -45 dBFS = 0.0056) + let pause = generators::sine_stereo(100.0, 100.0, sample_rate, section_sec, 0.0056); + + let mut session_audio = [speech, pause].concat(); + let total_frames = session_audio.len() / 2; + + // Process through the entire chain in 512-frame blocks + let block_size = 512; + let mut offset = 0; + while offset + block_size <= total_frames { + let block = &mut session_audio[offset * 2..(offset + block_size) * 2]; + gate.process(block, block_size); + declick.process(block, block_size); + deesser.process(block, block_size); + eq.process(block, block_size); + comp.process(block, block_size); + limiter.process(block, block_size); + meter.process(block, block_size); + offset += block_size; + } + + // Verification 1: Speech section peaks are strictly governed below limiter ceiling (0.892 = -1 dBFS) + let speech_peak = metrics::peak(&session_audio[..section_frames * 2]); + assert!( + speech_peak <= 0.892, + "Limiter ceiling breached: peak was {}", + speech_peak + ); + + // Verification 2: Background pause is gated to silence (< -50 dBFS) + let pause_tail = &session_audio[session_audio.len() - 2048..]; + let pause_peak = metrics::peak(pause_tail); + let pause_dbfs = metrics::to_dbfs(pause_peak); + assert!( + pause_dbfs < -50.0, + "Noise gate failed to silence room noise in pause: level = {} dBFS", + pause_dbfs + ); + + // Verification 3: Output meter recorded active levels + let snap = meter_handle.snapshot_and_decay(); + assert!( + !snap.peaks.is_empty() && snap.peaks[0] > 0.0, + "Level meter failed to record audio peaks" + ); +} + +/// Simulates auto-ducking in a podcast or live stream: +/// Background music is automatically lowered whenever the podcaster speaks. +/// +/// Plain English explanation: +/// In podcasts, background music should play at normal volume when no one is talking, +/// but smoothly drop down (-12 dB) whenever the host speaks so their voice is clearly heard. +/// This test verifies that sidechain compression ducks music during speech and restores it during silence. +#[test] +fn test_sidechain_ducking_podcast_scenario() { + let sample_rate = 48_000; + let (mut ducking_comp, _ctrl, _gr) = CompressorEffect::new( + CompressorData { + threshold_db: -18.0, + ratio: 6.0, + attack_ms: 10.0, + release_ms: 100.0, + knee_db: 2.0, + makeup_db: 0.0, + bypassed: false, + }, + sample_rate, + ); + + // Continuous background music bed (440 Hz tone at -6 dBFS = 0.5 amplitude) + let duration = 0.6; // 600ms + let mut music_channel = generators::sine_stereo(440.0, 440.0, sample_rate, duration, 0.5); + + // Host vocal sidechain key: + // First 250ms: Host speaks (0.9 amplitude = -0.9 dBFS, well above compressor threshold) + // Next 350ms: Host stops talking (0.0 silence) + let speech_key = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.25, 0.9); + let silence_key = vec![0.0f32; (sample_rate as f32 * 0.35) as usize * 2]; + let host_vocal_track = [speech_key, silence_key].concat(); + + let frames = music_channel.len() / 2; + ducking_comp.process_with_sidechain(&mut music_channel, Some(&host_vocal_track), frames); + + // Check music level while host is speaking (around 150ms to 200ms) + let ducked_start = (sample_rate as f32 * 0.15) as usize * 2; + let ducked_end = (sample_rate as f32 * 0.20) as usize * 2; + let ducked_rms = metrics::rms(&music_channel[ducked_start..ducked_end]); + + // Check music level after host stops speaking (around 500ms to 550ms, after release) + let restored_start = (sample_rate as f32 * 0.50) as usize * 2; + let restored_end = (sample_rate as f32 * 0.55) as usize * 2; + let restored_rms = metrics::rms(&music_channel[restored_start..restored_end]); + + assert!( + ducked_rms < restored_rms * 0.6, + "Music was not ducked adequately: ducked RMS = {}, restored RMS = {}", + ducked_rms, + restored_rms + ); + assert!( + (restored_rms - 0.35).abs() < 0.05, + "Music failed to restore to original volume: expected ~0.35, got {}", + restored_rms + ); +} + +/// Simulates a streamer mixing dual sample rates: +/// 48 kHz Game Audio + 44.1 kHz Voice Chat -> Master Summing Bus -> Master Limiter. +/// +/// Plain English explanation: +/// Different audio sources run at different hardware clock speeds (e.g. 44.1 kHz Discord and 48 kHz Game). +/// This test verifies that the MultiResampler seamlessly synchronizes the 44.1 kHz feed to 48 kHz, +/// and that both signals sum cleanly without buffer overflows or distortion. +#[test] +fn test_streamer_dual_sample_rate_mix() { + let out_rate = 48_000; + let in_rate = 44_100; + let duration = 0.2; // 200ms + let channels = 2; + let chunk_size = 512; + + // Game audio at 48 kHz (1000 Hz tone) + let game_audio = generators::sine_stereo(1000.0, 1000.0, out_rate, duration, 0.4); + + // Discord voice at 44.1 kHz (500 Hz tone) + let discord_audio = generators::sine_stereo(500.0, 500.0, in_rate, duration, 0.4); + + // Resample Discord voice from 44.1 kHz to 48 kHz + let mut resampler = MultiResampler::new(in_rate, out_rate, channels, chunk_size).expect("resampler"); + let mut discord_resampled = Vec::new(); + let mut chunk_out = vec![0.0f32; chunk_size * 2 * channels]; + + let mut offset = 0; + while offset + chunk_size * channels <= discord_audio.len() { + let chunk = &discord_audio[offset..offset + chunk_size * channels]; + resampler.process_chunk(chunk, &mut chunk_out).expect("resample"); + discord_resampled.extend_from_slice(&chunk_out); + offset += chunk_size * channels; + } + + // Sum Game and Resampled Discord into Master Bus + let min_frames = (game_audio.len() / 2).min(discord_resampled.len() / 2); + let mut master_bus = Vec::with_capacity(min_frames * 2); + for i in 0..min_frames * 2 { + master_bus.push(game_audio[i] + discord_resampled[i]); + } + + // Apply Master Limiter to prevent clipping when summing multiple active tracks + let (mut limiter, _ctrl, _meter) = LimiterEffect::new( + LimiterData { + ceiling_db: -0.5, + release_ms: 10.0, + lookahead_ms: 5.0, + bypassed: false, + }, + out_rate, + ); + + limiter.process(&mut master_bus, min_frames); + + let peak = metrics::peak(&master_bus); + assert!( + peak <= 0.945, // -0.5 dBFS linear is ~0.944 + "Master bus clipped after summing dual sources: peak was {}", + peak + ); + assert!( + min_frames > 8000, + "Expected at least 8000 frames of mixed audio, got {}", + min_frames + ); +} + +/// Simulates DJ console crossfading between Deck A and Deck B: +/// +/// Plain English explanation: +/// A DJ mixer crossfader blends between two running tracks. At 0.0 only Deck A is heard, +/// at 0.5 both tracks blend equally without perceived volume dip, and at 1.0 only Deck B is heard. +/// This test verifies smooth, click-free crossfade transitions across deck outputs. +#[test] +fn test_dj_crossfader_scenario() { + let sample_rate = 48_000; + + // Deck A plays 440 Hz, Deck B plays 880 Hz + let deck_a = generators::sine_stereo(440.0, 440.0, sample_rate, 0.05, 0.7); + let deck_b = generators::sine_stereo(880.0, 880.0, sample_rate, 0.05, 0.7); + + // Crossfader helper calculating constant-power sine/cosine blend + let crossfade = |a: &[f32], b: &[f32], pos: f32| -> Vec { + let gain_a = (pos * std::f32::consts::FRAC_PI_2).cos(); + let gain_b = (pos * std::f32::consts::FRAC_PI_2).sin(); + let mut out = vec![0.0f32; a.len()]; + for i in 0..a.len() { + out[i] = a[i] * gain_a + b[i] * gain_b; + } + out + }; + + // Position 0.0 (Deck A full, Deck B silent) + let mixed_deck_a_only = crossfade(&deck_a, &deck_b, 0.0); + let (_, diff_a) = metrics::verify_bit_exactness(&deck_a, &mixed_deck_a_only); + assert!(diff_a < 1e-4, "Deck A only crossfade should match Deck A"); + + // Position 1.0 (Deck B full, Deck A silent) + let mixed_deck_b_only = crossfade(&deck_a, &deck_b, 1.0); + let (_, diff_b) = metrics::verify_bit_exactness(&deck_b, &mixed_deck_b_only); + assert!(diff_b < 1e-4, "Deck B only crossfade should match Deck B"); + + // Position 0.5 (Center blend: equal energy) + let mixed_center = crossfade(&deck_a, &deck_b, 0.5); + let rms_center = metrics::rms(&mixed_center); + let rms_single = metrics::rms(&deck_a); + + // Constant power crossfade maintains total energy (~1.0x single deck RMS) + let ratio = rms_center / rms_single; + assert!( + (ratio - 1.0).abs() < 0.05, + "Constant-power crossfade power mismatch at center: ratio was {}", + ratio + ); +} + +/// Simulates electronic dance music (EDM) rhythmic sidechain pumping: +/// A four-on-the-floor kick drum rhythmically ducks a sustained synth bass pad. +/// +/// Plain English explanation: +/// In dance music, every time the kick drum hits, the loud synth bass instantly dips in volume +/// and swells back up, creating a characteristic energetic 'pumping' rhythm. +/// This test verifies cyclic gain reduction and recovery on periodic beat hits. +#[test] +fn test_dance_music_sidechain_pumping() { + let sample_rate = 48_000; + let (mut comp, _ctrl, _meter) = CompressorEffect::new( + CompressorData { + threshold_db: -20.0, + ratio: 8.0, + attack_ms: 2.0, + release_ms: 60.0, + knee_db: 0.0, + makeup_db: 0.0, + bypassed: false, + }, + sample_rate, + ); + + // Sustained synth bass (continuous 200 Hz tone at 0.7 amplitude) + let total_duration = 0.4; // 400ms (covers two kick hits spaced 200ms apart) + let mut synth_bass = generators::sine_stereo(200.0, 200.0, sample_rate, total_duration, 0.7); + + // Kick drum track: two 30ms kick bursts (at t=0ms and t=200ms) with silence in between + let kick_burst = generators::sine_stereo(60.0, 60.0, sample_rate, 0.03, 1.0); + let kick_pause = vec![0.0f32; (sample_rate as f32 * 0.17) as usize * 2]; + let kick_track = [kick_burst.clone(), kick_pause.clone(), kick_burst, kick_pause].concat(); + + let frames = synth_bass.len() / 2; + comp.process_with_sidechain(&mut synth_bass, Some(&kick_track), frames); + + // Sample 1: During first kick hit (t = 15ms) + let kick1_sample = (sample_rate as f32 * 0.015) as usize * 2; + let ducked_peak1 = metrics::peak(&synth_bass[kick1_sample..kick1_sample + 200]); + + // Sample 2: Between kicks during recovery (t = 150ms) + let recovery_sample = (sample_rate as f32 * 0.150) as usize * 2; + let recovered_peak = metrics::peak(&synth_bass[recovery_sample..recovery_sample + 200]); + + // Sample 3: During second kick hit (t = 215ms) + let kick2_sample = (sample_rate as f32 * 0.215) as usize * 2; + let ducked_peak2 = metrics::peak(&synth_bass[kick2_sample..kick2_sample + 200]); + + assert!( + ducked_peak1 < recovered_peak * 0.7, + "First kick failed to duck synth bass: ducked {} vs recovered {}", + ducked_peak1, + recovered_peak + ); + assert!( + ducked_peak2 < recovered_peak * 0.7, + "Second kick failed to duck synth bass: ducked {} vs recovered {}", + ducked_peak2, + recovered_peak + ); + assert!( + recovered_peak > 0.45, + "Synth bass failed to recover volume between kick beats: got {}", + recovered_peak + ); +} + +/// Simulates bursty operating system audio packet jitter and ring buffer recovery: +/// +/// Plain English explanation: +/// Due to operating system scheduling jitter (e.g. Wi-Fi audio, Bluetooth latency, or high CPU load), +/// audio packets often arrive in irregular bursts (e.g. 100 samples, then 0, then 600 samples). +/// This test verifies that the rtrb lock-free ring buffer absorbs bursty inputs and provides +/// a steady stream of fixed-size blocks to the DSP engine without underruns or sample loss. +#[test] +fn test_bursty_audio_jitter_ringbuffer_recovery() { + let (mut producer, mut consumer) = RingBuffer::::new(4096); + + let test_stream: Vec = (0..2048).map(|i| (i as f32) * 0.001).collect(); + + // Irregular burst arrival patterns simulating thread scheduling jitter + let burst_sizes = [128, 64, 512, 0, 256, 100, 300, 400, 288]; + let mut written = 0; + let mut read_output = Vec::with_capacity(2048); + + for &burst in &burst_sizes { + // Producer writes burst + if burst > 0 && written + burst <= test_stream.len() { + let chunk = &test_stream[written..written + burst]; + for &sample in chunk { + producer.push(sample).expect("RingBuffer push"); + } + written += burst; + } + + // Consumer reads in steady 128-sample DSP blocks whenever available + while consumer.slots() >= 128 { + for _ in 0..128 { + if let Ok(val) = consumer.pop() { + read_output.push(val); + } + } + } + } + + // Drain remainder + while let Ok(val) = consumer.pop() { + read_output.push(val); + } + + assert_eq!( + read_output.len(), + test_stream.len(), + "Ring buffer lost samples during bursty arrival: expected {}, got {}", + test_stream.len(), + read_output.len() + ); + + let (is_exact, max_diff) = metrics::verify_bit_exactness(&test_stream, &read_output); + assert!( + is_exact, + "Ring buffer corrupted sample order or values during jitter: max diff was {}", + max_diff + ); +} + +/// Simulates a delay effect with high feedback running into a limiter: +/// +/// Plain English explanation: +/// When delay feedback is pushed high, sound repeats indefinitely and can accumulate +/// runaway volume that would clip digital audio and harm speakers. +/// This test verifies that putting a Limiter immediately downstream clamps feedback overload +/// safely below -1 dBFS, guaranteeing system stability. +#[test] +fn test_limiter_protects_feedback_delay() { + let sample_rate = 48_000; + + // Aggressive delay with 80% feedback + let (mut delay, _delay_ctrl) = DelayEffect::new( + DelayData { + time_ms: 30.0, + feedback: 0.80, + mix: 0.70, + bypassed: false, + }, + sample_rate, + ); + + // Downstream brickwall limiter + let (mut limiter, _lim_ctrl, _meter) = LimiterEffect::new( + LimiterData { + ceiling_db: -1.0, + release_ms: 20.0, + lookahead_ms: 5.0, + bypassed: false, + }, + sample_rate, + ); + + // Loud impulse burst followed by silence + let burst = generators::sine_stereo(440.0, 440.0, sample_rate, 0.05, 1.5); + let silence = vec![0.0f32; (sample_rate as f32 * 0.25) as usize * 2]; + let mut signal = [burst, silence].concat(); + let frames = signal.len() / 2; + + delay.process(&mut signal, frames); + limiter.process(&mut signal, frames); + + let max_peak = metrics::peak(&signal); + assert!( + max_peak <= 0.892, // -1.0 dBFS ceiling + "Limiter failed to prevent feedback overload from exceeding ceiling: peak was {}", + max_peak + ); +} + +/// Verifies peak level metering ballistics and decay physics: +/// +/// Plain English explanation: +/// Professional audio level meters must instantly register sudden audio peaks (transient attack), +/// but decay smoothly rather than dropping immediately to zero so human eyes can track levels. +/// This test verifies instantaneous peak capture followed by predictable logarithmic decay. +#[test] +fn test_peak_metering_ballistics_and_decay() { + let (mut meter, handle) = LevelMeterEffect::new(LevelMeterData {}, "meter_test".into()); + + let frames = 256; + // Block 1: Extreme peak transient (1.0 amplitude) + let mut loud_block = vec![1.0f32; frames * 2]; + meter.process(&mut loud_block, frames); + + // Snapshot 1 should read instantaneous peak 1.0 and trigger decay for next tick + let snap1 = handle.snapshot_and_decay(); + assert_eq!( + snap1.peaks[0], 1.0, + "Meter failed to register instantaneous peak" + ); + + // Block 2: Total silence + let mut silent_block = vec![0.0f32; frames * 2]; + meter.process(&mut silent_block, frames); + + // Snapshot 2 should read decaying peak (~0.85 of previous peak) + let snap2 = handle.snapshot_and_decay(); + assert!( + snap2.peaks[0] < 0.90 && snap2.peaks[0] > 0.80, + "Meter ballistics decay failed: expected ~0.85, got {}", + snap2.peaks[0] + ); +} + +/// Verifies LUFS loudness metering against ITU-R BS.1770 / EBU R128 broadcast standards: +/// +/// Plain English explanation: +/// Broadcasting and streaming platforms (YouTube, Spotify, Apple Podcasts) require audio to adhere +/// to strict loudness targets (-14 to -23 LUFS). This test feeds a calibrated standard test tone +/// through the LUFS meter to verify accurate calculation of integrated and momentary loudness. +#[test] +fn test_lufs_metering_loudness_compliance() { + let sample_rate = 48_000; + let (mut lufs, handle) = LufsMeterEffect::new(LufsMeterData {}, "lufs_test".into(), sample_rate); + + // Calibrated 1 kHz tone at -20 dBFS (amplitude = 0.1) for 400ms + let mut tone = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.4, 0.1); + let frames = tone.len() / 2; + + lufs.process(&mut tone, frames); + + let snap = handle.snapshot(); + + // Standard 1 kHz tone at -20 dBFS in stereo registers around -20 to -23 LUFS + assert!( + snap.momentary > -26.0 && snap.momentary < -18.0, + "LUFS momentary loudness out of expected calibration range: got {} LUFS", + snap.momentary + ); + assert_eq!( + snap.clips, 0, + "Unclipped calibration signal registered accidental clips" + ); +} + +/// Simulates extended session stability across 100 continuous DSP audio blocks: +/// +/// Plain English explanation: +/// Audio pipelines must run reliably for hours without buffer drift, memory leaks, +/// denormal float slowdowns, or NaN values. This test streams over 1 second of audio +/// through a 6-stage effect chain, ensuring zero NaNs, zero infinities, and bounded energy. +#[test] +fn test_long_running_pipeline_dsp_stability() { + let sample_rate = 48_000; + let block_size = 512; + let num_blocks = 100; + + let (mut eq, _eq_c) = EqEffect::new(EqData { gains_db: [1.0; 10], bypassed: false }, sample_rate); + let (mut saturator, _sat_c) = SaturatorEffect::new(SaturatorData { drive_db: 3.0, threshold_db: -3.0, bypassed: false }); + let (mut comp, _comp_c, _comp_m) = CompressorEffect::new( + CompressorData { + threshold_db: -12.0, + ratio: 4.0, + attack_ms: 10.0, + release_ms: 50.0, + knee_db: 0.0, + makeup_db: 1.0, + bypassed: false, + }, + sample_rate, + ); + let (mut deesser, _de_c) = DeEsserEffect::new(DeEsserData { frequency: 6000.0, threshold_db: -20.0, ratio: 3.0, bypassed: false }, sample_rate); + let (mut reverb, _rev_c) = ReverbEffect::new(ReverbData { room_size: 0.5, damping: 0.5, width: 1.0, mix: 0.3, bypassed: false }, sample_rate); + let (mut limiter, _lim_c, _lim_m) = LimiterEffect::new(LimiterData { ceiling_db: -1.0, release_ms: 20.0, lookahead_ms: 5.0, bypassed: false }, sample_rate); + + let mut running_block = generators::sine_stereo(440.0, 880.0, sample_rate, 512.0 / 48000.0, 0.5); + + for block_idx in 0..num_blocks { + eq.process(&mut running_block, block_size); + saturator.process(&mut running_block, block_size); + comp.process(&mut running_block, block_size); + deesser.process(&mut running_block, block_size); + reverb.process(&mut running_block, block_size); + limiter.process(&mut running_block, block_size); + + // Assert numerical stability on every frame of every block + for &s in &running_block { + assert!( + s.is_finite(), + "Non-finite sample detected at block {}: value = {}", + block_idx, + s + ); + } + } + + let final_peak = metrics::peak(&running_block); + assert!( + final_peak <= 0.892, + "Long running stability failed: final peak breached ceiling ({})", + final_peak + ); +} + +/// Simulates real-time UI parameter modulation without audio stream interruption: +/// +/// Plain English explanation: +/// When a user drags a volume slider in the UI, the engine must update the live audio +/// smoothly in real time without audio glitches, stuttering, or needing to reload the engine. +/// This test verifies instant dynamic parameter response via EffectControl. +#[test] +fn test_dynamic_gain_slider_parameter_modulation() { + let (mut gain, ctrl) = GainEffect::new(GainData { gain_db: 0.0, bypassed: false }); + let sample_rate = 48_000; + let frames = 256; + + let mut audio_block = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.3); + + // Step 1: Process at 0 dB (unity) + gain.process(&mut audio_block, frames); + let rms_unity = metrics::rms(&audio_block); + + // Step 2: User drags slider to +6.02 dB (amplitude doubles) + ctrl.apply_update(&json!({ "gainDb": 6.02 })); + // Block 1 ramps gain smoothly from 1.0x to 2.0x (anti-click smoothing) + gain.process(&mut audio_block, frames); + // Block 2 achieves steady-state 2.0x gain + let mut steady_boosted = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.3); + gain.process(&mut steady_boosted, frames); + let rms_boosted = metrics::rms(&steady_boosted); + + // Step 3: User drags slider to -6.02 dB (amplitude halves relative to unity) + ctrl.apply_update(&json!({ "gainDb": -6.02 })); + // Block 1 ramps down smoothly + gain.process(&mut audio_block, frames); + // Block 2 achieves steady-state 0.5x unity gain (0.25x of boosted) + let mut steady_cut = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.3); + gain.process(&mut steady_cut, frames); + let rms_attenuated = metrics::rms(&steady_cut); + + let boost_ratio = rms_boosted / rms_unity; + let cut_ratio = rms_attenuated / rms_boosted; + + assert!( + (boost_ratio - 2.0).abs() < 0.05, + "Dynamic +6 dB boost failed: expected 2.0x, got {}", + boost_ratio + ); + assert!( + (cut_ratio - 0.25).abs() < 0.05, + "Dynamic -6 dB cut failed: expected 0.25x of boosted, got {}", + cut_ratio + ); +} + +/// Simulates surround-to-stereo downmixing and mono-fold compatibility: +/// +/// Plain English explanation: +/// When multi-channel audio (Left, Right, Center) is mixed down to stereo, +/// dialogue in the Center channel must be placed equally in both stereo channels (-3 dB each). +/// When summed to mono, the dialogue must maintain clarity without destructive phase cancellation. +#[test] +fn test_surround_to_stereo_downmix_compatibility() { + let sample_rate = 48_000; + let duration = 0.1; // 100ms + let frames = (sample_rate as f32 * duration) as usize; + + // Surround elements + let left_ch = generators::sine(300.0, sample_rate, duration, 0.5); + let right_ch = generators::sine(600.0, sample_rate, duration, 0.5); + let center_voice = generators::sine(1000.0, sample_rate, duration, 0.6); + + // ITU-R standard stereo downmix: + // Left_Out = Left + 0.7071 * Center + // Right_Out = Right + 0.7071 * Center + let center_gain = std::f32::consts::FRAC_1_SQRT_2; // -3.0 dB (~0.7071) + let mut stereo_out = Vec::with_capacity(frames * 2); + + for i in 0..frames { + let l = left_ch[i] + center_gain * center_voice[i]; + let r = right_ch[i] + center_gain * center_voice[i]; + stereo_out.push(l); + stereo_out.push(r); + } + + // Measure Center channel contribution in Left vs Right + // Both stereo channels should have equal energy contribution from the center dialogue + let l_samples: Vec = stereo_out.chunks_exact(2).map(|f| f[0]).collect(); + let r_samples: Vec = stereo_out.chunks_exact(2).map(|f| f[1]).collect(); + + // Sum stereo downmix to mono: Mono = (Left + Right) / 2 + let mut mono_sum = Vec::with_capacity(frames); + for i in 0..frames { + mono_sum.push((l_samples[i] + r_samples[i]) * 0.5); + } + + let mono_rms = metrics::rms(&mono_sum); + assert!( + mono_rms > 0.25, + "Mono downmix suffered destructive phase cancellation: RMS was {}", + mono_rms + ); +} From 72c85b04989f97c6c3c9754accbba1a7def679b4 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:56:15 +0300 Subject: [PATCH 2/7] refactor(audio): deduplicate cross-platform audio device, system audio, and cpal streams --- src-tauri/src/audio/device/macos.rs | 29 ++------- src-tauri/src/audio/device/mod.rs | 16 +++++ src-tauri/src/audio/device/windows.rs | 19 +----- .../src/audio/pipeline/input/cpal_input.rs | 65 +++++++++++++++++++ src-tauri/src/audio/pipeline/input/macos.rs | 59 ++++------------- src-tauri/src/audio/pipeline/input/mod.rs | 2 + src-tauri/src/audio/pipeline/input/windows.rs | 60 ++++------------- .../src/audio/pipeline/output/cpal_speaker.rs | 61 +++++++++++++++++ src-tauri/src/audio/pipeline/output/macos.rs | 55 ++-------------- src-tauri/src/audio/pipeline/output/mod.rs | 2 + .../src/audio/pipeline/output/windows.rs | 54 ++------------- src-tauri/src/audio/system_audio/linux.rs | 9 ++- src-tauri/src/audio/system_audio/macos.rs | 46 +++---------- src-tauri/src/audio/system_audio/mod.rs | 56 +++++++++++++++- src-tauri/src/audio/system_audio/windows.rs | 40 +++--------- 15 files changed, 264 insertions(+), 309 deletions(-) create mode 100644 src-tauri/src/audio/pipeline/input/cpal_input.rs create mode 100644 src-tauri/src/audio/pipeline/output/cpal_speaker.rs diff --git a/src-tauri/src/audio/device/macos.rs b/src-tauri/src/audio/device/macos.rs index 52aa702..159bf8d 100644 --- a/src-tauri/src/audio/device/macos.rs +++ b/src-tauri/src/audio/device/macos.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use cpal::traits::{DeviceTrait, HostTrait}; use crate::audio::macos_hal; @@ -25,38 +23,19 @@ pub fn device_info(kind: DeviceKind, name: &str) -> AppResult } pub fn list_inputs() -> AppResult> { - Ok(unique_named( - macos_hal::list_input_devices() - .into_iter() - .map(|d| d.name) - .collect(), + Ok(super::unique_named( + macos_hal::list_input_devices().into_iter().map(|d| d.name), DeviceKind::Input, )) } pub fn list_outputs() -> AppResult> { - Ok(unique_named( - macos_hal::list_output_devices() - .into_iter() - .map(|d| d.name) - .collect(), + Ok(super::unique_named( + macos_hal::list_output_devices().into_iter().map(|d| d.name), DeviceKind::Output, )) } -fn unique_named(names: Vec, kind: DeviceKind) -> Vec { - let mut seen = HashSet::new(); - names - .into_iter() - .filter(|n| seen.insert(n.clone())) - .map(|name| DeviceInfo { - id: name.clone(), - name, - kind, - }) - .collect() -} - // `host.devices()` returns is_default=false -> cpal uses HalOutput bound to a // specific AudioDeviceID. `default_*_device` returns is_default=true -> cpal // uses DefaultOutput which silently follows the system default when it changes. diff --git a/src-tauri/src/audio/device/mod.rs b/src-tauri/src/audio/device/mod.rs index 06b6fe0..4d5e12f 100644 --- a/src-tauri/src/audio/device/mod.rs +++ b/src-tauri/src/audio/device/mod.rs @@ -22,6 +22,22 @@ pub struct NativeDeviceInfo { pub sample_format: &'static str, } +pub(crate) fn unique_named(names: I, kind: DeviceKind) -> Vec +where + I: IntoIterator, +{ + let mut seen = std::collections::HashSet::new(); + names + .into_iter() + .filter(|n| seen.insert(n.clone())) + .map(|name| DeviceInfo { + id: name.clone(), + name, + kind, + }) + .collect() +} + #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] diff --git a/src-tauri/src/audio/device/windows.rs b/src-tauri/src/audio/device/windows.rs index 3e6645d..d1fac2b 100644 --- a/src-tauri/src/audio/device/windows.rs +++ b/src-tauri/src/audio/device/windows.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use cpal::traits::{DeviceTrait, HostTrait}; use crate::error::{AppError, AppResult}; @@ -25,7 +23,7 @@ pub fn list_inputs() -> AppResult> { let devices = host .input_devices() .map_err(|e| AppError::Host(e.to_string()))?; - Ok(unique_named(devices, DeviceKind::Input)) + Ok(super::unique_named(devices.filter_map(|d| d.name().ok()), DeviceKind::Input)) } pub fn list_outputs() -> AppResult> { @@ -33,7 +31,7 @@ pub fn list_outputs() -> AppResult> { let devices = host .output_devices() .map_err(|e| AppError::Host(e.to_string()))?; - Ok(unique_named(devices, DeviceKind::Output)) + Ok(super::unique_named(devices.filter_map(|d| d.name().ok()), DeviceKind::Output)) } pub fn find(kind: DeviceKind, id: &str) -> AppResult { @@ -47,16 +45,3 @@ pub fn find(kind: DeviceKind, id: &str) -> AppResult { .find(|d| d.name().map(|n| n == id).unwrap_or(false)) .ok_or_else(|| AppError::Device(format!("device not found: {id}"))) } - -fn unique_named(devices: impl Iterator, kind: DeviceKind) -> Vec { - let mut seen = HashSet::new(); - devices - .filter_map(|d| d.name().ok()) - .filter(|n| seen.insert(n.clone())) - .map(|name| DeviceInfo { - id: name.clone(), - name, - kind, - }) - .collect() -} diff --git a/src-tauri/src/audio/pipeline/input/cpal_input.rs b/src-tauri/src/audio/pipeline/input/cpal_input.rs new file mode 100644 index 0000000..3960702 --- /dev/null +++ b/src-tauri/src/audio/pipeline/input/cpal_input.rs @@ -0,0 +1,65 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use tracing::error; + +use crate::audio::device::{self, DeviceKind}; +use crate::audio::effects::MeterHandle; +use crate::audio::health; +use crate::audio::input_bridge::BroadcastRx; +use crate::audio::pipeline::native::native_config; +use crate::audio::streams; +use crate::error::AppResult; + +use super::{InputHandle, ResolvedInput}; + +pub(super) fn resolve_cpal_input(device_id: &str) -> AppResult { + let device = device::find(DeviceKind::Input, device_id)?; + let native = native_config(DeviceKind::Input, &device, device_id)?; + Ok(ResolvedInput::Cpal { + device, + config: native.config, + sample_format: native.sample_format, + src_channels: native.channels as usize, + sample_rate: native.sample_rate, + }) +} + +pub(super) fn start_cpal_input_stream( + node_id: &str, + device: cpal::Device, + config: cpal::StreamConfig, + sample_format: cpal::SampleFormat, + src_channels: usize, + bridge: BroadcastRx, + meter: Option, + app: &AppHandle, +) -> AppResult { + let dead = Arc::new(AtomicBool::new(false)); + let dead_cb = dead.clone(); + let app_err = app.clone(); + let node_id_cb = node_id.to_string(); + let err_cb = move |e: cpal::StreamError| { + if dead_cb.swap(true, Ordering::Relaxed) { + return; + } + health::bump(&health::STREAM_ERRORS, 1); + error!(node_id = %node_id_cb, error = %e, "input stream error"); + let _ = app_err.emit( + "audio://input_error", + json!({ "nodeId": node_id_cb, "error": format!("{e}") }), + ); + }; + let stream = streams::build_input_stream( + &device, + &config, + sample_format, + src_channels, + bridge, + meter, + err_cb, + )?; + Ok(InputHandle::Cpal(stream)) +} diff --git a/src-tauri/src/audio/pipeline/input/macos.rs b/src-tauri/src/audio/pipeline/input/macos.rs index 6fa4896..a87ce09 100644 --- a/src-tauri/src/audio/pipeline/input/macos.rs +++ b/src-tauri/src/audio/pipeline/input/macos.rs @@ -1,19 +1,13 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::AtomicBool; use std::sync::Arc; -use serde_json::json; -use tauri::{AppHandle, Emitter}; -use tracing::error; +use tauri::AppHandle; -use crate::audio::device::{self, DeviceKind}; use crate::audio::effects::MeterHandle; use crate::audio::graph::{InputSpec, ValidInput}; -use crate::audio::health; use crate::audio::input_bridge::BroadcastRx; -use crate::audio::streams; use crate::error::{AppError, AppResult}; -use super::super::native::native_config; use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; /// The graph downstream is laid out from the format resolved before the capture @@ -34,17 +28,7 @@ fn check_capture_format(capture: &crate::audio::capture::Capture) -> AppResult<( pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult { match &inp.spec { - InputSpec::Microphone { device_id } => { - let device = device::find(DeviceKind::Input, device_id)?; - let native = native_config(DeviceKind::Input, &device, device_id)?; - Ok(ResolvedInput::Cpal { - device, - config: native.config, - sample_format: native.sample_format, - src_channels: native.channels as usize, - sample_rate: native.sample_rate, - }) - } + InputSpec::Microphone { device_id } => super::cpal_input::resolve_cpal_input(device_id), InputSpec::SystemAudio { exclude_current_app, } => Ok(ResolvedInput::SystemAudio { @@ -78,33 +62,16 @@ pub(in crate::audio::pipeline) fn start_input_stream( sample_format, src_channels, .. - } => { - let dead = Arc::new(AtomicBool::new(false)); - let dead_cb = dead.clone(); - let app_err = app.clone(); - let node_id_cb = node_id.to_string(); - let err_cb = move |e: cpal::StreamError| { - if dead_cb.swap(true, Ordering::Relaxed) { - return; - } - health::bump(&health::STREAM_ERRORS, 1); - error!(node_id = %node_id_cb, error = %e, "input stream error"); - let _ = app_err.emit( - "audio://input_error", - json!({ "nodeId": node_id_cb, "error": format!("{e}") }), - ); - }; - let stream = streams::build_input_stream( - &device, - &config, - sample_format, - src_channels, - bridge, - meter, - err_cb, - )?; - Ok(InputHandle::Cpal(stream)) - } + } => super::cpal_input::start_cpal_input_stream( + node_id, + device, + config, + sample_format, + src_channels, + bridge, + meter, + app, + ), ResolvedInput::SystemAudio { sample_rate, exclude_current_app, diff --git a/src-tauri/src/audio/pipeline/input/mod.rs b/src-tauri/src/audio/pipeline/input/mod.rs index 97c1957..23e977d 100644 --- a/src-tauri/src/audio/pipeline/input/mod.rs +++ b/src-tauri/src/audio/pipeline/input/mod.rs @@ -19,6 +19,8 @@ use crate::error::{AppError, AppResult}; use super::dag::{ring_capacity_frames, RESAMPLE_CHUNK}; use super::file_reader::{probe_audio_file, start_audio_file_reader, AudioFileReader}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(super) mod cpal_input; #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] diff --git a/src-tauri/src/audio/pipeline/input/windows.rs b/src-tauri/src/audio/pipeline/input/windows.rs index 8035b4b..83006f6 100644 --- a/src-tauri/src/audio/pipeline/input/windows.rs +++ b/src-tauri/src/audio/pipeline/input/windows.rs @@ -1,18 +1,13 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::AtomicBool; use std::sync::Arc; -use serde_json::json; -use tauri::{AppHandle, Emitter}; -use tracing::{error, info}; +use tauri::AppHandle; +use tracing::info; -use crate::audio::device::{self, DeviceKind}; use crate::audio::graph::{InputSpec, ValidInput}; -use crate::audio::health; use crate::audio::input_bridge::BroadcastRx; -use crate::audio::streams; use crate::error::AppResult; -use super::super::native::native_config; use super::{resolve_audio_file, start_audio_file, InputHandle, ResolvedInput}; const LOOPBACK_FALLBACK_RATE: u32 = 48_000; @@ -21,17 +16,7 @@ const LOOPBACK_CHANNELS: usize = 2; pub(in crate::audio::pipeline) fn resolve_input(inp: &ValidInput) -> AppResult { match &inp.spec { - InputSpec::Microphone { device_id } => { - let device = device::find(DeviceKind::Input, device_id)?; - let native = native_config(DeviceKind::Input, &device, device_id)?; - Ok(ResolvedInput::Cpal { - device, - config: native.config, - sample_format: native.sample_format, - src_channels: native.channels as usize, - sample_rate: native.sample_rate, - }) - } + InputSpec::Microphone { device_id } => super::cpal::resolve_cpal_input(device_id), InputSpec::SystemAudio { exclude_current_app, } => Ok(ResolvedInput::SystemAudio { @@ -65,33 +50,16 @@ pub(in crate::audio::pipeline) fn start_input_stream( sample_format, src_channels, .. - } => { - let dead = Arc::new(AtomicBool::new(false)); - let dead_cb = dead.clone(); - let app_err = app.clone(); - let node_id_cb = node_id.to_string(); - let err_cb = move |e: cpal::StreamError| { - if dead_cb.swap(true, Ordering::Relaxed) { - return; - } - health::bump(&health::STREAM_ERRORS, 1); - error!(node_id = %node_id_cb, error = %e, "input stream error"); - let _ = app_err.emit( - "audio://input_error", - json!({ "nodeId": node_id_cb, "error": format!("{e}") }), - ); - }; - let stream = streams::build_input_stream( - &device, - &config, - sample_format, - src_channels, - bridge, - meter, - err_cb, - )?; - Ok(InputHandle::Cpal(stream)) - } + } => super::cpal_input::start_cpal_input_stream( + node_id, + device, + config, + sample_format, + src_channels, + bridge, + meter, + app, + ), ResolvedInput::SystemAudio { sample_rate, exclude_current_app, diff --git a/src-tauri/src/audio/pipeline/output/cpal_speaker.rs b/src-tauri/src/audio/pipeline/output/cpal_speaker.rs new file mode 100644 index 0000000..6f5e564 --- /dev/null +++ b/src-tauri/src/audio/pipeline/output/cpal_speaker.rs @@ -0,0 +1,61 @@ +use cpal::traits::StreamTrait; +use tracing::warn; + +use crate::audio::device::{self, DeviceKind}; +use crate::audio::pipeline::native::native_config; +use crate::error::AppResult; + +use super::{SpeakerWorker, StreamGuard}; + +pub(in crate::audio::pipeline) struct SpeakerResolved { + pub device: cpal::Device, + pub config: cpal::StreamConfig, + pub sample_format: cpal::SampleFormat, + pub out_channels: usize, + pub sample_rate: u32, +} + +/// Field order: the stream drops before the worker so the audio callback stops +/// before the ring is freed. +pub(in crate::audio::pipeline) struct SpeakerHandle { + _stream: cpal::Stream, + _worker: SpeakerWorker, + _alive: StreamGuard, +} + +impl SpeakerHandle { + pub(super) fn new(stream: cpal::Stream, worker: SpeakerWorker, alive: StreamGuard) -> Self { + Self { + _stream: stream, + _worker: worker, + _alive: alive, + } + } +} + +// cpal's coreaudio backend registers a device-alive property listener for any +// non-default device (which `device::find` always returns) whose callback closure +// holds another clone of the `Stream`'s inner `Arc`. That's a permanent reference +// cycle: dropping our `_stream` handle alone never reaches refcount zero, so the +// AudioUnit is never disposed and keeps calling `fill` on a ring nobody drains. +// `pause` reaches the device through `&self` and stops it for real. WASAPI +// benefits from the same explicit pause on teardown. +impl Drop for SpeakerHandle { + fn drop(&mut self) { + if let Err(e) = self._stream.pause() { + warn!(error = %e, "failed to pause speaker stream on teardown"); + } + } +} + +pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult { + let device = device::find(DeviceKind::Output, device_id)?; + let native = native_config(DeviceKind::Output, &device, device_id)?; + Ok(SpeakerResolved { + device, + config: native.config, + sample_format: native.sample_format, + out_channels: native.channels as usize, + sample_rate: native.sample_rate, + }) +} diff --git a/src-tauri/src/audio/pipeline/output/macos.rs b/src-tauri/src/audio/pipeline/output/macos.rs index f646b2c..84ba3f8 100644 --- a/src-tauri/src/audio/pipeline/output/macos.rs +++ b/src-tauri/src/audio/pipeline/output/macos.rs @@ -3,67 +3,24 @@ use std::sync::Arc; use std::thread; use std::time::Duration; -use cpal::traits::{DeviceTrait, StreamTrait}; +use cpal::traits::DeviceTrait; use serde_json::json; use tauri::{AppHandle, Emitter}; use tracing::{error, info, warn}; -use crate::audio::device::{self, DeviceKind}; use crate::audio::health; use crate::audio::streams; use crate::error::{AppError, AppResult}; use super::super::dag::OutputGraph; -use super::super::native::native_config; use super::super::worker::WorkerCtrl; -use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, SpeakerWorker, StreamGuard}; +use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, StreamGuard}; // Bluetooth AUHAL often returns DeviceNotAvailable on first bind; retry covers settling. const SPEAKER_MAX_ATTEMPTS: u32 = 3; const SPEAKER_RETRY_DELAY: Duration = Duration::from_millis(300); -pub(in crate::audio::pipeline) struct SpeakerResolved { - pub device: cpal::Device, - pub config: cpal::StreamConfig, - pub sample_format: cpal::SampleFormat, - pub out_channels: usize, - pub sample_rate: u32, -} - -// Field order: the stream drops before the worker so the audio callback stops -// before the ring is freed. -pub(in crate::audio::pipeline) struct SpeakerHandle { - _stream: cpal::Stream, - _worker: SpeakerWorker, - _alive: StreamGuard, -} - -// cpal's coreaudio backend registers a device-alive property listener for any -// non-default device (which `device::find` always returns -- see its comment) -// whose callback closure holds another clone of the `Stream`'s inner `Arc`. -// That's a permanent reference cycle: dropping our `_stream` handle alone -// never reaches refcount zero, so the AudioUnit is never disposed and keeps -// calling `fill` on a ring nobody drains anymore. `pause` reaches the -// AudioUnit through `&self` and stops it for real, independent of the cycle. -impl Drop for SpeakerHandle { - fn drop(&mut self) { - if let Err(e) = self._stream.pause() { - warn!(error = %e, "failed to pause speaker stream on teardown"); - } - } -} - -pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult { - let device = device::find(DeviceKind::Output, device_id)?; - let native = native_config(DeviceKind::Output, &device, device_id)?; - Ok(SpeakerResolved { - device, - config: native.config, - sample_format: native.sample_format, - out_channels: native.channels as usize, - sample_rate: native.sample_rate, - }) -} +pub(in crate::audio::pipeline) use super::cpal_speaker::{resolve_speaker, SpeakerHandle, SpeakerResolved}; // Substring match on cpal's stable Display -- AppError flattens the variant. fn is_device_not_available(e: &AppError) -> bool { @@ -178,11 +135,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( meter, )?; Ok(( - SpeakerHandle { - _stream: stream, - _worker: worker_handle, - _alive: StreamGuard::new(), - }, + SpeakerHandle::new(stream, worker_handle, StreamGuard::new()), ctrl, dead, io, diff --git a/src-tauri/src/audio/pipeline/output/mod.rs b/src-tauri/src/audio/pipeline/output/mod.rs index e8b57e1..8e73546 100644 --- a/src-tauri/src/audio/pipeline/output/mod.rs +++ b/src-tauri/src/audio/pipeline/output/mod.rs @@ -20,6 +20,8 @@ use crate::error::{AppError, AppResult}; use super::dag::{ring_capacity_frames, OutputGraph, DSP_BLOCK_FRAMES}; use super::worker::{dsp_worker, WorkerCtrl}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +mod cpal_speaker; #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] diff --git a/src-tauri/src/audio/pipeline/output/windows.rs b/src-tauri/src/audio/pipeline/output/windows.rs index e018b01..12185a4 100644 --- a/src-tauri/src/audio/pipeline/output/windows.rs +++ b/src-tauri/src/audio/pipeline/output/windows.rs @@ -1,59 +1,19 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use cpal::traits::{DeviceTrait, StreamTrait}; +use cpal::traits::DeviceTrait; use serde_json::json; use tauri::{AppHandle, Emitter}; -use tracing::{error, info, warn}; +use tracing::{error, info}; -use crate::audio::device::{self, DeviceKind}; use crate::audio::health; use crate::audio::streams; use crate::error::AppResult; use super::super::dag::OutputGraph; -use super::super::native::native_config; use super::super::worker::WorkerCtrl; -use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, SpeakerWorker, StreamGuard}; - -pub(in crate::audio::pipeline) struct SpeakerResolved { - pub device: cpal::Device, - pub config: cpal::StreamConfig, - pub sample_format: cpal::SampleFormat, - pub out_channels: usize, - pub sample_rate: u32, -} - -// The stream drops before the worker so the audio callback stops before the -// ring is freed. -pub(in crate::audio::pipeline) struct SpeakerHandle { - _stream: cpal::Stream, - _worker: SpeakerWorker, - _alive: StreamGuard, -} - -// `Stream::drop` isn't guaranteed to stop the underlying device (cpal's macOS -// backend never does for a non-default device, see the macOS SpeakerHandle); -// call `pause` explicitly so teardown doesn't depend on that guarantee here too. -impl Drop for SpeakerHandle { - fn drop(&mut self) { - if let Err(e) = self._stream.pause() { - warn!(error = %e, "failed to pause speaker stream on teardown"); - } - } -} - -pub(in crate::audio::pipeline) fn resolve_speaker(device_id: &str) -> AppResult { - let device = device::find(DeviceKind::Output, device_id)?; - let native = native_config(DeviceKind::Output, &device, device_id)?; - Ok(SpeakerResolved { - device, - config: native.config, - sample_format: native.sample_format, - out_channels: native.channels as usize, - sample_rate: native.sample_rate, - }) -} +use super::{spawn_speaker_worker, speaker_ring, SpeakerIo}; +pub(in crate::audio::pipeline) use super::cpal_speaker::{resolve_speaker, SpeakerHandle, SpeakerResolved}; pub(in crate::audio::pipeline) fn start_speaker_stream( node_id: &str, @@ -113,11 +73,7 @@ pub(in crate::audio::pipeline) fn start_speaker_stream( meter, )?; Ok(( - SpeakerHandle { - _stream: stream, - _worker: worker_handle, - _alive: StreamGuard::new(), - }, + SpeakerHandle::new(stream, worker_handle, super::StreamGuard::new()), ctrl, dead, io, diff --git a/src-tauri/src/audio/system_audio/linux.rs b/src-tauri/src/audio/system_audio/linux.rs index 1bb0150..d46b2a5 100644 --- a/src-tauri/src/audio/system_audio/linux.rs +++ b/src-tauri/src/audio/system_audio/linux.rs @@ -22,10 +22,13 @@ pub fn list_audio_applications() -> AppResult> { .map_err(|_| AppError::Host("pipewire enumeration thread panicked".into()))? } -pub fn load_app_icons(bundle_ids: Vec) -> HashMap { +pub(super) fn fetch_app_icons(bundle_ids: &[String]) -> Vec<(String, Option)> { bundle_ids - .into_iter() - .filter_map(|binary| Some((binary.clone(), STANDARD.encode(resolve_icon(&binary)?)))) + .iter() + .map(|binary| { + let icon = resolve_icon(binary).map(|bytes| STANDARD.encode(&bytes)); + (binary.clone(), icon) + }) .collect() } diff --git a/src-tauri/src/audio/system_audio/macos.rs b/src-tauri/src/audio/system_audio/macos.rs index dc10441..b984cd8 100644 --- a/src-tauri/src/audio/system_audio/macos.rs +++ b/src-tauri/src/audio/system_audio/macos.rs @@ -13,10 +13,6 @@ fn bundle_path_cache() -> &'static Mutex> { C.get_or_init(|| Mutex::new(HashMap::new())) } -fn icon_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) -} pub fn list_audio_applications() -> AppResult> { let workspace = NSWorkspace::sharedWorkspace(); @@ -47,41 +43,15 @@ pub fn list_audio_applications() -> AppResult> { Ok(out) } -pub fn load_app_icons(bundle_ids: Vec) -> HashMap { - let mut result = HashMap::new(); - let mut to_load: Vec<(String, PathBuf)> = Vec::new(); - - { - let paths = bundle_path_cache().lock().unwrap(); - let icons = icon_cache().lock().unwrap(); - for id in &bundle_ids { - if let Some(cached) = icons.get(id) { - if let Some(icon) = cached { - result.insert(id.clone(), icon.clone()); - } - continue; - } - if let Some(path) = paths.get(id) { - to_load.push((id.clone(), path.clone())); - } - } - } - - let loaded: Vec<(String, Option)> = to_load +pub(super) fn fetch_app_icons(bundle_ids: &[String]) -> Vec<(String, Option)> { + let paths = bundle_path_cache().lock().unwrap(); + bundle_ids .iter() - .map(|(id, path)| (id.clone(), icon_from_bundle(path))) - .collect(); - - { - let mut icons = icon_cache().lock().unwrap(); - for (id, icon) in loaded { - if let Some(ref png) = icon { - result.insert(id.clone(), png.clone()); - } - icons.insert(id, icon); - } - } - result + .map(|id| { + let icon = paths.get(id).and_then(|path| icon_from_bundle(path)); + (id.clone(), icon) + }) + .collect() } fn bundle_path(app: &NSRunningApplication) -> Option { diff --git a/src-tauri/src/audio/system_audio/mod.rs b/src-tauri/src/audio/system_audio/mod.rs index bc5d608..39fc99f 100644 --- a/src-tauri/src/audio/system_audio/mod.rs +++ b/src-tauri/src/audio/system_audio/mod.rs @@ -9,20 +9,70 @@ pub struct AudioApplication { pub icon: Option, } +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] -pub use macos::{list_audio_applications, load_app_icons}; +use macos as platform; +#[cfg(target_os = "macos")] +pub use macos::list_audio_applications; #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] -pub use linux::{list_audio_applications, load_app_icons}; +use linux as platform; +#[cfg(target_os = "linux")] +pub use linux::list_audio_applications; #[cfg(target_os = "windows")] mod windows; #[cfg(target_os = "windows")] -pub use windows::{list_audio_applications, load_app_icons, pid_for_exe}; +use windows as platform; +#[cfg(target_os = "windows")] +pub use windows::{list_audio_applications, pid_for_exe}; + +fn icon_cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Centralized cross-platform application icon loader with deduplicated memory caching. +pub fn load_app_icons(bundle_ids: Vec) -> HashMap { + let mut result = HashMap::new(); + let mut to_fetch = Vec::new(); + + { + let cache = icon_cache().lock().unwrap(); + for id in &bundle_ids { + if let Some(cached) = cache.get(id) { + if let Some(icon) = cached { + result.insert(id.clone(), icon.clone()); + } + } else { + to_fetch.push(id.clone()); + } + } + } + + if to_fetch.is_empty() { + return result; + } + + let fetched = platform::fetch_app_icons(&to_fetch); + { + let mut cache = icon_cache().lock().unwrap(); + for (id, icon_opt) in fetched { + if let Some(ref icon) = icon_opt { + result.insert(id.clone(), icon.clone()); + } + cache.insert(id, icon_opt); + } + } + + result +} #[cfg(test)] mod tests { diff --git a/src-tauri/src/audio/system_audio/windows.rs b/src-tauri/src/audio/system_audio/windows.rs index ce70478..852213e 100644 --- a/src-tauri/src/audio/system_audio/windows.rs +++ b/src-tauri/src/audio/system_audio/windows.rs @@ -30,10 +30,6 @@ fn path_cache() -> &'static Mutex> { C.get_or_init(|| Mutex::new(HashMap::new())) } -fn icon_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) -} fn ensure_com() { unsafe { @@ -109,33 +105,15 @@ pub fn pid_for_exe(target: &str) -> Option { } } -pub fn load_app_icons(bundle_ids: Vec) -> HashMap { - let mut result = HashMap::new(); - let mut to_load: Vec<(String, String)> = Vec::new(); - { - let paths = path_cache().lock().unwrap(); - let icons = icon_cache().lock().unwrap(); - for id in &bundle_ids { - if let Some(cached) = icons.get(id) { - if let Some(png) = cached { - result.insert(id.clone(), png.clone()); - } - continue; - } - if let Some(path) = paths.get(id) { - to_load.push((id.clone(), path.clone())); - } - } - } - - for (id, path) in to_load { - let icon = unsafe { icon_png_base64(&path) }; - if let Some(ref png) = icon { - result.insert(id.clone(), png.clone()); - } - icon_cache().lock().unwrap().insert(id, icon); - } - result +pub(super) fn fetch_app_icons(bundle_ids: &[String]) -> Vec<(String, Option)> { + let paths = path_cache().lock().unwrap(); + bundle_ids + .iter() + .map(|id| { + let icon = paths.get(id).and_then(|p| unsafe { icon_png_base64(p) }); + (id.clone(), icon) + }) + .collect() } unsafe fn process_exe_path(pid: u32) -> Option { From 80fd19d1cd8c7a00571a35e0c19c1161299cec95 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:57:17 +0300 Subject: [PATCH 3/7] refactor(tauri): decompose monolithic commands.rs into modular submodules --- src-tauri/src/commands.rs | 870 ---------------------- src-tauri/src/commands/apps.rs | 21 + src-tauri/src/commands/audio_files.rs | 28 + src-tauri/src/commands/debug.rs | 45 ++ src-tauri/src/commands/devices.rs | 71 ++ src-tauri/src/commands/helpers.rs | 35 + src-tauri/src/commands/mod.rs | 20 + src-tauri/src/commands/network.rs | 255 +++++++ src-tauri/src/commands/pipeline.rs | 149 ++++ src-tauri/src/commands/plugins.rs | 131 ++++ src-tauri/src/commands/updater.rs | 92 +++ src-tauri/src/commands/virtual_devices.rs | 75 ++ src-tauri/src/lib.rs | 8 +- 13 files changed, 926 insertions(+), 874 deletions(-) delete mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/commands/apps.rs create mode 100644 src-tauri/src/commands/audio_files.rs create mode 100644 src-tauri/src/commands/debug.rs create mode 100644 src-tauri/src/commands/devices.rs create mode 100644 src-tauri/src/commands/helpers.rs create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/network.rs create mode 100644 src-tauri/src/commands/pipeline.rs create mode 100644 src-tauri/src/commands/plugins.rs create mode 100644 src-tauri/src/commands/updater.rs create mode 100644 src-tauri/src/commands/virtual_devices.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs deleted file mode 100644 index 2739d6e..0000000 --- a/src-tauri/src/commands.rs +++ /dev/null @@ -1,870 +0,0 @@ -use std::sync::mpsc::{self, Sender}; -use std::time::Duration; - -use serde_json::json; -use tauri::{AppHandle, Emitter, State}; -use tracing::{error, info}; - -use crate::audio::device::{self, DeviceInfo, DeviceKind, NativeDeviceInfo}; -use crate::audio::engine::Command; -use crate::audio::graph::{GraphSpec, OpusApplication}; -use crate::audio::permission::{self, CapturePermission}; -use crate::audio::system_audio::{self, AudioApplication}; -use crate::audio::virtual_device::{self, VirtualDeviceConfig, VirtualDriverStatus}; -use crate::audio::{signaling, webrtc}; -use crate::error::{AppError, AppResult}; -use crate::state::AppState; - -const STATE_EVENT: &str = "audio://state"; - -/// Device open/close legitimately costs hundreds of ms; past this the thread is wedged. -const AUDIO_REPLY_TIMEOUT: Duration = Duration::from_secs(5); - -/// Waits off the main thread: Tauri runs sync commands there, freezing the webview. -async fn audio_request(tx: Sender, make: F) -> AppResult -where - T: Send + 'static, - F: FnOnce(Sender) -> Command + Send + 'static, -{ - tauri::async_runtime::spawn_blocking(move || { - let (reply_tx, reply_rx) = mpsc::channel(); - tx.send(make(reply_tx)) - .map_err(|_| AppError::Stream("audio thread is gone".into()))?; - match reply_rx.recv_timeout(AUDIO_REPLY_TIMEOUT) { - Ok(v) => Ok(v), - Err(mpsc::RecvTimeoutError::Timeout) => Err(AppError::Stream(format!( - "audio thread did not respond within {}s", - AUDIO_REPLY_TIMEOUT.as_secs() - ))), - Err(mpsc::RecvTimeoutError::Disconnected) => { - Err(AppError::Stream("audio thread reply lost".into())) - } - } - }) - .await - .map_err(|_| AppError::Stream("audio request task failed".into()))? -} - -/// Scans standard install directories for hostable plugins. Loading foreign -/// dylibs blocks and can be slow, so it runs off the main thread. -#[tauri::command] -pub async fn scan_plugins() -> AppResult> { - tauri::async_runtime::spawn_blocking(crate::audio::plugins::scan_all) - .await - .map_err(|_| AppError::Plugin("plugin scan task failed".into())) -} - -#[tauri::command] -pub async fn open_plugin_editor(node_id: String, title: String) -> AppResult<()> { - let id = node_id.clone(); - let r = tauri::async_runtime::spawn_blocking(move || { - crate::audio::plugins::main_thread::run(move || { - crate::audio::plugins::editor::open(&id, &title) - }) - }) - .await - .map_err(|_| AppError::Plugin(format!("editor task for {node_id} failed")))? - .map_err(|e| AppError::Plugin(format!("editor task for {node_id} failed: {e}")))? - .map_err(AppError::Plugin); - if let Err(e) = &r { - // The node id has to be re-supplied here: it was moved into the task. - error!(node_id, error = %e, "open_plugin_editor failed"); - } - r -} - -/// Serializes a running plugin's state to base64 so the FE can persist it in -/// the node's data. Returns null when the plugin isn't running or has no state. -#[tauri::command] -pub async fn get_plugin_state(node_id: String) -> AppResult> { - let id = node_id.clone(); - let res = tauri::async_runtime::spawn_blocking(move || { - crate::audio::plugins::main_thread::run(move || { - // A format without state persistence reports it; the node then has - // nothing to store, which is not the same as an empty state. - crate::audio::plugins::registry::for_node(&id).and_then(|h| match h.save_state(&id) { - Ok(state) => state, - Err(unsupported) => { - tracing::debug!( - ?unsupported.format, - capability = unsupported.capability, - "plugin state not persisted" - ); - None - } - }) - }) - }) - .await; - match res { - Ok(Ok(state)) => Ok(state), - Ok(Err(e)) => { - error!(node_id, error = %e, "get_plugin_state task failed"); - Ok(None) - } - Err(_) => { - error!(node_id, "get_plugin_state worker failed"); - Ok(None) - } - } -} - -/// Automatable parameters of a running plugin for the node UI. Empty when the -/// plugin is not running, does not advertise parameters, or on error. -#[tauri::command] -pub async fn get_plugin_params( - node_id: String, -) -> AppResult> { - let id = node_id.clone(); - let res = tauri::async_runtime::spawn_blocking(move || { - crate::audio::plugins::main_thread::run(move || { - crate::audio::plugins::registry::for_node(&id) - .map(|h| h.params(&id)) - .unwrap_or_default() - }) - }) - .await; - match res { - Ok(Ok(params)) => Ok(params), - Ok(Err(e)) => { - error!(node_id, error = %e, "get_plugin_params task failed"); - Ok(Vec::new()) - } - Err(_) => { - error!(node_id, "get_plugin_params worker failed"); - Ok(Vec::new()) - } - } -} - -/// Which plugin a node is actually running and whether it can show an editor. -/// The node waits on this after a change: a rebuild is not instant, and acting -/// on the outgoing plugin opens the wrong editor. -#[tauri::command] -pub async fn plugin_status(node_id: String) -> AppResult { - let id = node_id.clone(); - let res = tauri::async_runtime::spawn_blocking(move || { - crate::audio::plugins::main_thread::run(move || { - crate::audio::plugins::registry::for_node(&id) - .map(|h| h.status(&id)) - .unwrap_or_default() - }) - }) - .await; - match res { - Ok(Ok(status)) => Ok(status), - Ok(Err(e)) => { - error!(node_id, error = %e, "plugin_status task failed"); - Ok(Default::default()) - } - Err(_) => { - error!(node_id, "plugin_status worker failed"); - Ok(Default::default()) - } - } -} - -/// Persisted crash reports from previous runs, cleared as they are read. -#[tauri::command] -pub fn take_crash_reports() -> Vec { - crate::take_crash_reports() -} - -#[tauri::command] -pub fn get_logs() -> Vec { - crate::logs::snapshot() -} - -#[tauri::command] -pub fn clear_logs() { - crate::logs::clear(); -} - -/// Dev-only: panics on the main thread to exercise the crash-persistence path -/// (a real panic, not a faked event). Crashes the app on purpose. -#[tauri::command] -pub fn debug_panic(app: AppHandle) { - #[cfg(debug_assertions)] - { - let _ = app.run_on_main_thread(|| { - panic!("debug: intentional test panic"); - }); - } - #[cfg(not(debug_assertions))] - let _ = app; -} - -/// Dev-only: exercises the platform native signal/exception crash reporter. -#[tauri::command] -pub fn debug_native_crash() { - #[cfg(debug_assertions)] - crate::native_crash::trigger(); -} - -/// Dev-only: exits without panic or a catchable signal to test the session marker. -#[tauri::command] -pub fn debug_unexpected_exit() { - #[cfg(debug_assertions)] - std::process::exit(86); -} - -#[tauri::command] -pub async fn close_plugin_editor(node_id: String) -> AppResult<()> { - let id = node_id.clone(); - tauri::async_runtime::spawn_blocking(move || { - crate::audio::plugins::main_thread::run(move || crate::audio::plugins::editor::close(&id)) - }) - .await - .map_err(|_| AppError::Plugin(format!("editor close task for {node_id} failed")))? - .map_err(|e| AppError::Plugin(format!("editor close task for {node_id} failed: {e}")))? - .map_err(AppError::Plugin) -} - -#[tauri::command] -pub async fn play_cue(device_id: String, muted: bool, gain: f32, beep: bool) -> AppResult<()> { - tauri::async_runtime::spawn_blocking(move || { - crate::audio::pipeline::play_cue(&device_id, muted, gain, beep) - }) - .await - .map_err(|_| AppError::Stream("cue task failed".into()))? -} - -#[tauri::command] -pub fn list_input_devices() -> AppResult> { - let devices = device::list_inputs()?; - info!(count = devices.len(), "input devices listed"); - Ok(devices) -} - -#[tauri::command] -pub fn list_output_devices() -> AppResult> { - let devices = device::list_outputs()?; - info!(count = devices.len(), "output devices listed"); - Ok(devices) -} - -#[tauri::command] -pub fn list_audio_applications() -> AppResult> { - let apps = system_audio::list_audio_applications()?; - info!(count = apps.len(), "audio applications listed"); - Ok(apps) -} - -#[tauri::command] -pub fn get_app_icons(bundle_ids: Vec) -> std::collections::HashMap { - info!(count = bundle_ids.len(), "loading app icons"); - let icons = system_audio::load_app_icons(bundle_ids); - info!(loaded = icons.len(), "app icons loaded"); - icons -} - -#[tauri::command] -pub fn device_info(kind: DeviceKind, name: String) -> AppResult { - device::device_info(kind, &name) -} - -#[tauri::command] -pub fn check_capture_permission() -> CapturePermission { - let state = permission::capture(); - info!(?state, "capture permission checked"); - state -} - -#[tauri::command] -pub fn path_exists(path: String) -> bool { - std::fs::metadata(path).is_ok() -} - -/// Reads min/max peak bins from a WAV/AIFF file for a requested frame range, so -/// the File Recording node can show the whole file without holding its samples -/// in RAM. Compressed formats return an error and fall back to the live scope. -#[tauri::command] -pub async fn read_file_peaks( - path: String, - start_frame: u64, - frames_per_bin: u32, - bin_count: u32, -) -> AppResult { - tauri::async_runtime::spawn_blocking(move || { - crate::audio::encoders::read_peaks( - std::path::Path::new(&path), - start_frame, - frames_per_bin, - bin_count, - ) - }) - .await - .map_err(|_| AppError::Stream("peak read task failed".into()))? -} - -#[tauri::command] -pub async fn start_pipeline( - graph: GraphSpec, - state: State<'_, AppState>, - app: AppHandle, -) -> AppResult<()> { - info!(nodes = graph.nodes.len(), "starting pipeline"); - let valid = graph.validate()?; - let tx = state.audio_tx.clone(); - let spawned = app.clone(); - let result = audio_request(tx, move |reply| Command::Start { - graph: valid, - app: spawned, - reply, - }) - .await?; - if result.is_ok() { - info!("pipeline started"); - let _ = app.emit(STATE_EVENT, json!({ "kind": "started" })); - } - result -} - -#[tauri::command] -pub async fn update_effect( - node_id: String, - data: serde_json::Value, - state: State<'_, AppState>, -) -> AppResult<()> { - let tx = state.audio_tx.clone(); - audio_request(tx, move |reply| Command::UpdateEffect { - node_id, - data, - reply, - }) - .await? -} - -#[tauri::command] -pub fn get_device_volume( - kind: DeviceKind, - name: String, -) -> Option { - crate::audio::volume::device_volume(kind, &name) -} - -/// Starts emitting `audio://device_volume` for this device until unwatched. -#[tauri::command] -pub fn watch_device_volume(kind: DeviceKind, name: String, app: AppHandle) -> AppResult<()> { - crate::audio::volume::watch_device_volume(&app, kind, name) -} - -#[tauri::command] -pub fn unwatch_device_volume(kind: DeviceKind, name: String) { - crate::audio::volume::unwatch_device_volume(kind, name); -} - -#[tauri::command] -pub fn set_device_volume(kind: DeviceKind, name: String, scalar: f32) -> AppResult<()> { - if crate::audio::volume::set_device_volume(kind, &name, scalar) { - Ok(()) - } else { - Err(AppError::Device(format!( - "device {name:?} has no settable {kind:?} volume" - ))) - } -} - -#[tauri::command] -pub async fn reconcile_pipeline( - graph: GraphSpec, - state: State<'_, AppState>, - app: AppHandle, -) -> AppResult<()> { - info!(nodes = graph.nodes.len(), "reconciling pipeline"); - let valid = graph.validate()?; - let tx = state.audio_tx.clone(); - audio_request(tx, move |reply| Command::Reconcile { - graph: valid, - app, - reply, - }) - .await? -} - -#[tauri::command] -pub async fn seek_audio_file( - node_id: String, - frame: i64, - state: State<'_, AppState>, -) -> AppResult<()> { - let tx = state.audio_tx.clone(); - audio_request(tx, move |reply| Command::SeekAudioFile { - node_id, - frame, - reply, - }) - .await? -} - -#[tauri::command] -pub async fn set_audio_file_loop( - node_id: String, - enabled: bool, - state: State<'_, AppState>, -) -> AppResult<()> { - let tx = state.audio_tx.clone(); - audio_request(tx, move |reply| Command::SetAudioFileLoop { - node_id, - enabled, - reply, - }) - .await? -} - -#[tauri::command] -pub async fn set_audio_file_paused( - node_id: String, - paused: bool, - state: State<'_, AppState>, -) -> AppResult<()> { - let tx = state.audio_tx.clone(); - audio_request(tx, move |reply| Command::SetAudioFilePaused { - node_id, - paused, - reply, - }) - .await? -} - -#[tauri::command] -pub async fn set_input_volume( - node_id: String, - scalar: f32, - state: State<'_, AppState>, -) -> AppResult<()> { - let tx = state.audio_tx.clone(); - audio_request(tx, move |reply| Command::SetInputVolume { - node_id, - scalar, - reply, - }) - .await? -} - -#[tauri::command] -pub async fn is_pipeline_running(state: State<'_, AppState>) -> AppResult { - let tx = state.audio_tx.clone(); - audio_request(tx, |reply| Command::IsRunning { reply }).await -} - -#[tauri::command] -pub async fn output_latency_ms(state: State<'_, AppState>) -> AppResult { - let tx = state.audio_tx.clone(); - audio_request(tx, |reply| Command::OutputLatencyMs { reply }).await -} - -#[tauri::command] -pub fn virtual_driver_status() -> VirtualDriverStatus { - virtual_device::status() -} - -#[tauri::command] -pub async fn windows_virtual_cable_status( -) -> Result { - tauri::async_runtime::spawn_blocking(virtual_device::windows_virtual_cable_status) - .await - .map_err(|_| { - virtual_device::WindowsVirtualCableError::operation_failed( - "Status query stopped unexpectedly", - ) - })? -} - -#[tauri::command] -pub async fn install_windows_virtual_cable( -) -> Result { - tauri::async_runtime::spawn_blocking(virtual_device::install_windows_virtual_cable) - .await - .map_err(|_| { - virtual_device::WindowsVirtualCableError::operation_failed( - "Installation task stopped unexpectedly", - ) - })? -} - -#[tauri::command] -pub fn install_virtual_driver(app: AppHandle) -> Result<(), String> { - virtual_device::install(&app) -} - -#[tauri::command] -pub fn uninstall_virtual_driver() -> Result<(), String> { - virtual_device::uninstall() -} - -#[tauri::command] -pub async fn apply_virtual_devices( - devices: Vec, - state: State<'_, AppState>, - app: AppHandle, -) -> Result<(), String> { - info!(count = devices.len(), "applying virtual devices"); - // Reloading the driver yanks its devices; a pipeline holding one wedges mid-call. - let tx = state.audio_tx.clone(); - let stopped = match audio_request(tx, |reply| Command::Stop { reply }) - .await - .map_err(|e| e.to_string())? - { - Ok(()) => true, - // An idle engine already satisfies what Stop is here to guarantee. - Err(AppError::NotRunning) => false, - Err(e) => return Err(e.to_string()), - }; - if stopped { - let _ = app.emit(STATE_EVENT, json!({ "kind": "stopped" })); - } - tauri::async_runtime::spawn_blocking(move || virtual_device::apply_virtual_devices(devices)) - .await - .map_err(|_| "virtual device task failed".to_string())? -} - -#[tauri::command] -pub async fn stop_pipeline(state: State<'_, AppState>, app: AppHandle) -> AppResult<()> { - info!("stopping pipeline"); - let tx = state.audio_tx.clone(); - let result = audio_request(tx, |reply| Command::Stop { reply }).await?; - if result.is_ok() { - info!("pipeline stopped"); - let _ = app.emit(STATE_EVENT, json!({ "kind": "stopped" })); - } - result -} - -// Updater errors serialize Display-only, hiding reqwest's cause; unwind source()+Debug. -#[tauri::command] -pub async fn diagnose_update_error(app: AppHandle) -> String { - let report = match configured_updater(&app) { - Ok(updater) => match updater.check().await { - Ok(Some(u)) => format!("check succeeded; update {} is available", u.version), - Ok(None) => "check succeeded; no update available".to_string(), - Err(e) => format_error_chain(&e), - }, - Err(e) => e, - }; - error!(diagnostic = %report, "update check diagnostic"); - report -} - -// Bundled roots so update checks succeed even when the host trust store isn't -// visible to the process (sandboxed AppImage/Flatpak). `configure_client` runs -// on the updater's own reqwest builder, and the returned `Update` carries the -// same client into its download. Linux-only; macOS/Windows use the platform -// verifier (keychain / Windows store). -#[cfg(target_os = "linux")] -fn updater_tls_config() -> rustls::ClientConfig { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let provider = rustls::crypto::ring::default_provider(); - rustls::ClientConfig::builder_with_provider(provider.into()) - .with_safe_default_protocol_versions() - .expect("ring supports TLS 1.2/1.3") - .with_root_certificates(roots) - .with_no_client_auth() -} - -fn configured_updater(app: &AppHandle) -> Result { - use tauri_plugin_updater::UpdaterExt; - #[cfg(target_os = "linux")] - let builder = app - .updater_builder() - .configure_client(|b| b.tls_backend_preconfigured(updater_tls_config())); - #[cfg(not(target_os = "linux"))] - let builder = app.updater_builder(); - builder.build().map_err(|e| e.to_string()) -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateMetadata { - rid: tauri::ResourceId, - current_version: String, - version: String, - date: Option, - body: Option, - raw_json: serde_json::Value, -} - -/// Mirrors `plugin:updater|check` but with bundled roots wired into the HTTP -/// client; the plugin's own `check` command can't be configured. -#[tauri::command] -pub async fn check_for_updates(app: AppHandle) -> Result, String> { - use tauri::Manager; - let updater = configured_updater(&app)?; - let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { - return Ok(None); - }; - let current_version = update.current_version.clone(); - let version = update.version.clone(); - let body = update.body.clone(); - let raw_json = update.raw_json.clone(); - let rid = app.resources_table().add(update); - Ok(Some(UpdateMetadata { - rid, - current_version, - version, - date: None, - body, - raw_json, - })) -} - -fn format_error_chain(err: &E) -> String { - let mut out = format!("{err}\ndebug: {err:?}"); - let mut src = std::error::Error::source(err); - let mut depth = 0; - while let Some(s) = src { - out.push_str(&format!("\ncaused by [{depth}]: {s}\n debug: {s:?}")); - src = s.source(); - depth += 1; - } - out -} - -#[tauri::command] -pub async fn webrtc_create_offer( - node_id: String, - opus_bitrate: u32, - opus_application: OpusApplication, -) -> AppResult { - let (peer_id, offer_code) = - webrtc::create_offer(node_id, opus_bitrate, opus_application).await?; - Ok(serde_json::json!({ "peerId": peer_id, "offerCode": offer_code })) -} - -#[tauri::command] -pub async fn webrtc_accept_offer( - node_id: String, - offer_code: String, - opus_bitrate: u32, - opus_application: OpusApplication, -) -> AppResult { - let (peer_id, answer_code) = - webrtc::accept_offer(node_id, offer_code, opus_bitrate, opus_application).await?; - Ok(serde_json::json!({ "peerId": peer_id, "answerCode": answer_code })) -} - -#[tauri::command] -pub async fn webrtc_complete_handshake(node_id: String, answer_code: String) -> AppResult<()> { - webrtc::complete_handshake(node_id, answer_code).await -} - -#[tauri::command] -pub async fn webrtc_disconnect_peer(node_id: String, peer_id: String) -> AppResult<()> { - webrtc::disconnect_peer(node_id, peer_id).await -} - -#[tauri::command] -pub fn webrtc_set_peer_muted(node_id: String, peer_id: String, muted: bool) { - webrtc::set_peer_muted(&node_id, &peer_id, muted); -} - -#[tauri::command] -pub fn webrtc_peer_pings(node_id: String) -> std::collections::HashMap { - webrtc::peer_pings(&node_id) -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PeerStats { - pub ping_ms: u32, - pub packets: u64, - pub lost: u64, -} - -/// Per-peer receive quality: RTT ping plus cumulative received/lost packet -/// counters (windowed into a recent loss ratio on the frontend). -#[tauri::command] -pub fn webrtc_peer_stats(node_id: String) -> std::collections::HashMap { - webrtc::peer_stats(&node_id) - .into_iter() - .map(|(id, (ping_ms, packets, lost))| { - ( - id, - PeerStats { - ping_ms, - packets, - lost, - }, - ) - }) - .collect() -} - -/// Jitter buffer depth in ms, the latency this node adds. Session-wide: unlike -/// ping, it is a property of the buffer every peer plays out of. -#[tauri::command] -pub fn webrtc_buffer_ms(node_id: String) -> u32 { - webrtc::buffer_ms(&node_id) -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NetReceiverStats { - pub bytes: u64, - pub packets: u64, - pub lost: u64, - pub channels: u32, - pub buffer_ms: u32, - pub sample_rate: u32, - pub format: Option, - pub opus_bitrate: Option, - pub opus_app: Option, -} - -/// The DAG only binds receivers reachable from an output, so an unrouted node -/// could never report the stream it would carry. -#[tauri::command] -pub fn net_receiver_listen(node_id: String, port: u16) { - crate::audio::netaudio::receiver::get_or_create(&node_id, port); -} - -#[tauri::command] -pub fn net_receiver_release(node_id: String) { - crate::audio::netaudio::receiver::release(&node_id); -} - -/// Direct-IP receive stats: cumulative bytes, packets, and lost packets -/// (windowed into a recent loss ratio / rate on the frontend). -#[tauri::command] -pub fn net_receiver_stats(node_id: String) -> Option { - crate::audio::netaudio::receiver::stats(&node_id).map(|s| { - let format_str = s.format.map(|f| match f { - crate::audio::netaudio::packet::Format::PcmF32 => "pcm-f32".to_string(), - crate::audio::netaudio::packet::Format::PcmI16 => "pcm-i16".to_string(), - crate::audio::netaudio::packet::Format::Opus => "opus".to_string(), - }); - let opus_app_str = s.opus_app.and_then(|a| match a { - 1 => Some("voip".to_string()), - 2 => Some("audio".to_string()), - 3 => Some("low-delay".to_string()), - _ => None, - }); - NetReceiverStats { - bytes: s.bytes, - packets: s.packets, - lost: s.lost, - channels: s.channels, - buffer_ms: s.buffer_ms, - sample_rate: s.sample_rate, - format: format_str, - opus_bitrate: s.opus_bitrate, - opus_app: opus_app_str, - } - }) -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NetSenderStats { - pub bytes: u64, - pub packets: u64, -} - -/// Direct-IP send stats: total bytes and packets transmitted. -#[tauri::command] -pub fn net_sender_stats(node_id: String) -> Option { - crate::audio::netaudio::sender::stats(&node_id) - .map(|(bytes, packets)| NetSenderStats { bytes, packets }) -} - -/// Stores the local participant name and input count; peers receive them via -/// the ctrl channel's periodic meta message. -#[tauri::command] -pub fn webrtc_set_identity( - node_id: String, - name: String, - channels: u32, - codec: crate::audio::graph::NetCodec, - opus_bitrate: u32, - opus_application: OpusApplication, -) { - webrtc::get_or_create(&node_id, opus_bitrate, opus_application); - webrtc::set_identity(&node_id, name, channels, codec); -} - -#[tauri::command] -pub async fn webrtc_session_state(node_id: String) -> webrtc::WebRtcSessionState { - webrtc::session_state(&node_id).await -} - -// Returns the room code; host loop runs until leave_room, signalling connects via `audio://webrtc_connected`. -#[tauri::command] -pub async fn webrtc_create_room( - node_id: String, - opus_bitrate: u32, - opus_application: OpusApplication, - password_hash: String, - app: AppHandle, -) -> AppResult { - let code = signaling::random_room_code(); - webrtc::mark_room( - &node_id, - opus_bitrate, - opus_application, - "hosting", - Some(code.clone()), - ); - - let code_clone = code.clone(); - let task_node_id = node_id.clone(); - let handle = tokio::spawn(async move { - if let Err(e) = signaling::host_loop( - code_clone, - password_hash, - task_node_id.clone(), - opus_bitrate, - opus_application, - ) - .await - { - tracing::error!("signaling host: {e}"); - let _ = app.emit( - "audio://webrtc_error", - serde_json::json!({ "nodeId": task_node_id, "error": e.to_string() }), - ); - } - }); - webrtc::set_signaling_task(&node_id, handle); - - Ok(code) -} - -// Runs in background; result arrives via `audio://webrtc_connected` or `audio://webrtc_error`. -#[tauri::command] -pub async fn webrtc_join_room( - node_id: String, - room_code: String, - opus_bitrate: u32, - opus_application: OpusApplication, - password_hash: String, - app: AppHandle, -) -> AppResult<()> { - webrtc::mark_room(&node_id, opus_bitrate, opus_application, "joining", None); - let node_id_clone = node_id.clone(); - let handle = tokio::spawn(async move { - if let Err(e) = signaling::guest_join( - room_code, - password_hash, - node_id_clone.clone(), - opus_bitrate, - opus_application, - ) - .await - { - tracing::error!("signaling guest: {e}"); - let _ = app.emit( - "audio://webrtc_error", - serde_json::json!({ "nodeId": node_id_clone, "error": e.to_string() }), - ); - } - }); - webrtc::set_signaling_task(&node_id, handle); - - Ok(()) -} - -#[tauri::command] -pub async fn webrtc_leave_room(node_id: String) { - webrtc::leave_room(&node_id).await; -} diff --git a/src-tauri/src/commands/apps.rs b/src-tauri/src/commands/apps.rs new file mode 100644 index 0000000..6bf393c --- /dev/null +++ b/src-tauri/src/commands/apps.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; + +use tracing::info; + +use crate::audio::system_audio::{self, AudioApplication}; +use crate::error::AppResult; + +#[tauri::command] +pub fn list_audio_applications() -> AppResult> { + let apps = system_audio::list_audio_applications()?; + info!(count = apps.len(), "audio applications listed"); + Ok(apps) +} + +#[tauri::command] +pub fn get_app_icons(bundle_ids: Vec) -> HashMap { + info!(count = bundle_ids.len(), "loading app icons"); + let icons = system_audio::load_app_icons(bundle_ids); + info!(loaded = icons.len(), "app icons loaded"); + icons +} diff --git a/src-tauri/src/commands/audio_files.rs b/src-tauri/src/commands/audio_files.rs new file mode 100644 index 0000000..eb6d0c8 --- /dev/null +++ b/src-tauri/src/commands/audio_files.rs @@ -0,0 +1,28 @@ +use crate::error::{AppError, AppResult}; + +#[tauri::command] +pub fn path_exists(path: String) -> bool { + std::fs::metadata(path).is_ok() +} + +/// Reads min/max peak bins from a WAV/AIFF file for a requested frame range, so +/// the File Recording node can show the whole file without holding its samples +/// in RAM. Compressed formats return an error and fall back to the live scope. +#[tauri::command] +pub async fn read_file_peaks( + path: String, + start_frame: u64, + frames_per_bin: u32, + bin_count: u32, +) -> AppResult { + tauri::async_runtime::spawn_blocking(move || { + crate::audio::encoders::read_peaks( + std::path::Path::new(&path), + start_frame, + frames_per_bin, + bin_count, + ) + }) + .await + .map_err(|_| AppError::Stream("peak read task failed".into()))? +} diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs new file mode 100644 index 0000000..3501d18 --- /dev/null +++ b/src-tauri/src/commands/debug.rs @@ -0,0 +1,45 @@ +use tauri::AppHandle; + +/// Persisted crash reports from previous runs, cleared as they are read. +#[tauri::command] +pub fn take_crash_reports() -> Vec { + crate::take_crash_reports() +} + +#[tauri::command] +pub fn get_logs() -> Vec { + crate::logs::snapshot() +} + +#[tauri::command] +pub fn clear_logs() { + crate::logs::clear(); +} + +/// Dev-only: panics on the main thread to exercise the crash-persistence path +/// (a real panic, not a faked event). Crashes the app on purpose. +#[tauri::command] +pub fn debug_panic(app: AppHandle) { + #[cfg(debug_assertions)] + { + let _ = app.run_on_main_thread(|| { + panic!("debug: intentional test panic"); + }); + } + #[cfg(not(debug_assertions))] + let _ = app; +} + +/// Dev-only: exercises the platform native signal/exception crash reporter. +#[tauri::command] +pub fn debug_native_crash() { + #[cfg(debug_assertions)] + crate::native_crash::trigger(); +} + +/// Dev-only: exits without panic or a catchable signal to test the session marker. +#[tauri::command] +pub fn debug_unexpected_exit() { + #[cfg(debug_assertions)] + std::process::exit(86); +} diff --git a/src-tauri/src/commands/devices.rs b/src-tauri/src/commands/devices.rs new file mode 100644 index 0000000..c8bd887 --- /dev/null +++ b/src-tauri/src/commands/devices.rs @@ -0,0 +1,71 @@ +use tauri::AppHandle; +use tracing::info; + +use crate::audio::device::{self, DeviceInfo, DeviceKind, NativeDeviceInfo}; +use crate::audio::permission::{self, CapturePermission}; +use crate::error::{AppError, AppResult}; + +#[tauri::command] +pub async fn play_cue(device_id: String, muted: bool, gain: f32, beep: bool) -> AppResult<()> { + tauri::async_runtime::spawn_blocking(move || { + crate::audio::pipeline::play_cue(&device_id, muted, gain, beep) + }) + .await + .map_err(|_| AppError::Stream("cue task failed".into()))? +} + +#[tauri::command] +pub fn list_input_devices() -> AppResult> { + let devices = device::list_inputs()?; + info!(count = devices.len(), "input devices listed"); + Ok(devices) +} + +#[tauri::command] +pub fn list_output_devices() -> AppResult> { + let devices = device::list_outputs()?; + info!(count = devices.len(), "output devices listed"); + Ok(devices) +} + +#[tauri::command] +pub fn device_info(kind: DeviceKind, name: String) -> AppResult { + device::device_info(kind, &name) +} + +#[tauri::command] +pub fn check_capture_permission() -> CapturePermission { + let state = permission::capture(); + info!(?state, "capture permission checked"); + state +} + +#[tauri::command] +pub fn get_device_volume( + kind: DeviceKind, + name: String, +) -> Option { + crate::audio::volume::device_volume(kind, &name) +} + +/// Starts emitting `audio://device_volume` for this device until unwatched. +#[tauri::command] +pub fn watch_device_volume(kind: DeviceKind, name: String, app: AppHandle) -> AppResult<()> { + crate::audio::volume::watch_device_volume(&app, kind, name) +} + +#[tauri::command] +pub fn unwatch_device_volume(kind: DeviceKind, name: String) { + crate::audio::volume::unwatch_device_volume(kind, name); +} + +#[tauri::command] +pub fn set_device_volume(kind: DeviceKind, name: String, scalar: f32) -> AppResult<()> { + if crate::audio::volume::set_device_volume(kind, &name, scalar) { + Ok(()) + } else { + Err(AppError::Device(format!( + "device {name:?} has no settable {kind:?} volume" + ))) + } +} diff --git a/src-tauri/src/commands/helpers.rs b/src-tauri/src/commands/helpers.rs new file mode 100644 index 0000000..3e99c18 --- /dev/null +++ b/src-tauri/src/commands/helpers.rs @@ -0,0 +1,35 @@ +use std::sync::mpsc::{self, Sender}; +use std::time::Duration; + +use crate::audio::engine::Command; +use crate::error::{AppError, AppResult}; + +pub(super) const STATE_EVENT: &str = "audio://state"; + +/// Device open/close legitimately costs hundreds of ms; past this the thread is wedged. +const AUDIO_REPLY_TIMEOUT: Duration = Duration::from_secs(5); + +/// Waits off the main thread: Tauri runs sync commands there, freezing the webview. +pub(super) async fn audio_request(tx: Sender, make: F) -> AppResult +where + T: Send + 'static, + F: FnOnce(Sender) -> Command + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(move || { + let (reply_tx, reply_rx) = mpsc::channel(); + tx.send(make(reply_tx)) + .map_err(|_| AppError::Stream("audio thread is gone".into()))?; + match reply_rx.recv_timeout(AUDIO_REPLY_TIMEOUT) { + Ok(v) => Ok(v), + Err(mpsc::RecvTimeoutError::Timeout) => Err(AppError::Stream(format!( + "audio thread did not respond within {}s", + AUDIO_REPLY_TIMEOUT.as_secs() + ))), + Err(mpsc::RecvTimeoutError::Disconnected) => { + Err(AppError::Stream("audio thread reply lost".into())) + } + } + }) + .await + .map_err(|_| AppError::Stream("audio request task failed".into()))? +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..434492d --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,20 @@ +mod apps; +mod audio_files; +mod debug; +mod devices; +mod helpers; +mod network; +mod pipeline; +mod plugins; +mod updater; +mod virtual_devices; + +pub use apps::*; +pub use audio_files::*; +pub use debug::*; +pub use devices::*; +pub use network::*; +pub use pipeline::*; +pub use plugins::*; +pub use updater::*; +pub use virtual_devices::*; diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs new file mode 100644 index 0000000..998ebad --- /dev/null +++ b/src-tauri/src/commands/network.rs @@ -0,0 +1,255 @@ +use std::collections::HashMap; + +use tauri::{AppHandle, Emitter}; + +use crate::audio::graph::OpusApplication; +use crate::audio::{signaling, webrtc}; +use crate::error::AppResult; + +#[tauri::command] +pub async fn webrtc_create_offer( + node_id: String, + opus_bitrate: u32, + opus_application: OpusApplication, +) -> AppResult { + let (peer_id, offer_code) = + webrtc::create_offer(node_id, opus_bitrate, opus_application).await?; + Ok(serde_json::json!({ "peerId": peer_id, "offerCode": offer_code })) +} + +#[tauri::command] +pub async fn webrtc_accept_offer( + node_id: String, + offer_code: String, + opus_bitrate: u32, + opus_application: OpusApplication, +) -> AppResult { + let (peer_id, answer_code) = + webrtc::accept_offer(node_id, offer_code, opus_bitrate, opus_application).await?; + Ok(serde_json::json!({ "peerId": peer_id, "answerCode": answer_code })) +} + +#[tauri::command] +pub async fn webrtc_complete_handshake(node_id: String, answer_code: String) -> AppResult<()> { + webrtc::complete_handshake(node_id, answer_code).await +} + +#[tauri::command] +pub async fn webrtc_disconnect_peer(node_id: String, peer_id: String) -> AppResult<()> { + webrtc::disconnect_peer(node_id, peer_id).await +} + +#[tauri::command] +pub fn webrtc_set_peer_muted(node_id: String, peer_id: String, muted: bool) { + webrtc::set_peer_muted(&node_id, &peer_id, muted); +} + +#[tauri::command] +pub fn webrtc_peer_pings(node_id: String) -> HashMap { + webrtc::peer_pings(&node_id) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PeerStats { + pub ping_ms: u32, + pub packets: u64, + pub lost: u64, +} + +/// Per-peer receive quality: RTT ping plus cumulative received/lost packet +/// counters (windowed into a recent loss ratio on the frontend). +#[tauri::command] +pub fn webrtc_peer_stats(node_id: String) -> HashMap { + webrtc::peer_stats(&node_id) + .into_iter() + .map(|(id, (ping_ms, packets, lost))| { + ( + id, + PeerStats { + ping_ms, + packets, + lost, + }, + ) + }) + .collect() +} + +/// Jitter buffer depth in ms, the latency this node adds. Session-wide: unlike +/// ping, it is a property of the buffer every peer plays out of. +#[tauri::command] +pub fn webrtc_buffer_ms(node_id: String) -> u32 { + webrtc::buffer_ms(&node_id) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetReceiverStats { + pub bytes: u64, + pub packets: u64, + pub lost: u64, + pub channels: u32, + pub buffer_ms: u32, + pub sample_rate: u32, + pub format: Option, + pub opus_bitrate: Option, + pub opus_app: Option, +} + +/// The DAG only binds receivers reachable from an output, so an unrouted node +/// could never report the stream it would carry. +#[tauri::command] +pub fn net_receiver_listen(node_id: String, port: u16) { + crate::audio::netaudio::receiver::get_or_create(&node_id, port); +} + +#[tauri::command] +pub fn net_receiver_release(node_id: String) { + crate::audio::netaudio::receiver::release(&node_id); +} + +/// Direct-IP receive stats: cumulative bytes, packets, and lost packets +/// (windowed into a recent loss ratio / rate on the frontend). +#[tauri::command] +pub fn net_receiver_stats(node_id: String) -> Option { + crate::audio::netaudio::receiver::stats(&node_id).map(|s| { + let format_str = s.format.map(|f| match f { + crate::audio::netaudio::packet::Format::PcmF32 => "pcm-f32".to_string(), + crate::audio::netaudio::packet::Format::PcmI16 => "pcm-i16".to_string(), + crate::audio::netaudio::packet::Format::Opus => "opus".to_string(), + }); + let opus_app_str = s.opus_app.and_then(|a| match a { + 1 => Some("voip".to_string()), + 2 => Some("audio".to_string()), + 3 => Some("low-delay".to_string()), + _ => None, + }); + NetReceiverStats { + bytes: s.bytes, + packets: s.packets, + lost: s.lost, + channels: s.channels, + buffer_ms: s.buffer_ms, + sample_rate: s.sample_rate, + format: format_str, + opus_bitrate: s.opus_bitrate, + opus_app: opus_app_str, + } + }) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetSenderStats { + pub bytes: u64, + pub packets: u64, +} + +/// Direct-IP send stats: total bytes and packets transmitted. +#[tauri::command] +pub fn net_sender_stats(node_id: String) -> Option { + crate::audio::netaudio::sender::stats(&node_id) + .map(|(bytes, packets)| NetSenderStats { bytes, packets }) +} + +/// Stores the local participant name and input count; peers receive them via +/// the ctrl channel's periodic meta message. +#[tauri::command] +pub fn webrtc_set_identity( + node_id: String, + name: String, + channels: u32, + codec: crate::audio::graph::NetCodec, + opus_bitrate: u32, + opus_application: OpusApplication, +) { + webrtc::get_or_create(&node_id, opus_bitrate, opus_application); + webrtc::set_identity(&node_id, name, channels, codec); +} + +#[tauri::command] +pub async fn webrtc_session_state(node_id: String) -> webrtc::WebRtcSessionState { + webrtc::session_state(&node_id).await +} + +// Returns the room code; host loop runs until leave_room, signalling connects via `audio://webrtc_connected`. +#[tauri::command] +pub async fn webrtc_create_room( + node_id: String, + opus_bitrate: u32, + opus_application: OpusApplication, + password_hash: String, + app: AppHandle, +) -> AppResult { + let code = signaling::random_room_code(); + webrtc::mark_room( + &node_id, + opus_bitrate, + opus_application, + "hosting", + Some(code.clone()), + ); + + let code_clone = code.clone(); + let task_node_id = node_id.clone(); + let handle = tokio::spawn(async move { + if let Err(e) = signaling::host_loop( + code_clone, + password_hash, + task_node_id.clone(), + opus_bitrate, + opus_application, + ) + .await + { + tracing::error!("signaling host: {e}"); + let _ = app.emit( + "audio://webrtc_error", + serde_json::json!({ "nodeId": task_node_id, "error": e.to_string() }), + ); + } + }); + webrtc::set_signaling_task(&node_id, handle); + + Ok(code) +} + +// Runs in background; result arrives via `audio://webrtc_connected` or `audio://webrtc_error`. +#[tauri::command] +pub async fn webrtc_join_room( + node_id: String, + room_code: String, + opus_bitrate: u32, + opus_application: OpusApplication, + password_hash: String, + app: AppHandle, +) -> AppResult<()> { + webrtc::mark_room(&node_id, opus_bitrate, opus_application, "joining", None); + let node_id_clone = node_id.clone(); + let handle = tokio::spawn(async move { + if let Err(e) = signaling::guest_join( + room_code, + password_hash, + node_id_clone.clone(), + opus_bitrate, + opus_application, + ) + .await + { + tracing::error!("signaling guest: {e}"); + let _ = app.emit( + "audio://webrtc_error", + serde_json::json!({ "nodeId": node_id_clone, "error": e.to_string() }), + ); + } + }); + webrtc::set_signaling_task(&node_id, handle); + + Ok(()) +} + +#[tauri::command] +pub async fn webrtc_leave_room(node_id: String) { + webrtc::leave_room(&node_id).await; +} diff --git a/src-tauri/src/commands/pipeline.rs b/src-tauri/src/commands/pipeline.rs new file mode 100644 index 0000000..b65972d --- /dev/null +++ b/src-tauri/src/commands/pipeline.rs @@ -0,0 +1,149 @@ +use serde_json::json; +use tauri::{AppHandle, Emitter, State}; +use tracing::info; + +use crate::audio::engine::Command; +use crate::audio::graph::GraphSpec; +use crate::error::AppResult; +use crate::state::AppState; + +use super::helpers::{audio_request, STATE_EVENT}; + +#[tauri::command] +pub async fn start_pipeline( + graph: GraphSpec, + state: State<'_, AppState>, + app: AppHandle, +) -> AppResult<()> { + info!(nodes = graph.nodes.len(), "starting pipeline"); + let valid = graph.validate()?; + let tx = state.audio_tx.clone(); + let spawned = app.clone(); + let result = audio_request(tx, move |reply| Command::Start { + graph: valid, + app: spawned, + reply, + }) + .await?; + if result.is_ok() { + info!("pipeline started"); + let _ = app.emit(STATE_EVENT, json!({ "kind": "started" })); + } + result +} + +#[tauri::command] +pub async fn stop_pipeline(state: State<'_, AppState>, app: AppHandle) -> AppResult<()> { + info!("stopping pipeline"); + let tx = state.audio_tx.clone(); + let result = audio_request(tx, |reply| Command::Stop { reply }).await?; + if result.is_ok() { + info!("pipeline stopped"); + let _ = app.emit(STATE_EVENT, json!({ "kind": "stopped" })); + } + result +} + +#[tauri::command] +pub async fn reconcile_pipeline( + graph: GraphSpec, + state: State<'_, AppState>, + app: AppHandle, +) -> AppResult<()> { + info!(nodes = graph.nodes.len(), "reconciling pipeline"); + let valid = graph.validate()?; + let tx = state.audio_tx.clone(); + audio_request(tx, move |reply| Command::Reconcile { + graph: valid, + app, + reply, + }) + .await? +} + +#[tauri::command] +pub async fn update_effect( + node_id: String, + data: serde_json::Value, + state: State<'_, AppState>, +) -> AppResult<()> { + let tx = state.audio_tx.clone(); + audio_request(tx, move |reply| Command::UpdateEffect { + node_id, + data, + reply, + }) + .await? +} + +#[tauri::command] +pub async fn seek_audio_file( + node_id: String, + frame: i64, + state: State<'_, AppState>, +) -> AppResult<()> { + let tx = state.audio_tx.clone(); + audio_request(tx, move |reply| Command::SeekAudioFile { + node_id, + frame, + reply, + }) + .await? +} + +#[tauri::command] +pub async fn set_audio_file_loop( + node_id: String, + enabled: bool, + state: State<'_, AppState>, +) -> AppResult<()> { + let tx = state.audio_tx.clone(); + audio_request(tx, move |reply| Command::SetAudioFileLoop { + node_id, + enabled, + reply, + }) + .await? +} + +#[tauri::command] +pub async fn set_audio_file_paused( + node_id: String, + paused: bool, + state: State<'_, AppState>, +) -> AppResult<()> { + let tx = state.audio_tx.clone(); + audio_request(tx, move |reply| Command::SetAudioFilePaused { + node_id, + paused, + reply, + }) + .await? +} + +#[tauri::command] +pub async fn set_input_volume( + node_id: String, + scalar: f32, + state: State<'_, AppState>, +) -> AppResult<()> { + let tx = state.audio_tx.clone(); + audio_request(tx, move |reply| Command::SetInputVolume { + node_id, + scalar, + reply, + }) + .await? +} + +#[tauri::command] +pub async fn is_pipeline_running(state: State<'_, AppState>) -> AppResult { + let tx = state.audio_tx.clone(); + audio_request(tx, |reply| Command::IsRunning { reply }).await +} + +#[tauri::command] +pub async fn output_latency_ms(state: State<'_, AppState>) -> AppResult { + let tx = state.audio_tx.clone(); + audio_request(tx, |reply| Command::OutputLatencyMs { reply }).await +} diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs new file mode 100644 index 0000000..e2618e1 --- /dev/null +++ b/src-tauri/src/commands/plugins.rs @@ -0,0 +1,131 @@ +use tracing::error; + +use crate::error::{AppError, AppResult}; + +/// Scans standard install directories for hostable plugins. Loading foreign +/// dylibs blocks and can be slow, so it runs off the main thread. +#[tauri::command] +pub async fn scan_plugins() -> AppResult> { + tauri::async_runtime::spawn_blocking(crate::audio::plugins::scan_all) + .await + .map_err(|_| AppError::Plugin("plugin scan task failed".into())) +} + +#[tauri::command] +pub async fn open_plugin_editor(node_id: String, title: String) -> AppResult<()> { + let id = node_id.clone(); + let r = tauri::async_runtime::spawn_blocking(move || { + crate::audio::plugins::main_thread::run(move || { + crate::audio::plugins::editor::open(&id, &title) + }) + }) + .await + .map_err(|_| AppError::Plugin(format!("editor task for {node_id} failed")))? + .map_err(|e| AppError::Plugin(format!("editor task for {node_id} failed: {e}")))? + .map_err(AppError::Plugin); + if let Err(e) = &r { + error!(node_id, error = %e, "open_plugin_editor failed"); + } + r +} + +#[tauri::command] +pub async fn close_plugin_editor(node_id: String) -> AppResult<()> { + let id = node_id.clone(); + tauri::async_runtime::spawn_blocking(move || { + crate::audio::plugins::main_thread::run(move || crate::audio::plugins::editor::close(&id)) + }) + .await + .map_err(|_| AppError::Plugin(format!("editor close task for {node_id} failed")))? + .map_err(|e| AppError::Plugin(format!("editor close task for {node_id} failed: {e}")))? + .map_err(AppError::Plugin) +} + +/// Serializes a running plugin's state to base64 so the FE can persist it in +/// the node's data. Returns null when the plugin isn't running or has no state. +#[tauri::command] +pub async fn get_plugin_state(node_id: String) -> AppResult> { + let id = node_id.clone(); + let res = tauri::async_runtime::spawn_blocking(move || { + crate::audio::plugins::main_thread::run(move || { + crate::audio::plugins::registry::for_node(&id).and_then(|h| match h.save_state(&id) { + Ok(state) => state, + Err(unsupported) => { + tracing::debug!( + ?unsupported.format, + capability = unsupported.capability, + "plugin state not persisted" + ); + None + } + }) + }) + }) + .await; + match res { + Ok(Ok(state)) => Ok(state), + Ok(Err(e)) => { + error!(node_id, error = %e, "get_plugin_state task failed"); + Ok(None) + } + Err(_) => { + error!(node_id, "get_plugin_state worker failed"); + Ok(None) + } + } +} + +/// Automatable parameters of a running plugin for the node UI. Empty when the +/// plugin is not running, does not advertise parameters, or on error. +#[tauri::command] +pub async fn get_plugin_params( + node_id: String, +) -> AppResult> { + let id = node_id.clone(); + let res = tauri::async_runtime::spawn_blocking(move || { + crate::audio::plugins::main_thread::run(move || { + crate::audio::plugins::registry::for_node(&id) + .map(|h| h.params(&id)) + .unwrap_or_default() + }) + }) + .await; + match res { + Ok(Ok(params)) => Ok(params), + Ok(Err(e)) => { + error!(node_id, error = %e, "get_plugin_params task failed"); + Ok(Vec::new()) + } + Err(_) => { + error!(node_id, "get_plugin_params worker failed"); + Ok(Vec::new()) + } + } +} + +/// Which plugin a node is actually running and whether it can show an editor. +/// The node waits on this after a change: a rebuild is not instant, and acting +/// on the outgoing plugin opens the wrong editor. +#[tauri::command] +pub async fn plugin_status(node_id: String) -> AppResult { + let id = node_id.clone(); + let res = tauri::async_runtime::spawn_blocking(move || { + crate::audio::plugins::main_thread::run(move || { + crate::audio::plugins::registry::for_node(&id) + .map(|h| h.status(&id)) + .unwrap_or_default() + }) + }) + .await; + match res { + Ok(Ok(status)) => Ok(status), + Ok(Err(e)) => { + error!(node_id, error = %e, "plugin_status task failed"); + Ok(Default::default()) + } + Err(_) => { + error!(node_id, "plugin_status worker failed"); + Ok(Default::default()) + } + } +} diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs new file mode 100644 index 0000000..e9d89ef --- /dev/null +++ b/src-tauri/src/commands/updater.rs @@ -0,0 +1,92 @@ +use tauri::AppHandle; +use tracing::error; + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMetadata { + rid: tauri::ResourceId, + current_version: String, + version: String, + date: Option, + body: Option, + raw_json: serde_json::Value, +} + +// Updater errors serialize Display-only, hiding reqwest's cause; unwind source()+Debug. +#[tauri::command] +pub async fn diagnose_update_error(app: AppHandle) -> String { + let report = match configured_updater(&app) { + Ok(updater) => match updater.check().await { + Ok(Some(u)) => format!("check succeeded; update {} is available", u.version), + Ok(None) => "check succeeded; no update available".to_string(), + Err(e) => format_error_chain(&e), + }, + Err(e) => e, + }; + error!(diagnostic = %report, "update check diagnostic"); + report +} + +// Bundled roots so update checks succeed even when the host trust store isn't +// visible to the process (sandboxed AppImage/Flatpak). `configure_client` runs +// on the updater's own reqwest builder, and the returned `Update` carries the +// same client into its download. Linux-only; macOS/Windows use the platform +// verifier (keychain / Windows store). +#[cfg(target_os = "linux")] +fn updater_tls_config() -> rustls::ClientConfig { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let provider = rustls::crypto::ring::default_provider(); + rustls::ClientConfig::builder_with_provider(provider.into()) + .with_safe_default_protocol_versions() + .expect("ring supports TLS 1.2/1.3") + .with_root_certificates(roots) + .with_no_client_auth() +} + +fn configured_updater(app: &AppHandle) -> Result { + use tauri_plugin_updater::UpdaterExt; + #[cfg(target_os = "linux")] + let builder = app + .updater_builder() + .configure_client(|b| b.tls_backend_preconfigured(updater_tls_config())); + #[cfg(not(target_os = "linux"))] + let builder = app.updater_builder(); + builder.build().map_err(|e| e.to_string()) +} + +/// Mirrors `plugin:updater|check` but with bundled roots wired into the HTTP +/// client; the plugin's own `check` command can't be configured. +#[tauri::command] +pub async fn check_for_updates(app: AppHandle) -> Result, String> { + use tauri::Manager; + let updater = configured_updater(&app)?; + let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + return Ok(None); + }; + let current_version = update.current_version.clone(); + let version = update.version.clone(); + let body = update.body.clone(); + let raw_json = update.raw_json.clone(); + let rid = app.resources_table().add(update); + Ok(Some(UpdateMetadata { + rid, + current_version, + version, + date: None, + body, + raw_json, + })) +} + +fn format_error_chain(err: &E) -> String { + let mut out = format!("{err}\ndebug: {err:?}"); + let mut src = std::error::Error::source(err); + let mut depth = 0; + while let Some(s) = src { + out.push_str(&format!("\ncaused by [{depth}]: {s}\n debug: {s:?}")); + src = s.source(); + depth += 1; + } + out +} diff --git a/src-tauri/src/commands/virtual_devices.rs b/src-tauri/src/commands/virtual_devices.rs new file mode 100644 index 0000000..3f6db05 --- /dev/null +++ b/src-tauri/src/commands/virtual_devices.rs @@ -0,0 +1,75 @@ +use serde_json::json; +use tauri::{AppHandle, Emitter, State}; +use tracing::info; + +use crate::audio::engine::Command; +use crate::audio::virtual_device::{self, VirtualDeviceConfig, VirtualDriverStatus}; +use crate::error::AppError; +use crate::state::AppState; + +use super::helpers::{audio_request, STATE_EVENT}; + +#[tauri::command] +pub fn virtual_driver_status() -> VirtualDriverStatus { + virtual_device::status() +} + +#[tauri::command] +pub async fn windows_virtual_cable_status( +) -> Result { + tauri::async_runtime::spawn_blocking(virtual_device::windows_virtual_cable_status) + .await + .map_err(|_| { + virtual_device::WindowsVirtualCableError::operation_failed( + "Status query stopped unexpectedly", + ) + })? +} + +#[tauri::command] +pub async fn install_windows_virtual_cable( +) -> Result { + tauri::async_runtime::spawn_blocking(virtual_device::install_windows_virtual_cable) + .await + .map_err(|_| { + virtual_device::WindowsVirtualCableError::operation_failed( + "Installation task stopped unexpectedly", + ) + })? +} + +#[tauri::command] +pub fn install_virtual_driver(app: AppHandle) -> Result<(), String> { + virtual_device::install(&app) +} + +#[tauri::command] +pub fn uninstall_virtual_driver() -> Result<(), String> { + virtual_device::uninstall() +} + +#[tauri::command] +pub async fn apply_virtual_devices( + devices: Vec, + state: State<'_, AppState>, + app: AppHandle, +) -> Result<(), String> { + info!(count = devices.len(), "applying virtual devices"); + // Reloading the driver yanks its devices; a pipeline holding one wedges mid-call. + let tx = state.audio_tx.clone(); + let stopped = match audio_request(tx, |reply| Command::Stop { reply }) + .await + .map_err(|e| e.to_string())? + { + Ok(()) => true, + // An idle engine already satisfies what Stop is here to guarantee. + Err(AppError::NotRunning) => false, + Err(e) => return Err(e.to_string()), + }; + if stopped { + let _ = app.emit(STATE_EVENT, json!({ "kind": "stopped" })); + } + tauri::async_runtime::spawn_blocking(move || virtual_device::apply_virtual_devices(devices)) + .await + .map_err(|_| "virtual device task failed".to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 318e46a..a68fb7b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,9 @@ -mod audio; +pub mod audio; mod commands; -mod error; -mod logs; +pub mod error; +pub mod logs; mod native_crash; -mod state; +pub mod state; use std::path::PathBuf; use std::sync::OnceLock; From e97d24c8ea4d9ae491bf03e059fdd4041a6e57ef Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:01:35 +0300 Subject: [PATCH 4/7] refactor(effects): decompose effects mod.rs into controls, registry, and instantiate --- src-tauri/src/audio/effects/controls.rs | 267 ++++++ src-tauri/src/audio/effects/instantiate.rs | 619 +++++++++++++ src-tauri/src/audio/effects/mod.rs | 969 +-------------------- src-tauri/src/audio/effects/registry.rs | 81 ++ 4 files changed, 976 insertions(+), 960 deletions(-) create mode 100644 src-tauri/src/audio/effects/controls.rs create mode 100644 src-tauri/src/audio/effects/instantiate.rs create mode 100644 src-tauri/src/audio/effects/registry.rs diff --git a/src-tauri/src/audio/effects/controls.rs b/src-tauri/src/audio/effects/controls.rs new file mode 100644 index 0000000..f2a1180 --- /dev/null +++ b/src-tauri/src/audio/effects/controls.rs @@ -0,0 +1,267 @@ +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; + +use serde_json::Value; + +use super::noise_suppressor::NoiseSuppressorControls; +use super::util::{db_to_linear, num, store_f32}; + +#[derive(Clone)] +pub enum EffectControl { + Gain { + linear: Arc, + }, + Mute { + muted: Arc, + }, + ChannelBalance { + left: Arc, + right: Arc, + }, + Saturator { + ceiling: Arc, + drive: Arc, + }, + Eq { + /// One gain atomic per ISO octave band; see EQ_FREQUENCIES_HZ for order. + gains: [Arc; 10], + }, + Limiter { + ceiling: Arc, + release_ms: Arc, + }, + Compressor { + threshold_db: Arc, + ratio: Arc, + attack_ms: Arc, + release_ms: Arc, + knee_db: Arc, + makeup_db: Arc, + }, + NoiseGate { + threshold_db: Arc, + range_db: Arc, + attack_ms: Arc, + hold_ms: Arc, + release_ms: Arc, + }, + Delay { + time_ms: Arc, + feedback: Arc, + mix: Arc, + }, + Reverb { + room_size: Arc, + damping: Arc, + width: Arc, + mix: Arc, + }, + NoiseSuppressor { + controls: NoiseSuppressorControls, + }, + Declick { + sensitivity: Arc, + max_width_ms: Arc, + }, + DeEsser { + frequency: Arc, + threshold_db: Arc, + ratio: Arc, + }, + Plugin { + // Shared with the RT `PluginNode`; UI param writes flow through it. + events: Arc, + }, +} + +impl EffectControl { + /// Unknown keys are silently ignored — the frontend pushes the full + /// camelCase payload of the node, only some keys map to live controls. + pub fn apply_update(&self, data: &Value) { + match self { + EffectControl::Gain { linear } => { + if let Some(db) = num(data, "gainDb") { + store_f32(linear, db_to_linear(db)); + } + } + EffectControl::Mute { muted } => { + if let Some(b) = data.get("muted").and_then(Value::as_bool) { + muted.store(b, Ordering::Relaxed); + } + } + EffectControl::ChannelBalance { left, right } => { + if let Some(db) = num(data, "leftGainDb") { + store_f32(left, db_to_linear(db)); + } + if let Some(db) = num(data, "rightGainDb") { + store_f32(right, db_to_linear(db)); + } + } + EffectControl::Saturator { ceiling, drive } => { + if let Some(db) = num(data, "thresholdDb") { + let c = db_to_linear(db).max(1e-6); + store_f32(ceiling, c); + } + if let Some(db) = num(data, "driveDb") { + store_f32(drive, db_to_linear(db)); + } + } + EffectControl::Eq { gains } => { + if let Some(arr) = data.get("gainsDb").and_then(Value::as_array) { + for (i, slot) in gains.iter().enumerate() { + if let Some(v) = arr.get(i).and_then(Value::as_f64) { + store_f32(slot, v as f32); + } + } + } + } + EffectControl::Limiter { + ceiling, + release_ms, + } => { + if let Some(db) = num(data, "ceilingDb") { + store_f32(ceiling, db_to_linear(db).max(1e-6)); + } + if let Some(ms) = num(data, "releaseMs") { + store_f32(release_ms, ms.max(0.1)); + } + } + EffectControl::Compressor { + threshold_db, + ratio, + attack_ms, + release_ms, + knee_db, + makeup_db, + } => { + if let Some(v) = num(data, "thresholdDb") { + store_f32(threshold_db, v); + } + if let Some(v) = num(data, "ratio") { + store_f32(ratio, v.max(1.0)); + } + if let Some(v) = num(data, "attackMs") { + store_f32(attack_ms, v.max(0.01)); + } + if let Some(v) = num(data, "releaseMs") { + store_f32(release_ms, v.max(0.1)); + } + if let Some(v) = num(data, "kneeDb") { + store_f32(knee_db, v.max(0.0)); + } + if let Some(v) = num(data, "makeupDb") { + store_f32(makeup_db, v); + } + } + EffectControl::NoiseGate { + threshold_db, + range_db, + attack_ms, + hold_ms, + release_ms, + } => { + if let Some(v) = num(data, "thresholdDb") { + store_f32(threshold_db, v); + } + if let Some(v) = num(data, "rangeDb") { + store_f32(range_db, v.min(0.0)); + } + if let Some(v) = num(data, "attackMs") { + store_f32(attack_ms, v.max(0.01)); + } + if let Some(v) = num(data, "holdMs") { + store_f32(hold_ms, v.max(0.0)); + } + if let Some(v) = num(data, "releaseMs") { + store_f32(release_ms, v.max(0.1)); + } + } + EffectControl::Delay { + time_ms, + feedback, + mix, + } => { + if let Some(v) = num(data, "timeMs") { + store_f32(time_ms, v.max(1.0)); + } + if let Some(v) = num(data, "feedback") { + store_f32(feedback, v.clamp(0.0, 0.95)); + } + if let Some(v) = num(data, "mix") { + store_f32(mix, v.clamp(0.0, 1.0)); + } + } + EffectControl::Reverb { + room_size, + damping, + width, + mix, + } => { + if let Some(v) = num(data, "roomSize") { + store_f32(room_size, v.clamp(0.0, 1.0)); + } + if let Some(v) = num(data, "damping") { + store_f32(damping, v.clamp(0.0, 1.0)); + } + if let Some(v) = num(data, "width") { + store_f32(width, v.clamp(0.0, 1.0)); + } + if let Some(v) = num(data, "mix") { + store_f32(mix, v.clamp(0.0, 1.0)); + } + } + EffectControl::NoiseSuppressor { controls } => { + if let Some(v) = num(data, "attenuationLimitDb") { + store_f32(&controls.atten_lim_db, v.max(0.0)); + } + if let Some(v) = num(data, "postFilterBeta") { + store_f32(&controls.pf_beta, v.max(0.0)); + } + if let Some(v) = num(data, "minThreshDb") { + store_f32(&controls.min_thresh_db, v); + } + if let Some(v) = num(data, "maxErbThreshDb") { + store_f32(&controls.max_erb_thresh_db, v); + } + if let Some(v) = num(data, "maxDfThreshDb") { + store_f32(&controls.max_df_thresh_db, v); + } + } + EffectControl::Declick { + sensitivity, + max_width_ms, + } => { + if let Some(v) = num(data, "sensitivity") { + store_f32(sensitivity, v.clamp(0.0, 1.0)); + } + if let Some(v) = num(data, "maxWidthMs") { + store_f32(max_width_ms, v.clamp(0.3, 5.0)); + } + } + EffectControl::DeEsser { + frequency, + threshold_db, + ratio, + } => { + if let Some(v) = num(data, "frequency") { + store_f32(frequency, v.clamp(2000.0, 16000.0)); + } + if let Some(v) = num(data, "thresholdDb") { + store_f32(threshold_db, v.clamp(-80.0, 0.0)); + } + if let Some(v) = num(data, "ratio") { + store_f32(ratio, v.clamp(1.0, 12.0)); + } + } + EffectControl::Plugin { events } => { + if let Some(map) = data.get("pluginParams").and_then(Value::as_object) { + for (id, v) in map { + if let (Ok(id), Some(value)) = (id.parse::(), v.as_f64()) { + events.push(id, value); + } + } + } + } + } + } +} diff --git a/src-tauri/src/audio/effects/instantiate.rs b/src-tauri/src/audio/effects/instantiate.rs new file mode 100644 index 0000000..3907152 --- /dev/null +++ b/src-tauri/src/audio/effects/instantiate.rs @@ -0,0 +1,619 @@ +use std::sync::atomic::{AtomicBool, AtomicU32}; +use std::sync::Arc; + +use crate::audio::graph::EffectSpec; +use crate::audio::plugins::host_api::HostedEffect; + +use super::channel_balance::ChannelBalanceEffect; +use super::compressor::CompressorEffect; +use super::controls::EffectControl; +use super::de_esser::DeEsserEffect; +use super::declick::DeclickEffect; +use super::delay::DelayEffect; +use super::eq::EqEffect; +use super::gain::GainEffect; +use super::level_meter::{LevelMeterEffect, MeterHandle}; +use super::limiter::LimiterEffect; +use super::lufs_meter::{LufsHandle, LufsMeterEffect}; +use super::mute::MuteEffect; +use super::noise_gate::NoiseGateEffect; +use super::noise_suppressor::NoiseSuppressorEffect; +use super::registry::{EffectBuild, EffectRegistry, GrHandle}; +use super::reverb::ReverbEffect; +use super::saturator::SaturatorEffect; +use super::waveform::{WaveformEffect, WaveformHandle}; +use super::{RuntimeEffect, PLUGIN_MAX_BLOCK}; + +pub fn instantiate_effect( + spec: &EffectSpec, + node_id: &str, + sample_rate: u32, + // False for file-recording outputs: an offline render outruns real time, + // so an expensive effect must process in place instead of on a worker. + realtime: bool, + // False when building the monitor graph: a plugin instantiated there is a + // metering-only duplicate, not the one its editor window attaches to. + primary: bool, + // Channels this node carries. An effect that can take them all says so + // through `EffectBuild::full_width`; the rest are driven one pair at a time. + channels: usize, + registry: &mut EffectRegistry, +) -> EffectBuild { + let (bypass, bypass_is_new) = match registry.bypasses.get(node_id) { + Some(b) => (b.clone(), false), + None => { + let b = Arc::new(AtomicBool::new(spec.bypassed())); + registry.bypasses.insert(node_id.to_string(), b.clone()); + (b, true) + } + }; + let mk = |effect: RuntimeEffect, + control: Option, + meter: Option, + lufs: Option, + gr: Option, + scope: Option| EffectBuild { + effect, + control, + meter, + lufs, + gr, + scope, + bypass: bypass.clone(), + bypass_is_new, + full_width: false, + }; + match *spec { + EffectSpec::Gain(d) => match registry.controls.get(node_id) { + Some(EffectControl::Gain { linear }) => mk( + RuntimeEffect::Gain(GainEffect::from_state(linear.clone())), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = GainEffect::new(d); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::Gain(e), Some(c), None, None, None, None) + } + }, + EffectSpec::Mute(d) => match registry.controls.get(node_id) { + Some(EffectControl::Mute { muted }) => mk( + RuntimeEffect::Mute(MuteEffect::from_state(muted.clone())), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = MuteEffect::new(d); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::Mute(e), Some(c), None, None, None, None) + } + }, + EffectSpec::ChannelBalance(d) => match registry.controls.get(node_id) { + Some(EffectControl::ChannelBalance { left, right }) => mk( + RuntimeEffect::ChannelBalance(ChannelBalanceEffect::from_state( + left.clone(), + right.clone(), + )), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = ChannelBalanceEffect::new(d); + registry.controls.insert(node_id.to_string(), c.clone()); + mk( + RuntimeEffect::ChannelBalance(e), + Some(c), + None, + None, + None, + None, + ) + } + }, + EffectSpec::Saturator(d) => match registry.controls.get(node_id) { + Some(EffectControl::Saturator { ceiling, drive }) => mk( + RuntimeEffect::Saturator(SaturatorEffect::from_state( + ceiling.clone(), + drive.clone(), + )), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = SaturatorEffect::new(d); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::Saturator(e), Some(c), None, None, None, None) + } + }, + EffectSpec::Eq(d) => match registry.controls.get(node_id) { + Some(EffectControl::Eq { gains }) => mk( + RuntimeEffect::Eq(EqEffect::from_state(gains.clone(), sample_rate)), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = EqEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::Eq(e), Some(c), None, None, None, None) + } + }, + EffectSpec::LevelMeter(d) => match registry.meters.get(node_id) { + Some(handle) => mk( + RuntimeEffect::LevelMeter(LevelMeterEffect::from_handle(handle.clone())), + None, + None, + None, + None, + None, + ), + None => { + let (e, handle) = LevelMeterEffect::new(d, node_id.to_string()); + registry.meters.insert(node_id.to_string(), handle.clone()); + mk( + RuntimeEffect::LevelMeter(e), + None, + Some(handle), + None, + None, + None, + ) + } + }, + EffectSpec::LufsMeter(d) => match registry.lufs.get(node_id) { + Some(handle) => mk( + RuntimeEffect::LufsMeter(LufsMeterEffect::from_handle(handle.clone(), sample_rate)), + None, + None, + None, + None, + None, + ), + None => { + let (e, handle) = LufsMeterEffect::new(d, node_id.to_string(), sample_rate); + registry.lufs.insert(node_id.to_string(), handle.clone()); + mk( + RuntimeEffect::LufsMeter(e), + None, + None, + Some(handle), + None, + None, + ) + } + }, + EffectSpec::Waveform(d) => match registry.scopes.get(node_id) { + Some(handle) => mk( + RuntimeEffect::Waveform(WaveformEffect::from_handle(handle.clone())), + None, + None, + None, + None, + None, + ), + None => { + let (e, handle) = WaveformEffect::new(d, node_id.to_string(), sample_rate); + registry.scopes.insert(node_id.to_string(), handle.clone()); + mk( + RuntimeEffect::Waveform(e), + None, + None, + None, + None, + Some(handle), + ) + } + }, + // Spectrum reuses the scope's time-domain capture and SCOPE_EVENT + // transport; the UI runs the FFT. No distinct runtime effect is needed. + EffectSpec::Spectrum(_) => match registry.scopes.get(node_id) { + Some(handle) => mk( + RuntimeEffect::Waveform(WaveformEffect::from_handle(handle.clone())), + None, + None, + None, + None, + None, + ), + None => { + let (e, handle) = WaveformEffect::new_for(node_id.to_string(), sample_rate); + registry.scopes.insert(node_id.to_string(), handle.clone()); + mk( + RuntimeEffect::Waveform(e), + None, + None, + None, + None, + Some(handle), + ) + } + }, + EffectSpec::Limiter(d) => match registry.controls.get(node_id) { + Some(EffectControl::Limiter { + ceiling, + release_ms, + }) => { + let lookahead_frames = + ((d.lookahead_ms.max(0.1) * sample_rate as f32 / 1000.0) as usize).max(1); + let gr_arc = registry + .gr_atomics + .get(node_id) + .cloned() + .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))); + registry + .gr_atomics + .insert(node_id.to_string(), gr_arc.clone()); + let gr = GrHandle { + node_id: node_id.to_string(), + gr_lin: gr_arc.clone(), + }; + mk( + RuntimeEffect::Limiter(LimiterEffect::from_state( + ceiling.clone(), + release_ms.clone(), + lookahead_frames, + sample_rate, + gr_arc, + )), + None, + None, + None, + Some(gr), + None, + ) + } + _ => { + let (e, c, gr_arc) = LimiterEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + registry + .gr_atomics + .insert(node_id.to_string(), gr_arc.clone()); + let gr = GrHandle { + node_id: node_id.to_string(), + gr_lin: gr_arc, + }; + mk( + RuntimeEffect::Limiter(e), + Some(c), + None, + None, + Some(gr), + None, + ) + } + }, + EffectSpec::Compressor(d) => match registry.controls.get(node_id) { + Some(EffectControl::Compressor { + threshold_db, + ratio, + attack_ms, + release_ms, + knee_db, + makeup_db, + }) => { + let gr_arc = registry + .gr_atomics + .get(node_id) + .cloned() + .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))); + registry + .gr_atomics + .insert(node_id.to_string(), gr_arc.clone()); + let gr = GrHandle { + node_id: node_id.to_string(), + gr_lin: gr_arc.clone(), + }; + mk( + RuntimeEffect::Compressor(CompressorEffect::from_state( + threshold_db.clone(), + ratio.clone(), + attack_ms.clone(), + release_ms.clone(), + knee_db.clone(), + makeup_db.clone(), + sample_rate, + gr_arc, + )), + None, + None, + None, + Some(gr), + None, + ) + } + _ => { + let (e, c, gr_arc) = CompressorEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + registry + .gr_atomics + .insert(node_id.to_string(), gr_arc.clone()); + let gr = GrHandle { + node_id: node_id.to_string(), + gr_lin: gr_arc, + }; + mk( + RuntimeEffect::Compressor(e), + Some(c), + None, + None, + Some(gr), + None, + ) + } + }, + EffectSpec::NoiseGate(d) => match registry.controls.get(node_id) { + Some(EffectControl::NoiseGate { + threshold_db, + range_db, + attack_ms, + hold_ms, + release_ms, + }) => { + let gr_arc = registry + .gr_atomics + .get(node_id) + .cloned() + .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))); + registry + .gr_atomics + .insert(node_id.to_string(), gr_arc.clone()); + let gr = GrHandle { + node_id: node_id.to_string(), + gr_lin: gr_arc.clone(), + }; + mk( + RuntimeEffect::NoiseGate(NoiseGateEffect::from_state( + threshold_db.clone(), + range_db.clone(), + attack_ms.clone(), + hold_ms.clone(), + release_ms.clone(), + sample_rate, + gr_arc, + )), + None, + None, + None, + Some(gr), + None, + ) + } + _ => { + let (e, c, gr_arc) = NoiseGateEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + registry + .gr_atomics + .insert(node_id.to_string(), gr_arc.clone()); + let gr = GrHandle { + node_id: node_id.to_string(), + gr_lin: gr_arc, + }; + mk( + RuntimeEffect::NoiseGate(e), + Some(c), + None, + None, + Some(gr), + None, + ) + } + }, + EffectSpec::Delay(d) => match registry.controls.get(node_id) { + Some(EffectControl::Delay { + time_ms, + feedback, + mix, + }) => mk( + RuntimeEffect::Delay(DelayEffect::from_state( + time_ms.clone(), + feedback.clone(), + mix.clone(), + sample_rate, + )), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = DelayEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::Delay(e), Some(c), None, None, None, None) + } + }, + EffectSpec::Reverb(d) => match registry.controls.get(node_id) { + Some(EffectControl::Reverb { + room_size, + damping, + width, + mix, + }) => mk( + RuntimeEffect::Reverb(ReverbEffect::from_state( + room_size.clone(), + damping.clone(), + width.clone(), + mix.clone(), + sample_rate, + )), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = ReverbEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::Reverb(e), Some(c), None, None, None, None) + } + }, + EffectSpec::NoiseSuppressor(d) => match registry.controls.get(node_id) { + Some(EffectControl::NoiseSuppressor { controls }) => mk( + RuntimeEffect::NoiseSuppressor(NoiseSuppressorEffect::from_state( + controls.clone(), + sample_rate, + realtime, + )), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = NoiseSuppressorEffect::new(d, sample_rate, realtime); + registry.controls.insert(node_id.to_string(), c.clone()); + mk( + RuntimeEffect::NoiseSuppressor(e), + Some(c), + None, + None, + None, + None, + ) + } + }, + EffectSpec::Declick(d) => match registry.controls.get(node_id) { + Some(EffectControl::Declick { + sensitivity, + max_width_ms, + }) => mk( + RuntimeEffect::Declick(DeclickEffect::from_state( + sensitivity.clone(), + max_width_ms.clone(), + sample_rate, + )), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = DeclickEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::Declick(e), Some(c), None, None, None, None) + } + }, + EffectSpec::DeEsser(d) => match registry.controls.get(node_id) { + Some(EffectControl::DeEsser { + frequency, + threshold_db, + ratio, + }) => mk( + RuntimeEffect::DeEsser(DeEsserEffect::from_state( + frequency.clone(), + threshold_db.clone(), + ratio.clone(), + sample_rate, + )), + None, + None, + None, + None, + None, + ), + _ => { + let (e, c) = DeEsserEffect::new(d, sample_rate); + registry.controls.insert(node_id.to_string(), c.clone()); + mk(RuntimeEffect::DeEsser(e), Some(c), None, None, None, None) + } + }, + EffectSpec::Plugin { + format, + ref path, + ref plugin_id, + ref state, + .. + } => { + if path.is_empty() { + crate::audio::plugins::registry::forget(node_id); + let muted = Arc::new(AtomicBool::new(false)); + return mk( + RuntimeEffect::Mute(MuteEffect::from_state(muted)), + None, + None, + None, + None, + None, + ); + } + let Some(format) = format else { + tracing::error!(node_id, path, "plugin node has no format"); + let muted = Arc::new(AtomicBool::new(true)); + return mk( + RuntimeEffect::Mute(MuteEffect::from_state(muted)), + None, + None, + None, + None, + None, + ); + }; + let primary = primary && !registry.plugin_primary_claimed.contains(node_id); + let ring = registry + .plugin_param_rings + .entry(node_id.to_string()) + .or_insert_with(|| Arc::new(crate::audio::plugins::ParamRing::new())) + .clone(); + let request = crate::audio::plugins::host_api::ActivateRequest { + node_id, + path, + plugin_id, + sample_rate, + max_frames: PLUGIN_MAX_BLOCK, + channels, + state: state.as_deref(), + primary, + params: ring.clone(), + }; + match crate::audio::plugins::registry::activate(format, request) { + Ok(node) => { + if primary { + registry.plugin_primary_claimed.insert(node_id.to_string()); + } + let control = primary.then_some(EffectControl::Plugin { events: ring }); + let full_width = node.channels() == channels; + let mut build = mk( + RuntimeEffect::HostedPlugin(HostedEffect::new(node, realtime)), + control, + None, + None, + None, + None, + ); + build.full_width = full_width; + build + } + Err(e) => { + tracing::error!(node_id, path, plugin_id, error = %e, "plugin failed to load"); + crate::audio::plugins::registry::forget(node_id); + let muted = Arc::new(AtomicBool::new(true)); + mk( + RuntimeEffect::Mute(MuteEffect::from_state(muted)), + None, + None, + None, + None, + None, + ) + } + } + } + } +} diff --git a/src-tauri/src/audio/effects/mod.rs b/src-tauri/src/audio/effects/mod.rs index e67331d..ce42e4f 100644 --- a/src-tauri/src/audio/effects/mod.rs +++ b/src-tauri/src/audio/effects/mod.rs @@ -5,26 +5,22 @@ //! moves and mute toggles take effect within a couple of milliseconds without //! restarting the pipeline. -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; - -use serde_json::Value; - -use crate::audio::graph::EffectSpec; use crate::audio::plugins::host_api::HostedEffect; /// Fixed DSP block size; hosted-plugin scratch buffers are sized to it. Must /// stay >= the pipeline's `DSP_BLOCK_FRAMES`, or a block would overrun them. -const PLUGIN_MAX_BLOCK: usize = 1024; +pub(crate) const PLUGIN_MAX_BLOCK: usize = 1024; pub mod biquad; pub mod channel_balance; pub mod compressor; +pub mod controls; pub mod de_esser; pub mod declick; pub mod delay; pub mod eq; pub mod gain; +pub mod instantiate; pub mod level_meter; pub mod limiter; pub mod lufs_meter; @@ -32,13 +28,12 @@ pub mod mute; pub mod noise_gate; pub mod noise_suppressor; pub(crate) mod offload; +pub mod registry; pub mod reverb; pub mod saturator; -mod util; +pub(crate) mod util; pub mod waveform; -use util::{db_to_linear, num, store_f32}; - use channel_balance::ChannelBalanceEffect; use compressor::CompressorEffect; use de_esser::DeEsserEffect; @@ -51,19 +46,14 @@ use limiter::LimiterEffect; pub use lufs_meter::{LufsHandle, LufsMeterEffect}; use mute::MuteEffect; use noise_gate::NoiseGateEffect; -use noise_suppressor::{NoiseSuppressorControls, NoiseSuppressorEffect}; +use noise_suppressor::NoiseSuppressorEffect; use reverb::ReverbEffect; use saturator::SaturatorEffect; pub use waveform::{WaveformEffect, WaveformHandle}; -/// Shared atom for a dynamic-gain effect (compressor / noise gate / limiter). -/// The audio thread writes the block's minimum gain each block; the meter tick -/// thread reads it and emits `audio://gr` to the frontend. -#[derive(Clone)] -pub struct GrHandle { - pub node_id: String, - pub gr_lin: Arc, -} +pub use controls::EffectControl; +pub use instantiate::instantiate_effect; +pub use registry::{EffectBuild, EffectRegistry, GrHandle}; pub trait Effect: Send { fn process(&mut self, samples: &mut [f32], frames: usize); @@ -150,944 +140,3 @@ impl RuntimeEffect { } } -#[derive(Clone)] -pub enum EffectControl { - Gain { - linear: Arc, - }, - Mute { - muted: Arc, - }, - ChannelBalance { - left: Arc, - right: Arc, - }, - Saturator { - ceiling: Arc, - drive: Arc, - }, - Eq { - /// One gain atomic per ISO octave band; see EQ_FREQUENCIES_HZ for order. - gains: [Arc; 10], - }, - Limiter { - ceiling: Arc, - release_ms: Arc, - }, - Compressor { - threshold_db: Arc, - ratio: Arc, - attack_ms: Arc, - release_ms: Arc, - knee_db: Arc, - makeup_db: Arc, - }, - NoiseGate { - threshold_db: Arc, - range_db: Arc, - attack_ms: Arc, - hold_ms: Arc, - release_ms: Arc, - }, - Delay { - time_ms: Arc, - feedback: Arc, - mix: Arc, - }, - Reverb { - room_size: Arc, - damping: Arc, - width: Arc, - mix: Arc, - }, - NoiseSuppressor { - controls: NoiseSuppressorControls, - }, - Declick { - sensitivity: Arc, - max_width_ms: Arc, - }, - DeEsser { - frequency: Arc, - threshold_db: Arc, - ratio: Arc, - }, - Plugin { - // Shared with the RT `PluginNode`; UI param writes flow through it. - events: Arc, - }, -} - -impl EffectControl { - /// Unknown keys are silently ignored — the frontend pushes the full - /// camelCase payload of the node, only some keys map to live controls. - pub fn apply_update(&self, data: &Value) { - match self { - EffectControl::Gain { linear } => { - if let Some(db) = num(data, "gainDb") { - store_f32(linear, db_to_linear(db)); - } - } - EffectControl::Mute { muted } => { - if let Some(b) = data.get("muted").and_then(Value::as_bool) { - muted.store(b, Ordering::Relaxed); - } - } - EffectControl::ChannelBalance { left, right } => { - if let Some(db) = num(data, "leftGainDb") { - store_f32(left, db_to_linear(db)); - } - if let Some(db) = num(data, "rightGainDb") { - store_f32(right, db_to_linear(db)); - } - } - EffectControl::Saturator { ceiling, drive } => { - if let Some(db) = num(data, "thresholdDb") { - let c = db_to_linear(db).max(1e-6); - store_f32(ceiling, c); - } - if let Some(db) = num(data, "driveDb") { - store_f32(drive, db_to_linear(db)); - } - } - EffectControl::Eq { gains } => { - if let Some(arr) = data.get("gainsDb").and_then(Value::as_array) { - for (i, slot) in gains.iter().enumerate() { - if let Some(v) = arr.get(i).and_then(Value::as_f64) { - store_f32(slot, v as f32); - } - } - } - } - EffectControl::Limiter { - ceiling, - release_ms, - } => { - if let Some(db) = num(data, "ceilingDb") { - store_f32(ceiling, db_to_linear(db).max(1e-6)); - } - if let Some(ms) = num(data, "releaseMs") { - store_f32(release_ms, ms.max(0.1)); - } - } - EffectControl::Compressor { - threshold_db, - ratio, - attack_ms, - release_ms, - knee_db, - makeup_db, - } => { - if let Some(v) = num(data, "thresholdDb") { - store_f32(threshold_db, v); - } - if let Some(v) = num(data, "ratio") { - store_f32(ratio, v.max(1.0)); - } - if let Some(v) = num(data, "attackMs") { - store_f32(attack_ms, v.max(0.01)); - } - if let Some(v) = num(data, "releaseMs") { - store_f32(release_ms, v.max(0.1)); - } - if let Some(v) = num(data, "kneeDb") { - store_f32(knee_db, v.max(0.0)); - } - if let Some(v) = num(data, "makeupDb") { - store_f32(makeup_db, v); - } - } - EffectControl::NoiseGate { - threshold_db, - range_db, - attack_ms, - hold_ms, - release_ms, - } => { - if let Some(v) = num(data, "thresholdDb") { - store_f32(threshold_db, v); - } - if let Some(v) = num(data, "rangeDb") { - store_f32(range_db, v.min(0.0)); - } - if let Some(v) = num(data, "attackMs") { - store_f32(attack_ms, v.max(0.01)); - } - if let Some(v) = num(data, "holdMs") { - store_f32(hold_ms, v.max(0.0)); - } - if let Some(v) = num(data, "releaseMs") { - store_f32(release_ms, v.max(0.1)); - } - } - EffectControl::Delay { - time_ms, - feedback, - mix, - } => { - if let Some(v) = num(data, "timeMs") { - store_f32(time_ms, v.max(1.0)); - } - if let Some(v) = num(data, "feedback") { - store_f32(feedback, v.clamp(0.0, 0.95)); - } - if let Some(v) = num(data, "mix") { - store_f32(mix, v.clamp(0.0, 1.0)); - } - } - EffectControl::Reverb { - room_size, - damping, - width, - mix, - } => { - if let Some(v) = num(data, "roomSize") { - store_f32(room_size, v.clamp(0.0, 1.0)); - } - if let Some(v) = num(data, "damping") { - store_f32(damping, v.clamp(0.0, 1.0)); - } - if let Some(v) = num(data, "width") { - store_f32(width, v.clamp(0.0, 1.0)); - } - if let Some(v) = num(data, "mix") { - store_f32(mix, v.clamp(0.0, 1.0)); - } - } - EffectControl::NoiseSuppressor { controls } => { - if let Some(v) = num(data, "attenuationLimitDb") { - store_f32(&controls.atten_lim_db, v.max(0.0)); - } - if let Some(v) = num(data, "postFilterBeta") { - store_f32(&controls.pf_beta, v.max(0.0)); - } - if let Some(v) = num(data, "minThreshDb") { - store_f32(&controls.min_thresh_db, v); - } - if let Some(v) = num(data, "maxErbThreshDb") { - store_f32(&controls.max_erb_thresh_db, v); - } - if let Some(v) = num(data, "maxDfThreshDb") { - store_f32(&controls.max_df_thresh_db, v); - } - } - EffectControl::Declick { - sensitivity, - max_width_ms, - } => { - if let Some(v) = num(data, "sensitivity") { - store_f32(sensitivity, v.clamp(0.0, 1.0)); - } - if let Some(v) = num(data, "maxWidthMs") { - store_f32(max_width_ms, v.clamp(0.3, 5.0)); - } - } - EffectControl::DeEsser { - frequency, - threshold_db, - ratio, - } => { - if let Some(v) = num(data, "frequency") { - store_f32(frequency, v.clamp(2000.0, 16000.0)); - } - if let Some(v) = num(data, "thresholdDb") { - store_f32(threshold_db, v.clamp(-80.0, 0.0)); - } - if let Some(v) = num(data, "ratio") { - store_f32(ratio, v.clamp(1.0, 12.0)); - } - } - EffectControl::Plugin { events } => { - // `{ pluginParams: { "": value } }` from the node UI. - if let Some(map) = data.get("pluginParams").and_then(Value::as_object) { - for (id, v) in map { - if let (Ok(id), Some(value)) = (id.parse::(), v.as_f64()) { - events.push(id, value); - } - } - } - } - } - } -} - -pub struct EffectBuild { - pub effect: RuntimeEffect, - /// Some only on the first instantiation per node id. - pub control: Option, - /// Some only on the first instantiation per node id. - pub meter: Option, - /// Some only on the first instantiation per node id. - pub lufs: Option, - /// Some only on the first instantiation per node id, for GR-capable effects. - pub gr: Option, - /// Some only on the first instantiation per node id, for oscilloscope nodes. - pub scope: Option, - pub bypass: Arc, - pub bypass_is_new: bool, - /// The effect took the node's whole width, so the pipeline must hand it - /// every channel at once instead of splitting into stereo pairs. - pub full_width: bool, -} - -/// Shared atomics keyed by node id so a fan-out effect (one node feeding -/// multiple outputs) keeps live params in sync across instances. -#[derive(Default)] -pub struct EffectRegistry { - controls: std::collections::HashMap, - bypasses: std::collections::HashMap>, - meters: std::collections::HashMap, - lufs: std::collections::HashMap, - gr_atomics: std::collections::HashMap>, - scopes: std::collections::HashMap, - // Per-plugin UI->RT parameter queue, reused across rebuilds so the control - // handed to the frontend keeps reaching the current `PluginNode`. - plugin_param_rings: std::collections::HashMap>, - // Plugin node ids that already claimed the editor-target (primary) instance - // in the current reconcile. A node feeding several real outputs is built - // once per output; only the first claim owns the editor, the rest are - // metering/duplicate instances parked in the graveyard. - plugin_primary_claimed: std::collections::HashSet, -} - -impl EffectRegistry { - pub fn new() -> Self { - Self::default() - } - - /// Clears per-reconcile scratch. Call once before rebuilding the graphs so - /// the primary-instance claim is decided fresh each pass. - pub fn begin_reconcile(&mut self) { - self.plugin_primary_claimed.clear(); - } -} - -impl Drop for EffectRegistry { - /// A host keeps its own instance per plugin node so the UI thread can reach - /// it. Nothing else marks the end of a pipeline's life, so the registry - /// releases those holds as it goes; the instances themselves live on until - /// their RT nodes are dropped too. - fn drop(&mut self) { - for node_id in self.plugin_param_rings.keys() { - crate::audio::plugins::registry::forget(node_id); - } - } -} - -pub fn instantiate_effect( - spec: &EffectSpec, - node_id: &str, - sample_rate: u32, - // False for file-recording outputs: an offline render outruns real time, - // so an expensive effect must process in place instead of on a worker. - realtime: bool, - // False when building the monitor graph: a plugin instantiated there is a - // metering-only duplicate, not the one its editor window attaches to. - primary: bool, - // Channels this node carries. An effect that can take them all says so - // through `EffectBuild::full_width`; the rest are driven one pair at a time. - channels: usize, - registry: &mut EffectRegistry, -) -> EffectBuild { - let (bypass, bypass_is_new) = match registry.bypasses.get(node_id) { - Some(b) => (b.clone(), false), - None => { - let b = Arc::new(AtomicBool::new(spec.bypassed())); - registry.bypasses.insert(node_id.to_string(), b.clone()); - (b, true) - } - }; - let mk = |effect: RuntimeEffect, - control: Option, - meter: Option, - lufs: Option, - gr: Option, - scope: Option| EffectBuild { - effect, - control, - meter, - lufs, - gr, - scope, - bypass: bypass.clone(), - bypass_is_new, - full_width: false, - }; - match *spec { - EffectSpec::Gain(d) => match registry.controls.get(node_id) { - Some(EffectControl::Gain { linear }) => mk( - RuntimeEffect::Gain(GainEffect::from_state(linear.clone())), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = GainEffect::new(d); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::Gain(e), Some(c), None, None, None, None) - } - }, - EffectSpec::Mute(d) => match registry.controls.get(node_id) { - Some(EffectControl::Mute { muted }) => mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted.clone())), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = MuteEffect::new(d); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::Mute(e), Some(c), None, None, None, None) - } - }, - EffectSpec::ChannelBalance(d) => match registry.controls.get(node_id) { - Some(EffectControl::ChannelBalance { left, right }) => mk( - RuntimeEffect::ChannelBalance(ChannelBalanceEffect::from_state( - left.clone(), - right.clone(), - )), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = ChannelBalanceEffect::new(d); - registry.controls.insert(node_id.to_string(), c.clone()); - mk( - RuntimeEffect::ChannelBalance(e), - Some(c), - None, - None, - None, - None, - ) - } - }, - EffectSpec::Saturator(d) => match registry.controls.get(node_id) { - Some(EffectControl::Saturator { ceiling, drive }) => mk( - RuntimeEffect::Saturator(SaturatorEffect::from_state( - ceiling.clone(), - drive.clone(), - )), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = SaturatorEffect::new(d); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::Saturator(e), Some(c), None, None, None, None) - } - }, - EffectSpec::Eq(d) => match registry.controls.get(node_id) { - Some(EffectControl::Eq { gains }) => mk( - RuntimeEffect::Eq(EqEffect::from_state(gains.clone(), sample_rate)), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = EqEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::Eq(e), Some(c), None, None, None, None) - } - }, - EffectSpec::LevelMeter(d) => match registry.meters.get(node_id) { - Some(handle) => mk( - RuntimeEffect::LevelMeter(LevelMeterEffect::from_handle(handle.clone())), - None, - None, - None, - None, - None, - ), - None => { - let (e, handle) = LevelMeterEffect::new(d, node_id.to_string()); - registry.meters.insert(node_id.to_string(), handle.clone()); - mk( - RuntimeEffect::LevelMeter(e), - None, - Some(handle), - None, - None, - None, - ) - } - }, - EffectSpec::LufsMeter(d) => match registry.lufs.get(node_id) { - Some(handle) => mk( - RuntimeEffect::LufsMeter(LufsMeterEffect::from_handle(handle.clone(), sample_rate)), - None, - None, - None, - None, - None, - ), - None => { - let (e, handle) = LufsMeterEffect::new(d, node_id.to_string(), sample_rate); - registry.lufs.insert(node_id.to_string(), handle.clone()); - mk( - RuntimeEffect::LufsMeter(e), - None, - None, - Some(handle), - None, - None, - ) - } - }, - EffectSpec::Waveform(d) => match registry.scopes.get(node_id) { - Some(handle) => mk( - RuntimeEffect::Waveform(WaveformEffect::from_handle(handle.clone())), - None, - None, - None, - None, - None, - ), - None => { - let (e, handle) = WaveformEffect::new(d, node_id.to_string(), sample_rate); - registry.scopes.insert(node_id.to_string(), handle.clone()); - mk( - RuntimeEffect::Waveform(e), - None, - None, - None, - None, - Some(handle), - ) - } - }, - // Spectrum reuses the scope's time-domain capture and SCOPE_EVENT - // transport; the UI runs the FFT. No distinct runtime effect is needed. - EffectSpec::Spectrum(_) => match registry.scopes.get(node_id) { - Some(handle) => mk( - RuntimeEffect::Waveform(WaveformEffect::from_handle(handle.clone())), - None, - None, - None, - None, - None, - ), - None => { - let (e, handle) = WaveformEffect::new_for(node_id.to_string(), sample_rate); - registry.scopes.insert(node_id.to_string(), handle.clone()); - mk( - RuntimeEffect::Waveform(e), - None, - None, - None, - None, - Some(handle), - ) - } - }, - EffectSpec::Limiter(d) => match registry.controls.get(node_id) { - Some(EffectControl::Limiter { - ceiling, - release_ms, - }) => { - let lookahead_frames = - ((d.lookahead_ms.max(0.1) * sample_rate as f32 / 1000.0) as usize).max(1); - let gr_arc = registry - .gr_atomics - .get(node_id) - .cloned() - .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))); - registry - .gr_atomics - .insert(node_id.to_string(), gr_arc.clone()); - // Republished on every rebuild: without this the meter thread - // loses the handle after the first reconcile and the readout - // freezes at its initial value. - let gr = GrHandle { - node_id: node_id.to_string(), - gr_lin: gr_arc.clone(), - }; - mk( - RuntimeEffect::Limiter(LimiterEffect::from_state( - ceiling.clone(), - release_ms.clone(), - lookahead_frames, - sample_rate, - gr_arc, - )), - None, - None, - None, - Some(gr), - None, - ) - } - _ => { - let (e, c, gr_arc) = LimiterEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - registry - .gr_atomics - .insert(node_id.to_string(), gr_arc.clone()); - let gr = GrHandle { - node_id: node_id.to_string(), - gr_lin: gr_arc, - }; - mk( - RuntimeEffect::Limiter(e), - Some(c), - None, - None, - Some(gr), - None, - ) - } - }, - EffectSpec::Compressor(d) => match registry.controls.get(node_id) { - Some(EffectControl::Compressor { - threshold_db, - ratio, - attack_ms, - release_ms, - knee_db, - makeup_db, - }) => { - let gr_arc = registry - .gr_atomics - .get(node_id) - .cloned() - .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))); - registry - .gr_atomics - .insert(node_id.to_string(), gr_arc.clone()); - // Republished on every rebuild: without this the meter thread - // loses the handle after the first reconcile and the readout - // freezes at its initial value. - let gr = GrHandle { - node_id: node_id.to_string(), - gr_lin: gr_arc.clone(), - }; - mk( - RuntimeEffect::Compressor(CompressorEffect::from_state( - threshold_db.clone(), - ratio.clone(), - attack_ms.clone(), - release_ms.clone(), - knee_db.clone(), - makeup_db.clone(), - sample_rate, - gr_arc, - )), - None, - None, - None, - Some(gr), - None, - ) - } - _ => { - let (e, c, gr_arc) = CompressorEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - registry - .gr_atomics - .insert(node_id.to_string(), gr_arc.clone()); - let gr = GrHandle { - node_id: node_id.to_string(), - gr_lin: gr_arc, - }; - mk( - RuntimeEffect::Compressor(e), - Some(c), - None, - None, - Some(gr), - None, - ) - } - }, - EffectSpec::NoiseGate(d) => match registry.controls.get(node_id) { - Some(EffectControl::NoiseGate { - threshold_db, - range_db, - attack_ms, - hold_ms, - release_ms, - }) => { - let gr_arc = registry - .gr_atomics - .get(node_id) - .cloned() - .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))); - registry - .gr_atomics - .insert(node_id.to_string(), gr_arc.clone()); - // Republished on every rebuild: without this the meter thread - // loses the handle after the first reconcile and the readout - // freezes at its initial value. - let gr = GrHandle { - node_id: node_id.to_string(), - gr_lin: gr_arc.clone(), - }; - mk( - RuntimeEffect::NoiseGate(NoiseGateEffect::from_state( - threshold_db.clone(), - range_db.clone(), - attack_ms.clone(), - hold_ms.clone(), - release_ms.clone(), - sample_rate, - gr_arc, - )), - None, - None, - None, - Some(gr), - None, - ) - } - _ => { - let (e, c, gr_arc) = NoiseGateEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - registry - .gr_atomics - .insert(node_id.to_string(), gr_arc.clone()); - let gr = GrHandle { - node_id: node_id.to_string(), - gr_lin: gr_arc, - }; - mk( - RuntimeEffect::NoiseGate(e), - Some(c), - None, - None, - Some(gr), - None, - ) - } - }, - EffectSpec::Delay(d) => match registry.controls.get(node_id) { - Some(EffectControl::Delay { - time_ms, - feedback, - mix, - }) => mk( - RuntimeEffect::Delay(DelayEffect::from_state( - time_ms.clone(), - feedback.clone(), - mix.clone(), - sample_rate, - )), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = DelayEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::Delay(e), Some(c), None, None, None, None) - } - }, - EffectSpec::Reverb(d) => match registry.controls.get(node_id) { - Some(EffectControl::Reverb { - room_size, - damping, - width, - mix, - }) => mk( - RuntimeEffect::Reverb(ReverbEffect::from_state( - room_size.clone(), - damping.clone(), - width.clone(), - mix.clone(), - sample_rate, - )), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = ReverbEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::Reverb(e), Some(c), None, None, None, None) - } - }, - EffectSpec::NoiseSuppressor(d) => match registry.controls.get(node_id) { - Some(EffectControl::NoiseSuppressor { controls }) => mk( - RuntimeEffect::NoiseSuppressor(NoiseSuppressorEffect::from_state( - controls.clone(), - sample_rate, - realtime, - )), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = NoiseSuppressorEffect::new(d, sample_rate, realtime); - registry.controls.insert(node_id.to_string(), c.clone()); - mk( - RuntimeEffect::NoiseSuppressor(e), - Some(c), - None, - None, - None, - None, - ) - } - }, - EffectSpec::Declick(d) => match registry.controls.get(node_id) { - Some(EffectControl::Declick { - sensitivity, - max_width_ms, - }) => mk( - RuntimeEffect::Declick(DeclickEffect::from_state( - sensitivity.clone(), - max_width_ms.clone(), - sample_rate, - )), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = DeclickEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::Declick(e), Some(c), None, None, None, None) - } - }, - EffectSpec::DeEsser(d) => match registry.controls.get(node_id) { - Some(EffectControl::DeEsser { - frequency, - threshold_db, - ratio, - }) => mk( - RuntimeEffect::DeEsser(DeEsserEffect::from_state( - frequency.clone(), - threshold_db.clone(), - ratio.clone(), - sample_rate, - )), - None, - None, - None, - None, - None, - ), - _ => { - let (e, c) = DeEsserEffect::new(d, sample_rate); - registry.controls.insert(node_id.to_string(), c.clone()); - mk(RuntimeEffect::DeEsser(e), Some(c), None, None, None, None) - } - }, - EffectSpec::Plugin { - format, - ref path, - ref plugin_id, - ref state, - .. - } => { - // Empty path == node not yet configured: inert passthrough, not a - // failure. Silence is reserved for a real load error below. - if path.is_empty() { - crate::audio::plugins::registry::forget(node_id); - let muted = Arc::new(AtomicBool::new(false)); - return mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted)), - None, - None, - None, - None, - None, - ); - } - // Surfaced as a load failure rather than guessed at: a path without - // a format is stored data we cannot act on. - let Some(format) = format else { - tracing::error!(node_id, path, "plugin node has no format"); - let muted = Arc::new(AtomicBool::new(true)); - return mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted)), - None, - None, - None, - None, - None, - ); - }; - // Claimed only once the instance actually exists (below): a burned - // claim would leave the node with no editor target while the - // previous plugin stayed installed behind it. - let primary = primary && !registry.plugin_primary_claimed.contains(node_id); - // Every stereo pair shares the persistent per-node broadcast ring - // (kept across rebuilds); each pair reads it through its own cursor, - // so a UI write reaches all pairs, not just the first. - let ring = registry - .plugin_param_rings - .entry(node_id.to_string()) - .or_insert_with(|| Arc::new(crate::audio::plugins::ParamRing::new())) - .clone(); - let request = crate::audio::plugins::host_api::ActivateRequest { - node_id, - path, - plugin_id, - sample_rate, - max_frames: PLUGIN_MAX_BLOCK, - channels, - state: state.as_deref(), - primary, - params: ring.clone(), - }; - match crate::audio::plugins::registry::activate(format, request) { - // Only the editor-target build publishes the control, so the UI - // writes reach the audible instance. - Ok(node) => { - if primary { - registry.plugin_primary_claimed.insert(node_id.to_string()); - } - let control = primary.then_some(EffectControl::Plugin { events: ring }); - // The plugin took the node whole, so the pipeline must stop - // splitting it into pairs and hand it every channel. - let full_width = node.channels() == channels; - let mut build = mk( - RuntimeEffect::HostedPlugin(HostedEffect::new(node, realtime)), - control, - None, - None, - None, - None, - ); - build.full_width = full_width; - build - } - Err(e) => { - // Surface as silence, never a passthrough that hides the failure. - tracing::error!(node_id, path, plugin_id, error = %e, "plugin failed to load"); - crate::audio::plugins::registry::forget(node_id); - let muted = Arc::new(AtomicBool::new(true)); - mk( - RuntimeEffect::Mute(MuteEffect::from_state(muted)), - None, - None, - None, - None, - None, - ) - } - } - } - } -} diff --git a/src-tauri/src/audio/effects/registry.rs b/src-tauri/src/audio/effects/registry.rs new file mode 100644 index 0000000..694f985 --- /dev/null +++ b/src-tauri/src/audio/effects/registry.rs @@ -0,0 +1,81 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicU32}; +use std::sync::Arc; + +use super::controls::EffectControl; +use super::level_meter::MeterHandle; +use super::lufs_meter::LufsHandle; +use super::waveform::WaveformHandle; +use super::RuntimeEffect; + +/// Shared atom for a dynamic-gain effect (compressor / noise gate / limiter). +/// The audio thread writes the block's minimum gain each block; the meter tick +/// thread reads it and emits `audio://gr` to the frontend. +#[derive(Clone)] +pub struct GrHandle { + pub node_id: String, + pub gr_lin: Arc, +} + +pub struct EffectBuild { + pub effect: RuntimeEffect, + /// Some only on the first instantiation per node id. + pub control: Option, + /// Some only on the first instantiation per node id. + pub meter: Option, + /// Some only on the first instantiation per node id. + pub lufs: Option, + /// Some only on the first instantiation per node id, for GR-capable effects. + pub gr: Option, + /// Some only on the first instantiation per node id, for oscilloscope nodes. + pub scope: Option, + pub bypass: Arc, + pub bypass_is_new: bool, + /// The effect took the node's whole width, so the pipeline must hand it + /// every channel at once instead of splitting into stereo pairs. + pub full_width: bool, +} + +/// Shared atomics keyed by node id so a fan-out effect (one node feeding +/// multiple outputs) keeps live params in sync across instances. +#[derive(Default)] +pub struct EffectRegistry { + pub(super) controls: HashMap, + pub(super) bypasses: HashMap>, + pub(super) meters: HashMap, + pub(super) lufs: HashMap, + pub(super) gr_atomics: HashMap>, + pub(super) scopes: HashMap, + // Per-plugin UI->RT parameter queue, reused across rebuilds so the control + // handed to the frontend keeps reaching the current `PluginNode`. + pub(super) plugin_param_rings: HashMap>, + // Plugin node ids that already claimed the editor-target (primary) instance + // in the current reconcile. A node feeding several real outputs is built + // once per output; only the first claim owns the editor, the rest are + // metering/duplicate instances parked in the graveyard. + pub(super) plugin_primary_claimed: HashSet, +} + +impl EffectRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Clears per-reconcile scratch. Call once before rebuilding the graphs so + /// the primary-instance claim is decided fresh each pass. + pub fn begin_reconcile(&mut self) { + self.plugin_primary_claimed.clear(); + } +} + +impl Drop for EffectRegistry { + /// A host keeps its own instance per plugin node so the UI thread can reach + /// it. Nothing else marks the end of a pipeline's life, so the registry + /// releases those holds as it goes; the instances themselves live on until + /// their RT nodes are dropped too. + fn drop(&mut self) { + for node_id in self.plugin_param_rings.keys() { + crate::audio::plugins::registry::forget(node_id); + } + } +} From 94fe6405c357937d1c7afa6b9cd88d7bf3940d46 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:07:15 +0300 Subject: [PATCH 5/7] refactor(dag): decompose monolithic dag.rs into staging, nodes, graph, and builder --- src-tauri/src/audio/pipeline/dag.rs | 2030 ------------------- src-tauri/src/audio/pipeline/dag/builder.rs | 870 ++++++++ src-tauri/src/audio/pipeline/dag/graph.rs | 349 ++++ src-tauri/src/audio/pipeline/dag/mod.rs | 25 + src-tauri/src/audio/pipeline/dag/nodes.rs | 640 ++++++ src-tauri/src/audio/pipeline/dag/staging.rs | 80 + 6 files changed, 1964 insertions(+), 2030 deletions(-) delete mode 100644 src-tauri/src/audio/pipeline/dag.rs create mode 100644 src-tauri/src/audio/pipeline/dag/builder.rs create mode 100644 src-tauri/src/audio/pipeline/dag/graph.rs create mode 100644 src-tauri/src/audio/pipeline/dag/mod.rs create mode 100644 src-tauri/src/audio/pipeline/dag/nodes.rs create mode 100644 src-tauri/src/audio/pipeline/dag/staging.rs diff --git a/src-tauri/src/audio/pipeline/dag.rs b/src-tauri/src/audio/pipeline/dag.rs deleted file mode 100644 index 5bbb068..0000000 --- a/src-tauri/src/audio/pipeline/dag.rs +++ /dev/null @@ -1,2030 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use rtrb::{Consumer, Producer, RingBuffer}; -use tracing::{info, warn}; - -use crate::audio::effects::{ - instantiate_effect, update_meter, EffectControl, EffectRegistry, GrHandle, LufsHandle, - MeterHandle, RuntimeEffect, WaveformHandle, -}; -use crate::audio::graph::{EdgeKind, EffectSpec, InputSpec, NetCodec, OutputSpec, ValidGraph}; -use crate::audio::health; -use crate::audio::input_bridge::CaptureStats; -use crate::audio::netaudio::packet::Format; -use crate::audio::resample::MultiResampler; -use crate::audio::stream_recv::ChannelReceiver; -use crate::audio::streams::bulk_push_counted; -use crate::error::{AppError, AppResult}; - -/// One second of frames at the ring's own clock rate. -pub(super) fn ring_capacity_frames(sample_rate: u32) -> usize { - sample_rate.max(1) as usize -} - -/// Block size used by the resampler. 256 frames @ 48 kHz ~ 5.3 ms. -pub(super) const RESAMPLE_CHUNK: usize = 256; - -pub const DSP_BLOCK_FRAMES: usize = 1024; - -const MAX_NET_CH: u32 = crate::audio::netaudio::MAX_CHANNELS as u32; - -/// How long a source can go without delivering before the availability-paced -/// worker stops waiting on it. SCK in normal operation delivers every ~20 ms, -/// so 150 ms is ~7x headroom -- enough to avoid false positives on bursty -/// delivery, short enough that a real stall doesn't drown the FAST source's -/// ring buffer. -const STALL_THRESHOLD: Duration = Duration::from_millis(150); - -const SOURCE_BACKLOG_HIGH_BLOCKS: usize = 4; -const SOURCE_BACKLOG_LOW_BLOCKS: usize = 2; -/// Ceiling on one block's trim. Backlog drains over a few seconds instead of -/// vanishing in a single splice, which is what makes it inaudible. -const TRIM_MAX_FRAMES_PER_BLOCK: usize = 64; -/// Crossfade length across a trim's cut. Long enough to kill the step, short -/// enough that the replayed audio reads as texture rather than an echo. -const SPLICE_FADE_FRAMES: usize = 32; - -/// Fixed-capacity FIFO; allocates once. Overrun clamps and counts drops -- -/// wrapping the write head past the read head would corrupt subsequent pops. -struct StagingRing { - buf: Box<[f32]>, - head: usize, - tail: usize, - len: usize, - dropped: u64, -} - -impl StagingRing { - fn with_capacity(capacity: usize) -> Self { - Self { - buf: vec![0.0_f32; capacity].into_boxed_slice(), - head: 0, - tail: 0, - len: 0, - dropped: 0, - } - } - - #[inline] - fn len(&self) -> usize { - self.len - } - - #[allow(dead_code)] - #[inline] - fn dropped(&self) -> u64 { - self.dropped - } - - fn clear(&mut self) { - self.head = 0; - self.tail = 0; - self.len = 0; - } - - fn pop_into(&mut self, dst: &mut [f32]) -> usize { - let n = dst.len().min(self.len); - let cap = self.buf.len(); - for slot in dst.iter_mut().take(n) { - *slot = self.buf[self.head]; - self.head = if self.head + 1 == cap { - 0 - } else { - self.head + 1 - }; - } - self.len -= n; - n - } - - fn extend_from_slice(&mut self, src: &[f32]) { - let cap = self.buf.len(); - let free = cap - self.len; - debug_assert!( - src.len() <= free, - "StagingRing overrun: have {} + {} new > cap {}", - self.len, - src.len(), - cap - ); - let take = src.len().min(free); - for &v in &src[..take] { - self.buf[self.tail] = v; - self.tail = if self.tail + 1 == cap { - 0 - } else { - self.tail + 1 - }; - } - self.len += take; - let overrun = (src.len() - take) as u64; - self.dropped = self.dropped.saturating_add(overrun); - health::bump(&health::STAGING_OVERRUN_SAMPLES, overrun); - } -} - -/// One node in an output's DAG. `Source` reads from a ring + resamples, -/// `Effect` sums its upstreams' buffers and runs DSP, `Producer` emits -/// network-received audio on named channel handles. Each exposes an -/// interleaved `out_buf` of `DSP_BLOCK_FRAMES * node_channels` that downstream -/// nodes consume. -enum DagNode { - Source(SourceState), - Effect(EffectState), - Producer(ProducerState), - Consumer(ConsumerState), -} - -impl DagNode { - fn out_buf(&self) -> &[f32] { - match self { - DagNode::Source(s) => &s.out_buf, - DagNode::Effect(e) => &e.out_buf, - DagNode::Producer(p) => &p.out_buf, - // Terminal sink; validation forbids outgoing edges, so never read. - DagNode::Consumer(_) => &[], - } - } - - /// Unknown or absent handles fall back to the node's main `out_buf`. - fn out_buf_for_handle(&self, handle: Option<&str>) -> &[f32] { - let (handle_bufs, out_buf) = match self { - DagNode::Effect(e) => (&e.handle_bufs, &e.out_buf), - DagNode::Producer(p) => (&p.handle_bufs, &p.out_buf), - DagNode::Source(s) => (&s.handle_bufs, &s.out_buf), - DagNode::Consumer(_) => return self.out_buf(), - }; - match handle { - Some(h) => handle_bufs - .iter() - .find(|(id, _)| id == h) - .map(|(_, buf)| buf.as_slice()) - .unwrap_or(out_buf), - None => out_buf, - } - } -} - -/// Per-source counters + gauge read by the non-RT tick thread (`meter::spawn_xrun_thread`). -/// Every write is `Ordering::Relaxed` -- RT-safe, no allocation, no other sync. -#[derive(Clone)] -pub(super) struct SourceStats { - /// Samples zero-filled on genuine mid-stream underrun (ring ran dry while streaming). - pub xrun: Arc, - /// Samples silenced because the source delivered nothing for longer than - /// `STALL_THRESHOLD`. Silent by design, but it is still missing audio. - pub stalled: Arc, - /// Samples discarded by the backlog trim in `fill_block`. - pub trimmed: Arc, - /// Samples actually read out of the source ring. - pub consumed: Arc, - /// Ring occupancy (samples) at the end of the last `fill_block`. A gauge, - /// not a counter -- plain `store`, no accumulation. - pub level: Arc, -} - -impl SourceStats { - fn new() -> Self { - Self { - xrun: Arc::new(AtomicU64::new(0)), - stalled: Arc::new(AtomicU64::new(0)), - trimmed: Arc::new(AtomicU64::new(0)), - consumed: Arc::new(AtomicU64::new(0)), - level: Arc::new(AtomicU64::new(0)), - } - } -} - -/// Identifies one source for the tick thread: its counters plus enough -/// context (channel count, native rate) to convert sample deltas into a frame -/// rate comparable against real time. -#[derive(Clone)] -pub(super) struct SourceMeta { - pub label: String, - pub stats: SourceStats, - pub channels: usize, - pub native_sr: u32, - /// Native-rate frames this source consumes per block. The rate check needs - /// it as the counter's step size, since a window boundary can misattribute - /// a whole block. - pub frames_per_block: usize, - /// Graph id of the captured input this source reads, for matching against - /// the broadcast slot's `CaptureStats` once the bridge wires it up. `None` - /// for ring-sources and network producers -- they don't go through a - /// capture broadcast. - pub input_id: Option, - /// Owning output id (or "monitor"), the other half of the key that - /// disambiguates one input feeding several outputs. - pub output_id: String, - /// Capture-side fed/dropped counters, filled in by `pipeline/mod.rs` - /// after `BroadcastTx::add` returns them for this source's ring. - pub capture: Option, -} - -/// Identifies one output for the tick thread: its per-block counter plus the -/// sample rate that defines its expected block cadence. `channels` and `io` -/// are only meaningful for speaker outputs -- `build_output_graph` doesn't -/// know the device's real channel count yet, so the caller fills both in -/// after `start_speaker_stream` returns (see `pipeline/mod.rs`). -#[derive(Clone)] -pub(super) struct OutputMeta { - pub label: String, - pub blocks: Arc, - pub sample_rate: u32, - pub channels: usize, - pub io: Option, -} - -struct SourceState { - label: String, - channels: usize, - consumer: Consumer, - resampler: Option, - input_staging: Vec, - /// Holds a trim's crossfaded join until the refill path picks it up. - splice_tmp: Vec, - out_pending: StagingRing, - chunk_tmp: Vec, - out_buf: Vec, - input_samples_per_block: usize, - realtime: bool, - /// >STALL_THRESHOLD since last pop => zero-fill and stop waiting on this source. - last_pop_at: Instant, - first_data_logged: bool, - volume: Arc, - paused: Option>, - // u64 generation (not AtomicBool) so every output's SourceState detects the - // seek independently; swap(false) would clear the flag for the first reader. - drain: Option>, - last_drain_gen: u64, - meter: Option, - // Per-channel taps ("chK") drawn off this source. - handle_bufs: Vec<(String, Vec)>, - stats: SourceStats, -} - -impl SourceState { - fn is_stalled(&self) -> bool { - self.last_pop_at.elapsed() > STALL_THRESHOLD - } - - /// Taps are filled at the end of `fill_block`, so an early return would - /// leave them looping their last block -- a buzz at the block rate. - fn silence(&mut self) { - self.out_buf.fill(0.0); - for (_, buf) in self.handle_bufs.iter_mut() { - buf.fill(0.0); - } - } - - fn fill_block(&mut self) { - if let Some(p) = &self.paused { - if p.load(Ordering::SeqCst) { - let avail = self.consumer.slots(); - if avail > 0 { - if let Ok(chunk) = self.consumer.read_chunk(avail) { - chunk.commit_all(); - } - } - self.input_staging.clear(); - self.out_pending.clear(); - self.silence(); - return; - } - } - if let Some(d) = &self.drain { - let gen = d.load(Ordering::SeqCst); - if gen != self.last_drain_gen { - self.last_drain_gen = gen; - let avail = self.consumer.slots(); - if avail > 0 { - if let Ok(chunk) = self.consumer.read_chunk(avail) { - chunk.commit_all(); - } - } - self.input_staging.clear(); - self.out_pending.clear(); - self.silence(); - return; - } - } - // Trim input backlog toward LOW so latency stays bounded, a slice per - // block and spliced rather than cut: drift needs a trickle, and one - // discard of hundreds of milliseconds is an audible tear. - if self.realtime { - let have = self.consumer.slots(); - let high = self.input_samples_per_block * SOURCE_BACKLOG_HIGH_BLOCKS; - if have > high { - let low = self.input_samples_per_block * SOURCE_BACKLOG_LOW_BLOCKS; - let fade = SPLICE_FADE_FRAMES * self.channels; - let budget = TRIM_MAX_FRAMES_PER_BLOCK * self.channels; - let excess = (have - low).min(budget); - let drop = excess - excess % self.channels; - // The splice reads a fade-out and a fade-in around the cut, so - // the ring has to hold both on top of what it discards. - if drop > 0 && have >= drop + 2 * fade { - self.splice_trim(drop, fade); - } - } - } - let need = self.out_buf.len(); - let mut written = self.out_pending.pop_into(&mut self.out_buf[..]); - while written < need { - self.try_refill_one_chunk(); - if self.out_pending.len() == 0 { - // Ring empty too -- zero-fill the rest (real underrun). - for s in &mut self.out_buf[written..] { - *s = 0.0; - } - // A stalled/paused source silences by design; only a source that - // is actively streaming and ran dry mid-block is a real xrun. - let counter = if self.is_stalled() { - &self.stats.stalled - } else { - &self.stats.xrun - }; - counter.fetch_add((need - written) as u64, Ordering::Relaxed); - break; - } - let n = self.out_pending.pop_into(&mut self.out_buf[written..]); - written += n; - } - const ONE_BITS: u32 = 0x3F80_0000; - let vol_bits = self.volume.load(Ordering::Relaxed); - if vol_bits != ONE_BITS { - let vol = f32::from_bits(vol_bits); - for s in self.out_buf.iter_mut() { - *s *= vol; - } - } - if let Some(m) = &self.meter { - update_meter(m, &self.out_buf, self.channels); - } - if !self.handle_bufs.is_empty() { - let w = self.channels; - for (h, buf) in self.handle_bufs.iter_mut() { - if let Some(a) = parse_stereo(h) { - let c0 = (a - 1).min(w - 1); - let c1 = a.min(w - 1); - for f in 0..DSP_BLOCK_FRAMES { - buf[f * 2] = self.out_buf[f * w + c0]; - buf[f * 2 + 1] = self.out_buf[f * w + c1]; - } - } else { - let c = parse_ch(h).map(|k| (k - 1).min(w - 1)).unwrap_or(0); - for f in 0..DSP_BLOCK_FRAMES { - buf[f] = self.out_buf[f * w + c]; - } - } - } - } - self.stats - .level - .store(self.consumer.slots() as u64, Ordering::Relaxed); - } - - /// Removes `drop` samples from the input ring, crossfading the `fade` - /// samples before the cut into the `fade` after it. The joined slice leads - /// the stream through `input_staging`, so the listener hears one short - /// blend instead of a step. - fn splice_trim(&mut self, drop: usize, fade: usize) { - self.splice_tmp.clear(); - let Ok(outgoing) = self.consumer.read_chunk(fade) else { - return; - }; - let (first, second) = outgoing.as_slices(); - self.splice_tmp.extend_from_slice(first); - self.splice_tmp.extend_from_slice(second); - outgoing.commit_all(); - - if let Ok(cut) = self.consumer.read_chunk(drop) { - cut.commit_all(); - } - - if let Ok(incoming) = self.consumer.read_chunk(fade) { - let (first, second) = incoming.as_slices(); - crossfade_into(&mut self.splice_tmp, first, second, self.channels); - incoming.commit_all(); - } - - self.input_staging.extend_from_slice(&self.splice_tmp); - // What left the ring, versus what the stream actually loses: the - // fade-out is re-injected, so only the cut and the fade-in are gone. - let popped = (drop + 2 * fade) as u64; - let removed = (drop + fade) as u64; - self.stats.consumed.fetch_add(popped, Ordering::Relaxed); - self.stats.trimmed.fetch_add(removed, Ordering::Relaxed); - health::bump(&health::SOURCE_TRIM_DROPPED_SAMPLES, removed); - self.last_pop_at = Instant::now(); - } - - fn try_refill_one_chunk(&mut self) { - if let Some(rs) = &mut self.resampler { - let needed = rs.chunk_in() * self.channels; - // Bulk read what we still need (one rtrb reservation instead of - // one atomic op per sample -- RT-friendly). - let want = needed - self.input_staging.len(); - let avail = self.consumer.slots().min(want); - if avail > 0 { - if let Ok(chunk) = self.consumer.read_chunk(avail) { - let (first, second) = chunk.as_slices(); - self.input_staging.extend_from_slice(first); - self.input_staging.extend_from_slice(second); - chunk.commit_all(); - self.stats - .consumed - .fetch_add(avail as u64, Ordering::Relaxed); - self.last_pop_at = Instant::now(); - } - } - if self.input_staging.len() < needed { - return; - } - self.chunk_tmp.clear(); - if let Err(e) = rs.process_chunk(&self.input_staging[..needed], &mut self.chunk_tmp) { - warn!(source = %self.label, error = %e, "resampler chunk failed"); - self.input_staging.drain(..needed); - return; - } - self.input_staging.drain(..needed); - } else { - self.chunk_tmp.clear(); - let mut want = RESAMPLE_CHUNK * self.channels; - // A splice staged its joined frames ahead of the ring. - if !self.input_staging.is_empty() { - let n = self.input_staging.len().min(want); - self.chunk_tmp.extend_from_slice(&self.input_staging[..n]); - self.input_staging.drain(..n); - want -= n; - } - let avail = self.consumer.slots().min(want); - if avail > 0 { - if let Ok(chunk) = self.consumer.read_chunk(avail) { - let (first, second) = chunk.as_slices(); - self.chunk_tmp.extend_from_slice(first); - self.chunk_tmp.extend_from_slice(second); - chunk.commit_all(); - self.stats - .consumed - .fetch_add(avail as u64, Ordering::Relaxed); - self.last_pop_at = Instant::now(); - } - } - } - // Whole-frame guarantee (don't split a frame across channels). - let frames = self.chunk_tmp.len() / self.channels; - self.chunk_tmp.truncate(frames * self.channels); - if !self.chunk_tmp.is_empty() { - if !self.first_data_logged { - info!(source = %self.label, "source online"); - self.first_data_logged = true; - } - self.out_pending.extend_from_slice(&self.chunk_tmp); - } - } -} - -struct EffectState { - // One instance per stereo pair; each carries its own DSP state but shares - // the parameter atomics. `effects[0]` alone for width <= 2. - effects: Vec, - // Analyzers (level meter) read the whole N-wide buffer at once instead of - // being split into stereo pairs, so they report every channel. - full_width: bool, - bypass: Arc, - incoming: Vec, - sidechain: Vec, - out_buf: Vec, - sidechain_buf: Option>, - // Scratch for deinterleaving one pair out of a >2-wide buffer. - pair_main: Vec, - pair_side: Vec, - handle_bufs: Vec<(String, Vec)>, - // When this node fans out to several outputs it is computed once (here, in - // its owning output's graph) and its `out_buf` is published each block into - // one ring per other consuming output, which reads it via a ring-source. - taps: Vec>, -} - -impl EffectState { - /// Run the effect chain over `out_buf`. Width <= 2 processes in place; wider - /// buffers are split into stereo pairs, each through its own instance so - /// per-channel filter state never bleeds across pairs. - fn run(&mut self, frames: usize) { - let w = self.out_buf.len() / frames; - if self.full_width || w == 2 { - let sc = self.sidechain_buf.as_deref(); - self.effects[0].process_with_sidechain(&mut self.out_buf, sc, frames); - return; - } - for p in 0..self.effects.len() { - let (c0, c1) = (2 * p, 2 * p + 1); - for f in 0..frames { - let base = f * w; - self.pair_main[f * 2] = self.out_buf[base + c0]; - self.pair_main[f * 2 + 1] = if c1 < w { self.out_buf[base + c1] } else { 0.0 }; - } - let sc = if let Some(scb) = self.sidechain_buf.as_ref() { - for f in 0..frames { - let base = f * w; - self.pair_side[f * 2] = scb[base + c0]; - self.pair_side[f * 2 + 1] = if c1 < w { scb[base + c1] } else { 0.0 }; - } - Some(self.pair_side.as_slice()) - } else { - None - }; - self.effects[p].process_with_sidechain(&mut self.pair_main, sc, frames); - for f in 0..frames { - let base = f * w; - self.out_buf[base + c0] = self.pair_main[f * 2]; - if c1 < w { - self.out_buf[base + c1] = self.pair_main[f * 2 + 1]; - } - } - } - } -} - -/// A source with no graph inputs that emits network-received audio: `out_buf` -/// is the mix of every channel, `handle_bufs` are the per-channel outputs (keyed -/// by the source handle id, which matches the tap key). -struct ProducerState { - receiver: ChannelReceiver, - out_buf: Vec, - handle_bufs: Vec<(String, Vec)>, - /// What each `handle_bufs` entry draws from the tap map, which is keyed by - /// what the sender stamped rather than by the handle the UI draws. - wire_keys: Vec, -} - -/// A handle either reads one tap or sums a group of them: a WebRTC peer's mix -/// is every channel that peer sends, and the peer is the key prefix. -enum TapKey { - Channel(String), - PrefixMix(String), -} - -impl ProducerState { - fn process(&mut self) { - self.receiver.mix_block(&mut self.out_buf); - for ((_, buf), key) in self.handle_bufs.iter_mut().zip(&self.wire_keys) { - match key { - TapKey::Channel(k) => self.receiver.channel(k, buf), - TapKey::PrefixMix(p) => self.receiver.prefix_mix(p, buf), - } - } - } -} - -/// A terminal sink that consumes per-channel inputs (summed by target handle -/// into `channel_bufs`, keyed "ch1".."chN") and pushes each channel into its -/// send ring for a background transmitter (direct-IP NetSender). -struct ConsumerState { - incoming: Vec, - channel_bufs: Vec<(String, Vec)>, - send_producers: Vec>, -} - -/// `delay` is `Some` when this path is shorter than the longest reaching the -/// same mixing point -- pads it for sample-alignment before summing. -struct IncomingEdge { - src_idx: usize, - source_handle: Option, - target_handle: Option, - delay: Option, -} - -struct TerminalEdge { - src_idx: usize, - source_handle: Option, - /// `Some((off, width))` routes this edge to a physical output block: width 1 - /// (`chK`) downmixes to mono at `off`, width 2 (`stA`) places a stereo pair. - route: Option<(usize, usize)>, - delay: Option, -} - -/// Parse a `chK` handle into its 1-based channel number. -#[inline] -fn parse_ch(handle: &str) -> Option { - handle - .strip_prefix("ch") - .and_then(|s| s.parse::().ok()) -} - -/// What a network producer's source handle reads from the tap map. `chN` is the -/// direct-IP wire index (0-based on the wire, 1-based in the UI); `peer::` -/// is a WebRTC tap key verbatim, and `peer:` sums that peer's channels. -fn tap_key(handle: &str) -> Option { - if let Some(rest) = handle.strip_prefix("peer:") { - return Some(if rest.contains(':') { - TapKey::Channel(rest.to_string()) - } else { - TapKey::PrefixMix(format!("{rest}:")) - }); - } - parse_ch(handle).map(|ch| TapKey::Channel((ch - 1).to_string())) -} - -/// Parse an `stA` stereo-group handle into its 1-based lower channel; the group -/// carries channels A and A+1. -#[inline] -fn parse_stereo(handle: &str) -> Option { - handle - .strip_prefix("st") - .and_then(|s| s.parse::().ok()) -} - -/// Channel width an edge actually carries. A `chK`/`stA` source handle taps a -/// slice of its node, so the node's own width would be wrong. -fn edge_channels( - nodes: &[DagNode], - node_channels: &[usize], - idx: usize, - source_handle: Option<&str>, -) -> usize { - match source_handle { - Some(h) if tap_handle_width(h).is_some() => { - nodes[idx].out_buf_for_handle(Some(h)).len() / DSP_BLOCK_FRAMES - } - _ => node_channels[idx], - } -} - -/// A per-channel tap handle (`chK` mono or `stA` stereo) and its channel width. -#[inline] -fn tap_handle_width(handle: &str) -> Option { - if parse_stereo(handle).is_some() { - Some(2) - } else if parse_ch(handle).is_some() { - Some(1) - } else { - None - } -} - -/// Route a target handle to a `(physical channel offset, width)` block: `chK` -/// lands on one channel, `stA` on the pair starting at A. -#[inline] -fn target_route(handle: &str) -> Option<(usize, usize)> { - if let Some(a) = parse_stereo(handle) { - Some((a - 1, 2)) - } else if let Some(k) = parse_ch(handle) { - Some((k - 1, 1)) - } else { - None - } -} - -/// Sum `src` into `dst` mapping channel-for-channel when the two have different -/// widths (min of the two; extra source channels dropped, extra dest channels -/// left untouched). Widths are inferred from length: `len / DSP_BLOCK_FRAMES`. -#[inline] -fn add_mapped(src: &[f32], dst: &mut [f32]) { - let src_ch = src.len() / DSP_BLOCK_FRAMES; - let dst_ch = dst.len() / DSP_BLOCK_FRAMES; - if src_ch == 0 || dst_ch == 0 { - return; - } - if src_ch == dst_ch { - for (d, &s) in dst.iter_mut().zip(src.iter()) { - *d += s; - } - return; - } - if dst_ch == 1 { - let g = 1.0 / src_ch as f32; - for f in 0..DSP_BLOCK_FRAMES { - let sb = f * src_ch; - let mut acc = 0.0; - for c in 0..src_ch { - acc += src[sb + c]; - } - dst[f] += acc * g; - } - return; - } - if src_ch == 1 { - // Mono upmix: a single-channel source feeds every destination channel. - for f in 0..DSP_BLOCK_FRAMES { - let v = src[f]; - let db = f * dst_ch; - for c in 0..dst_ch { - dst[db + c] += v; - } - } - return; - } - let n = src_ch.min(dst_ch); - for f in 0..DSP_BLOCK_FRAMES { - let sb = f * src_ch; - let db = f * dst_ch; - for c in 0..n { - dst[db + c] += src[sb + c]; - } - } -} - -/// Add `src` (downmixed to mono) into a single physical channel `ch` of `dst`. -#[inline] -fn add_to_channel(src: &[f32], dst: &mut [f32], ch: usize) { - let src_ch = src.len() / DSP_BLOCK_FRAMES; - let dst_ch = dst.len() / DSP_BLOCK_FRAMES; - if src_ch == 0 || ch >= dst_ch { - return; - } - let g = 1.0 / src_ch as f32; - for f in 0..DSP_BLOCK_FRAMES { - let sb = f * src_ch; - let mut acc = 0.0; - for c in 0..src_ch { - acc += src[sb + c]; - } - dst[f * dst_ch + ch] += acc * g; - } -} - -/// Place `src`'s channels into `dst` starting at channel `off`. Distinct offsets -/// leave inputs side by side; `dst` is zeroed each block so this is a copy. -#[inline] -fn add_block_at(src: &[f32], dst: &mut [f32], off: usize) { - let src_ch = src.len() / DSP_BLOCK_FRAMES; - let dst_ch = dst.len() / DSP_BLOCK_FRAMES; - if src_ch == 0 || off >= dst_ch { - return; - } - let n = src_ch.min(dst_ch - off); - for f in 0..DSP_BLOCK_FRAMES { - let sb = f * src_ch; - let db = f * dst_ch + off; - for c in 0..n { - dst[db + c] += src[sb + c]; - } - } -} - -/// Pure time shift. It deliberately does no channel mapping: the caller adds -/// the shifted block through the same `add_*` path an undelayed edge takes, so -/// routing, upmix and downmix cannot drift between the two. -/// Blends `dst` (the audio before a trim's cut) into the audio after it, given -/// as a ring's two halves. Both ends stay continuous: frame 0 is pure `dst` and -/// the last frame is pure incoming, so neither join is a step. -fn crossfade_into(dst: &mut [f32], first: &[f32], second: &[f32], channels: usize) { - let span = (SPLICE_FADE_FRAMES - 1).max(1) as f32; - for (i, s) in first.iter().chain(second.iter()).enumerate() { - if i >= dst.len() { - break; - } - let w = ((i / channels) as f32 / span).min(1.0); - dst[i] = dst[i] * (1.0 - w) + s * w; - } -} - -struct DelayLine { - buf: Box<[f32]>, - scratch: Box<[f32]>, - pos: usize, -} - -impl DelayLine { - fn new(delay_frames: usize, channels: usize) -> Self { - Self { - buf: vec![0.0; delay_frames * channels].into_boxed_slice(), - scratch: vec![0.0; DSP_BLOCK_FRAMES * channels].into_boxed_slice(), - pos: 0, - } - } - - fn delayed<'a>(&'a mut self, input: &'a [f32]) -> &'a [f32] { - let cap = self.buf.len(); - if cap == 0 { - return input; - } - let n = input.len().min(self.scratch.len()); - let mut pos = self.pos; - for i in 0..n { - self.scratch[i] = self.buf[pos]; - self.buf[pos] = input[i]; - pos = if pos + 1 == cap { 0 } else { pos + 1 }; - } - self.pos = pos; - &self.scratch[..n] - } -} - -/// Per-output DAG runtime: sources + effects in topological order plus the -/// terminal edges whose buffers get summed into the final output. -pub(super) struct OutputGraph { - sample_rate: u32, - /// Interleaved channel width of `process_block`'s output. Stereo unless a - /// speaker sets it to the device's channel count. - out_channels: usize, - nodes: Vec, - terminals: Vec, - /// Lookahead the graph's delay compensation has aligned every path to: the - /// deepest cumulative effect latency from any source to this output. The - /// whole mix is delayed by this, so it is the graph's own latency. - latency_frames: usize, - /// Blocks produced by `process_block`. A clone lives in this build's - /// `BuiltOutputGraph::output` so the non-RT tick thread can compare this - /// worker's real block rate against `sample_rate / DSP_BLOCK_FRAMES`. - blocks: Arc, -} - -impl OutputGraph { - pub(super) fn sample_rate(&self) -> u32 { - self.sample_rate - } - - pub(super) fn out_channels(&self) -> usize { - self.out_channels - } - - pub(super) fn latency_frames(&self) -> usize { - self.latency_frames - } - - pub(super) fn set_out_channels(&mut self, channels: usize) { - self.out_channels = channels; - } - - pub(super) fn active_output_channels(&self) -> usize { - self.terminals - .iter() - .map(|terminal| match terminal.route { - Some((offset, width)) => offset + width, - None => { - self.nodes[terminal.src_idx] - .out_buf_for_handle(terminal.source_handle.as_deref()) - .len() - / DSP_BLOCK_FRAMES - } - }) - .max() - .unwrap_or(1) - .clamp(1, self.out_channels) - } - - /// Attach a publish ring to a fan-out effect node; its `out_buf` is pushed - /// there each block for another output's ring-source to read. - pub(super) fn attach_tap(&mut self, node_idx: usize, prod: Producer) { - if let Some(DagNode::Effect(e)) = self.nodes.get_mut(node_idx) { - e.taps.push(prod); - } - } - - /// Fill `output` (`DSP_BLOCK_FRAMES * out_channels` long) with one block of - /// mixed audio at `sample_rate`. - pub(super) fn process_block(&mut self, output: &mut [f32]) { - self.blocks.fetch_add(1, Ordering::Relaxed); - for node in &mut self.nodes { - match node { - DagNode::Source(s) => s.fill_block(), - DagNode::Producer(p) => p.process(), - DagNode::Effect(_) | DagNode::Consumer(_) => {} - } - } - // `split_at_mut` gives mutable access to effect `i` while keeping - // immutable access to its upstreams (all at indices < i by topo sort). - for i in 0..self.nodes.len() { - let (head, tail) = self.nodes.split_at_mut(i); - if let DagNode::Consumer(cons) = &mut tail[0] { - for (_, buf) in cons.channel_bufs.iter_mut() { - for s in buf.iter_mut() { - *s = 0.0; - } - } - for edge in &mut cons.incoming { - let src = head[edge.src_idx].out_buf_for_handle(edge.source_handle.as_deref()); - let target = edge.target_handle.as_deref(); - let src = match &mut edge.delay { - Some(d) => d.delayed(src), - None => src, - }; - let Some((_, buf)) = cons - .channel_bufs - .iter_mut() - .find(|(h, _)| Some(h.as_str()) == target) - else { - continue; - }; - add_mapped(src, buf); - } - for (i, (_, buf)) in cons.channel_bufs.iter().enumerate() { - if let Some(prod) = cons.send_producers.get_mut(i) { - bulk_push_counted(prod, buf, &health::TAP_RING_OVERRUN_SAMPLES); - } - } - continue; - } - if let DagNode::Effect(eff) = &mut tail[0] { - for s in eff.out_buf.iter_mut() { - *s = 0.0; - } - for edge in &mut eff.incoming { - let src = head[edge.src_idx].out_buf_for_handle(edge.source_handle.as_deref()); - let route = edge.target_handle.as_deref().and_then(target_route); - let src = match &mut edge.delay { - Some(d) => d.delayed(src), - None => src, - }; - match route { - Some((off, 1)) => add_to_channel(src, &mut eff.out_buf, off), - Some((off, _)) => add_block_at(src, &mut eff.out_buf, off), - None => add_mapped(src, &mut eff.out_buf), - } - } - if let Some(sc_buf) = eff.sidechain_buf.as_mut() { - for s in sc_buf.iter_mut() { - *s = 0.0; - } - for edge in &mut eff.sidechain { - let src = - head[edge.src_idx].out_buf_for_handle(edge.source_handle.as_deref()); - let src = match &mut edge.delay { - Some(d) => d.delayed(src), - None => src, - }; - add_mapped(src, sc_buf); - } - } - if !eff.bypass.load(Ordering::Relaxed) { - eff.run(DSP_BLOCK_FRAMES); - } - let w = eff.out_buf.len() / DSP_BLOCK_FRAMES; - for (h, buf) in eff.handle_bufs.iter_mut() { - if let Some(a) = parse_stereo(h) { - let c0 = (a - 1).min(w - 1); - let c1 = a.min(w - 1); - for f in 0..DSP_BLOCK_FRAMES { - buf[f * 2] = eff.out_buf[f * w + c0]; - buf[f * 2 + 1] = eff.out_buf[f * w + c1]; - } - } else if let Some(k) = parse_ch(h) { - let c = (k - 1).min(w - 1); - for f in 0..DSP_BLOCK_FRAMES { - buf[f] = eff.out_buf[f * w + c]; - } - } - } - // Publish the processed block to every consuming output's ring. - for prod in eff.taps.iter_mut() { - bulk_push_counted(prod, &eff.out_buf, &health::TAP_RING_OVERRUN_SAMPLES); - } - } - } - for s in output.iter_mut() { - *s = 0.0; - } - for terminal in &mut self.terminals { - let src = - self.nodes[terminal.src_idx].out_buf_for_handle(terminal.source_handle.as_deref()); - let src = match &mut terminal.delay { - Some(d) => d.delayed(src), - None => src, - }; - match terminal.route { - Some((off, 1)) => add_to_channel(src, output, off), - Some((off, _)) => add_block_at(src, output, off), - None => add_mapped(src, output), - } - } - } -} - -pub(super) struct BuiltOutputGraph { - pub graph: OutputGraph, - pub controls: Vec<(String, EffectControl)>, - pub bypasses: Vec<(String, Arc)>, - pub meters: Vec, - pub lufs: Vec, - pub gr_handles: Vec, - pub scopes: Vec, - pub sources: Vec, - pub output: OutputMeta, - /// Effect node id -> (node index, channel width). Used to attach publish - /// taps to nodes that fan out to other outputs. - pub node_meta: HashMap, -} - -/// Build the per-output DAG: walk backward from `output_id`, topo-sort the -/// reachable sub-graph, instantiate sources (with their rings) and effects -/// (with their parameter atomics) in order. -/// -/// `output_id = None` means monitor mode: every surviving input + effect is -/// reachable (validate already trimmed anything that doesn't drive an -/// analyzer), and the resulting graph has no output terminals. -/// `producer_pairs` carries Producer ends of the ring per Source node, -/// paired with their input node id. Caller tags each pair with the owning -/// output id and routes them into the matching input's broadcast. -pub(super) fn build_output_graph( - output_id: Option<&str>, - output_sr: u32, - realtime: bool, - valid: &ValidGraph, - input_native_sr: &HashMap, - input_native_channels: &HashMap, - producer_pairs: &mut Vec<(String, Producer)>, - registry: &mut EffectRegistry, - input_volumes: &HashMap>, - input_paused: &HashMap>, - input_drain: &HashMap>, - input_meters: &HashMap, - // Effect nodes provided by a ring instead of built here: each is computed - // once in its owning output's graph and read back as a ring-source. Maps - // node id -> (ring consumer, owner-graph sample rate, channel width). - mut cut_leaves: HashMap, u32, usize)>, -) -> AppResult { - let cut_leaf_ids: HashSet = cut_leaves.keys().cloned().collect(); - let reachable: HashSet = match output_id { - Some(id) => reachable_backward_cut(id, valid, &cut_leaf_ids), - // Monitor: everything feeding an analyzer, stopping at cut nodes (whose - // processed output is read back from the owning output's ring). - None => { - let roots: Vec = valid - .effects - .iter() - .filter(|e| is_analyzer(&e.spec)) - .map(|e| e.id.clone()) - .collect(); - reachable_backward_from(&roots, valid, &cut_leaf_ids) - } - }; - - // Topo sort restricted to the reachable sub-graph. Inputs have indegree 0 - // within the sub-graph; outputs are excluded entirely (they're not DAG - // nodes here, just sinks). - let mut indegree: HashMap = HashMap::new(); - for id in &reachable { - indegree.entry(id.clone()).or_insert(0); - } - for edge in &valid.edges { - if reachable.contains(&edge.from) && reachable.contains(&edge.to) { - *indegree.entry(edge.to.clone()).or_insert(0) += 1; - } - } - let mut queue: Vec = indegree - .iter() - .filter(|(_, d)| **d == 0) - .map(|(id, _)| id.clone()) - .collect(); - queue.sort(); - let mut topo: Vec = Vec::with_capacity(reachable.len()); - while let Some(id) = queue.pop() { - topo.push(id.clone()); - for edge in &valid.edges { - if edge.from == id && reachable.contains(&edge.to) { - let d = indegree.get_mut(&edge.to).unwrap(); - *d -= 1; - if *d == 0 { - queue.push(edge.to.clone()); - } - } - } - } - if topo.len() != reachable.len() { - return Err(AppError::Validation(format!( - "internal: topo sort failed for output {}", - output_id.unwrap_or("") - ))); - } - - // Build nodes in topo order. `id_to_index` lets effects resolve their - // upstream node positions in the final Vec. - let mut nodes: Vec = Vec::with_capacity(topo.len()); - let mut id_to_index: HashMap = HashMap::new(); - // Effect node id -> (index in `nodes`, channel width). Lets the caller wire - // publish taps onto a node that fans out to other outputs' ring-sources. - let mut node_meta: HashMap = HashMap::new(); - let mut controls: Vec<(String, EffectControl)> = Vec::new(); - let mut bypasses: Vec<(String, Arc)> = Vec::new(); - let mut meters: Vec = Vec::new(); - let mut lufs: Vec = Vec::new(); - let mut gr_handles: Vec = Vec::new(); - let mut scopes: Vec = Vec::new(); - let mut sources: Vec = Vec::new(); - let mut node_latencies: Vec = Vec::with_capacity(topo.len()); - // Per-node channel width; effects inherit the max width of their upstreams. - let mut node_channels: Vec = Vec::with_capacity(topo.len()); - - for id in &topo { - // A fan-out node owned by an earlier output: read its published block - // from the ring instead of rebuilding the whole upstream chain. - if let Some((consumer, owner_sr, width)) = cut_leaves.remove(id) { - let source = ring_source(id, consumer, owner_sr, output_sr, width, realtime, valid)?; - sources.push(SourceMeta { - label: format!("{} out={}", source.label, output_id.unwrap_or("monitor")), - stats: source.stats.clone(), - channels: width, - native_sr: owner_sr, - frames_per_block: source.input_samples_per_block / width.max(1), - input_id: None, - output_id: output_id.unwrap_or("monitor").to_string(), - capture: None, - }); - id_to_index.insert(id.clone(), nodes.len()); - nodes.push(DagNode::Source(source)); - node_latencies.push(0); - node_channels.push(width); - continue; - } - if let Some(input) = valid.inputs.iter().find(|i| &i.id == id) { - // Network producers are not captured sources: they emit per-channel - // outputs from a shared jitter buffer at the output rate. The handle - // naming is all that separates the two -- direct-IP draws `chN` off - // one sender, WebRTC draws `peer:[:]` off many. - let network = match &input.spec { - InputSpec::NetReceiver { port } => { - let receiver = crate::audio::netaudio::receiver::get_or_create(id, *port); - Some(ChannelReceiver::new( - receiver.register_consumer(output_sr, realtime), - )) - } - InputSpec::WebRtcRecv { - node_id, - opus_bitrate, - opus_application, - } => { - let session = crate::audio::webrtc::get_or_create( - node_id, - *opus_bitrate, - *opus_application, - ); - Some(ChannelReceiver::new( - session.register_bridge(output_sr, realtime), - )) - } - _ => None, - }; - if let Some(receiver) = network { - let mut handles: Vec = valid - .edges - .iter() - .filter(|e| &e.from == id) - .filter_map(|e| e.source_handle.clone()) - .collect(); - handles.sort(); - handles.dedup(); - let pw = 2; - let mut handle_bufs = Vec::with_capacity(handles.len()); - let mut wire_keys = Vec::with_capacity(handles.len()); - for h in handles { - let Some(key) = tap_key(&h) else { continue }; - wire_keys.push(key); - handle_bufs.push((h, vec![0.0; DSP_BLOCK_FRAMES])); - } - id_to_index.insert(id.clone(), nodes.len()); - nodes.push(DagNode::Producer(ProducerState { - receiver, - out_buf: vec![0.0; DSP_BLOCK_FRAMES * pw], - handle_bufs, - wire_keys, - })); - node_latencies.push(0); - node_channels.push(pw); - continue; - } - // File sources are paced by backpressure; dropping backlog plays fast. - let source_realtime = realtime && !matches!(input.spec, InputSpec::AudioFile { .. }); - let input_sr = *input_native_sr - .get(id) - .ok_or_else(|| AppError::Validation(format!("input {id} has no SR")))?; - let source_channels = input_native_channels.get(id).copied().unwrap_or(2) as usize; - // Scale by channels to keep the buffered span constant in time; at - // high channel counts a smaller cushion starves on capture-clock drift. - let (producer, consumer) = - RingBuffer::::new(ring_capacity_frames(input_sr) * source_channels); - producer_pairs.push((id.clone(), producer)); - let mut ch_handles: Vec = valid - .edges - .iter() - .filter(|e| &e.from == id) - .filter_map(|e| e.source_handle.clone()) - .filter(|h| tap_handle_width(h).is_some()) - .collect(); - ch_handles.sort(); - ch_handles.dedup(); - let source_handle_bufs: Vec<(String, Vec)> = ch_handles - .into_iter() - .map(|h| { - let w = tap_handle_width(&h).unwrap_or(1); - (h, vec![0.0; DSP_BLOCK_FRAMES * w]) - }) - .collect(); - let resampler = if input_sr == output_sr { - None - } else { - Some(MultiResampler::new( - input_sr, - output_sr, - RESAMPLE_CHUNK, - source_channels, - )?) - }; - let out_max = resampler - .as_ref() - .map(|r| r.out_max()) - .unwrap_or(RESAMPLE_CHUNK); - // x4 headroom: one chunk draining + one in-flight + alignment slack. - let staging_cap = (out_max * 4 + DSP_BLOCK_FRAMES) * source_channels; - let input_frames_per_block = - (DSP_BLOCK_FRAMES as u64 * input_sr as u64 + output_sr as u64 - 1) - / output_sr as u64; - let input_samples_per_block = (input_frames_per_block as usize) * source_channels; - - let kind = match &input.spec { - InputSpec::Microphone { device_id } => format!("mic:{device_id}"), - InputSpec::SystemAudio { .. } => "system-audio".to_string(), - InputSpec::AppAudio { bundle_id } => format!("app:{bundle_id}"), - InputSpec::AudioFile { file_path } => format!("file:{file_path}"), - InputSpec::NetReceiver { .. } | InputSpec::WebRtcRecv { .. } => { - unreachable!("network inputs are built as producers") - } - }; - let label = format!( - "{kind}@{input_sr}->{output_sr} out={}", - output_id.unwrap_or("monitor") - ); - let stats = SourceStats::new(); - sources.push(SourceMeta { - label: label.clone(), - stats: stats.clone(), - channels: source_channels, - native_sr: input_sr, - frames_per_block: input_frames_per_block as usize, - input_id: Some(id.clone()), - output_id: output_id.unwrap_or("monitor").to_string(), - capture: None, - }); - let source = SourceState { - label, - channels: source_channels, - consumer, - resampler, - input_staging: Vec::with_capacity( - (RESAMPLE_CHUNK + SPLICE_FADE_FRAMES) * source_channels + 8, - ), - splice_tmp: Vec::with_capacity(SPLICE_FADE_FRAMES * source_channels), - out_pending: StagingRing::with_capacity(staging_cap), - chunk_tmp: Vec::with_capacity(out_max * source_channels), - out_buf: vec![0.0; DSP_BLOCK_FRAMES * source_channels], - input_samples_per_block, - realtime: source_realtime, - last_pop_at: Instant::now(), - first_data_logged: false, - volume: input_volumes - .get(id) - .cloned() - .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))), - paused: input_paused.get(id).cloned(), - drain: input_drain.get(id).cloned(), - last_drain_gen: 0, - meter: input_meters.get(id).cloned(), - handle_bufs: source_handle_bufs, - stats, - }; - id_to_index.insert(id.clone(), nodes.len()); - nodes.push(DagNode::Source(source)); - node_latencies.push(0); - node_channels.push(source_channels); - } else if let Some(effect) = valid.effects.iter().find(|e| &e.id == id) { - // The cut plan builds each node in exactly one graph (its owner -- - // a real output, or the monitor for analyzer-only nodes), so this - // build is the sole plugin instance and always the editor target. - type Upstream = (usize, Option, Option); - let mut main_upstream: Vec = Vec::new(); - let mut side_upstream: Vec = Vec::new(); - for e in &valid.edges { - if &e.to == id && reachable.contains(&e.from) { - let idx = id_to_index[&e.from]; - let entry = (idx, e.source_handle.clone(), e.target_handle.clone()); - match e.kind { - EdgeKind::Main => main_upstream.push(entry), - EdgeKind::Sidechain => side_upstream.push(entry), - } - } - } - let max_upstream = main_upstream - .iter() - .chain(side_upstream.iter()) - .map(|(i, _, _)| node_latencies[*i]) - .max() - .unwrap_or(0); - // Width is the max of: upstream widths, any `chK` target channel fed - // in, and any `chK` output tap drawn off this effect. - // A chK source handle carries exactly its tapped channel (mono), so - // the edge width is the tap buffer's, not the source node's full width. - let upstream_w = main_upstream - .iter() - .map(|(i, sh, _)| edge_channels(&nodes, &node_channels, *i, sh.as_deref())) - .max() - .unwrap_or(2); - let target_w = main_upstream - .iter() - .filter_map(|(_, _, t)| t.as_deref().and_then(target_route)) - .map(|(off, w)| off + w) - .max() - .unwrap_or(0); - let tap_w = valid - .edges - .iter() - .filter(|e| &e.from == id) - .filter_map(|e| e.source_handle.as_deref()) - .filter_map(|h| parse_stereo(h).map(|a| a + 1).or_else(|| parse_ch(h))) - .max() - .unwrap_or(0); - let eff_channels = upstream_w.max(target_w).max(tap_w).max(1); - // Built once the node's width is known: a plugin is offered that - // width and may take it whole, the way a DAW instantiates one - // multichannel plugin instead of several stereo ones. - let build = instantiate_effect( - &effect.spec, - id, - output_sr, - realtime, - true, - eff_channels, - registry, - ); - if let Some(c) = build.control { - controls.push((id.clone(), c)); - } - if build.bypass_is_new { - bypasses.push((id.clone(), build.bypass.clone())); - } - if let Some(m) = build.meter { - meters.push(m); - } - if let Some(l) = build.lufs { - lufs.push(l); - } - if let Some(g) = build.gr { - gr_handles.push(g); - } - if let Some(s) = build.scope { - scopes.push(s); - } - let bypass = build.bypass; - let make_edge = - |src_idx: usize, source_handle: Option, target_handle: Option| { - let pad = max_upstream - node_latencies[src_idx]; - let width = - edge_channels(&nodes, &node_channels, src_idx, source_handle.as_deref()); - IncomingEdge { - src_idx, - source_handle, - target_handle, - delay: if pad > 0 { - Some(DelayLine::new(pad, width)) - } else { - None - }, - } - }; - let incoming: Vec = main_upstream - .into_iter() - .map(|(i, s, t)| make_edge(i, s, t)) - .collect(); - let sidechain: Vec = side_upstream - .into_iter() - .map(|(i, s, t)| make_edge(i, s, t)) - .collect(); - let sidechain_buf = if sidechain.is_empty() { - None - } else { - Some(vec![0.0; DSP_BLOCK_FRAMES * eff_channels]) - }; - // Generic `chK` per-channel taps drawn off this effect. A stale - // handle just yields silence. - let mut handle_ids: Vec = valid - .edges - .iter() - .filter(|e| &e.from == id) - .filter_map(|e| e.source_handle.clone()) - .filter(|h| tap_handle_width(h).is_some()) - .collect(); - handle_ids.sort(); - handle_ids.dedup(); - let handle_bufs: Vec<(String, Vec)> = handle_ids - .into_iter() - .map(|h| { - let w = tap_handle_width(&h).unwrap_or(2); - (h, vec![0.0; DSP_BLOCK_FRAMES * w]) - }) - .collect(); - // Analyzers read all channels at once, and so does a plugin that - // accepted the node's full width. Everything else runs one instance - // per stereo pair. - let full_width = build.full_width - || matches!( - effect.spec, - EffectSpec::LevelMeter(_) | EffectSpec::Waveform(_) | EffectSpec::Spectrum(_) - ); - let pairs = if full_width { - 1 - } else { - eff_channels.div_ceil(2) - }; - let mut effects = Vec::with_capacity(pairs); - let own = build.effect.latency_frames(); - effects.push(build.effect); - for _ in 1..pairs { - // Extra stereo pairs are separate instances for wider audio, - // never the editor target. - // Extra pairs exist only when the node is driven pairwise, so - // each is asked for stereo rather than the node's full width. - let extra = - instantiate_effect(&effect.spec, id, output_sr, realtime, false, 2, registry); - effects.push(extra.effect); - } - id_to_index.insert(id.clone(), nodes.len()); - nodes.push(DagNode::Effect(EffectState { - effects, - full_width, - bypass, - incoming, - sidechain, - out_buf: vec![0.0; DSP_BLOCK_FRAMES * eff_channels], - sidechain_buf, - pair_main: vec![0.0; DSP_BLOCK_FRAMES * 2], - pair_side: vec![0.0; DSP_BLOCK_FRAMES * 2], - handle_bufs, - taps: Vec::new(), - })); - node_meta.insert(id.clone(), (nodes.len() - 1, eff_channels)); - node_latencies.push(max_upstream + own); - node_channels.push(eff_channels); - } - } - - // Matches the source label style (`out=` / "monitor"). - let out_label = output_id - .map(|id| format!("out={id}")) - .unwrap_or_else(|| "monitor".to_string()); - let blocks = Arc::new(AtomicU64::new(0)); - - // A wire sender (direct-IP or WebRTC) is a terminal Consumer node inside the - // DAG (not a summed output terminal): it sums per-channel inputs and pushes - // them into send rings drained by a background transmitter. - let wire_sender = output_id - .and_then(|oid| valid.outputs.iter().find(|o| o.id == oid)) - .and_then(|o| match &o.spec { - OutputSpec::NetSender { .. } | OutputSpec::WebRtcSend { .. } => Some(o.spec.clone()), - _ => None, - }); - if let Some(spec) = wire_sender { - let oid = output_id.unwrap(); - let mut up: Vec<(usize, Option, Option)> = Vec::new(); - for e in &valid.edges { - if e.to == oid && reachable.contains(&e.from) { - let idx = id_to_index[&e.from]; - up.push((idx, e.source_handle.clone(), e.target_handle.clone())); - } - } - let max_up = up - .iter() - .map(|(i, _, _)| node_latencies[*i]) - .max() - .unwrap_or(0); - let incoming: Vec = up - .into_iter() - .map(|(idx, source_handle, target_handle)| { - let pad = max_up - node_latencies[idx]; - let width = edge_channels(&nodes, &node_channels, idx, source_handle.as_deref()); - IncomingEdge { - src_idx: idx, - source_handle, - target_handle, - delay: if pad > 0 { - Some(DelayLine::new(pad, width)) - } else { - None - }, - } - }) - .collect(); - - let channels = match &spec { - OutputSpec::NetSender { channels, .. } | OutputSpec::WebRtcSend { channels, .. } => { - *channels - } - _ => unreachable!("wire sender spec"), - }; - let n = channels.clamp(1, MAX_NET_CH) as usize; - let mut channel_bufs: Vec<(String, Vec)> = Vec::with_capacity(n); - let mut send_producers: Vec> = Vec::with_capacity(n); - let mut send_consumers: Vec> = Vec::with_capacity(n); - for c in 1..=n { - channel_bufs.push((format!("ch{c}"), vec![0.0; DSP_BLOCK_FRAMES])); - let (prod, cons) = RingBuffer::::new(crate::audio::netaudio::SEND_RING); - send_producers.push(prod); - send_consumers.push(cons); - } - match &spec { - OutputSpec::NetSender { - node_id, - target, - codec, - opus_bitrate, - opus_application, - .. - } => { - let format = match codec { - NetCodec::PcmF32 => Format::PcmF32, - NetCodec::PcmI16 => Format::PcmI16, - NetCodec::Opus => Format::Opus, - }; - let sender = crate::audio::netaudio::sender::get_or_create( - node_id, - *target, - format, - *opus_bitrate, - *opus_application, - output_sr, - ); - sender.set_send_consumers(send_consumers); - } - OutputSpec::WebRtcSend { - node_id, - opus_bitrate, - opus_application, - .. - } => { - let session = - crate::audio::webrtc::get_or_create(node_id, *opus_bitrate, *opus_application); - // This graph already runs at the wire rate, so the encode task's - // own resampler stays out of the path. - session.set_send_consumers(send_consumers, output_sr); - } - _ => unreachable!("wire sender spec"), - } - - nodes.push(DagNode::Consumer(ConsumerState { - incoming, - channel_bufs, - send_producers, - })); - - return Ok(BuiltOutputGraph { - graph: OutputGraph { - sample_rate: output_sr, - out_channels: 2, - nodes, - terminals: Vec::new(), - latency_frames: max_up, - blocks: blocks.clone(), - }, - controls, - bypasses, - meters, - lufs, - gr_handles, - scopes, - sources, - output: OutputMeta { - label: out_label, - blocks, - sample_rate: output_sr, - channels: 2, - io: None, - }, - node_meta, - }); - } - - let terminals: Vec = match output_id { - Some(id) => { - let upstream: Vec<(usize, Option, Option<(usize, usize)>)> = valid - .edges - .iter() - .filter(|e| e.to == id) - .filter_map(|e| { - id_to_index.get(&e.from).copied().map(|idx| { - let route = e.target_handle.as_deref().and_then(target_route); - (idx, e.source_handle.clone(), route) - }) - }) - .collect(); - let max_upstream = upstream - .iter() - .map(|(i, _, _)| node_latencies[*i]) - .max() - .unwrap_or(0); - upstream - .into_iter() - .map(|(src_idx, source_handle, route)| { - let pad = max_upstream - node_latencies[src_idx]; - let width = - edge_channels(&nodes, &node_channels, src_idx, source_handle.as_deref()); - TerminalEdge { - src_idx, - source_handle, - route, - delay: if pad > 0 { - Some(DelayLine::new(pad, width)) - } else { - None - }, - } - }) - .collect() - } - None => Vec::new(), - }; - - Ok(BuiltOutputGraph { - graph: OutputGraph { - sample_rate: output_sr, - out_channels: 2, - nodes, - terminals, - latency_frames: node_latencies.iter().copied().max().unwrap_or(0), - blocks: blocks.clone(), - }, - controls, - bypasses, - meters, - lufs, - gr_handles, - scopes, - sources, - output: OutputMeta { - label: out_label, - blocks, - sample_rate: output_sr, - channels: 2, - io: None, - }, - node_meta, - }) -} - -/// Builds a `SourceState` that reads a fan-out node's published block from a -/// ring (written at `owner_sr`) and resamples it to this graph's `output_sr`. -/// Reuses the source machinery so per-channel taps and backlog-dropping behave -/// exactly like a captured input. -#[allow(clippy::too_many_arguments)] -fn ring_source( - id: &str, - consumer: Consumer, - owner_sr: u32, - output_sr: u32, - channels: usize, - realtime: bool, - valid: &ValidGraph, -) -> AppResult { - let resampler = if owner_sr == output_sr { - None - } else { - Some(MultiResampler::new( - owner_sr, - output_sr, - RESAMPLE_CHUNK, - channels, - )?) - }; - let out_max = resampler - .as_ref() - .map(|r| r.out_max()) - .unwrap_or(RESAMPLE_CHUNK); - let staging_cap = (out_max * 4 + DSP_BLOCK_FRAMES) * channels; - let input_frames_per_block = - (DSP_BLOCK_FRAMES as u64 * owner_sr as u64 + output_sr as u64 - 1) / output_sr as u64; - let input_samples_per_block = input_frames_per_block as usize * channels; - - let mut ch_handles: Vec = valid - .edges - .iter() - .filter(|e| e.from == id) - .filter_map(|e| e.source_handle.clone()) - .filter(|h| tap_handle_width(h).is_some()) - .collect(); - ch_handles.sort(); - ch_handles.dedup(); - let handle_bufs: Vec<(String, Vec)> = ch_handles - .into_iter() - .map(|h| { - let w = tap_handle_width(&h).unwrap_or(1); - (h, vec![0.0; DSP_BLOCK_FRAMES * w]) - }) - .collect(); - - Ok(SourceState { - label: format!("cut:{id}"), - channels, - consumer, - resampler, - input_staging: Vec::with_capacity((RESAMPLE_CHUNK + SPLICE_FADE_FRAMES) * channels + 8), - splice_tmp: Vec::with_capacity(SPLICE_FADE_FRAMES * channels), - out_pending: StagingRing::with_capacity(staging_cap), - chunk_tmp: Vec::with_capacity(out_max * channels), - out_buf: vec![0.0; DSP_BLOCK_FRAMES * channels], - input_samples_per_block, - realtime, - last_pop_at: Instant::now(), - first_data_logged: false, - volume: Arc::new(AtomicU32::new(0x3F80_0000)), - paused: None, - drain: None, - last_drain_gen: 0, - meter: None, - handle_bufs, - stats: SourceStats::new(), - }) -} - -/// Cross-output fan-out plan: which effect nodes are computed once and shared -/// via rings. `owner[n]` builds node `n` and publishes it; every output in -/// `consumers[n]` reads it back as a ring-source. -pub(super) struct CutPlan { - pub owner: HashMap, - pub consumers: HashMap>, -} - -impl CutPlan { - /// Outputs that participate in any cut (owners + consumers). When one of - /// them is rebuilt they must all rebuild together, so producer and consumer - /// ends of every ring are created in the same pass. - pub(super) fn participants(&self) -> HashSet { - let mut set = HashSet::new(); - for (node, cons) in &self.consumers { - if cons.is_empty() { - continue; - } - if let Some(o) = self.owner.get(node) { - set.insert(o.clone()); - } - set.extend(cons.iter().cloned()); - } - set - } -} - -/// Assigns each effect node to the first output (in graph order) that can -/// compute it, and records where later graphs must read it back via a ring. -/// Traversal stops at nodes already owned by an earlier graph -- those become -/// ring-source leaves -- so a shared node is computed exactly once. The monitor -/// (identified by `monitor_key`) is treated as a final consumer, so a plugin -/// feeding both a speaker and an analyzer is computed once, not duplicated. -pub(super) fn plan_cuts(valid: &ValidGraph, monitor_key: Option<&str>) -> CutPlan { - let effect_ids: HashSet<&str> = valid.effects.iter().map(|e| e.id.as_str()).collect(); - let mut owner: HashMap = HashMap::new(); - let mut consumers: HashMap> = HashMap::new(); - - let mut assign = |oid: &str, starts: Vec| { - let mut visited: HashSet = HashSet::new(); - let mut stack = starts; - while let Some(m) = stack.pop() { - // Only effect nodes are cut; inputs already fan out via their own - // per-output source rings. - if !effect_ids.contains(m.as_str()) { - continue; - } - if owner.contains_key(&m) { - consumers.entry(m).or_default().push(oid.to_string()); - continue; - } - if !visited.insert(m.clone()) { - continue; - } - for e in &valid.edges { - if e.to == m { - stack.push(e.from.clone()); - } - } - } - for m in visited { - owner.insert(m, oid.to_string()); - } - }; - - for out in &valid.outputs { - let starts = valid - .edges - .iter() - .filter(|e| e.to == out.id) - .map(|e| e.from.clone()) - .collect(); - assign(&out.id, starts); - } - // Monitor last: it reaches every analyzer, so shared nodes owned by a real - // output are read from their ring and only monitor-only nodes stay local. - if let Some(mk) = monitor_key { - let starts = valid - .effects - .iter() - .filter(|e| is_analyzer(&e.spec)) - .map(|e| e.id.clone()) - .collect(); - assign(mk, starts); - } - - for v in consumers.values_mut() { - v.dedup(); - } - CutPlan { owner, consumers } -} - -/// Analyzer effects are monitor-graph roots: they render telemetry and have no -/// audio successor, so the monitor sub-graph is everything that feeds one. -fn is_analyzer(spec: &EffectSpec) -> bool { - matches!( - spec, - EffectSpec::LevelMeter(_) - | EffectSpec::LufsMeter(_) - | EffectSpec::Waveform(_) - | EffectSpec::Spectrum(_) - ) -} - -/// Like `reachable_backward` but does not expand through `stop` nodes: they are -/// included as leaves (built as ring-sources) but their upstream chain is not. -fn reachable_backward_cut( - output_id: &str, - valid: &ValidGraph, - stop: &HashSet, -) -> HashSet { - let starts: Vec = valid - .edges - .iter() - .filter(|e| e.to == output_id) - .map(|e| e.from.clone()) - .collect(); - reachable_backward_from(&starts, valid, stop) -} - -/// Backward reachability from a set of start nodes (the starts are included), -/// not expanding through `stop` nodes. -fn reachable_backward_from( - starts: &[String], - valid: &ValidGraph, - stop: &HashSet, -) -> HashSet { - let mut seen = HashSet::new(); - let mut stack: Vec = starts.to_vec(); - while let Some(id) = stack.pop() { - if !seen.insert(id.clone()) { - continue; - } - if stop.contains(&id) { - continue; - } - for edge in &valid.edges { - if edge.to == id { - stack.push(edge.from.clone()); - } - } - } - seen -} - -/// Node ids reachable backward from `output_id`, excluding the output node itself. -pub(super) fn reachable_backward(output_id: &str, valid: &ValidGraph) -> HashSet { - let mut seen = HashSet::new(); - let mut stack: Vec = valid - .edges - .iter() - .filter(|e| e.to == output_id) - .map(|e| e.from.clone()) - .collect(); - while let Some(id) = stack.pop() { - if !seen.insert(id.clone()) { - continue; - } - for edge in &valid.edges { - if edge.to == id { - stack.push(edge.from.clone()); - } - } - } - seen -} - -#[allow(dead_code)] -pub(super) fn inputs_feeding_output<'a>(output_id: &str, valid: &'a ValidGraph) -> Vec<&'a str> { - let reachable = reachable_backward(output_id, valid); - valid - .inputs - .iter() - .filter(|i| reachable.contains(&i.id)) - .map(|i| i.id.as_str()) - .collect() -} - -#[cfg(test)] -mod tests { - use super::{add_mapped, crossfade_into, DelayLine, DSP_BLOCK_FRAMES, SPLICE_FADE_FRAMES}; - - // Latency compensation on a branch that bypasses a latent effect must be a - // pure delay: same samples, same order, only shifted. - #[test] - fn delay_line_shifts_without_losing_samples() { - const PAD_FRAMES: usize = 482; - let mut line = DelayLine::new(PAD_FRAMES, 2); - let mut fed: Vec = Vec::new(); - let mut got: Vec = Vec::new(); - for b in 0..4 { - let mut input = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; - for f in 0..DSP_BLOCK_FRAMES { - let v = (b * DSP_BLOCK_FRAMES + f) as f32; - input[f * 2] = v; - input[f * 2 + 1] = -v; - } - fed.extend_from_slice(&input); - let mut dst = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; - add_mapped(line.delayed(&input), &mut dst); - got.extend_from_slice(&dst); - } - let shift = PAD_FRAMES * 2; - for i in shift..got.len() { - assert_eq!(got[i], fed[i - shift], "sample {i} differs"); - } - } - - // A mono tap drawn off a stereo node carries one channel, not two. Sizing - // the line by the node's width instead of the edge's dropped half of every - // block and paired consecutive samples as L/R, doubling the pitch. - #[test] - fn delay_line_fills_a_whole_mono_block() { - const PAD_FRAMES: usize = 482; - let mut line = DelayLine::new(PAD_FRAMES, 1); - let mut fed: Vec = Vec::new(); - let mut got: Vec = Vec::new(); - for b in 0..4 { - let mut input = vec![0.0_f32; DSP_BLOCK_FRAMES]; - for (f, s) in input.iter_mut().enumerate() { - *s = (b * DSP_BLOCK_FRAMES + f) as f32 + 1.0; - } - fed.extend_from_slice(&input); - let mut dst = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; - add_mapped(line.delayed(&input), &mut dst); - got.extend_from_slice(&dst); - } - // Mono upmixes to both channels, and no frame of any block stays silent. - for b in 1..4 { - for f in 0..DSP_BLOCK_FRAMES { - let i = b * DSP_BLOCK_FRAMES * 2 + f * 2; - assert_ne!(got[i], 0.0, "left silent at block {b} frame {f}"); - assert_eq!(got[i], got[i + 1], "channels differ at block {b} frame {f}"); - } - } - for f in PAD_FRAMES..fed.len() { - assert_eq!(got[f * 2], fed[f - PAD_FRAMES], "frame {f} differs"); - } - } - - // A trim's join must be continuous at both ends, or the splice it was meant - // to hide becomes two smaller steps. - #[test] - fn crossfade_joins_without_a_step() { - const CH: usize = 2; - let mut dst = vec![1.0_f32; SPLICE_FADE_FRAMES * CH]; - let incoming = vec![0.0_f32; SPLICE_FADE_FRAMES * CH]; - crossfade_into(&mut dst, &incoming, &[], CH); - - assert_eq!(dst[0], 1.0, "first frame must stay pure outgoing"); - assert_eq!(dst[1], 1.0, "both channels of the first frame agree"); - let last = (SPLICE_FADE_FRAMES - 1) * CH; - assert_eq!(dst[last], 0.0, "last frame must reach pure incoming"); - assert_eq!(dst[last + 1], 0.0, "both channels of the last frame agree"); - - for f in 1..SPLICE_FADE_FRAMES { - assert!(dst[f * CH] < dst[(f - 1) * CH], "fade must be monotonic"); - assert_eq!(dst[f * CH], dst[f * CH + 1], "channels share a weight"); - } - } - - #[test] - fn add_mapped_maps_by_channel() { - // 4->2: first two channels pass, rest dropped. - let mut src = vec![0.0; DSP_BLOCK_FRAMES * 4]; - for f in 0..DSP_BLOCK_FRAMES { - for c in 0..4 { - src[f * 4 + c] = c as f32 + 1.0; - } - } - let mut dst = vec![0.0; DSP_BLOCK_FRAMES * 2]; - add_mapped(&src, &mut dst); - assert_eq!(dst[0], 1.0); - assert_eq!(dst[1], 2.0); - - // 2->1: mono downmix is the mean. - let mut stereo = vec![0.0; DSP_BLOCK_FRAMES * 2]; - for f in 0..DSP_BLOCK_FRAMES { - stereo[f * 2] = 1.0; - stereo[f * 2 + 1] = 3.0; - } - let mut mono = vec![0.0; DSP_BLOCK_FRAMES]; - add_mapped(&stereo, &mut mono); - assert!((mono[0] - 2.0).abs() < 1e-6); - - // equal width: straight sum-in. - let src = vec![0.5; DSP_BLOCK_FRAMES * 3]; - let mut dst = vec![0.25; DSP_BLOCK_FRAMES * 3]; - add_mapped(&src, &mut dst); - assert!((dst[0] - 0.75).abs() < 1e-6); - } -} diff --git a/src-tauri/src/audio/pipeline/dag/builder.rs b/src-tauri/src/audio/pipeline/dag/builder.rs new file mode 100644 index 0000000..f3ff4c8 --- /dev/null +++ b/src-tauri/src/audio/pipeline/dag/builder.rs @@ -0,0 +1,870 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; +use std::sync::Arc; +use std::time::Instant; + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::audio::effects::{ + instantiate_effect, EffectControl, EffectRegistry, GrHandle, LufsHandle, MeterHandle, + WaveformHandle, +}; +use crate::audio::graph::{EdgeKind, EffectSpec, InputSpec, NetCodec, OutputSpec, ValidGraph}; +use crate::audio::netaudio::packet::Format; +use crate::audio::resample::MultiResampler; +use crate::audio::stream_recv::ChannelReceiver; +use crate::error::{AppError, AppResult}; + +use super::graph::{BuiltOutputGraph, DelayLine, OutputGraph}; +use super::nodes::{ + edge_channels, parse_ch, parse_stereo, tap_handle_width, tap_key, target_route, ConsumerState, + DagNode, EffectState, IncomingEdge, OutputMeta, ProducerState, SourceMeta, SourceState, + SourceStats, TerminalEdge, SPLICE_FADE_FRAMES, +}; +use super::staging::StagingRing; +use super::{ring_capacity_frames, DSP_BLOCK_FRAMES, MAX_NET_CH, RESAMPLE_CHUNK}; + +/// Build the per-output DAG: walk backward from `output_id`, topo-sort the +/// reachable sub-graph, instantiate sources (with their rings) and effects +/// (with their parameter atomics) in order. +/// +/// `output_id = None` means monitor mode: every surviving input + effect is +/// reachable (validate already trimmed anything that doesn't drive an +/// analyzer), and the resulting graph has no output terminals. +/// `producer_pairs` carries Producer ends of the ring per Source node, +/// paired with their input node id. Caller tags each pair with the owning +/// output id and routes them into the matching input's broadcast. +pub fn build_output_graph( + output_id: Option<&str>, + output_sr: u32, + realtime: bool, + valid: &ValidGraph, + input_native_sr: &HashMap, + input_native_channels: &HashMap, + producer_pairs: &mut Vec<(String, Producer)>, + registry: &mut EffectRegistry, + input_volumes: &HashMap>, + input_paused: &HashMap>, + input_drain: &HashMap>, + input_meters: &HashMap, + mut cut_leaves: HashMap, u32, usize)>, +) -> AppResult { + let cut_leaf_ids: HashSet = cut_leaves.keys().cloned().collect(); + let reachable: HashSet = match output_id { + Some(id) => reachable_backward_cut(id, valid, &cut_leaf_ids), + None => { + let roots: Vec = valid + .effects + .iter() + .filter(|e| is_analyzer(&e.spec)) + .map(|e| e.id.clone()) + .collect(); + reachable_backward_from(&roots, valid, &cut_leaf_ids) + } + }; + + let mut indegree: HashMap = HashMap::new(); + for id in &reachable { + indegree.entry(id.clone()).or_insert(0); + } + for edge in &valid.edges { + if reachable.contains(&edge.from) && reachable.contains(&edge.to) { + *indegree.entry(edge.to.clone()).or_insert(0) += 1; + } + } + let mut queue: Vec = indegree + .iter() + .filter(|(_, d)| **d == 0) + .map(|(id, _)| id.clone()) + .collect(); + queue.sort(); + let mut topo: Vec = Vec::with_capacity(reachable.len()); + while let Some(id) = queue.pop() { + topo.push(id.clone()); + for edge in &valid.edges { + if edge.from == id && reachable.contains(&edge.to) { + let d = indegree.get_mut(&edge.to).unwrap(); + *d -= 1; + if *d == 0 { + queue.push(edge.to.clone()); + } + } + } + } + if topo.len() != reachable.len() { + return Err(AppError::Validation(format!( + "internal: topo sort failed for output {}", + output_id.unwrap_or("") + ))); + } + + let mut nodes: Vec = Vec::with_capacity(topo.len()); + let mut id_to_index: HashMap = HashMap::new(); + let mut node_meta: HashMap = HashMap::new(); + let mut controls: Vec<(String, EffectControl)> = Vec::new(); + let mut bypasses: Vec<(String, Arc)> = Vec::new(); + let mut meters: Vec = Vec::new(); + let mut lufs: Vec = Vec::new(); + let mut gr_handles: Vec = Vec::new(); + let mut scopes: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); + let mut node_latencies: Vec = Vec::with_capacity(topo.len()); + let mut node_channels: Vec = Vec::with_capacity(topo.len()); + + for id in &topo { + if let Some((consumer, owner_sr, width)) = cut_leaves.remove(id) { + let source = ring_source(id, consumer, owner_sr, output_sr, width, realtime, valid)?; + sources.push(SourceMeta { + label: format!("{} out={}", source.label, output_id.unwrap_or("monitor")), + stats: source.stats.clone(), + channels: width, + native_sr: owner_sr, + frames_per_block: source.input_samples_per_block / width.max(1), + input_id: None, + output_id: output_id.unwrap_or("monitor").to_string(), + capture: None, + }); + id_to_index.insert(id.clone(), nodes.len()); + nodes.push(DagNode::Source(source)); + node_latencies.push(0); + node_channels.push(width); + continue; + } + if let Some(input) = valid.inputs.iter().find(|i| &i.id == id) { + let network = match &input.spec { + InputSpec::NetReceiver { port } => { + let receiver = crate::audio::netaudio::receiver::get_or_create(id, *port); + Some(ChannelReceiver::new( + receiver.register_consumer(output_sr, realtime), + )) + } + InputSpec::WebRtcRecv { + node_id, + opus_bitrate, + opus_application, + } => { + let session = crate::audio::webrtc::get_or_create( + node_id, + *opus_bitrate, + *opus_application, + ); + Some(ChannelReceiver::new( + session.register_bridge(output_sr, realtime), + )) + } + _ => None, + }; + if let Some(receiver) = network { + let mut handles: Vec = valid + .edges + .iter() + .filter(|e| &e.from == id) + .filter_map(|e| e.source_handle.clone()) + .collect(); + handles.sort(); + handles.dedup(); + let pw = 2; + let mut handle_bufs = Vec::with_capacity(handles.len()); + let mut wire_keys = Vec::with_capacity(handles.len()); + for h in handles { + let Some(key) = tap_key(&h) else { continue }; + wire_keys.push(key); + handle_bufs.push((h, vec![0.0; DSP_BLOCK_FRAMES])); + } + id_to_index.insert(id.clone(), nodes.len()); + nodes.push(DagNode::Producer(ProducerState { + receiver, + out_buf: vec![0.0; DSP_BLOCK_FRAMES * pw], + handle_bufs, + wire_keys, + })); + node_latencies.push(0); + node_channels.push(pw); + continue; + } + let source_realtime = realtime && !matches!(input.spec, InputSpec::AudioFile { .. }); + let input_sr = *input_native_sr + .get(id) + .ok_or_else(|| AppError::Validation(format!("input {id} has no SR")))?; + let source_channels = input_native_channels.get(id).copied().unwrap_or(2) as usize; + let (producer, consumer) = + RingBuffer::::new(ring_capacity_frames(input_sr) * source_channels); + producer_pairs.push((id.clone(), producer)); + let mut ch_handles: Vec = valid + .edges + .iter() + .filter(|e| &e.from == id) + .filter_map(|e| e.source_handle.clone()) + .filter(|h| tap_handle_width(h).is_some()) + .collect(); + ch_handles.sort(); + ch_handles.dedup(); + let source_handle_bufs: Vec<(String, Vec)> = ch_handles + .into_iter() + .map(|h| { + let w = tap_handle_width(&h).unwrap_or(1); + (h, vec![0.0; DSP_BLOCK_FRAMES * w]) + }) + .collect(); + let resampler = if input_sr == output_sr { + None + } else { + Some(MultiResampler::new( + input_sr, + output_sr, + RESAMPLE_CHUNK, + source_channels, + )?) + }; + let out_max = resampler + .as_ref() + .map(|r| r.out_max()) + .unwrap_or(RESAMPLE_CHUNK); + let staging_cap = (out_max * 4 + DSP_BLOCK_FRAMES) * source_channels; + let input_frames_per_block = + (DSP_BLOCK_FRAMES as u64 * input_sr as u64 + output_sr as u64 - 1) + / output_sr as u64; + let input_samples_per_block = (input_frames_per_block as usize) * source_channels; + + let kind = match &input.spec { + InputSpec::Microphone { device_id } => format!("mic:{device_id}"), + InputSpec::SystemAudio { .. } => "system-audio".to_string(), + InputSpec::AppAudio { bundle_id } => format!("app:{bundle_id}"), + InputSpec::AudioFile { file_path } => format!("file:{file_path}"), + InputSpec::NetReceiver { .. } | InputSpec::WebRtcRecv { .. } => { + unreachable!("network inputs are built as producers") + } + }; + let label = format!( + "{kind}@{input_sr}->{output_sr} out={}", + output_id.unwrap_or("monitor") + ); + let stats = SourceStats::new(); + sources.push(SourceMeta { + label: label.clone(), + stats: stats.clone(), + channels: source_channels, + native_sr: input_sr, + frames_per_block: input_frames_per_block as usize, + input_id: Some(id.clone()), + output_id: output_id.unwrap_or("monitor").to_string(), + capture: None, + }); + let source = SourceState { + label, + channels: source_channels, + consumer, + resampler, + input_staging: Vec::with_capacity( + (RESAMPLE_CHUNK + SPLICE_FADE_FRAMES) * source_channels + 8, + ), + splice_tmp: Vec::with_capacity(SPLICE_FADE_FRAMES * source_channels), + out_pending: StagingRing::with_capacity(staging_cap), + chunk_tmp: Vec::with_capacity(out_max * source_channels), + out_buf: vec![0.0; DSP_BLOCK_FRAMES * source_channels], + input_samples_per_block, + realtime: source_realtime, + last_pop_at: Instant::now(), + first_data_logged: false, + volume: input_volumes + .get(id) + .cloned() + .unwrap_or_else(|| Arc::new(AtomicU32::new(1.0f32.to_bits()))), + paused: input_paused.get(id).cloned(), + drain: input_drain.get(id).cloned(), + last_drain_gen: 0, + meter: input_meters.get(id).cloned(), + handle_bufs: source_handle_bufs, + stats, + }; + id_to_index.insert(id.clone(), nodes.len()); + nodes.push(DagNode::Source(source)); + node_latencies.push(0); + node_channels.push(source_channels); + } else if let Some(effect) = valid.effects.iter().find(|e| &e.id == id) { + type Upstream = (usize, Option, Option); + let mut main_upstream: Vec = Vec::new(); + let mut side_upstream: Vec = Vec::new(); + for e in &valid.edges { + if &e.to == id && reachable.contains(&e.from) { + let idx = id_to_index[&e.from]; + let entry = (idx, e.source_handle.clone(), e.target_handle.clone()); + match e.kind { + EdgeKind::Main => main_upstream.push(entry), + EdgeKind::Sidechain => side_upstream.push(entry), + } + } + } + let max_upstream = main_upstream + .iter() + .chain(side_upstream.iter()) + .map(|(i, _, _)| node_latencies[*i]) + .max() + .unwrap_or(0); + let upstream_w = main_upstream + .iter() + .map(|(i, sh, _)| edge_channels(&nodes, &node_channels, *i, sh.as_deref())) + .max() + .unwrap_or(2); + let target_w = main_upstream + .iter() + .filter_map(|(_, _, t)| t.as_deref().and_then(target_route)) + .map(|(off, w)| off + w) + .max() + .unwrap_or(0); + let tap_w = valid + .edges + .iter() + .filter(|e| &e.from == id) + .filter_map(|e| e.source_handle.as_deref()) + .filter_map(|h| parse_stereo(h).map(|a| a + 1).or_else(|| parse_ch(h))) + .max() + .unwrap_or(0); + let eff_channels = upstream_w.max(target_w).max(tap_w).max(1); + let build = instantiate_effect( + &effect.spec, + id, + output_sr, + realtime, + true, + eff_channels, + registry, + ); + if let Some(c) = build.control { + controls.push((id.clone(), c)); + } + if build.bypass_is_new { + bypasses.push((id.clone(), build.bypass.clone())); + } + if let Some(m) = build.meter { + meters.push(m); + } + if let Some(l) = build.lufs { + lufs.push(l); + } + if let Some(g) = build.gr { + gr_handles.push(g); + } + if let Some(s) = build.scope { + scopes.push(s); + } + let bypass = build.bypass; + let make_edge = + |src_idx: usize, source_handle: Option, target_handle: Option| { + let pad = max_upstream - node_latencies[src_idx]; + let width = + edge_channels(&nodes, &node_channels, src_idx, source_handle.as_deref()); + IncomingEdge { + src_idx, + source_handle, + target_handle, + delay: if pad > 0 { + Some(DelayLine::new(pad, width)) + } else { + None + }, + } + }; + let incoming: Vec = main_upstream + .into_iter() + .map(|(i, s, t)| make_edge(i, s, t)) + .collect(); + let sidechain: Vec = side_upstream + .into_iter() + .map(|(i, s, t)| make_edge(i, s, t)) + .collect(); + let sidechain_buf = if sidechain.is_empty() { + None + } else { + Some(vec![0.0; DSP_BLOCK_FRAMES * eff_channels]) + }; + let mut handle_ids: Vec = valid + .edges + .iter() + .filter(|e| &e.from == id) + .filter_map(|e| e.source_handle.clone()) + .filter(|h| tap_handle_width(h).is_some()) + .collect(); + handle_ids.sort(); + handle_ids.dedup(); + let handle_bufs: Vec<(String, Vec)> = handle_ids + .into_iter() + .map(|h| { + let w = tap_handle_width(&h).unwrap_or(2); + (h, vec![0.0; DSP_BLOCK_FRAMES * w]) + }) + .collect(); + let full_width = build.full_width + || matches!( + effect.spec, + EffectSpec::LevelMeter(_) | EffectSpec::Waveform(_) | EffectSpec::Spectrum(_) + ); + let pairs = if full_width { + 1 + } else { + eff_channels.div_ceil(2) + }; + let mut effects = Vec::with_capacity(pairs); + let own = build.effect.latency_frames(); + effects.push(build.effect); + for _ in 1..pairs { + let extra = + instantiate_effect(&effect.spec, id, output_sr, realtime, false, 2, registry); + effects.push(extra.effect); + } + id_to_index.insert(id.clone(), nodes.len()); + nodes.push(DagNode::Effect(EffectState { + effects, + full_width, + bypass, + incoming, + sidechain, + out_buf: vec![0.0; DSP_BLOCK_FRAMES * eff_channels], + sidechain_buf, + pair_main: vec![0.0; DSP_BLOCK_FRAMES * 2], + pair_side: vec![0.0; DSP_BLOCK_FRAMES * 2], + handle_bufs, + taps: Vec::new(), + })); + node_meta.insert(id.clone(), (nodes.len() - 1, eff_channels)); + node_latencies.push(max_upstream + own); + node_channels.push(eff_channels); + } + } + + let out_label = output_id + .map(|id| format!("out={id}")) + .unwrap_or_else(|| "monitor".to_string()); + let blocks = Arc::new(AtomicU64::new(0)); + + let wire_sender = output_id + .and_then(|oid| valid.outputs.iter().find(|o| o.id == oid)) + .and_then(|o| match &o.spec { + OutputSpec::NetSender { .. } | OutputSpec::WebRtcSend { .. } => Some(o.spec.clone()), + _ => None, + }); + if let Some(spec) = wire_sender { + let oid = output_id.unwrap(); + let mut up: Vec<(usize, Option, Option)> = Vec::new(); + for e in &valid.edges { + if e.to == oid && reachable.contains(&e.from) { + let idx = id_to_index[&e.from]; + up.push((idx, e.source_handle.clone(), e.target_handle.clone())); + } + } + let max_up = up + .iter() + .map(|(i, _, _)| node_latencies[*i]) + .max() + .unwrap_or(0); + let incoming: Vec = up + .into_iter() + .map(|(idx, source_handle, target_handle)| { + let pad = max_up - node_latencies[idx]; + let width = edge_channels(&nodes, &node_channels, idx, source_handle.as_deref()); + IncomingEdge { + src_idx: idx, + source_handle, + target_handle, + delay: if pad > 0 { + Some(DelayLine::new(pad, width)) + } else { + None + }, + } + }) + .collect(); + + let channels = match &spec { + OutputSpec::NetSender { channels, .. } | OutputSpec::WebRtcSend { channels, .. } => { + *channels + } + _ => unreachable!("wire sender spec"), + }; + let n = channels.clamp(1, MAX_NET_CH) as usize; + let mut channel_bufs: Vec<(String, Vec)> = Vec::with_capacity(n); + let mut send_producers: Vec> = Vec::with_capacity(n); + let mut send_consumers: Vec> = Vec::with_capacity(n); + for c in 1..=n { + channel_bufs.push((format!("ch{c}"), vec![0.0; DSP_BLOCK_FRAMES])); + let (prod, cons) = RingBuffer::::new(crate::audio::netaudio::SEND_RING); + send_producers.push(prod); + send_consumers.push(cons); + } + match &spec { + OutputSpec::NetSender { + node_id, + target, + codec, + opus_bitrate, + opus_application, + .. + } => { + let format = match codec { + NetCodec::PcmF32 => Format::PcmF32, + NetCodec::PcmI16 => Format::PcmI16, + NetCodec::Opus => Format::Opus, + }; + let sender = crate::audio::netaudio::sender::get_or_create( + node_id, + *target, + format, + *opus_bitrate, + *opus_application, + output_sr, + ); + sender.set_send_consumers(send_consumers); + } + OutputSpec::WebRtcSend { + node_id, + opus_bitrate, + opus_application, + .. + } => { + let session = + crate::audio::webrtc::get_or_create(node_id, *opus_bitrate, *opus_application); + session.set_send_consumers(send_consumers, output_sr); + } + _ => unreachable!("wire sender spec"), + } + + nodes.push(DagNode::Consumer(ConsumerState { + incoming, + channel_bufs, + send_producers, + })); + + return Ok(BuiltOutputGraph { + graph: OutputGraph { + sample_rate: output_sr, + out_channels: 2, + nodes, + terminals: Vec::new(), + latency_frames: max_up, + blocks: blocks.clone(), + }, + controls, + bypasses, + meters, + lufs, + gr_handles, + scopes, + sources, + output: OutputMeta { + label: out_label, + blocks, + sample_rate: output_sr, + channels: 2, + io: None, + }, + node_meta, + }); + } + + let terminals: Vec = match output_id { + Some(id) => { + let upstream: Vec<(usize, Option, Option<(usize, usize)>)> = valid + .edges + .iter() + .filter(|e| e.to == id) + .filter_map(|e| { + id_to_index.get(&e.from).copied().map(|idx| { + let route = e.target_handle.as_deref().and_then(target_route); + (idx, e.source_handle.clone(), route) + }) + }) + .collect(); + let max_upstream = upstream + .iter() + .map(|(i, _, _)| node_latencies[*i]) + .max() + .unwrap_or(0); + upstream + .into_iter() + .map(|(src_idx, source_handle, route)| { + let pad = max_upstream - node_latencies[src_idx]; + let width = + edge_channels(&nodes, &node_channels, src_idx, source_handle.as_deref()); + TerminalEdge { + src_idx, + source_handle, + route, + delay: if pad > 0 { + Some(DelayLine::new(pad, width)) + } else { + None + }, + } + }) + .collect() + } + None => Vec::new(), + }; + + Ok(BuiltOutputGraph { + graph: OutputGraph { + sample_rate: output_sr, + out_channels: 2, + nodes, + terminals, + latency_frames: node_latencies.iter().copied().max().unwrap_or(0), + blocks: blocks.clone(), + }, + controls, + bypasses, + meters, + lufs, + gr_handles, + scopes, + sources, + output: OutputMeta { + label: out_label, + blocks, + sample_rate: output_sr, + channels: 2, + io: None, + }, + node_meta, + }) +} + +/// Builds a `SourceState` that reads a fan-out node's published block from a +/// ring (written at `owner_sr`) and resamples it to this graph's `output_sr`. +/// Reuses the source machinery so per-channel taps and backlog-dropping behave +/// exactly like a captured input. +#[allow(clippy::too_many_arguments)] +fn ring_source( + id: &str, + consumer: Consumer, + owner_sr: u32, + output_sr: u32, + channels: usize, + realtime: bool, + valid: &ValidGraph, +) -> AppResult { + let resampler = if owner_sr == output_sr { + None + } else { + Some(MultiResampler::new( + owner_sr, + output_sr, + RESAMPLE_CHUNK, + channels, + )?) + }; + let out_max = resampler + .as_ref() + .map(|r| r.out_max()) + .unwrap_or(RESAMPLE_CHUNK); + let staging_cap = (out_max * 4 + DSP_BLOCK_FRAMES) * channels; + let input_frames_per_block = + (DSP_BLOCK_FRAMES as u64 * owner_sr as u64 + output_sr as u64 - 1) / output_sr as u64; + let input_samples_per_block = input_frames_per_block as usize * channels; + + let mut ch_handles: Vec = valid + .edges + .iter() + .filter(|e| e.from == id) + .filter_map(|e| e.source_handle.clone()) + .filter(|h| tap_handle_width(h).is_some()) + .collect(); + ch_handles.sort(); + ch_handles.dedup(); + let handle_bufs: Vec<(String, Vec)> = ch_handles + .into_iter() + .map(|h| { + let w = tap_handle_width(&h).unwrap_or(1); + (h, vec![0.0; DSP_BLOCK_FRAMES * w]) + }) + .collect(); + + Ok(SourceState { + label: format!("cut:{id}"), + channels, + consumer, + resampler, + input_staging: Vec::with_capacity((RESAMPLE_CHUNK + SPLICE_FADE_FRAMES) * channels + 8), + splice_tmp: Vec::with_capacity(SPLICE_FADE_FRAMES * channels), + out_pending: StagingRing::with_capacity(staging_cap), + chunk_tmp: Vec::with_capacity(out_max * channels), + out_buf: vec![0.0; DSP_BLOCK_FRAMES * channels], + input_samples_per_block, + realtime, + last_pop_at: Instant::now(), + first_data_logged: false, + volume: Arc::new(AtomicU32::new(0x3F80_0000)), + paused: None, + drain: None, + last_drain_gen: 0, + meter: None, + handle_bufs, + stats: SourceStats::new(), + }) +} + +/// Cross-output fan-out plan: which effect nodes are computed once and shared +/// via rings. `owner[n]` builds node `n` and publishes it; every output in +/// `consumers[n]` reads it back as a ring-source. +pub struct CutPlan { + pub owner: HashMap, + pub consumers: HashMap>, +} + +impl CutPlan { + /// Outputs that participate in any cut (owners + consumers). When one of + /// them is rebuilt they must all rebuild together, so producer and consumer + /// ends of every ring are created in the same pass. + pub fn participants(&self) -> HashSet { + let mut set = HashSet::new(); + for (node, cons) in &self.consumers { + if cons.is_empty() { + continue; + } + if let Some(o) = self.owner.get(node) { + set.insert(o.clone()); + } + set.extend(cons.iter().cloned()); + } + set + } +} + +/// Assigns each effect node to the first output (in graph order) that can +/// compute it, and records where later graphs must read it back via a ring. +/// Traversal stops at nodes already owned by an earlier graph -- those become +/// ring-source leaves -- so a shared node is computed exactly once. The monitor +/// (identified by `monitor_key`) is treated as a final consumer, so a plugin +/// feeding both a speaker and an analyzer is computed once, not duplicated. +pub fn plan_cuts(valid: &ValidGraph, monitor_key: Option<&str>) -> CutPlan { + let effect_ids: HashSet<&str> = valid.effects.iter().map(|e| e.id.as_str()).collect(); + let mut owner: HashMap = HashMap::new(); + let mut consumers: HashMap> = HashMap::new(); + + let mut assign = |oid: &str, starts: Vec| { + let mut visited: HashSet = HashSet::new(); + let mut stack = starts; + while let Some(m) = stack.pop() { + if !effect_ids.contains(m.as_str()) { + continue; + } + if owner.contains_key(&m) { + consumers.entry(m).or_default().push(oid.to_string()); + continue; + } + if !visited.insert(m.clone()) { + continue; + } + for e in &valid.edges { + if e.to == m { + stack.push(e.from.clone()); + } + } + } + for m in visited { + owner.insert(m, oid.to_string()); + } + }; + + for out in &valid.outputs { + let starts = valid + .edges + .iter() + .filter(|e| e.to == out.id) + .map(|e| e.from.clone()) + .collect(); + assign(&out.id, starts); + } + if let Some(mk) = monitor_key { + let starts = valid + .effects + .iter() + .filter(|e| is_analyzer(&e.spec)) + .map(|e| e.id.clone()) + .collect(); + assign(mk, starts); + } + + for v in consumers.values_mut() { + v.dedup(); + } + CutPlan { owner, consumers } +} + +fn is_analyzer(spec: &EffectSpec) -> bool { + matches!( + spec, + EffectSpec::LevelMeter(_) + | EffectSpec::LufsMeter(_) + | EffectSpec::Waveform(_) + | EffectSpec::Spectrum(_) + ) +} + +fn reachable_backward_cut( + output_id: &str, + valid: &ValidGraph, + stop: &HashSet, +) -> HashSet { + let starts: Vec = valid + .edges + .iter() + .filter(|e| e.to == output_id) + .map(|e| e.from.clone()) + .collect(); + reachable_backward_from(&starts, valid, stop) +} + +fn reachable_backward_from( + starts: &[String], + valid: &ValidGraph, + stop: &HashSet, +) -> HashSet { + let mut seen = HashSet::new(); + let mut stack: Vec = starts.to_vec(); + while let Some(id) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + if stop.contains(&id) { + continue; + } + for edge in &valid.edges { + if edge.to == id { + stack.push(edge.from.clone()); + } + } + } + seen +} + +pub fn reachable_backward(output_id: &str, valid: &ValidGraph) -> HashSet { + let mut seen = HashSet::new(); + let mut stack: Vec = valid + .edges + .iter() + .filter(|e| e.to == output_id) + .map(|e| e.from.clone()) + .collect(); + while let Some(id) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + for edge in &valid.edges { + if edge.to == id { + stack.push(edge.from.clone()); + } + } + } + seen +} + +#[allow(dead_code)] +pub fn inputs_feeding_output<'a>(output_id: &str, valid: &'a ValidGraph) -> Vec<&'a str> { + let reachable = reachable_backward(output_id, valid); + valid + .inputs + .iter() + .filter(|i| reachable.contains(&i.id)) + .map(|i| i.id.as_str()) + .collect() +} diff --git a/src-tauri/src/audio/pipeline/dag/graph.rs b/src-tauri/src/audio/pipeline/dag/graph.rs new file mode 100644 index 0000000..8201bc4 --- /dev/null +++ b/src-tauri/src/audio/pipeline/dag/graph.rs @@ -0,0 +1,349 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use rtrb::Producer; + +use crate::audio::effects::{ + EffectControl, GrHandle, LufsHandle, MeterHandle, WaveformHandle, +}; +use crate::audio::health; +use crate::audio::streams::bulk_push_counted; + +use super::nodes::{ + add_block_at, add_mapped, add_to_channel, parse_ch, parse_stereo, target_route, + DagNode, OutputMeta, SourceMeta, TerminalEdge, +}; +use super::DSP_BLOCK_FRAMES; + +pub(super) struct DelayLine { + buf: Box<[f32]>, + scratch: Box<[f32]>, + pos: usize, +} + +impl DelayLine { + pub(super) fn new(delay_frames: usize, channels: usize) -> Self { + Self { + buf: vec![0.0; delay_frames * channels].into_boxed_slice(), + scratch: vec![0.0; DSP_BLOCK_FRAMES * channels].into_boxed_slice(), + pos: 0, + } + } + + pub(super) fn delayed<'a>(&'a mut self, input: &'a [f32]) -> &'a [f32] { + let cap = self.buf.len(); + if cap == 0 { + return input; + } + let n = input.len().min(self.scratch.len()); + let mut pos = self.pos; + for i in 0..n { + self.scratch[i] = self.buf[pos]; + self.buf[pos] = input[i]; + pos = if pos + 1 == cap { 0 } else { pos + 1 }; + } + self.pos = pos; + &self.scratch[..n] + } +} + +/// Per-output DAG runtime: sources + effects in topological order plus the +/// terminal edges whose buffers get summed into the final output. +pub struct OutputGraph { + pub(super) sample_rate: u32, + /// Interleaved channel width of `process_block`'s output. Stereo unless a + /// speaker sets it to the device's channel count. + pub(super) out_channels: usize, + pub(super) nodes: Vec, + pub(super) terminals: Vec, + /// Lookahead the graph's delay compensation has aligned every path to: the + /// deepest cumulative effect latency from any source to this output. The + /// whole mix is delayed by this, so it is the graph's own latency. + pub(super) latency_frames: usize, + /// Blocks produced by `process_block`. A clone lives in this build's + /// `BuiltOutputGraph::output` so the non-RT tick thread can compare this + /// worker's real block rate against `sample_rate / DSP_BLOCK_FRAMES`. + pub(super) blocks: Arc, +} + +impl OutputGraph { + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn out_channels(&self) -> usize { + self.out_channels + } + + pub fn latency_frames(&self) -> usize { + self.latency_frames + } + + pub fn set_out_channels(&mut self, channels: usize) { + self.out_channels = channels; + } + + pub fn active_output_channels(&self) -> usize { + self.terminals + .iter() + .map(|terminal| match terminal.route { + Some((offset, width)) => offset + width, + None => { + self.nodes[terminal.src_idx] + .out_buf_for_handle(terminal.source_handle.as_deref()) + .len() + / DSP_BLOCK_FRAMES + } + }) + .max() + .unwrap_or(1) + .clamp(1, self.out_channels) + } + + /// Attach a publish ring to a fan-out effect node; its `out_buf` is pushed + /// there each block for another output's ring-source to read. + pub fn attach_tap(&mut self, node_idx: usize, prod: Producer) { + if let Some(DagNode::Effect(e)) = self.nodes.get_mut(node_idx) { + e.taps.push(prod); + } + } + + /// Fill `output` (`DSP_BLOCK_FRAMES * out_channels` long) with one block of + /// mixed audio at `sample_rate`. + pub fn process_block(&mut self, output: &mut [f32]) { + self.blocks.fetch_add(1, Ordering::Relaxed); + for node in &mut self.nodes { + match node { + DagNode::Source(s) => s.fill_block(), + DagNode::Producer(p) => p.process(), + DagNode::Effect(_) | DagNode::Consumer(_) => {} + } + } + // `split_at_mut` gives mutable access to effect `i` while keeping + // immutable access to its upstreams (all at indices < i by topo sort). + for i in 0..self.nodes.len() { + let (head, tail) = self.nodes.split_at_mut(i); + if let DagNode::Consumer(cons) = &mut tail[0] { + for (_, buf) in cons.channel_bufs.iter_mut() { + for s in buf.iter_mut() { + *s = 0.0; + } + } + for edge in &mut cons.incoming { + let src = head[edge.src_idx].out_buf_for_handle(edge.source_handle.as_deref()); + let target = edge.target_handle.as_deref(); + let src = match &mut edge.delay { + Some(d) => d.delayed(src), + None => src, + }; + let Some((_, buf)) = cons + .channel_bufs + .iter_mut() + .find(|(h, _)| Some(h.as_str()) == target) + else { + continue; + }; + add_mapped(src, buf); + } + for (i, (_, buf)) in cons.channel_bufs.iter().enumerate() { + if let Some(prod) = cons.send_producers.get_mut(i) { + bulk_push_counted(prod, buf, &health::TAP_RING_OVERRUN_SAMPLES); + } + } + continue; + } + if let DagNode::Effect(eff) = &mut tail[0] { + for s in eff.out_buf.iter_mut() { + *s = 0.0; + } + for edge in &mut eff.incoming { + let src = head[edge.src_idx].out_buf_for_handle(edge.source_handle.as_deref()); + let route = edge.target_handle.as_deref().and_then(target_route); + let src = match &mut edge.delay { + Some(d) => d.delayed(src), + None => src, + }; + match route { + Some((off, 1)) => add_to_channel(src, &mut eff.out_buf, off), + Some((off, _)) => add_block_at(src, &mut eff.out_buf, off), + None => add_mapped(src, &mut eff.out_buf), + } + } + if let Some(sc_buf) = eff.sidechain_buf.as_mut() { + for s in sc_buf.iter_mut() { + *s = 0.0; + } + for edge in &mut eff.sidechain { + let src = + head[edge.src_idx].out_buf_for_handle(edge.source_handle.as_deref()); + let src = match &mut edge.delay { + Some(d) => d.delayed(src), + None => src, + }; + add_mapped(src, sc_buf); + } + } + if !eff.bypass.load(Ordering::Relaxed) { + eff.run(DSP_BLOCK_FRAMES); + } + let w = eff.out_buf.len() / DSP_BLOCK_FRAMES; + for (h, buf) in eff.handle_bufs.iter_mut() { + if let Some(a) = parse_stereo(h) { + let c0 = (a - 1).min(w - 1); + let c1 = a.min(w - 1); + for f in 0..DSP_BLOCK_FRAMES { + buf[f * 2] = eff.out_buf[f * w + c0]; + buf[f * 2 + 1] = eff.out_buf[f * w + c1]; + } + } else if let Some(k) = parse_ch(h) { + let c = (k - 1).min(w - 1); + for f in 0..DSP_BLOCK_FRAMES { + buf[f] = eff.out_buf[f * w + c]; + } + } + } + // Publish the processed block to every consuming output's ring. + for prod in eff.taps.iter_mut() { + bulk_push_counted(prod, &eff.out_buf, &health::TAP_RING_OVERRUN_SAMPLES); + } + } + } + for s in output.iter_mut() { + *s = 0.0; + } + for terminal in &mut self.terminals { + let src = + self.nodes[terminal.src_idx].out_buf_for_handle(terminal.source_handle.as_deref()); + let src = match &mut terminal.delay { + Some(d) => d.delayed(src), + None => src, + }; + match terminal.route { + Some((off, 1)) => add_to_channel(src, output, off), + Some((off, _)) => add_block_at(src, output, off), + None => add_mapped(src, output), + } + } + } +} + +pub(in crate::audio::pipeline) struct BuiltOutputGraph { + pub graph: OutputGraph, + pub controls: Vec<(String, EffectControl)>, + pub bypasses: Vec<(String, Arc)>, + pub meters: Vec, + pub lufs: Vec, + pub gr_handles: Vec, + pub scopes: Vec, + pub sources: Vec, + pub output: OutputMeta, + /// Effect node id -> (node index, channel width). Used to attach publish + /// taps to nodes that fan out to other outputs. + pub node_meta: HashMap, +} + +#[cfg(test)] +mod tests { + use super::super::nodes::{add_mapped, crossfade_into, SPLICE_FADE_FRAMES}; + use super::*; + + #[test] + fn delay_line_shifts_without_losing_samples() { + const PAD_FRAMES: usize = 482; + let mut line = DelayLine::new(PAD_FRAMES, 2); + let mut fed: Vec = Vec::new(); + let mut got: Vec = Vec::new(); + for b in 0..4 { + let mut input = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; + for f in 0..DSP_BLOCK_FRAMES { + let v = (b * DSP_BLOCK_FRAMES + f) as f32; + input[f * 2] = v; + input[f * 2 + 1] = -v; + } + fed.extend_from_slice(&input); + let mut dst = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; + add_mapped(line.delayed(&input), &mut dst); + got.extend_from_slice(&dst); + } + let shift = PAD_FRAMES * 2; + for i in shift..got.len() { + assert_eq!(got[i], fed[i - shift], "sample {i} differs"); + } + } + + #[test] + fn delay_line_fills_a_whole_mono_block() { + const PAD_FRAMES: usize = 482; + let mut line = DelayLine::new(PAD_FRAMES, 1); + let mut fed: Vec = Vec::new(); + let mut got: Vec = Vec::new(); + for b in 0..4 { + let mut input = vec![0.0_f32; DSP_BLOCK_FRAMES]; + for (f, s) in input.iter_mut().enumerate() { + *s = (b * DSP_BLOCK_FRAMES + f) as f32 + 1.0; + } + fed.extend_from_slice(&input); + let mut dst = vec![0.0_f32; DSP_BLOCK_FRAMES * 2]; + add_mapped(line.delayed(&input), &mut dst); + got.extend_from_slice(&dst); + } + for b in 1..4 { + for f in 0..DSP_BLOCK_FRAMES { + let i = b * DSP_BLOCK_FRAMES * 2 + f * 2; + assert_ne!(got[i], 0.0, "left silent at block {b} frame {f}"); + assert_eq!(got[i], got[i + 1], "channels differ at block {b} frame {f}"); + } + } + for f in PAD_FRAMES..fed.len() { + assert_eq!(got[f * 2], fed[f - PAD_FRAMES], "frame {f} differs"); + } + } + + #[test] + fn crossfade_joins_without_a_step() { + const CH: usize = 2; + let mut dst = vec![1.0_f32; SPLICE_FADE_FRAMES * CH]; + let incoming = vec![0.0_f32; SPLICE_FADE_FRAMES * CH]; + crossfade_into(&mut dst, &incoming, &[], CH); + + assert_eq!(dst[0], 1.0, "first frame must stay pure outgoing"); + assert_eq!(dst[1], 1.0, "both channels of the first frame agree"); + let last = (SPLICE_FADE_FRAMES - 1) * CH; + assert_eq!(dst[last], 0.0, "last frame must reach pure incoming"); + assert_eq!(dst[last + 1], 0.0, "both channels of the last frame agree"); + + for f in 1..SPLICE_FADE_FRAMES { + assert!(dst[f * CH] < dst[(f - 1) * CH], "fade must be monotonic"); + assert_eq!(dst[f * CH], dst[f * CH + 1], "channels share a weight"); + } + } + + #[test] + fn add_mapped_maps_by_channel() { + let mut src = vec![0.0; DSP_BLOCK_FRAMES * 4]; + for f in 0..DSP_BLOCK_FRAMES { + for c in 0..4 { + src[f * 4 + c] = c as f32 + 1.0; + } + } + let mut dst = vec![0.0; DSP_BLOCK_FRAMES * 2]; + add_mapped(&src, &mut dst); + assert_eq!(dst[0], 1.0); + assert_eq!(dst[1], 2.0); + + let mut stereo = vec![0.0; DSP_BLOCK_FRAMES * 2]; + for f in 0..DSP_BLOCK_FRAMES { + stereo[f * 2] = 1.0; + stereo[f * 2 + 1] = 3.0; + } + let mut mono = vec![0.0; DSP_BLOCK_FRAMES]; + add_mapped(&stereo, &mut mono); + assert!((mono[0] - 2.0).abs() < 1e-6); + + let src = vec![0.5; DSP_BLOCK_FRAMES * 3]; + let mut dst = vec![0.25; DSP_BLOCK_FRAMES * 3]; + add_mapped(&src, &mut dst); + assert!((dst[0] - 0.75).abs() < 1e-6); + } +} diff --git a/src-tauri/src/audio/pipeline/dag/mod.rs b/src-tauri/src/audio/pipeline/dag/mod.rs new file mode 100644 index 0000000..ce9022e --- /dev/null +++ b/src-tauri/src/audio/pipeline/dag/mod.rs @@ -0,0 +1,25 @@ +pub(super) mod builder; +pub(super) mod graph; +pub(super) mod nodes; +pub(super) mod staging; + +#[allow(unused_imports)] +pub(super) use builder::{ + build_output_graph, inputs_feeding_output, plan_cuts, reachable_backward, CutPlan, +}; +#[allow(unused_imports)] +pub(super) use graph::{BuiltOutputGraph, OutputGraph}; +#[allow(unused_imports)] +pub(super) use nodes::{OutputMeta, SourceMeta, SourceStats}; + +/// One second of frames at the ring's own clock rate. +pub fn ring_capacity_frames(sample_rate: u32) -> usize { + sample_rate.max(1) as usize +} + +/// Block size used by the resampler. 256 frames @ 48 kHz ~ 5.3 ms. +pub const RESAMPLE_CHUNK: usize = 256; + +pub const DSP_BLOCK_FRAMES: usize = 1024; + +pub(super) const MAX_NET_CH: u32 = crate::audio::netaudio::MAX_CHANNELS as u32; diff --git a/src-tauri/src/audio/pipeline/dag/nodes.rs b/src-tauri/src/audio/pipeline/dag/nodes.rs new file mode 100644 index 0000000..19cd730 --- /dev/null +++ b/src-tauri/src/audio/pipeline/dag/nodes.rs @@ -0,0 +1,640 @@ +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rtrb::{Consumer, Producer}; +use tracing::{info, warn}; + +use crate::audio::effects::{update_meter, MeterHandle, RuntimeEffect}; +use crate::audio::health; +use crate::audio::input_bridge::CaptureStats; +use crate::audio::resample::MultiResampler; +use crate::audio::stream_recv::ChannelReceiver; + +use super::graph::DelayLine; +use super::staging::StagingRing; +use super::{DSP_BLOCK_FRAMES, RESAMPLE_CHUNK}; + +/// How long a source can go without delivering before the availability-paced +/// worker stops waiting on it. SCK in normal operation delivers every ~20 ms, +/// so 150 ms is ~7x headroom -- enough to avoid false positives on bursty +/// delivery, short enough that a real stall doesn't drown the FAST source's +/// ring buffer. +pub(super) const STALL_THRESHOLD: Duration = Duration::from_millis(150); + +const SOURCE_BACKLOG_HIGH_BLOCKS: usize = 4; +const SOURCE_BACKLOG_LOW_BLOCKS: usize = 2; +/// Ceiling on one block's trim. Backlog drains over a few seconds instead of +/// vanishing in a single splice, which is what makes it inaudible. +const TRIM_MAX_FRAMES_PER_BLOCK: usize = 64; +/// Crossfade length across a trim's cut. Long enough to kill the step, short +/// enough that the replayed audio reads as texture rather than an echo. +pub(super) const SPLICE_FADE_FRAMES: usize = 32; + +/// One node in an output's DAG. `Source` reads from a ring + resamples, +/// `Effect` sums its upstreams' buffers and runs DSP, `Producer` emits +/// network-received audio on named channel handles. Each exposes an +/// interleaved `out_buf` of `DSP_BLOCK_FRAMES * node_channels` that downstream +/// nodes consume. +pub(super) enum DagNode { + Source(SourceState), + Effect(EffectState), + Producer(ProducerState), + Consumer(ConsumerState), +} + +impl DagNode { + pub(super) fn out_buf(&self) -> &[f32] { + match self { + DagNode::Source(s) => &s.out_buf, + DagNode::Effect(e) => &e.out_buf, + DagNode::Producer(p) => &p.out_buf, + // Terminal sink; validation forbids outgoing edges, so never read. + DagNode::Consumer(_) => &[], + } + } + + /// Unknown or absent handles fall back to the node's main `out_buf`. + pub(super) fn out_buf_for_handle(&self, handle: Option<&str>) -> &[f32] { + let (handle_bufs, out_buf) = match self { + DagNode::Effect(e) => (&e.handle_bufs, &e.out_buf), + DagNode::Producer(p) => (&p.handle_bufs, &p.out_buf), + DagNode::Source(s) => (&s.handle_bufs, &s.out_buf), + DagNode::Consumer(_) => return self.out_buf(), + }; + match handle { + Some(h) => handle_bufs + .iter() + .find(|(id, _)| id == h) + .map(|(_, buf)| buf.as_slice()) + .unwrap_or(out_buf), + None => out_buf, + } + } +} + +/// Per-source counters + gauge read by the non-RT tick thread (`meter::spawn_xrun_thread`). +/// Every write is `Ordering::Relaxed` -- RT-safe, no allocation, no other sync. +#[derive(Clone)] +pub struct SourceStats { + /// Samples zero-filled on genuine mid-stream underrun (ring ran dry while streaming). + pub xrun: Arc, + /// Samples silenced because the source delivered nothing for longer than + /// `STALL_THRESHOLD`. Silent by design, but it is still missing audio. + pub stalled: Arc, + /// Samples discarded by the backlog trim in `fill_block`. + pub trimmed: Arc, + /// Samples actually read out of the source ring. + pub consumed: Arc, + /// Ring occupancy (samples) at the end of the last `fill_block`. A gauge, + /// not a counter -- plain `store`, no accumulation. + pub level: Arc, +} + +impl SourceStats { + pub(super) fn new() -> Self { + Self { + xrun: Arc::new(AtomicU64::new(0)), + stalled: Arc::new(AtomicU64::new(0)), + trimmed: Arc::new(AtomicU64::new(0)), + consumed: Arc::new(AtomicU64::new(0)), + level: Arc::new(AtomicU64::new(0)), + } + } +} + +/// Identifies one source for the tick thread: its counters plus enough +/// context (channel count, native rate) to convert sample deltas into a frame +/// rate comparable against real time. +#[derive(Clone)] +pub struct SourceMeta { + pub label: String, + pub stats: SourceStats, + pub channels: usize, + pub native_sr: u32, + /// Native-rate frames this source consumes per block. The rate check needs + /// it as the counter's step size, since a window boundary can misattribute + /// a whole block. + pub frames_per_block: usize, + /// Graph id of the captured input this source reads, for matching against + /// the broadcast slot's `CaptureStats` once the bridge wires it up. `None` + /// for ring-sources and network producers -- they don't go through a + /// capture broadcast. + pub input_id: Option, + /// Owning output id (or "monitor"), the other half of the key that + /// disambiguates one input feeding several outputs. + pub output_id: String, + /// Capture-side fed/dropped counters, filled in by `pipeline/mod.rs` + /// after `BroadcastTx::add` returns them for this source's ring. + pub capture: Option, +} + +/// Identifies one output for the tick thread: its per-block counter plus the +/// sample rate that defines its expected block cadence. `channels` and `io` +/// are only meaningful for speaker outputs -- `build_output_graph` doesn't +/// know the device's real channel count yet, so the caller fills both in +/// after `start_speaker_stream` returns (see `pipeline/mod.rs`). +#[derive(Clone)] +pub(in crate::audio::pipeline) struct OutputMeta { + pub label: String, + pub blocks: Arc, + pub sample_rate: u32, + pub channels: usize, + pub io: Option, +} + +pub(super) struct SourceState { + pub(super) label: String, + pub(super) channels: usize, + pub(super) consumer: Consumer, + pub(super) resampler: Option, + pub(super) input_staging: Vec, + /// Holds a trim's crossfaded join until the refill path picks it up. + pub(super) splice_tmp: Vec, + pub(super) out_pending: StagingRing, + pub(super) chunk_tmp: Vec, + pub(super) out_buf: Vec, + pub(super) input_samples_per_block: usize, + pub(super) realtime: bool, + /// >STALL_THRESHOLD since last pop => zero-fill and stop waiting on this source. + pub(super) last_pop_at: Instant, + pub(super) first_data_logged: bool, + pub(super) volume: Arc, + pub(super) paused: Option>, + // u64 generation (not AtomicBool) so every output's SourceState detects the + // seek independently; swap(false) would clear the flag for the first reader. + pub(super) drain: Option>, + pub(super) last_drain_gen: u64, + pub(super) meter: Option, + // Per-channel taps ("chK") drawn off this source. + pub(super) handle_bufs: Vec<(String, Vec)>, + pub(super) stats: SourceStats, +} + +impl SourceState { + pub(super) fn is_stalled(&self) -> bool { + self.last_pop_at.elapsed() > STALL_THRESHOLD + } + + /// Taps are filled at the end of `fill_block`, so an early return would + /// leave them looping their last block -- a buzz at the block rate. + pub(super) fn silence(&mut self) { + self.out_buf.fill(0.0); + for (_, buf) in self.handle_bufs.iter_mut() { + buf.fill(0.0); + } + } + + pub(super) fn fill_block(&mut self) { + if let Some(p) = &self.paused { + if p.load(Ordering::SeqCst) { + let avail = self.consumer.slots(); + if avail > 0 { + if let Ok(chunk) = self.consumer.read_chunk(avail) { + chunk.commit_all(); + } + } + self.input_staging.clear(); + self.out_pending.clear(); + self.silence(); + return; + } + } + if let Some(d) = &self.drain { + let gen = d.load(Ordering::SeqCst); + if gen != self.last_drain_gen { + self.last_drain_gen = gen; + let avail = self.consumer.slots(); + if avail > 0 { + if let Ok(chunk) = self.consumer.read_chunk(avail) { + chunk.commit_all(); + } + } + self.input_staging.clear(); + self.out_pending.clear(); + self.silence(); + return; + } + } + // Trim input backlog toward LOW so latency stays bounded, a slice per + // block and spliced rather than cut: drift needs a trickle, and one + // discard of hundreds of milliseconds is an audible tear. + if self.realtime { + let have = self.consumer.slots(); + let high = self.input_samples_per_block * SOURCE_BACKLOG_HIGH_BLOCKS; + if have > high { + let low = self.input_samples_per_block * SOURCE_BACKLOG_LOW_BLOCKS; + let fade = SPLICE_FADE_FRAMES * self.channels; + let budget = TRIM_MAX_FRAMES_PER_BLOCK * self.channels; + let excess = (have - low).min(budget); + let drop = excess - excess % self.channels; + // The splice reads a fade-out and a fade-in around the cut, so + // the ring has to hold both on top of what it discards. + if drop > 0 && have >= drop + 2 * fade { + self.splice_trim(drop, fade); + } + } + } + let need = self.out_buf.len(); + let mut written = self.out_pending.pop_into(&mut self.out_buf[..]); + while written < need { + self.try_refill_one_chunk(); + if self.out_pending.len() == 0 { + // Ring empty too -- zero-fill the rest (real underrun). + for s in &mut self.out_buf[written..] { + *s = 0.0; + } + // A stalled/paused source silences by design; only a source that + // is actively streaming and ran dry mid-block is a real xrun. + let counter = if self.is_stalled() { + &self.stats.stalled + } else { + &self.stats.xrun + }; + counter.fetch_add((need - written) as u64, Ordering::Relaxed); + break; + } + let n = self.out_pending.pop_into(&mut self.out_buf[written..]); + written += n; + } + const ONE_BITS: u32 = 0x3F80_0000; + let vol_bits = self.volume.load(Ordering::Relaxed); + if vol_bits != ONE_BITS { + let vol = f32::from_bits(vol_bits); + for s in self.out_buf.iter_mut() { + *s *= vol; + } + } + if let Some(m) = &self.meter { + update_meter(m, &self.out_buf, self.channels); + } + if !self.handle_bufs.is_empty() { + let w = self.channels; + for (h, buf) in self.handle_bufs.iter_mut() { + if let Some(a) = parse_stereo(h) { + let c0 = (a - 1).min(w - 1); + let c1 = a.min(w - 1); + for f in 0..DSP_BLOCK_FRAMES { + buf[f * 2] = self.out_buf[f * w + c0]; + buf[f * 2 + 1] = self.out_buf[f * w + c1]; + } + } else { + let c = parse_ch(h).map(|k| (k - 1).min(w - 1)).unwrap_or(0); + for f in 0..DSP_BLOCK_FRAMES { + buf[f] = self.out_buf[f * w + c]; + } + } + } + } + self.stats + .level + .store(self.consumer.slots() as u64, Ordering::Relaxed); + } + + /// Removes `drop` samples from the input ring, crossfading the `fade` + /// samples before the cut into the `fade` after it. The joined slice leads + /// the stream through `input_staging`, so the listener hears one short + /// blend instead of a step. + fn splice_trim(&mut self, drop: usize, fade: usize) { + self.splice_tmp.clear(); + let Ok(outgoing) = self.consumer.read_chunk(fade) else { + return; + }; + let (first, second) = outgoing.as_slices(); + self.splice_tmp.extend_from_slice(first); + self.splice_tmp.extend_from_slice(second); + outgoing.commit_all(); + + if let Ok(cut) = self.consumer.read_chunk(drop) { + cut.commit_all(); + } + + if let Ok(incoming) = self.consumer.read_chunk(fade) { + let (first, second) = incoming.as_slices(); + crossfade_into(&mut self.splice_tmp, first, second, self.channels); + incoming.commit_all(); + } + + self.input_staging.extend_from_slice(&self.splice_tmp); + // What left the ring, versus what the stream actually loses: the + // fade-out is re-injected, so only the cut and the fade-in are gone. + let popped = (drop + 2 * fade) as u64; + let removed = (drop + fade) as u64; + self.stats.consumed.fetch_add(popped, Ordering::Relaxed); + self.stats.trimmed.fetch_add(removed, Ordering::Relaxed); + health::bump(&health::SOURCE_TRIM_DROPPED_SAMPLES, removed); + self.last_pop_at = Instant::now(); + } + + fn try_refill_one_chunk(&mut self) { + if let Some(rs) = &mut self.resampler { + let needed = rs.chunk_in() * self.channels; + // Bulk read what we still need (one rtrb reservation instead of + // one atomic op per sample -- RT-friendly). + let want = needed - self.input_staging.len(); + let avail = self.consumer.slots().min(want); + if avail > 0 { + if let Ok(chunk) = self.consumer.read_chunk(avail) { + let (first, second) = chunk.as_slices(); + self.input_staging.extend_from_slice(first); + self.input_staging.extend_from_slice(second); + chunk.commit_all(); + self.stats + .consumed + .fetch_add(avail as u64, Ordering::Relaxed); + self.last_pop_at = Instant::now(); + } + } + if self.input_staging.len() < needed { + return; + } + self.chunk_tmp.clear(); + if let Err(e) = rs.process_chunk(&self.input_staging[..needed], &mut self.chunk_tmp) { + warn!(source = %self.label, error = %e, "resampler chunk failed"); + self.input_staging.drain(..needed); + return; + } + self.input_staging.drain(..needed); + } else { + self.chunk_tmp.clear(); + let mut want = RESAMPLE_CHUNK * self.channels; + // A splice staged its joined frames ahead of the ring. + if !self.input_staging.is_empty() { + let n = self.input_staging.len().min(want); + self.chunk_tmp.extend_from_slice(&self.input_staging[..n]); + self.input_staging.drain(..n); + want -= n; + } + let avail = self.consumer.slots().min(want); + if avail > 0 { + if let Ok(chunk) = self.consumer.read_chunk(avail) { + let (first, second) = chunk.as_slices(); + self.chunk_tmp.extend_from_slice(first); + self.chunk_tmp.extend_from_slice(second); + chunk.commit_all(); + self.stats + .consumed + .fetch_add(avail as u64, Ordering::Relaxed); + self.last_pop_at = Instant::now(); + } + } + } + // Whole-frame guarantee (don't split a frame across channels). + let frames = self.chunk_tmp.len() / self.channels; + self.chunk_tmp.truncate(frames * self.channels); + if !self.chunk_tmp.is_empty() { + if !self.first_data_logged { + info!(source = %self.label, "source online"); + self.first_data_logged = true; + } + self.out_pending.extend_from_slice(&self.chunk_tmp); + } + } +} + +pub(super) struct EffectState { + pub(super) effects: Vec, + pub(super) full_width: bool, + pub(super) bypass: Arc, + pub(super) incoming: Vec, + pub(super) sidechain: Vec, + pub(super) out_buf: Vec, + pub(super) sidechain_buf: Option>, + pub(super) pair_main: Vec, + pub(super) pair_side: Vec, + pub(super) handle_bufs: Vec<(String, Vec)>, + pub(super) taps: Vec>, +} + +impl EffectState { + pub(super) fn run(&mut self, frames: usize) { + let w = self.out_buf.len() / frames; + if self.full_width || w == 2 { + let sc = self.sidechain_buf.as_deref(); + self.effects[0].process_with_sidechain(&mut self.out_buf, sc, frames); + return; + } + for p in 0..self.effects.len() { + let (c0, c1) = (2 * p, 2 * p + 1); + for f in 0..frames { + let base = f * w; + self.pair_main[f * 2] = self.out_buf[base + c0]; + self.pair_main[f * 2 + 1] = if c1 < w { self.out_buf[base + c1] } else { 0.0 }; + } + let sc = if let Some(scb) = self.sidechain_buf.as_ref() { + for f in 0..frames { + let base = f * w; + self.pair_side[f * 2] = scb[base + c0]; + self.pair_side[f * 2 + 1] = if c1 < w { scb[base + c1] } else { 0.0 }; + } + Some(self.pair_side.as_slice()) + } else { + None + }; + self.effects[p].process_with_sidechain(&mut self.pair_main, sc, frames); + for f in 0..frames { + let base = f * w; + self.out_buf[base + c0] = self.pair_main[f * 2]; + if c1 < w { + self.out_buf[base + c1] = self.pair_main[f * 2 + 1]; + } + } + } + } +} + +pub(super) struct ProducerState { + pub(super) receiver: ChannelReceiver, + pub(super) out_buf: Vec, + pub(super) handle_bufs: Vec<(String, Vec)>, + pub(super) wire_keys: Vec, +} + +pub(super) enum TapKey { + Channel(String), + PrefixMix(String), +} + +impl ProducerState { + pub(super) fn process(&mut self) { + self.receiver.mix_block(&mut self.out_buf); + for ((_, buf), key) in self.handle_bufs.iter_mut().zip(&self.wire_keys) { + match key { + TapKey::Channel(k) => self.receiver.channel(k, buf), + TapKey::PrefixMix(p) => self.receiver.prefix_mix(p, buf), + } + } + } +} + +pub(super) struct ConsumerState { + pub(super) incoming: Vec, + pub(super) channel_bufs: Vec<(String, Vec)>, + pub(super) send_producers: Vec>, +} + +pub(super) struct IncomingEdge { + pub(super) src_idx: usize, + pub(super) source_handle: Option, + pub(super) target_handle: Option, + pub(super) delay: Option, +} + +pub(super) struct TerminalEdge { + pub(super) src_idx: usize, + pub(super) source_handle: Option, + pub(super) route: Option<(usize, usize)>, + pub(super) delay: Option, +} + +#[inline] +pub(super) fn parse_ch(handle: &str) -> Option { + handle + .strip_prefix("ch") + .and_then(|s| s.parse::().ok()) +} + +pub(super) fn tap_key(handle: &str) -> Option { + if let Some(rest) = handle.strip_prefix("peer:") { + return Some(if rest.contains(':') { + TapKey::Channel(rest.to_string()) + } else { + TapKey::PrefixMix(format!("{rest}:")) + }); + } + parse_ch(handle).map(|ch| TapKey::Channel((ch - 1).to_string())) +} + +#[inline] +pub(super) fn parse_stereo(handle: &str) -> Option { + handle + .strip_prefix("st") + .and_then(|s| s.parse::().ok()) +} + +pub(super) fn edge_channels( + nodes: &[DagNode], + node_channels: &[usize], + idx: usize, + source_handle: Option<&str>, +) -> usize { + match source_handle { + Some(h) if tap_handle_width(h).is_some() => { + nodes[idx].out_buf_for_handle(Some(h)).len() / DSP_BLOCK_FRAMES + } + _ => node_channels[idx], + } +} + +#[inline] +pub(super) fn tap_handle_width(handle: &str) -> Option { + if parse_stereo(handle).is_some() { + Some(2) + } else if parse_ch(handle).is_some() { + Some(1) + } else { + None + } +} + +#[inline] +pub(super) fn target_route(handle: &str) -> Option<(usize, usize)> { + if let Some(a) = parse_stereo(handle) { + Some((a - 1, 2)) + } else if let Some(k) = parse_ch(handle) { + Some((k - 1, 1)) + } else { + None + } +} + +#[inline] +pub(super) fn add_mapped(src: &[f32], dst: &mut [f32]) { + let src_ch = src.len() / DSP_BLOCK_FRAMES; + let dst_ch = dst.len() / DSP_BLOCK_FRAMES; + if src_ch == 0 || dst_ch == 0 { + return; + } + if src_ch == dst_ch { + for (d, &s) in dst.iter_mut().zip(src.iter()) { + *d += s; + } + return; + } + if dst_ch == 1 { + let g = 1.0 / src_ch as f32; + for f in 0..DSP_BLOCK_FRAMES { + let sb = f * src_ch; + let mut acc = 0.0; + for c in 0..src_ch { + acc += src[sb + c]; + } + dst[f] += acc * g; + } + return; + } + if src_ch == 1 { + for f in 0..DSP_BLOCK_FRAMES { + let v = src[f]; + let db = f * dst_ch; + for c in 0..dst_ch { + dst[db + c] += v; + } + } + return; + } + let n = src_ch.min(dst_ch); + for f in 0..DSP_BLOCK_FRAMES { + let sb = f * src_ch; + let db = f * dst_ch; + for c in 0..n { + dst[db + c] += src[sb + c]; + } + } +} + +#[inline] +pub(super) fn add_to_channel(src: &[f32], dst: &mut [f32], ch: usize) { + let src_ch = src.len() / DSP_BLOCK_FRAMES; + let dst_ch = dst.len() / DSP_BLOCK_FRAMES; + if src_ch == 0 || ch >= dst_ch { + return; + } + let g = 1.0 / src_ch as f32; + for f in 0..DSP_BLOCK_FRAMES { + let sb = f * src_ch; + let mut acc = 0.0; + for c in 0..src_ch { + acc += src[sb + c]; + } + dst[f * dst_ch + ch] += acc * g; + } +} + +#[inline] +pub(super) fn add_block_at(src: &[f32], dst: &mut [f32], off: usize) { + let src_ch = src.len() / DSP_BLOCK_FRAMES; + let dst_ch = dst.len() / DSP_BLOCK_FRAMES; + if src_ch == 0 || off >= dst_ch { + return; + } + let n = src_ch.min(dst_ch - off); + for f in 0..DSP_BLOCK_FRAMES { + let sb = f * src_ch; + let db = f * dst_ch + off; + for c in 0..n { + dst[db + c] += src[sb + c]; + } + } +} + +pub(super) fn crossfade_into(dst: &mut [f32], first: &[f32], second: &[f32], channels: usize) { + let span = (SPLICE_FADE_FRAMES - 1).max(1) as f32; + for (i, s) in first.iter().chain(second.iter()).enumerate() { + if i >= dst.len() { + break; + } + let w = ((i / channels) as f32 / span).min(1.0); + dst[i] = dst[i] * (1.0 - w) + s * w; + } +} diff --git a/src-tauri/src/audio/pipeline/dag/staging.rs b/src-tauri/src/audio/pipeline/dag/staging.rs new file mode 100644 index 0000000..a6c76a2 --- /dev/null +++ b/src-tauri/src/audio/pipeline/dag/staging.rs @@ -0,0 +1,80 @@ +use crate::audio::health; + +/// Fixed-capacity FIFO; allocates once. Overrun clamps and counts drops -- +/// wrapping the write head past the read head would corrupt subsequent pops. +pub(super) struct StagingRing { + buf: Box<[f32]>, + head: usize, + tail: usize, + len: usize, + dropped: u64, +} + +impl StagingRing { + pub(super) fn with_capacity(capacity: usize) -> Self { + Self { + buf: vec![0.0_f32; capacity].into_boxed_slice(), + head: 0, + tail: 0, + len: 0, + dropped: 0, + } + } + + #[inline] + pub(super) fn len(&self) -> usize { + self.len + } + + #[allow(dead_code)] + #[inline] + pub(super) fn dropped(&self) -> u64 { + self.dropped + } + + pub(super) fn clear(&mut self) { + self.head = 0; + self.tail = 0; + self.len = 0; + } + + pub(super) fn pop_into(&mut self, dst: &mut [f32]) -> usize { + let n = dst.len().min(self.len); + let cap = self.buf.len(); + for slot in dst.iter_mut().take(n) { + *slot = self.buf[self.head]; + self.head = if self.head + 1 == cap { + 0 + } else { + self.head + 1 + }; + } + self.len -= n; + n + } + + pub(super) fn extend_from_slice(&mut self, src: &[f32]) { + let cap = self.buf.len(); + let free = cap - self.len; + debug_assert!( + src.len() <= free, + "StagingRing overrun: have {} + {} new > cap {}", + self.len, + src.len(), + cap + ); + let take = src.len().min(free); + for &v in &src[..take] { + self.buf[self.tail] = v; + self.tail = if self.tail + 1 == cap { + 0 + } else { + self.tail + 1 + }; + } + self.len += take; + let overrun = (src.len() - take) as u64; + self.dropped = self.dropped.saturating_add(overrun); + health::bump(&health::STAGING_OVERRUN_SAMPLES, overrun); + } +} From 165de78b5fdd9dcd85e20b333cdb27ac8b4bb293 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:17:32 +0300 Subject: [PATCH 6/7] refactor(graph): decompose audio graph validation, specs, and tests --- package.json | 4 +- src-tauri/src/audio/graph.rs | 1492 ------------------------- src-tauri/src/audio/graph/mod.rs | 7 + src-tauri/src/audio/graph/tests.rs | 192 ++++ src-tauri/src/audio/graph/types.rs | 714 ++++++++++++ src-tauri/src/audio/graph/validate.rs | 580 ++++++++++ 6 files changed, 1495 insertions(+), 1494 deletions(-) delete mode 100644 src-tauri/src/audio/graph.rs create mode 100644 src-tauri/src/audio/graph/mod.rs create mode 100644 src-tauri/src/audio/graph/tests.rs create mode 100644 src-tauri/src/audio/graph/types.rs create mode 100644 src-tauri/src/audio/graph/validate.rs diff --git a/package.json b/package.json index 154f6e2..18dc464 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "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", - "generate": "cd src-tauri && cargo test", - "test": "cargo test --manifest-path src-tauri/Cargo.toml" + "generate": "cd src-tauri && cargo test -- --test-threads=1", + "test": "cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1" }, "license": "MIT", "dependencies": { diff --git a/src-tauri/src/audio/graph.rs b/src-tauri/src/audio/graph.rs deleted file mode 100644 index 5e3f17c..0000000 --- a/src-tauri/src/audio/graph.rs +++ /dev/null @@ -1,1492 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::net::{IpAddr, SocketAddr}; - -use serde::Deserialize; -use ts_rs::TS; - -use crate::error::{AppError, AppResult}; - -#[derive(Debug, Deserialize)] -pub struct GraphSpec { - pub nodes: Vec, - pub edges: Vec, - #[serde(default)] - pub sample_rate: Option, -} - -#[derive(Debug, Deserialize)] -pub struct NodeSpec { - pub id: String, - pub kind: NodeKind, - pub data: serde_json::Value, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EdgeSpec { - #[allow(dead_code)] - pub id: String, - pub source: String, - /// `Some("peer:")` selects a WebRTC per-peer output; `None` is the main out. - pub source_handle: Option, - pub target: String, - /// `Some("sidechain")` routes to an effect's sidechain key input. - pub target_handle: Option, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub enum NodeKind { - Microphone, - SystemAudio, - AppAudio, - Speaker, - FileRecording, - Gain, - Mute, - ChannelBalance, - Saturator, - Eq, - LevelMeter, - LufsMeter, - Waveform, - Spectrum, - Limiter, - Compressor, - NoiseGate, - Delay, - Reverb, - NoiseSuppressor, - Declick, - DeEsser, - AudioFile, - WebRtcCollaborator, - NetReceiver, - NetSender, - Plugin, -} - -impl NodeKind { - pub fn category(self) -> NodeCategory { - match self { - NodeKind::Microphone - | NodeKind::SystemAudio - | NodeKind::AppAudio - | NodeKind::NetReceiver - | NodeKind::AudioFile => NodeCategory::Input, - NodeKind::Speaker | NodeKind::FileRecording | NodeKind::NetSender => { - NodeCategory::Output - } - NodeKind::Gain - | NodeKind::Mute - | NodeKind::ChannelBalance - | NodeKind::Saturator - | NodeKind::Eq - | NodeKind::LevelMeter - | NodeKind::LufsMeter - | NodeKind::Waveform - | NodeKind::Spectrum - | NodeKind::Limiter - | NodeKind::Compressor - | NodeKind::NoiseGate - | NodeKind::Delay - | NodeKind::Reverb - | NodeKind::NoiseSuppressor - | NodeKind::Declick - | NodeKind::DeEsser - | NodeKind::Plugin => NodeCategory::Effect, - // Two destinations in one UI node: it sends to peers and emits what - // they send back. `expand_roles` splits it into an output half and - // an input half, so no single category is ever asked for. - NodeKind::WebRtcCollaborator => { - unreachable!("WebRtcCollaborator is split by expand_roles") - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NodeCategory { - Input, - Output, - Effect, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct MicrophoneData { - pub device_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct SystemAudioData { - #[serde(default = "default_true")] - pub exclude_current_app: bool, - #[serde(default = "default_one")] - pub volume: f32, -} -fn default_true() -> bool { - true -} -fn default_one() -> f32 { - 1.0 -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct AppAudioData { - pub bundle_id: Option, - #[serde(default = "default_one")] - pub volume: f32, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct AudioFileData { - pub file_path: Option, - #[serde(default)] - pub loop_enabled: bool, - #[serde(default = "default_one")] - pub volume: f32, - #[serde(default = "default_true")] - pub auto_start: bool, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct SpeakerData { - pub device_id: Option, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export)] -pub enum WavBitDepth { - F32, - I24, - I16, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export)] -pub enum FlacBitDepth { - I24, - I16, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export)] -pub enum AiffBitDepth { - I24, - I16, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export)] -pub enum FlacCompression { - Fast, - Default, - Best, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export)] -pub enum OpusApplication { - Audio, - Voip, - LowDelay, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)] -#[serde(tag = "kind", rename_all = "lowercase")] -#[ts(export)] -pub enum RecordingFormat { - Wav { - #[serde(rename = "bitDepth")] - bit_depth: WavBitDepth, - }, - Flac { - #[serde(rename = "bitDepth")] - bit_depth: FlacBitDepth, - compression: FlacCompression, - }, - Opus { - bitrate: u32, - application: OpusApplication, - }, - Mp3 { - #[serde(rename = "bitrateKbps")] - bitrate_kbps: u32, - }, - Aac { - bitrate: u32, - }, - Aiff { - #[serde(rename = "bitDepth")] - bit_depth: AiffBitDepth, - }, -} - -impl Default for RecordingFormat { - fn default() -> Self { - RecordingFormat::Wav { - bit_depth: WavBitDepth::F32, - } - } -} - -impl RecordingFormat { - /// LAME, the plain Opus encoder and Apple's AAC encoder are two-channel - /// (probed: CoreAudio's AAC rejects 3+ channels); FLAC caps by spec. - pub fn max_channels(self) -> u16 { - match self { - RecordingFormat::Mp3 { .. } - | RecordingFormat::Opus { .. } - | RecordingFormat::Aac { .. } => 2, - RecordingFormat::Flac { .. } => 8, - RecordingFormat::Wav { .. } | RecordingFormat::Aiff { .. } => 512, - } - } -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub enum RecordingMode { - New, - Overwrite, - Append, -} - -impl Default for RecordingMode { - fn default() -> Self { - RecordingMode::New - } -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct FileRecordingData { - pub file_path: Option, - #[serde(default)] - pub format: RecordingFormat, - #[serde(default)] - pub mode: RecordingMode, - #[serde(default = "default_two")] - pub channels: u16, - /// Pinned file sample rate; defaults to 48 kHz so the recorded rate is - /// always explicit. Ignored for Opus/Mp3, which are locked to 48 kHz. - #[serde(default = "default_rec_sample_rate")] - pub sample_rate: Option, - #[serde(default)] - pub waveform_hidden: bool, -} - -fn default_rec_sample_rate() -> Option { - Some(48_000) -} - -fn default_two() -> u16 { - 2 -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct GainData { - pub gain_db: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct MuteData { - pub muted: bool, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct ChannelBalanceData { - pub left_gain_db: f32, - pub right_gain_db: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct SaturatorData { - pub threshold_db: f32, - pub drive_db: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct DeclickData { - /// 0..1; higher flags smaller spikes as clicks. - pub sensitivity: f32, - /// Longest click span repaired, in milliseconds. - #[serde(default = "default_declick_width")] - pub max_width_ms: f32, - #[serde(default)] - pub bypassed: bool, -} - -fn default_declick_width() -> f32 { - 2.0 -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct DeEsserData { - /// Crossover / detector frequency in Hz; the band above it is de-essed. - pub frequency: f32, - /// Level (dBFS) above which the sibilant band is compressed. - pub threshold_db: f32, - /// Compression ratio applied to the sibilant band. - pub ratio: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct EqData { - /// One gain per ISO octave band (see `EQ_FREQUENCIES_HZ` in effects.rs). - pub gains_db: [f32; 10], - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase", default)] -#[ts(export)] -pub struct LevelMeterData {} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase", default)] -#[ts(export)] -pub struct LufsMeterData {} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase", default)] -#[ts(export)] -pub struct WaveformData {} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase", default)] -#[ts(export)] -pub struct SpectrumData {} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct LimiterData { - pub ceiling_db: f32, - pub lookahead_ms: f32, - pub release_ms: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct CompressorData { - pub threshold_db: f32, - pub ratio: f32, - pub attack_ms: f32, - pub release_ms: f32, - pub knee_db: f32, - pub makeup_db: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct NoiseGateData { - pub threshold_db: f32, - pub range_db: f32, - pub attack_ms: f32, - pub hold_ms: f32, - pub release_ms: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct DelayData { - pub time_ms: f32, - pub feedback: f32, - pub mix: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct ReverbData { - pub room_size: f32, - pub damping: f32, - pub width: f32, - pub mix: f32, - #[serde(default)] - pub bypassed: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct NoiseSuppressorData { - pub attenuation_limit_db: f32, - // Runtime knobs mirroring the upstream DeepFilterNet LADSPA plugin. - #[serde(default)] - pub post_filter_beta: f32, - #[serde(default = "default_min_thresh_db")] - pub min_thresh_db: f32, - #[serde(default = "default_max_erb_thresh_db")] - pub max_erb_thresh_db: f32, - #[serde(default = "default_max_df_thresh_db")] - pub max_df_thresh_db: f32, - #[serde(default)] - pub bypassed: bool, -} -fn default_min_thresh_db() -> f32 { - -10.0 -} -fn default_max_erb_thresh_db() -> f32 { - 30.0 -} -fn default_max_df_thresh_db() -> f32 { - 20.0 -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct NetReceiverData { - pub port: u16, - #[serde(default = "default_channels")] - pub channels: u32, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export)] -pub enum NetCodec { - PcmF32, - PcmI16, - Opus, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct NetSenderData { - pub target_ip: String, - pub port: u16, - #[serde(default = "default_channels")] - pub channels: u32, - pub codec: NetCodec, - pub opus_bitrate: u32, - pub opus_application: OpusApplication, - #[serde(default)] - pub sample_rate: Option, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct PluginData { - /// None until a plugin is picked, which pairs with an empty `path`. - #[serde(default)] - pub format: Option, - pub path: String, - pub plugin_id: String, - #[serde(default)] - pub bypassed: bool, - #[serde(default)] - pub state: Option, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct WebRtcCollaboratorData { - pub opus_bitrate: u32, - pub opus_application: OpusApplication, - #[serde(default = "default_channels")] - pub channels: u32, - #[serde(default = "default_codec")] - pub codec: NetCodec, -} -fn default_channels() -> u32 { - 1 -} -fn default_codec() -> NetCodec { - NetCodec::Opus -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum InputSpec { - Microphone { - device_id: String, - }, - SystemAudio { - exclude_current_app: bool, - }, - AppAudio { - bundle_id: String, - }, - AudioFile { - file_path: String, - }, - NetReceiver { - port: u16, - }, - /// Receive half of a WebRTC collaborator: audio arriving from peers, tapped - /// per peer and per channel out of the session's jitter buffer. - WebRtcRecv { - node_id: String, - opus_bitrate: u32, - opus_application: OpusApplication, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum OutputSpec { - Speaker { - device_id: String, - }, - FileRecording { - file_path: String, - format: RecordingFormat, - channels: u16, - mode: RecordingMode, - sample_rate: Option, - }, - NetSender { - node_id: String, - target: SocketAddr, - channels: u32, - codec: NetCodec, - opus_bitrate: u32, - opus_application: OpusApplication, - sample_rate: Option, - }, - /// Send half of a WebRTC collaborator: per-channel audio handed to the - /// session's encode task. The wire codec is set by the UI, not the graph. - WebRtcSend { - node_id: String, - channels: u32, - opus_bitrate: u32, - opus_application: OpusApplication, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum EffectSpec { - Gain(GainData), - Mute(MuteData), - ChannelBalance(ChannelBalanceData), - Saturator(SaturatorData), - Eq(EqData), - LevelMeter(LevelMeterData), - LufsMeter(LufsMeterData), - Waveform(WaveformData), - Spectrum(SpectrumData), - Limiter(LimiterData), - Compressor(CompressorData), - NoiseGate(NoiseGateData), - Delay(DelayData), - Reverb(ReverbData), - NoiseSuppressor(NoiseSuppressorData), - Declick(DeclickData), - DeEsser(DeEsserData), - Plugin { - node_id: String, - format: Option, - path: String, - plugin_id: String, - bypassed: bool, - // Base64 CLAP state blob restored on instantiation; None keeps defaults. - state: Option, - }, -} - -impl EffectSpec { - pub fn bypassed(&self) -> bool { - match self { - EffectSpec::Gain(d) => d.bypassed, - EffectSpec::Mute(d) => d.bypassed, - EffectSpec::ChannelBalance(d) => d.bypassed, - EffectSpec::Saturator(d) => d.bypassed, - EffectSpec::Eq(d) => d.bypassed, - EffectSpec::Limiter(d) => d.bypassed, - EffectSpec::Compressor(d) => d.bypassed, - EffectSpec::NoiseGate(d) => d.bypassed, - EffectSpec::Delay(d) => d.bypassed, - EffectSpec::Reverb(d) => d.bypassed, - EffectSpec::NoiseSuppressor(d) => d.bypassed, - EffectSpec::Declick(d) => d.bypassed, - EffectSpec::DeEsser(d) => d.bypassed, - EffectSpec::Plugin { bypassed, .. } => *bypassed, - EffectSpec::LevelMeter(_) - | EffectSpec::LufsMeter(_) - | EffectSpec::Waveform(_) - | EffectSpec::Spectrum(_) => false, - } - } -} - -#[derive(Debug, Clone)] -pub struct ValidInput { - pub id: String, - pub spec: InputSpec, - pub volume: f32, - pub auto_start: bool, -} - -#[derive(Debug, Clone)] -pub struct ValidOutput { - pub id: String, - pub spec: OutputSpec, -} - -#[derive(Debug, Clone)] -pub struct ValidEffect { - pub id: String, - pub spec: EffectSpec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EdgeKind { - Main, - Sidechain, -} - -#[derive(Debug, Clone)] -pub struct ValidEdge { - pub from: String, - pub source_handle: Option, - pub to: String, - /// Target-side handle, e.g. `Some("ch1")` for a WebRTC bridge channel input. - pub target_handle: Option, - pub kind: EdgeKind, -} - -/// Validated DAG. Effects may have multiple incoming edges (mixer-bus -/// behaviour), at most one outgoing edge. Inputs may fan out to many -/// downstream nodes. The engine assembles a per-output sub-graph from these -/// fields at start time. -#[derive(Debug, Clone)] -pub struct ValidGraph { - pub inputs: Vec, - pub outputs: Vec, - pub effects: Vec, - pub edges: Vec, - pub sample_rate: u32, -} - -/// One node of the expanded graph. A dual-role UI node appears once per role it -/// plays, so `role` belongs to this entry rather than to `kind`. -struct RoleNode<'a> { - id: String, - kind: NodeKind, - role: NodeCategory, - data: &'a serde_json::Value, -} - -/// Marks the receive half of a split dual-role node. Node ids are cuid2 -/// (alphanumeric), so this can never collide with one. -const RECV_SUFFIX: &str = "#recv"; - -fn is_analyzer_kind(kind: NodeKind) -> bool { - matches!( - kind, - NodeKind::LevelMeter | NodeKind::LufsMeter | NodeKind::Waveform | NodeKind::Spectrum - ) -} - -impl GraphSpec { - /// Splits dual-role nodes so that every node below plays exactly one role. - /// Each half of a WebRTC collaborator exists only if something is wired to - /// that side: a send-only node opens no receive tap, and a receive-only node - /// never clocks silence onto the wire. - fn expand_roles(&self) -> (Vec>, Vec) { - let mut nodes: Vec> = Vec::with_capacity(self.nodes.len()); - for n in &self.nodes { - if n.kind != NodeKind::WebRtcCollaborator { - nodes.push(RoleNode { - id: n.id.clone(), - kind: n.kind, - role: n.kind.category(), - data: &n.data, - }); - continue; - } - if self.edges.iter().any(|e| e.target == n.id) { - nodes.push(RoleNode { - id: n.id.clone(), - kind: n.kind, - role: NodeCategory::Output, - data: &n.data, - }); - } - if self.edges.iter().any(|e| e.source == n.id) { - nodes.push(RoleNode { - id: format!("{}{RECV_SUFFIX}", n.id), - kind: n.kind, - role: NodeCategory::Input, - data: &n.data, - }); - } - } - - // The send half keeps the original id, so edges into the node need no - // rewrite; edges out of it now start at the receive half. - let split: HashSet<&str> = self - .nodes - .iter() - .filter(|n| n.kind == NodeKind::WebRtcCollaborator) - .map(|n| n.id.as_str()) - .collect(); - let edges = self - .edges - .iter() - .map(|e| EdgeSpec { - id: e.id.clone(), - source: if split.contains(e.source.as_str()) { - format!("{}{RECV_SUFFIX}", e.source) - } else { - e.source.clone() - }, - source_handle: e.source_handle.clone(), - target: e.target.clone(), - target_handle: e.target_handle.clone(), - }) - .collect(); - (nodes, edges) - } - - /// Rules: - /// - Inputs may fan out to many downstream nodes; if none, they're dropped. - /// - Outputs may receive many incoming edges (mixed at the output). - /// - Effects may have ≥1 incoming (act as a mixer-bus) and ≤1 outgoing. - /// - Anything not on a path from some input to some output is dropped. - /// - Cycles are rejected. - pub fn validate(&self) -> AppResult { - let (nodes, edges) = self.expand_roles(); - let nodes_by_id: HashMap<&str, &RoleNode> = - nodes.iter().map(|n| (n.id.as_str(), n)).collect(); - - let mut outgoing: HashMap<&str, Vec<&str>> = HashMap::new(); - let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new(); - for edge in &edges { - if !nodes_by_id.contains_key(edge.source.as_str()) - || !nodes_by_id.contains_key(edge.target.as_str()) - { - return Err(AppError::Validation(format!( - "edge {} references unknown node", - edge.id - ))); - } - // Edges into an input node make no sense — fail loudly. - if let Some(n) = nodes_by_id.get(edge.target.as_str()) { - if n.role == NodeCategory::Input { - return Err(AppError::Validation(format!( - "edge points into input node {:?}", - n.id - ))); - } - } - // Edges out of an output node likewise. - if let Some(n) = nodes_by_id.get(edge.source.as_str()) { - if n.role == NodeCategory::Output { - return Err(AppError::Validation(format!( - "edge starts from output node {:?}", - n.id - ))); - } - } - outgoing - .entry(edge.source.as_str()) - .or_default() - .push(edge.target.as_str()); - incoming - .entry(edge.target.as_str()) - .or_default() - .push(edge.source.as_str()); - } - - check_acyclic(&nodes, &outgoing)?; - - let has_destination = nodes - .iter() - .any(|n| n.role == NodeCategory::Output || is_analyzer_kind(n.kind)) - // A collaborator holds a live peer session from the moment it - // exists, so an unwired one is a destination in waiting, not a - // graph error. - || self.nodes.iter().any(|n| n.kind == NodeKind::WebRtcCollaborator); - if !has_destination { - return Err(AppError::Validation( - "no routing — connect an input to an output or a meter".into(), - )); - } - - let reachable_from_inputs = bfs_forward(&nodes, &outgoing, NodeCategory::Input); - let reachable_from_terminals: HashSet<&str> = bfs_backward_pred(&nodes, &incoming, |n| { - n.role == NodeCategory::Output || is_analyzer_kind(n.kind) - }); - let routed: HashSet<&str> = reachable_from_inputs - .intersection(&reachable_from_terminals) - .copied() - .collect(); - // Keep unrouted input nodes (so their capture + level meter run) - // as well as nodes reachable from terminals (outputs, analyzers, and - // their upstream effect chains, which stream silence if inputs disconnect). - let mut keep = reachable_from_terminals; - for n in &nodes { - if n.role == NodeCategory::Input { - keep.insert(n.id.as_str()); - } - } - - let inputs = resolve_inputs(&nodes, &keep, &routed)?; - let outputs = resolve_outputs(&nodes, &keep, &routed)?; - let effects = resolve_effects(&nodes, &keep)?; - - let edges: Vec = edges - .iter() - .filter(|e| keep.contains(e.source.as_str()) && keep.contains(e.target.as_str())) - .map(|e| ValidEdge { - from: e.source.clone(), - source_handle: e.source_handle.clone(), - to: e.target.clone(), - target_handle: e.target_handle.clone(), - kind: match e.target_handle.as_deref() { - Some("sidechain") => EdgeKind::Sidechain, - _ => EdgeKind::Main, - }, - }) - .collect(); - - let sample_rate = match self.sample_rate { - Some(sr) if !(8_000..=384_000).contains(&sr) => { - return Err(AppError::Validation(format!( - "pipeline sample rate {sr} out of bounds (8000..=384000)" - ))); - } - Some(sr) => sr, - None => 48_000, - }; - - Ok(ValidGraph { - inputs, - outputs, - effects, - edges, - sample_rate, - }) - } -} - -/// `routed` are inputs on a real path to a terminal — they must resolve or -/// validation fails. `keep` may also include unrouted inputs (kept so their -/// capture + level meter run); if one of those fails to resolve (e.g. no -/// device selected yet) it's dropped silently rather than failing the graph. -fn resolve_inputs( - nodes: &[RoleNode<'_>], - keep: &HashSet<&str>, - routed: &HashSet<&str>, -) -> AppResult> { - let mut result = Vec::new(); - for n in nodes { - if n.role != NodeCategory::Input || !keep.contains(n.id.as_str()) { - continue; - } - let resolved = (|| -> AppResult<(InputSpec, f32, bool)> { - Ok(match n.kind { - NodeKind::Microphone => { - let data: MicrophoneData = parse(n.data, "Microphone")?; - let spec = InputSpec::Microphone { - device_id: data - .device_id - .ok_or_else(|| miss(&n.id, "Microphone has no device selected"))?, - }; - (spec, 1.0f32, true) - } - NodeKind::SystemAudio => { - let data: SystemAudioData = parse(n.data, "SystemAudio")?; - let spec = InputSpec::SystemAudio { - exclude_current_app: data.exclude_current_app, - }; - (spec, data.volume, true) - } - NodeKind::AppAudio => { - let data: AppAudioData = parse(n.data, "AppAudio")?; - let spec = InputSpec::AppAudio { - bundle_id: data - .bundle_id - .ok_or_else(|| miss(&n.id, "App Audio has no application selected"))?, - }; - (spec, data.volume, true) - } - NodeKind::AudioFile => { - let data: AudioFileData = parse(n.data, "AudioFile")?; - let spec = InputSpec::AudioFile { - file_path: data - .file_path - .ok_or_else(|| miss(&n.id, "Audio File has no file selected"))?, - }; - (spec, data.volume, data.auto_start) - } - NodeKind::NetReceiver => { - let data: NetReceiverData = parse(n.data, "NetReceiver")?; - (InputSpec::NetReceiver { port: data.port }, 1.0f32, true) - } - // Receive half of a collaborator: the session is keyed by the - // UI node, so the split suffix comes back off. - NodeKind::WebRtcCollaborator => { - let data: WebRtcCollaboratorData = parse(n.data, "WebRtcCollaborator")?; - let spec = InputSpec::WebRtcRecv { - node_id: n.id.strip_suffix(RECV_SUFFIX).unwrap_or(&n.id).to_string(), - opus_bitrate: data.opus_bitrate, - opus_application: data.opus_application, - }; - (spec, 1.0f32, true) - } - _ => unreachable!(), - }) - })(); - let (spec, volume, auto_start) = match resolved { - Ok(v) => v, - Err(e) if routed.contains(n.id.as_str()) => return Err(e), - Err(_) => continue, - }; - result.push(ValidInput { - id: n.id.clone(), - spec, - volume, - auto_start, - }); - } - Ok(result) -} - -fn resolve_outputs( - nodes: &[RoleNode<'_>], - keep: &HashSet<&str>, - routed: &HashSet<&str>, -) -> AppResult> { - let mut result = Vec::new(); - for n in nodes { - if n.role != NodeCategory::Output || !keep.contains(n.id.as_str()) { - continue; - } - let resolved = (|| -> AppResult { - Ok(match n.kind { - NodeKind::Speaker => { - let data: SpeakerData = parse(n.data, "Speaker")?; - OutputSpec::Speaker { - device_id: data - .device_id - .ok_or_else(|| miss(&n.id, "Speaker has no device selected"))?, - } - } - NodeKind::FileRecording => { - let data: FileRecordingData = parse(n.data, "FileRecording")?; - let file_path = data - .file_path - .ok_or_else(|| miss(&n.id, "File Recording has no path"))?; - let path = std::path::Path::new(&file_path); - let parent = path.parent().unwrap_or(std::path::Path::new(".")); - if !parent.exists() { - return Err(choose_file_err(&n.id, "directory does not exist")); - } - match data.mode { - RecordingMode::New => { - if path.exists() { - return Err(choose_file_err(&n.id, "file already exists")); - } - } - RecordingMode::Overwrite => {} - RecordingMode::Append => { - if !matches!( - data.format, - RecordingFormat::Wav { .. } | RecordingFormat::Aiff { .. } - ) { - return Err(AppError::Validation(format!( - "append recording is only supported for WAV/AIFF (node {})", - n.id - ))); - } - } - } - #[cfg(not(target_os = "macos"))] - if matches!(data.format, RecordingFormat::Aac { .. }) { - return Err(AppError::Validation(format!( - "AAC recording is only supported on macOS (node {})", - n.id - ))); - } - let max = data.format.max_channels(); - if data.channels == 0 || data.channels > max { - return Err(AppError::Validation(format!( - "recording node {} asks for {} channels; format allows 1..{max}", - n.id, data.channels - ))); - } - if let Some(sr) = data.sample_rate { - // FLAC's format tops out at 655350 Hz (20-bit rate field); - // every other recording format caps at 384000. - let max = if matches!(data.format, RecordingFormat::Flac { .. }) { - 655_350 - } else { - 384_000 - }; - if !(8000..=max).contains(&sr) { - return Err(AppError::Validation(format!( - "recording node {} pins sample rate {sr}; expected 8000..{max}", - n.id - ))); - } - } - OutputSpec::FileRecording { - file_path, - format: data.format, - channels: data.channels, - mode: data.mode, - sample_rate: data.sample_rate.filter(|_| { - !matches!( - data.format, - RecordingFormat::Opus { .. } | RecordingFormat::Mp3 { .. } - ) - }), - } - } - NodeKind::NetSender => { - let data: NetSenderData = parse(n.data, "NetSender")?; - let ip: IpAddr = data - .target_ip - .trim() - .parse() - .map_err(|_| miss(&n.id, "Net Sender has an invalid target IP"))?; - OutputSpec::NetSender { - node_id: n.id.clone(), - target: SocketAddr::new(ip, data.port), - channels: data - .channels - .clamp(1, crate::audio::netaudio::MAX_CHANNELS as u32), - codec: data.codec, - opus_bitrate: data.opus_bitrate, - opus_application: data.opus_application, - sample_rate: data.sample_rate.filter(|_| data.codec != NetCodec::Opus), - } - } - // Send half of a collaborator: audio wired in goes to peers, - // which is a destination like any other sender. - NodeKind::WebRtcCollaborator => { - let data: WebRtcCollaboratorData = parse(n.data, "WebRtcCollaborator")?; - OutputSpec::WebRtcSend { - node_id: n.id.clone(), - channels: data - .channels - .clamp(1, crate::audio::netaudio::MAX_CHANNELS as u32), - opus_bitrate: data.opus_bitrate, - opus_application: data.opus_application, - } - } - _ => unreachable!(), - }) - })(); - match resolved { - Ok(spec) => result.push(ValidOutput { - id: n.id.clone(), - spec, - }), - Err(e) if routed.contains(n.id.as_str()) => return Err(e), - Err(_) => continue, - } - } - Ok(result) -} - -fn resolve_effects(nodes: &[RoleNode<'_>], keep: &HashSet<&str>) -> AppResult> { - let mut result = Vec::new(); - for n in nodes { - if n.role != NodeCategory::Effect || !keep.contains(n.id.as_str()) { - continue; - } - result.push(ValidEffect { - id: n.id.clone(), - spec: effect_from_node(n)?, - }); - } - Ok(result) -} - -fn bfs_forward<'a>( - nodes: &'a [RoleNode<'a>], - outgoing: &HashMap<&'a str, Vec<&'a str>>, - start_role: NodeCategory, -) -> HashSet<&'a str> { - let mut seen = HashSet::new(); - let mut stack: Vec<&str> = nodes - .iter() - .filter(|n| n.role == start_role) - .map(|n| n.id.as_str()) - .collect(); - while let Some(cur) = stack.pop() { - if !seen.insert(cur) { - continue; - } - if let Some(kids) = outgoing.get(cur) { - for &k in kids { - stack.push(k); - } - } - } - seen -} - -fn bfs_backward_pred<'a>( - nodes: &'a [RoleNode<'a>], - incoming: &HashMap<&'a str, Vec<&'a str>>, - is_terminal: impl Fn(&RoleNode<'_>) -> bool, -) -> HashSet<&'a str> { - let mut seen = HashSet::new(); - let mut stack: Vec<&str> = nodes - .iter() - .filter(|n| is_terminal(n)) - .map(|n| n.id.as_str()) - .collect(); - while let Some(cur) = stack.pop() { - if !seen.insert(cur) { - continue; - } - if let Some(parents) = incoming.get(cur) { - for &p in parents { - stack.push(p); - } - } - } - seen -} - -fn effect_from_node(n: &RoleNode<'_>) -> AppResult { - Ok(match n.kind { - NodeKind::Gain => EffectSpec::Gain(parse(n.data, "Gain")?), - NodeKind::Mute => EffectSpec::Mute(parse(n.data, "Mute")?), - NodeKind::ChannelBalance => EffectSpec::ChannelBalance(parse(n.data, "ChannelBalance")?), - NodeKind::Saturator => EffectSpec::Saturator(parse(n.data, "Saturator")?), - NodeKind::Eq => EffectSpec::Eq(parse(n.data, "Eq")?), - NodeKind::LevelMeter => EffectSpec::LevelMeter(parse(n.data, "LevelMeter")?), - NodeKind::LufsMeter => EffectSpec::LufsMeter(parse(n.data, "LufsMeter")?), - NodeKind::Waveform => EffectSpec::Waveform(parse(n.data, "Waveform")?), - NodeKind::Spectrum => EffectSpec::Spectrum(parse(n.data, "Spectrum")?), - NodeKind::Limiter => EffectSpec::Limiter(parse(n.data, "Limiter")?), - NodeKind::Compressor => EffectSpec::Compressor(parse(n.data, "Compressor")?), - NodeKind::NoiseGate => EffectSpec::NoiseGate(parse(n.data, "NoiseGate")?), - NodeKind::Delay => EffectSpec::Delay(parse(n.data, "Delay")?), - NodeKind::Reverb => EffectSpec::Reverb(parse(n.data, "Reverb")?), - NodeKind::NoiseSuppressor => EffectSpec::NoiseSuppressor(parse(n.data, "NoiseSuppressor")?), - NodeKind::Declick => EffectSpec::Declick(parse(n.data, "Declick")?), - NodeKind::DeEsser => EffectSpec::DeEsser(parse(n.data, "DeEsser")?), - NodeKind::Plugin => { - let data: PluginData = parse(n.data, "Plugin")?; - EffectSpec::Plugin { - node_id: n.id.clone(), - format: data.format, - path: data.path, - plugin_id: data.plugin_id, - bypassed: data.bypassed, - state: data.state, - } - } - _ => unreachable!("non-effect kind passed to effect_from_node"), - }) -} - -fn parse Deserialize<'de>>(value: &serde_json::Value, ctx: &str) -> AppResult { - serde_json::from_value::(value.clone()) - .map_err(|e| AppError::Validation(format!("invalid {ctx} data: {e}"))) -} - -fn miss(node_id: &str, msg: &str) -> AppError { - AppError::Validation(format!("{msg} (node {node_id})")) -} - -fn choose_file_err(node_id: &str, reason: &str) -> AppError { - AppError::Validation(format!("choose-file (node {node_id}): {reason}")) -} - -fn check_acyclic(nodes: &[RoleNode<'_>], outgoing: &HashMap<&str, Vec<&str>>) -> AppResult<()> { - #[derive(Clone, Copy, PartialEq, Eq)] - enum Mark { - Unseen, - InProgress, - Done, - } - let mut marks: HashMap<&str, Mark> = nodes - .iter() - .map(|n| (n.id.as_str(), Mark::Unseen)) - .collect(); - for n in nodes { - if marks[n.id.as_str()] == Mark::Unseen { - visit(n.id.as_str(), outgoing, &mut marks)?; - } - } - return Ok(()); - - fn visit<'a>( - cur: &'a str, - outgoing: &HashMap<&str, Vec<&'a str>>, - marks: &mut HashMap<&'a str, Mark>, - ) -> AppResult<()> { - match marks.get(cur).copied().unwrap_or(Mark::Unseen) { - Mark::Done => return Ok(()), - Mark::InProgress => { - return Err(AppError::Validation(format!( - "cycle detected at node {cur}" - ))); - } - Mark::Unseen => {} - } - marks.insert(cur, Mark::InProgress); - if let Some(kids) = outgoing.get(cur) { - for &k in kids { - visit(k, outgoing, marks)?; - } - } - marks.insert(cur, Mark::Done); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn node(id: &str, kind: NodeKind, data: serde_json::Value) -> NodeSpec { - NodeSpec { - id: id.to_string(), - kind, - data, - } - } - - fn mic(id: &str) -> NodeSpec { - node( - id, - NodeKind::Microphone, - serde_json::json!({ "deviceId": "dev" }), - ) - } - - fn collab(id: &str) -> NodeSpec { - node( - id, - NodeKind::WebRtcCollaborator, - serde_json::json!({ "opusBitrate": 96_000, "opusApplication": "audio" }), - ) - } - - fn speaker(id: &str) -> NodeSpec { - node( - id, - NodeKind::Speaker, - serde_json::json!({ "deviceId": "dev" }), - ) - } - - fn edge( - id: &str, - source: &str, - source_handle: Option<&str>, - target: &str, - target_handle: Option<&str>, - ) -> EdgeSpec { - EdgeSpec { - id: id.to_string(), - source: source.to_string(), - source_handle: source_handle.map(str::to_string), - target: target.to_string(), - target_handle: target_handle.map(str::to_string), - } - } - - #[test] - fn send_only_collaborator_is_an_output() { - let g = GraphSpec { - sample_rate: None, - nodes: vec![mic("m"), collab("w")], - edges: vec![edge("e", "m", None, "w", Some("ch1"))], - }; - let v = g.validate().expect("send-only graph is valid"); - // The send half is a destination in its own right: no speaker, no meter, - // and no monitor needed for the mic to reach peers. - assert_eq!(v.outputs.len(), 1); - assert!(matches!( - v.outputs[0].spec, - OutputSpec::WebRtcSend { channels: 1, .. } - )); - assert_eq!(v.outputs[0].id, "w"); - assert_eq!(v.inputs.len(), 1); - assert!(!v - .inputs - .iter() - .any(|i| matches!(i.spec, InputSpec::WebRtcRecv { .. }))); - } - - #[test] - fn recv_only_collaborator_is_an_input() { - let g = GraphSpec { - sample_rate: None, - nodes: vec![collab("w"), speaker("s")], - edges: vec![edge("e", "w", Some("peer:p:0"), "s", None)], - }; - let v = g.validate().expect("recv-only graph is valid"); - assert_eq!(v.inputs.len(), 1); - assert_eq!(v.inputs[0].id, "w#recv"); - // The session is keyed by the UI node, so the split suffix is local. - assert!( - matches!(&v.inputs[0].spec, InputSpec::WebRtcRecv { node_id, .. } if node_id == "w") - ); - assert!(!v - .outputs - .iter() - .any(|o| matches!(o.spec, OutputSpec::WebRtcSend { .. }))); - assert_eq!(v.edges[0].from, "w#recv"); - } - - #[test] - fn duplex_collaborator_is_both() { - let g = GraphSpec { - sample_rate: None, - nodes: vec![mic("m"), collab("w"), speaker("s")], - edges: vec![ - edge("e1", "m", None, "w", Some("ch1")), - edge("e2", "w", Some("peer:p:0"), "s", None), - ], - }; - let v = g.validate().expect("duplex graph is valid"); - assert!(v.outputs.iter().any(|o| o.id == "w")); - assert!(v.inputs.iter().any(|i| i.id == "w#recv")); - assert!(v.edges.iter().any(|e| e.to == "w")); - assert!(v.edges.iter().any(|e| e.from == "w#recv")); - } - - #[test] - fn unwired_collaborator_is_not_a_routing_error() { - let g = GraphSpec { - sample_rate: None, - nodes: vec![collab("w")], - edges: vec![], - }; - let v = g - .validate() - .expect("an unwired collaborator is a destination in waiting"); - assert!(v.inputs.is_empty()); - assert!(v.outputs.is_empty()); - } - - #[test] - fn unrouted_output_is_valid_and_streams_silence() { - let g = GraphSpec { - sample_rate: None, - nodes: vec![speaker("s")], - edges: vec![], - }; - let v = g.validate().expect("unrouted output is valid"); - assert!(v.inputs.is_empty()); - assert_eq!(v.outputs.len(), 1); - assert_eq!(v.outputs[0].id, "s"); - } - - #[test] - fn effect_leading_to_output_survives_when_input_disconnects() { - fn gain(id: &str) -> NodeSpec { - node(id, NodeKind::Gain, serde_json::json!({ "gainDb": 0.0 })) - } - - let g = GraphSpec { - sample_rate: None, - nodes: vec![gain("g"), speaker("s")], - edges: vec![edge("e", "g", None, "s", None)], - }; - let v = g - .validate() - .expect("effect + output without inputs is valid"); - assert!(v.inputs.is_empty()); - assert_eq!(v.effects.len(), 1); - assert_eq!(v.effects[0].id, "g"); - assert_eq!(v.outputs.len(), 1); - assert_eq!(v.outputs[0].id, "s"); - assert_eq!(v.edges.len(), 1); - } - - #[test] - fn default_sample_rate_is_48000() { - let g = GraphSpec { - sample_rate: None, - nodes: vec![speaker("s")], - edges: vec![], - }; - let v = g.validate().expect("graph valid"); - assert_eq!(v.sample_rate, 48_000); - } - - #[test] - fn custom_sample_rate_is_preserved() { - for sr in [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 384_000] { - let g = GraphSpec { - sample_rate: Some(sr), - nodes: vec![speaker("s")], - edges: vec![], - }; - let v = g.validate().expect("graph valid"); - assert_eq!(v.sample_rate, sr); - } - } - - #[test] - fn out_of_bounds_sample_rate_is_rejected() { - for sr in [0, 4_000, 7_999, 384_001, 1_000_000] { - let g = GraphSpec { - sample_rate: Some(sr), - nodes: vec![speaker("s")], - edges: vec![], - }; - assert!(g.validate().is_err()); - } - } -} diff --git a/src-tauri/src/audio/graph/mod.rs b/src-tauri/src/audio/graph/mod.rs new file mode 100644 index 0000000..2c8fa30 --- /dev/null +++ b/src-tauri/src/audio/graph/mod.rs @@ -0,0 +1,7 @@ +pub mod types; +pub mod validate; + +pub use types::*; + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/audio/graph/tests.rs b/src-tauri/src/audio/graph/tests.rs new file mode 100644 index 0000000..b609ba1 --- /dev/null +++ b/src-tauri/src/audio/graph/tests.rs @@ -0,0 +1,192 @@ +use super::*; + +fn node(id: &str, kind: NodeKind, data: serde_json::Value) -> NodeSpec { + NodeSpec { + id: id.to_string(), + kind, + data, + } +} + +fn mic(id: &str) -> NodeSpec { + node( + id, + NodeKind::Microphone, + serde_json::json!({ "deviceId": "dev" }), + ) +} + +fn collab(id: &str) -> NodeSpec { + node( + id, + NodeKind::WebRtcCollaborator, + serde_json::json!({ "opusBitrate": 96_000, "opusApplication": "audio" }), + ) +} + +fn speaker(id: &str) -> NodeSpec { + node( + id, + NodeKind::Speaker, + serde_json::json!({ "deviceId": "dev" }), + ) +} + +fn edge( + id: &str, + source: &str, + source_handle: Option<&str>, + target: &str, + target_handle: Option<&str>, +) -> EdgeSpec { + EdgeSpec { + id: id.to_string(), + source: source.to_string(), + source_handle: source_handle.map(str::to_string), + target: target.to_string(), + target_handle: target_handle.map(str::to_string), + } +} + +#[test] +fn send_only_collaborator_is_an_output() { + let g = GraphSpec { + sample_rate: None, + nodes: vec![mic("m"), collab("w")], + edges: vec![edge("e", "m", None, "w", Some("ch1"))], + }; + let v = g.validate().expect("send-only graph is valid"); + assert_eq!(v.outputs.len(), 1); + assert!(matches!( + v.outputs[0].spec, + OutputSpec::WebRtcSend { channels: 1, .. } + )); + assert_eq!(v.outputs[0].id, "w"); + assert_eq!(v.inputs.len(), 1); + assert!(!v + .inputs + .iter() + .any(|i| matches!(i.spec, InputSpec::WebRtcRecv { .. }))); +} + +#[test] +fn recv_only_collaborator_is_an_input() { + let g = GraphSpec { + sample_rate: None, + nodes: vec![collab("w"), speaker("s")], + edges: vec![edge("e", "w", Some("peer:p:0"), "s", None)], + }; + let v = g.validate().expect("recv-only graph is valid"); + assert_eq!(v.inputs.len(), 1); + assert_eq!(v.inputs[0].id, "w#recv"); + assert!( + matches!(&v.inputs[0].spec, InputSpec::WebRtcRecv { node_id, .. } if node_id == "w") + ); + assert!(!v + .outputs + .iter() + .any(|o| matches!(o.spec, OutputSpec::WebRtcSend { .. }))); + assert_eq!(v.edges[0].from, "w#recv"); +} + +#[test] +fn duplex_collaborator_is_both() { + let g = GraphSpec { + sample_rate: None, + nodes: vec![mic("m"), collab("w"), speaker("s")], + edges: vec![ + edge("e1", "m", None, "w", Some("ch1")), + edge("e2", "w", Some("peer:p:0"), "s", None), + ], + }; + let v = g.validate().expect("duplex graph is valid"); + assert!(v.outputs.iter().any(|o| o.id == "w")); + assert!(v.inputs.iter().any(|i| i.id == "w#recv")); + assert!(v.edges.iter().any(|e| e.to == "w")); + assert!(v.edges.iter().any(|e| e.from == "w#recv")); +} + +#[test] +fn unwired_collaborator_is_not_a_routing_error() { + let g = GraphSpec { + sample_rate: None, + nodes: vec![collab("w")], + edges: vec![], + }; + let v = g + .validate() + .expect("an unwired collaborator is a destination in waiting"); + assert!(v.inputs.is_empty()); + assert!(v.outputs.is_empty()); +} + +#[test] +fn unrouted_output_is_valid_and_streams_silence() { + let g = GraphSpec { + sample_rate: None, + nodes: vec![speaker("s")], + edges: vec![], + }; + let v = g.validate().expect("unrouted output is valid"); + assert!(v.inputs.is_empty()); + assert_eq!(v.outputs.len(), 1); + assert_eq!(v.outputs[0].id, "s"); +} + +#[test] +fn effect_leading_to_output_survives_when_input_disconnects() { + fn gain(id: &str) -> NodeSpec { + node(id, NodeKind::Gain, serde_json::json!({ "gainDb": 0.0 })) + } + + let g = GraphSpec { + sample_rate: None, + nodes: vec![gain("g"), speaker("s")], + edges: vec![edge("e", "g", None, "s", None)], + }; + let v = g + .validate() + .expect("effect + output without inputs is valid"); + assert!(v.inputs.is_empty()); + assert_eq!(v.effects.len(), 1); + assert_eq!(v.effects[0].id, "g"); + assert_eq!(v.outputs.len(), 1); + assert_eq!(v.outputs[0].id, "s"); + assert_eq!(v.edges.len(), 1); +} + +#[test] +fn default_sample_rate_is_48000() { + let g = GraphSpec { + sample_rate: None, + nodes: vec![speaker("s")], + edges: vec![], + }; + let v = g.validate().expect("graph valid"); + assert_eq!(v.sample_rate, 48_000); +} + +#[test] +fn custom_sample_rate_is_preserved() { + for sr in [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 384_000] { + let g = GraphSpec { + sample_rate: Some(sr), + nodes: vec![speaker("s")], + edges: vec![], + }; + let v = g.validate().expect("graph valid"); + assert_eq!(v.sample_rate, sr); + } +} + +#[test] +fn out_of_bounds_sample_rate_is_rejected() { + for sr in [0, 4_000, 7_999, 384_001, 1_000_000] { + let g = GraphSpec { + sample_rate: Some(sr), + nodes: vec![speaker("s")], + edges: vec![], + }; + assert!(g.validate().is_err()); + } +} diff --git a/src-tauri/src/audio/graph/types.rs b/src-tauri/src/audio/graph/types.rs new file mode 100644 index 0000000..2f9ebcf --- /dev/null +++ b/src-tauri/src/audio/graph/types.rs @@ -0,0 +1,714 @@ +use std::net::SocketAddr; + +use serde::Deserialize; +use ts_rs::TS; + +#[derive(Debug, Deserialize)] +pub struct GraphSpec { + pub nodes: Vec, + pub edges: Vec, + #[serde(default)] + pub sample_rate: Option, +} + +#[derive(Debug, Deserialize)] +pub struct NodeSpec { + pub id: String, + pub kind: NodeKind, + pub data: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EdgeSpec { + #[allow(dead_code)] + pub id: String, + pub source: String, + /// `Some("peer:")` selects a WebRTC per-peer output; `None` is the main out. + pub source_handle: Option, + pub target: String, + /// `Some("sidechain")` routes to an effect's sidechain key input. + pub target_handle: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum NodeKind { + Microphone, + SystemAudio, + AppAudio, + Speaker, + FileRecording, + Gain, + Mute, + ChannelBalance, + Saturator, + Eq, + LevelMeter, + LufsMeter, + Waveform, + Spectrum, + Limiter, + Compressor, + NoiseGate, + Delay, + Reverb, + NoiseSuppressor, + Declick, + DeEsser, + AudioFile, + WebRtcCollaborator, + NetReceiver, + NetSender, + Plugin, +} + +impl NodeKind { + pub fn category(self) -> NodeCategory { + match self { + NodeKind::Microphone + | NodeKind::SystemAudio + | NodeKind::AppAudio + | NodeKind::NetReceiver + | NodeKind::AudioFile => NodeCategory::Input, + NodeKind::Speaker | NodeKind::FileRecording | NodeKind::NetSender => { + NodeCategory::Output + } + NodeKind::Gain + | NodeKind::Mute + | NodeKind::ChannelBalance + | NodeKind::Saturator + | NodeKind::Eq + | NodeKind::LevelMeter + | NodeKind::LufsMeter + | NodeKind::Waveform + | NodeKind::Spectrum + | NodeKind::Limiter + | NodeKind::Compressor + | NodeKind::NoiseGate + | NodeKind::Delay + | NodeKind::Reverb + | NodeKind::NoiseSuppressor + | NodeKind::Declick + | NodeKind::DeEsser + | NodeKind::Plugin => NodeCategory::Effect, + // Two destinations in one UI node: it sends to peers and emits what + // they send back. `expand_roles` splits it into an output half and + // an input half, so no single category is ever asked for. + NodeKind::WebRtcCollaborator => { + unreachable!("WebRtcCollaborator is split by expand_roles") + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeCategory { + Input, + Output, + Effect, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MicrophoneData { + pub device_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SystemAudioData { + #[serde(default = "default_true")] + pub exclude_current_app: bool, + #[serde(default = "default_one")] + pub volume: f32, +} +fn default_true() -> bool { + true +} +fn default_one() -> f32 { + 1.0 +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AppAudioData { + pub bundle_id: Option, + #[serde(default = "default_one")] + pub volume: f32, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AudioFileData { + pub file_path: Option, + #[serde(default)] + pub loop_enabled: bool, + #[serde(default = "default_one")] + pub volume: f32, + #[serde(default = "default_true")] + pub auto_start: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SpeakerData { + pub device_id: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export)] +pub enum WavBitDepth { + F32, + I24, + I16, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export)] +pub enum FlacBitDepth { + I24, + I16, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export)] +pub enum AiffBitDepth { + I24, + I16, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum FlacCompression { + Fast, + Default, + Best, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export)] +pub enum OpusApplication { + Audio, + Voip, + LowDelay, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)] +#[serde(tag = "kind", rename_all = "lowercase")] +#[ts(export)] +pub enum RecordingFormat { + Wav { + #[serde(rename = "bitDepth")] + bit_depth: WavBitDepth, + }, + Flac { + #[serde(rename = "bitDepth")] + bit_depth: FlacBitDepth, + compression: FlacCompression, + }, + Opus { + bitrate: u32, + application: OpusApplication, + }, + Mp3 { + #[serde(rename = "bitrateKbps")] + bitrate_kbps: u32, + }, + Aac { + bitrate: u32, + }, + Aiff { + #[serde(rename = "bitDepth")] + bit_depth: AiffBitDepth, + }, +} + +impl Default for RecordingFormat { + fn default() -> Self { + RecordingFormat::Wav { + bit_depth: WavBitDepth::F32, + } + } +} + +impl RecordingFormat { + /// LAME, the plain Opus encoder and Apple's AAC encoder are two-channel + /// (probed: CoreAudio's AAC rejects 3+ channels); FLAC caps by spec. + pub fn max_channels(self) -> u16 { + match self { + RecordingFormat::Mp3 { .. } + | RecordingFormat::Opus { .. } + | RecordingFormat::Aac { .. } => 2, + RecordingFormat::Flac { .. } => 8, + RecordingFormat::Wav { .. } | RecordingFormat::Aiff { .. } => 512, + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum RecordingMode { + New, + Overwrite, + Append, +} + +impl Default for RecordingMode { + fn default() -> Self { + RecordingMode::New + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct FileRecordingData { + pub file_path: Option, + #[serde(default)] + pub format: RecordingFormat, + #[serde(default)] + pub mode: RecordingMode, + #[serde(default = "default_two")] + pub channels: u16, + /// Pinned file sample rate; defaults to 48 kHz so the recorded rate is + /// always explicit. Ignored for Opus/Mp3, which are locked to 48 kHz. + #[serde(default = "default_rec_sample_rate")] + pub sample_rate: Option, + #[serde(default)] + pub waveform_hidden: bool, +} + +fn default_rec_sample_rate() -> Option { + Some(48_000) +} + +fn default_two() -> u16 { + 2 +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct GainData { + pub gain_db: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MuteData { + pub muted: bool, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ChannelBalanceData { + pub left_gain_db: f32, + pub right_gain_db: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SaturatorData { + pub threshold_db: f32, + pub drive_db: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct DeclickData { + /// 0..1; higher flags smaller spikes as clicks. + pub sensitivity: f32, + /// Longest click span repaired, in milliseconds. + #[serde(default = "default_declick_width")] + pub max_width_ms: f32, + #[serde(default)] + pub bypassed: bool, +} + +fn default_declick_width() -> f32 { + 2.0 +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct DeEsserData { + /// Crossover / detector frequency in Hz; the band above it is de-essed. + pub frequency: f32, + /// Level (dBFS) above which the sibilant band is compressed. + pub threshold_db: f32, + /// Compression ratio applied to the sibilant band. + pub ratio: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EqData { + /// One gain per ISO octave band (see `EQ_FREQUENCIES_HZ` in effects.rs). + pub gains_db: [f32; 10], + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase", default)] +#[ts(export)] +pub struct LevelMeterData {} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase", default)] +#[ts(export)] +pub struct LufsMeterData {} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase", default)] +#[ts(export)] +pub struct WaveformData {} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase", default)] +#[ts(export)] +pub struct SpectrumData {} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct LimiterData { + pub ceiling_db: f32, + pub lookahead_ms: f32, + pub release_ms: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CompressorData { + pub threshold_db: f32, + pub ratio: f32, + pub attack_ms: f32, + pub release_ms: f32, + pub knee_db: f32, + pub makeup_db: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct NoiseGateData { + pub threshold_db: f32, + pub range_db: f32, + pub attack_ms: f32, + pub hold_ms: f32, + pub release_ms: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct DelayData { + pub time_ms: f32, + pub feedback: f32, + pub mix: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ReverbData { + pub room_size: f32, + pub damping: f32, + pub width: f32, + pub mix: f32, + #[serde(default)] + pub bypassed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct NoiseSuppressorData { + pub attenuation_limit_db: f32, + // Runtime knobs mirroring the upstream DeepFilterNet LADSPA plugin. + #[serde(default)] + pub post_filter_beta: f32, + #[serde(default = "default_min_thresh_db")] + pub min_thresh_db: f32, + #[serde(default = "default_max_erb_thresh_db")] + pub max_erb_thresh_db: f32, + #[serde(default = "default_max_df_thresh_db")] + pub max_df_thresh_db: f32, + #[serde(default)] + pub bypassed: bool, +} +fn default_min_thresh_db() -> f32 { + -10.0 +} +fn default_max_erb_thresh_db() -> f32 { + 30.0 +} +fn default_max_df_thresh_db() -> f32 { + 20.0 +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct NetReceiverData { + pub port: u16, + #[serde(default = "default_channels")] + pub channels: u32, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export)] +pub enum NetCodec { + PcmF32, + PcmI16, + Opus, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct NetSenderData { + pub target_ip: String, + pub port: u16, + #[serde(default = "default_channels")] + pub channels: u32, + pub codec: NetCodec, + pub opus_bitrate: u32, + pub opus_application: OpusApplication, + #[serde(default)] + pub sample_rate: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct PluginData { + /// None until a plugin is picked, which pairs with an empty `path`. + #[serde(default)] + pub format: Option, + pub path: String, + pub plugin_id: String, + #[serde(default)] + pub bypassed: bool, + #[serde(default)] + pub state: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct WebRtcCollaboratorData { + pub opus_bitrate: u32, + pub opus_application: OpusApplication, + #[serde(default = "default_channels")] + pub channels: u32, + #[serde(default = "default_codec")] + pub codec: NetCodec, +} +fn default_channels() -> u32 { + 1 +} +fn default_codec() -> NetCodec { + NetCodec::Opus +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputSpec { + Microphone { + device_id: String, + }, + SystemAudio { + exclude_current_app: bool, + }, + AppAudio { + bundle_id: String, + }, + AudioFile { + file_path: String, + }, + NetReceiver { + port: u16, + }, + /// Receive half of a WebRTC collaborator: audio arriving from peers, tapped + /// per peer and per channel out of the session's jitter buffer. + WebRtcRecv { + node_id: String, + opus_bitrate: u32, + opus_application: OpusApplication, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutputSpec { + Speaker { + device_id: String, + }, + FileRecording { + file_path: String, + format: RecordingFormat, + channels: u16, + mode: RecordingMode, + sample_rate: Option, + }, + NetSender { + node_id: String, + target: SocketAddr, + channels: u32, + codec: NetCodec, + opus_bitrate: u32, + opus_application: OpusApplication, + sample_rate: Option, + }, + /// Send half of a WebRTC collaborator: per-channel audio handed to the + /// session's encode task. The wire codec is set by the UI, not the graph. + WebRtcSend { + node_id: String, + channels: u32, + opus_bitrate: u32, + opus_application: OpusApplication, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum EffectSpec { + Gain(GainData), + Mute(MuteData), + ChannelBalance(ChannelBalanceData), + Saturator(SaturatorData), + Eq(EqData), + LevelMeter(LevelMeterData), + LufsMeter(LufsMeterData), + Waveform(WaveformData), + Spectrum(SpectrumData), + Limiter(LimiterData), + Compressor(CompressorData), + NoiseGate(NoiseGateData), + Delay(DelayData), + Reverb(ReverbData), + NoiseSuppressor(NoiseSuppressorData), + Declick(DeclickData), + DeEsser(DeEsserData), + Plugin { + node_id: String, + format: Option, + path: String, + plugin_id: String, + bypassed: bool, + // Base64 CLAP state blob restored on instantiation; None keeps defaults. + state: Option, + }, +} + +impl EffectSpec { + pub fn bypassed(&self) -> bool { + match self { + EffectSpec::Gain(d) => d.bypassed, + EffectSpec::Mute(d) => d.bypassed, + EffectSpec::ChannelBalance(d) => d.bypassed, + EffectSpec::Saturator(d) => d.bypassed, + EffectSpec::Eq(d) => d.bypassed, + EffectSpec::Limiter(d) => d.bypassed, + EffectSpec::Compressor(d) => d.bypassed, + EffectSpec::NoiseGate(d) => d.bypassed, + EffectSpec::Delay(d) => d.bypassed, + EffectSpec::Reverb(d) => d.bypassed, + EffectSpec::NoiseSuppressor(d) => d.bypassed, + EffectSpec::Declick(d) => d.bypassed, + EffectSpec::DeEsser(d) => d.bypassed, + EffectSpec::Plugin { bypassed, .. } => *bypassed, + EffectSpec::LevelMeter(_) + | EffectSpec::LufsMeter(_) + | EffectSpec::Waveform(_) + | EffectSpec::Spectrum(_) => false, + } + } +} + +#[derive(Debug, Clone)] +pub struct ValidInput { + pub id: String, + pub spec: InputSpec, + pub volume: f32, + pub auto_start: bool, +} + +#[derive(Debug, Clone)] +pub struct ValidOutput { + pub id: String, + pub spec: OutputSpec, +} + +#[derive(Debug, Clone)] +pub struct ValidEffect { + pub id: String, + pub spec: EffectSpec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeKind { + Main, + Sidechain, +} + +#[derive(Debug, Clone)] +pub struct ValidEdge { + pub from: String, + pub source_handle: Option, + pub to: String, + /// Target-side handle, e.g. `Some("ch1")` for a WebRTC bridge channel input. + pub target_handle: Option, + pub kind: EdgeKind, +} + +/// Validated DAG. Effects may have multiple incoming edges (mixer-bus +/// behaviour), at most one outgoing edge. Inputs may fan out to many +/// downstream nodes. The engine assembles a per-output sub-graph from these +/// fields at start time. +#[derive(Debug, Clone)] +pub struct ValidGraph { + pub inputs: Vec, + pub outputs: Vec, + pub effects: Vec, + pub edges: Vec, + pub sample_rate: u32, +} diff --git a/src-tauri/src/audio/graph/validate.rs b/src-tauri/src/audio/graph/validate.rs new file mode 100644 index 0000000..b393897 --- /dev/null +++ b/src-tauri/src/audio/graph/validate.rs @@ -0,0 +1,580 @@ +use std::collections::{HashMap, HashSet}; +use std::net::{IpAddr, SocketAddr}; + +use serde::Deserialize; + +use crate::error::{AppError, AppResult}; + +use super::types::*; + +/// One node of the expanded graph. A dual-role UI node appears once per role it +/// plays, so `role` belongs to this entry rather than to `kind`. +struct RoleNode<'a> { + id: String, + kind: NodeKind, + role: NodeCategory, + data: &'a serde_json::Value, +} + +/// Marks the receive half of a split dual-role node. Node ids are cuid2 +/// (alphanumeric), so this can never collide with one. +pub(crate) const RECV_SUFFIX: &str = "#recv"; + +fn is_analyzer_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::LevelMeter | NodeKind::LufsMeter | NodeKind::Waveform | NodeKind::Spectrum + ) +} + +impl GraphSpec { + /// Splits dual-role nodes so that every node below plays exactly one role. + /// Each half of a WebRTC collaborator exists only if something is wired to + /// that side: a send-only node opens no receive tap, and a receive-only node + /// never clocks silence onto the wire. + fn expand_roles(&self) -> (Vec>, Vec) { + let mut nodes: Vec> = Vec::with_capacity(self.nodes.len()); + for n in &self.nodes { + if n.kind != NodeKind::WebRtcCollaborator { + nodes.push(RoleNode { + id: n.id.clone(), + kind: n.kind, + role: n.kind.category(), + data: &n.data, + }); + continue; + } + if self.edges.iter().any(|e| e.target == n.id) { + nodes.push(RoleNode { + id: n.id.clone(), + kind: n.kind, + role: NodeCategory::Output, + data: &n.data, + }); + } + if self.edges.iter().any(|e| e.source == n.id) { + nodes.push(RoleNode { + id: format!("{}{RECV_SUFFIX}", n.id), + kind: n.kind, + role: NodeCategory::Input, + data: &n.data, + }); + } + } + + // The send half keeps the original id, so edges into the node need no + // rewrite; edges out of it now start at the receive half. + let split: HashSet<&str> = self + .nodes + .iter() + .filter(|n| n.kind == NodeKind::WebRtcCollaborator) + .map(|n| n.id.as_str()) + .collect(); + let edges = self + .edges + .iter() + .map(|e| EdgeSpec { + id: e.id.clone(), + source: if split.contains(e.source.as_str()) { + format!("{}{RECV_SUFFIX}", e.source) + } else { + e.source.clone() + }, + source_handle: e.source_handle.clone(), + target: e.target.clone(), + target_handle: e.target_handle.clone(), + }) + .collect(); + (nodes, edges) + } + + /// Rules: + /// - Inputs may fan out to many downstream nodes; if none, they're dropped. + /// - Outputs may receive many incoming edges (mixed at the output). + /// - Effects may have ≥1 incoming (act as a mixer-bus) and ≤1 outgoing. + /// - Anything not on a path from some input to some output is dropped. + /// - Cycles are rejected. + pub fn validate(&self) -> AppResult { + let (nodes, edges) = self.expand_roles(); + let nodes_by_id: HashMap<&str, &RoleNode> = + nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + + let mut outgoing: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new(); + for edge in &edges { + if !nodes_by_id.contains_key(edge.source.as_str()) + || !nodes_by_id.contains_key(edge.target.as_str()) + { + return Err(AppError::Validation(format!( + "edge {} references unknown node", + edge.id + ))); + } + // Edges into an input node make no sense — fail loudly. + if let Some(n) = nodes_by_id.get(edge.target.as_str()) { + if n.role == NodeCategory::Input { + return Err(AppError::Validation(format!( + "edge points into input node {:?}", + n.id + ))); + } + } + // Edges out of an output node likewise. + if let Some(n) = nodes_by_id.get(edge.source.as_str()) { + if n.role == NodeCategory::Output { + return Err(AppError::Validation(format!( + "edge starts from output node {:?}", + n.id + ))); + } + } + outgoing + .entry(edge.source.as_str()) + .or_default() + .push(edge.target.as_str()); + incoming + .entry(edge.target.as_str()) + .or_default() + .push(edge.source.as_str()); + } + + check_acyclic(&nodes, &outgoing)?; + + let has_destination = nodes + .iter() + .any(|n| n.role == NodeCategory::Output || is_analyzer_kind(n.kind)) + // A collaborator holds a live peer session from the moment it + // exists, so an unwired one is a destination in waiting, not a + // graph error. + || self.nodes.iter().any(|n| n.kind == NodeKind::WebRtcCollaborator); + if !has_destination { + return Err(AppError::Validation( + "no routing — connect an input to an output or a meter".into(), + )); + } + + let reachable_from_inputs = bfs_forward(&nodes, &outgoing, NodeCategory::Input); + let reachable_from_terminals: HashSet<&str> = bfs_backward_pred(&nodes, &incoming, |n| { + n.role == NodeCategory::Output || is_analyzer_kind(n.kind) + }); + let routed: HashSet<&str> = reachable_from_inputs + .intersection(&reachable_from_terminals) + .copied() + .collect(); + // Keep unrouted input nodes (so their capture + level meter run) + // as well as nodes reachable from terminals (outputs, analyzers, and + // their upstream effect chains, which stream silence if inputs disconnect). + let mut keep = reachable_from_terminals; + for n in &nodes { + if n.role == NodeCategory::Input { + keep.insert(n.id.as_str()); + } + } + + let inputs = resolve_inputs(&nodes, &keep, &routed)?; + let outputs = resolve_outputs(&nodes, &keep, &routed)?; + let effects = resolve_effects(&nodes, &keep)?; + + let edges: Vec = edges + .iter() + .filter(|e| keep.contains(e.source.as_str()) && keep.contains(e.target.as_str())) + .map(|e| ValidEdge { + from: e.source.clone(), + source_handle: e.source_handle.clone(), + to: e.target.clone(), + target_handle: e.target_handle.clone(), + kind: match e.target_handle.as_deref() { + Some("sidechain") => EdgeKind::Sidechain, + _ => EdgeKind::Main, + }, + }) + .collect(); + + let sample_rate = match self.sample_rate { + Some(sr) if !(8_000..=384_000).contains(&sr) => { + return Err(AppError::Validation(format!( + "pipeline sample rate {sr} out of bounds (8000..=384000)" + ))); + } + Some(sr) => sr, + None => 48_000, + }; + + Ok(ValidGraph { + inputs, + outputs, + effects, + edges, + sample_rate, + }) + } +} + +/// `routed` are inputs on a real path to a terminal — they must resolve or +/// validation fails. `keep` may also include unrouted inputs (kept so their +/// capture + level meter run); if one of those fails to resolve (e.g. no +/// device selected yet) it's dropped silently rather than failing the graph. +fn resolve_inputs( + nodes: &[RoleNode<'_>], + keep: &HashSet<&str>, + routed: &HashSet<&str>, +) -> AppResult> { + let mut result = Vec::new(); + for n in nodes { + if n.role != NodeCategory::Input || !keep.contains(n.id.as_str()) { + continue; + } + let resolved = (|| -> AppResult<(InputSpec, f32, bool)> { + Ok(match n.kind { + NodeKind::Microphone => { + let data: MicrophoneData = parse(n.data, "Microphone")?; + let spec = InputSpec::Microphone { + device_id: data + .device_id + .ok_or_else(|| miss(&n.id, "Microphone has no device selected"))?, + }; + (spec, 1.0f32, true) + } + NodeKind::SystemAudio => { + let data: SystemAudioData = parse(n.data, "SystemAudio")?; + let spec = InputSpec::SystemAudio { + exclude_current_app: data.exclude_current_app, + }; + (spec, data.volume, true) + } + NodeKind::AppAudio => { + let data: AppAudioData = parse(n.data, "AppAudio")?; + let spec = InputSpec::AppAudio { + bundle_id: data + .bundle_id + .ok_or_else(|| miss(&n.id, "App Audio has no application selected"))?, + }; + (spec, data.volume, true) + } + NodeKind::AudioFile => { + let data: AudioFileData = parse(n.data, "AudioFile")?; + let spec = InputSpec::AudioFile { + file_path: data + .file_path + .ok_or_else(|| miss(&n.id, "Audio File has no file selected"))?, + }; + (spec, data.volume, data.auto_start) + } + NodeKind::NetReceiver => { + let data: NetReceiverData = parse(n.data, "NetReceiver")?; + (InputSpec::NetReceiver { port: data.port }, 1.0f32, true) + } + // Receive half of a collaborator: the session is keyed by the + // UI node, so the split suffix comes back off. + NodeKind::WebRtcCollaborator => { + let data: WebRtcCollaboratorData = parse(n.data, "WebRtcCollaborator")?; + let spec = InputSpec::WebRtcRecv { + node_id: n.id.strip_suffix(RECV_SUFFIX).unwrap_or(&n.id).to_string(), + opus_bitrate: data.opus_bitrate, + opus_application: data.opus_application, + }; + (spec, 1.0f32, true) + } + _ => unreachable!(), + }) + })(); + let (spec, volume, auto_start) = match resolved { + Ok(v) => v, + Err(e) if routed.contains(n.id.as_str()) => return Err(e), + Err(_) => continue, + }; + result.push(ValidInput { + id: n.id.clone(), + spec, + volume, + auto_start, + }); + } + Ok(result) +} + +fn resolve_outputs( + nodes: &[RoleNode<'_>], + keep: &HashSet<&str>, + routed: &HashSet<&str>, +) -> AppResult> { + let mut result = Vec::new(); + for n in nodes { + if n.role != NodeCategory::Output || !keep.contains(n.id.as_str()) { + continue; + } + let resolved = (|| -> AppResult { + Ok(match n.kind { + NodeKind::Speaker => { + let data: SpeakerData = parse(n.data, "Speaker")?; + OutputSpec::Speaker { + device_id: data + .device_id + .ok_or_else(|| miss(&n.id, "Speaker has no device selected"))?, + } + } + NodeKind::FileRecording => { + let data: FileRecordingData = parse(n.data, "FileRecording")?; + let file_path = data + .file_path + .ok_or_else(|| miss(&n.id, "File Recording has no path"))?; + let path = std::path::Path::new(&file_path); + let parent = path.parent().unwrap_or(std::path::Path::new(".")); + if !parent.exists() { + return Err(choose_file_err(&n.id, "directory does not exist")); + } + match data.mode { + RecordingMode::New => { + if path.exists() { + return Err(choose_file_err(&n.id, "file already exists")); + } + } + RecordingMode::Overwrite => {} + RecordingMode::Append => { + if !matches!( + data.format, + RecordingFormat::Wav { .. } | RecordingFormat::Aiff { .. } + ) { + return Err(AppError::Validation(format!( + "append recording is only supported for WAV/AIFF (node {})", + n.id + ))); + } + } + } + #[cfg(not(target_os = "macos"))] + if matches!(data.format, RecordingFormat::Aac { .. }) { + return Err(AppError::Validation(format!( + "AAC recording is only supported on macOS (node {})", + n.id + ))); + } + let max = data.format.max_channels(); + if data.channels == 0 || data.channels > max { + return Err(AppError::Validation(format!( + "recording node {} asks for {} channels; format allows 1..{max}", + n.id, data.channels + ))); + } + if let Some(sr) = data.sample_rate { + let max = if matches!(data.format, RecordingFormat::Flac { .. }) { + 655_350 + } else { + 384_000 + }; + if !(8000..=max).contains(&sr) { + return Err(AppError::Validation(format!( + "recording node {} pins sample rate {sr}; expected 8000..{max}", + n.id + ))); + } + } + OutputSpec::FileRecording { + file_path, + format: data.format, + channels: data.channels, + mode: data.mode, + sample_rate: data.sample_rate.filter(|_| { + !matches!( + data.format, + RecordingFormat::Opus { .. } | RecordingFormat::Mp3 { .. } + ) + }), + } + } + NodeKind::NetSender => { + let data: NetSenderData = parse(n.data, "NetSender")?; + let ip: IpAddr = data + .target_ip + .trim() + .parse() + .map_err(|_| miss(&n.id, "Net Sender has an invalid target IP"))?; + OutputSpec::NetSender { + node_id: n.id.clone(), + target: SocketAddr::new(ip, data.port), + channels: data + .channels + .clamp(1, crate::audio::netaudio::MAX_CHANNELS as u32), + codec: data.codec, + opus_bitrate: data.opus_bitrate, + opus_application: data.opus_application, + sample_rate: data.sample_rate.filter(|_| data.codec != NetCodec::Opus), + } + } + NodeKind::WebRtcCollaborator => { + let data: WebRtcCollaboratorData = parse(n.data, "WebRtcCollaborator")?; + OutputSpec::WebRtcSend { + node_id: n.id.clone(), + channels: data + .channels + .clamp(1, crate::audio::netaudio::MAX_CHANNELS as u32), + opus_bitrate: data.opus_bitrate, + opus_application: data.opus_application, + } + } + _ => unreachable!(), + }) + })(); + match resolved { + Ok(spec) => result.push(ValidOutput { + id: n.id.clone(), + spec, + }), + Err(e) if routed.contains(n.id.as_str()) => return Err(e), + Err(_) => continue, + } + } + Ok(result) +} + +fn resolve_effects(nodes: &[RoleNode<'_>], keep: &HashSet<&str>) -> AppResult> { + let mut result = Vec::new(); + for n in nodes { + if n.role != NodeCategory::Effect || !keep.contains(n.id.as_str()) { + continue; + } + result.push(ValidEffect { + id: n.id.clone(), + spec: effect_from_node(n)?, + }); + } + Ok(result) +} + +fn bfs_forward<'a>( + nodes: &'a [RoleNode<'a>], + outgoing: &HashMap<&'a str, Vec<&'a str>>, + start_role: NodeCategory, +) -> HashSet<&'a str> { + let mut seen = HashSet::new(); + let mut stack: Vec<&str> = nodes + .iter() + .filter(|n| n.role == start_role) + .map(|n| n.id.as_str()) + .collect(); + while let Some(cur) = stack.pop() { + if !seen.insert(cur) { + continue; + } + if let Some(kids) = outgoing.get(cur) { + for &k in kids { + stack.push(k); + } + } + } + seen +} + +fn bfs_backward_pred<'a>( + nodes: &'a [RoleNode<'a>], + incoming: &HashMap<&'a str, Vec<&'a str>>, + is_terminal: impl Fn(&RoleNode<'_>) -> bool, +) -> HashSet<&'a str> { + let mut seen = HashSet::new(); + let mut stack: Vec<&str> = nodes + .iter() + .filter(|n| is_terminal(n)) + .map(|n| n.id.as_str()) + .collect(); + while let Some(cur) = stack.pop() { + if !seen.insert(cur) { + continue; + } + if let Some(parents) = incoming.get(cur) { + for &p in parents { + stack.push(p); + } + } + } + seen +} + +fn effect_from_node(n: &RoleNode<'_>) -> AppResult { + Ok(match n.kind { + NodeKind::Gain => EffectSpec::Gain(parse(n.data, "Gain")?), + NodeKind::Mute => EffectSpec::Mute(parse(n.data, "Mute")?), + NodeKind::ChannelBalance => EffectSpec::ChannelBalance(parse(n.data, "ChannelBalance")?), + NodeKind::Saturator => EffectSpec::Saturator(parse(n.data, "Saturator")?), + NodeKind::Eq => EffectSpec::Eq(parse(n.data, "Eq")?), + NodeKind::LevelMeter => EffectSpec::LevelMeter(parse(n.data, "LevelMeter")?), + NodeKind::LufsMeter => EffectSpec::LufsMeter(parse(n.data, "LufsMeter")?), + NodeKind::Waveform => EffectSpec::Waveform(parse(n.data, "Waveform")?), + NodeKind::Spectrum => EffectSpec::Spectrum(parse(n.data, "Spectrum")?), + NodeKind::Limiter => EffectSpec::Limiter(parse(n.data, "Limiter")?), + NodeKind::Compressor => EffectSpec::Compressor(parse(n.data, "Compressor")?), + NodeKind::NoiseGate => EffectSpec::NoiseGate(parse(n.data, "NoiseGate")?), + NodeKind::Delay => EffectSpec::Delay(parse(n.data, "Delay")?), + NodeKind::Reverb => EffectSpec::Reverb(parse(n.data, "Reverb")?), + NodeKind::NoiseSuppressor => EffectSpec::NoiseSuppressor(parse(n.data, "NoiseSuppressor")?), + NodeKind::Declick => EffectSpec::Declick(parse(n.data, "Declick")?), + NodeKind::DeEsser => EffectSpec::DeEsser(parse(n.data, "DeEsser")?), + NodeKind::Plugin => { + let data: PluginData = parse(n.data, "Plugin")?; + EffectSpec::Plugin { + node_id: n.id.clone(), + format: data.format, + path: data.path, + plugin_id: data.plugin_id, + bypassed: data.bypassed, + state: data.state, + } + } + _ => unreachable!("non-effect kind passed to effect_from_node"), + }) +} + +fn parse Deserialize<'de>>(value: &serde_json::Value, ctx: &str) -> AppResult { + serde_json::from_value::(value.clone()) + .map_err(|e| AppError::Validation(format!("invalid {ctx} data: {e}"))) +} + +fn miss(node_id: &str, msg: &str) -> AppError { + AppError::Validation(format!("{msg} (node {node_id})")) +} + +fn choose_file_err(node_id: &str, reason: &str) -> AppError { + AppError::Validation(format!("choose-file (node {node_id}): {reason}")) +} + +fn check_acyclic(nodes: &[RoleNode<'_>], outgoing: &HashMap<&str, Vec<&str>>) -> AppResult<()> { + #[derive(Clone, Copy, PartialEq, Eq)] + enum Mark { + Unseen, + InProgress, + Done, + } + let mut marks: HashMap<&str, Mark> = nodes + .iter() + .map(|n| (n.id.as_str(), Mark::Unseen)) + .collect(); + for n in nodes { + if marks[n.id.as_str()] == Mark::Unseen { + visit(n.id.as_str(), outgoing, &mut marks)?; + } + } + return Ok(()); + + fn visit<'a>( + cur: &'a str, + outgoing: &HashMap<&str, Vec<&'a str>>, + marks: &mut HashMap<&'a str, Mark>, + ) -> AppResult<()> { + match marks.get(cur).copied().unwrap_or(Mark::Unseen) { + Mark::Done => return Ok(()), + Mark::InProgress => { + return Err(AppError::Validation(format!( + "cycle detected at node {cur}" + ))); + } + Mark::Unseen => {} + } + marks.insert(cur, Mark::InProgress); + if let Some(kids) = outgoing.get(cur) { + for &k in kids { + visit(k, outgoing, marks)?; + } + } + marks.insert(cur, Mark::Done); + Ok(()) + } +} From ff52e855a558f8990a162f8b7951e18678d7ece4 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:28:32 +0300 Subject: [PATCH 7/7] fix(dsp): replace EQ crossover ladder with cascaded peaking biquads --- .gitignore | 2 - docs/RULES.md | 17 +- docs/UI.md | 106 +++++---- src-tauri/src/audio/device/windows.rs | 10 +- src-tauri/src/audio/effects/biquad.rs | 49 +++- src-tauri/src/audio/effects/eq.rs | 133 +++++------ src-tauri/src/audio/effects/mod.rs | 1 - src-tauri/src/audio/graph/tests.rs | 4 +- src-tauri/src/audio/pipeline/dag/graph.rs | 8 +- src-tauri/src/audio/pipeline/output/macos.rs | 4 +- .../src/audio/pipeline/output/windows.rs | 4 +- src-tauri/src/audio/system_audio/macos.rs | 1 - src-tauri/src/audio/system_audio/windows.rs | 1 - src-tauri/tests/audio_quality.rs | 212 ++++++++++++++---- src-tauri/tests/boundary_and_precision.rs | 184 ++++++++++++--- src-tauri/tests/common/mod.rs | 32 ++- src-tauri/tests/pipeline_scenarios.rs | 107 +++++++-- 17 files changed, 621 insertions(+), 254 deletions(-) diff --git a/.gitignore b/.gitignore index f7e59ce..4fea1ac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ vite.config.ts.timestamp-* /flatpak/splitwave.deb /flatpak/*.flatpak -# Internal defects documentation -docs/ENGINE_DEFECTS.md diff --git a/docs/RULES.md b/docs/RULES.md index 4fc84f8..38d1eb6 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -53,12 +53,14 @@ PR checklist and testing: [CONTRIBUTING.md](../CONTRIBUTING.md). The real-time path comprises all callbacks executed by cpal, ScreenCaptureKit, CoreAudio, PipeWire, WASAPI, and the inner loop of `DspWorker::run`. ### Forbidden inside RT Audio Path: + - ❌ **Allocations**: Growing vectors (`Vec::push`, `Vec::resize`), strings (`String::from`), box allocations (`Box::new`), hash maps. - ❌ **System Locks**: `Mutex::lock`, `RwLock::write`. (Only lock-free atomic swaps or non-blocking `try_lock` if dropping a block is strictly acceptable). - ❌ **Syscalls and I/O**: File access, sockets, logging macros (`tracing::info!`, `println!`), IPC. - ❌ **Unbounded Loops**: Catch-up loops that iterate indefinitely without yielding to the transport clock. ### Permitted inside RT Audio Path: + - ✅ **Preallocated Buffers**: Slices and arrays allocated during stream initialization. - ✅ **Lock-Free Rings**: `rtrb` SPSC ring buffers using bulk operations (`bulk_pop`, `bulk_push`). - ✅ **Atomics**: `Arc`, `Arc` with `Ordering::Relaxed` for runtime controls and meter telemetry. @@ -71,13 +73,13 @@ The real-time path comprises all callbacks executed by cpal, ScreenCaptureKit, C - **Focus on the Non-Obvious WHY**: Comments explain hidden invariants, hardware workarounds, concurrency assumptions, and mathematical rationale. Naming handles WHAT. - **Terse and Timeless**: Describe the code as it currently exists. - **Forbidden Comments**: - - ❌ Never write conversational change logs ("now instead of", "was previously", "changed to fix bug", "old implementation"). - - ❌ Never narrate trivial mechanics (`// return result`, `// increment counter`). - - ❌ Never leave abandoned `TODO` or `FIXME` without a tracking issue. + - ❌ Never write conversational change logs ("now instead of", "was previously", "changed to fix bug", "old implementation"). + - ❌ Never narrate trivial mechanics (`// return result`, `// increment counter`). + - ❌ Never leave abandoned `TODO` or `FIXME` without a tracking issue. - **Mandatory Comments**: - - ✅ Invariants on memory ordering (`Ordering::Relaxed` vs `Ordering::SeqCst`). - - ✅ Hardware or OS quirks (e.g. CoreAudio reference cycles, Windows MTA COM initialization, PipeWire RTKit quantum semantics). - - ✅ Buffer sizing assumptions (e.g. ring buffer capacities, FFT chunk requirements). + - ✅ Invariants on memory ordering (`Ordering::Relaxed` vs `Ordering::SeqCst`). + - ✅ Hardware or OS quirks (e.g. CoreAudio reference cycles, Windows MTA COM initialization, PipeWire RTKit quantum semantics). + - ✅ Buffer sizing assumptions (e.g. ring buffer capacities, FFT chunk requirements). --- @@ -93,9 +95,11 @@ The real-time path comprises all callbacks executed by cpal, ScreenCaptureKit, C ## Commits Format: + ``` type(scope): subject ``` + - Standard [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). - Valid types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `style`. - Keep formatting-only commits separate from behavioral changes to preserve clean `git blame`. @@ -105,6 +109,7 @@ type(scope): subject ## Verification Checklist Before considering any refactoring complete: + 1. `cargo check --manifest-path src-tauri/Cargo.toml` passes. 2. `cargo test --manifest-path src-tauri/Cargo.toml` passes all unit and integration tests. 3. `bun run check` passes with 0 errors. diff --git a/docs/UI.md b/docs/UI.md index 4a4dadb..4d75656 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -10,35 +10,27 @@ Every node in the graph editor must follow this exact layout contract: ```svelte -
- -
+
+ +
``` ### Width & Sizing Rules: + - **Base Width**: - - `w-48` for simple nodes (Gain, Mute, Delay). - - `w-52` for detailed nodes (Compressor, EQ). - - Uncapped width (`wide={true}`) is allowed **only** for full-width visualizers (Waveform Scope, Spectrum, Multi-channel Level Meters). + - `w-48` for simple nodes (Gain, Mute, Delay). + - `w-52` for detailed nodes (Compressor, EQ). + - Uncapped width (`wide={true}`) is allowed **only** for full-width visualizers (Waveform Scope, Spectrum, Multi-channel Level Meters). - **Vertical Rhythm**: Controls stack with `flex flex-col gap-1.5`. - **Canvas Drag Isolation**: All interactive inputs, buttons, sliders, and steppers **must** include CSS classes `nodrag nopan`. Any scrollable sub-container must also include `nowheel`. @@ -48,13 +40,13 @@ Every node in the graph editor must follow this exact layout contract: Every node is color-coded by its functional category via the `accent` prop on `Wrapper`: -| Category | `accent` Prop | Text & Icon Class | Role in Graph | -| :--- | :--- | :--- | :--- | -| **Input** | `"input"` | `text-emerald-600 dark:text-emerald-400` | Microphones, system audio, file player | -| **Effect** | `"effect"` | `text-violet-600 dark:text-violet-400` | EQ, compressor, gate, reverb, plugins | -| **Output** | `"output"` | `text-sky-600 dark:text-sky-400` | Speakers, headphones, file recorder | -| **Monitor** | `"monitor"` | `text-amber-600 dark:text-amber-400` | Level meter, LUFS meter, waveform, spectrum | -| **Network** | `"network"` | `text-rose-600 dark:text-rose-400` | WebRTC collaborator, network sender/receiver | +| Category | `accent` Prop | Text & Icon Class | Role in Graph | +| :---------- | :------------ | :--------------------------------------- | :------------------------------------------- | +| **Input** | `"input"` | `text-emerald-600 dark:text-emerald-400` | Microphones, system audio, file player | +| **Effect** | `"effect"` | `text-violet-600 dark:text-violet-400` | EQ, compressor, gate, reverb, plugins | +| **Output** | `"output"` | `text-sky-600 dark:text-sky-400` | Speakers, headphones, file recorder | +| **Monitor** | `"monitor"` | `text-amber-600 dark:text-amber-400` | Level meter, LUFS meter, waveform, spectrum | +| **Network** | `"network"` | `text-rose-600 dark:text-rose-400` | WebRTC collaborator, network sender/receiver | --- @@ -63,19 +55,19 @@ Every node is color-coded by its functional category via the `accent` prop on `W Do not hand-roll custom inputs or sliders. Reuse standard primitives: 1. **`Slider` (`src/lib/modules/flow/ui/effect/_slider.svelte`)**: - - Standard control for continuous parameters (dB, ms, Hz, %). - - Always supply `defaultValue` (double-clicking the track resets to it). - - Double-clicking the numeric badge opens inline text input. + - Standard control for continuous parameters (dB, ms, Hz, %). + - Always supply `defaultValue` (double-clicking the track resets to it). + - Double-clicking the numeric badge opens inline text input. 2. **`SegmentedButtons` (`src/lib/components/segmented_buttons.svelte`)**: - - Mode switcher for 2 to 4 mutually exclusive states (e.g. `Stereo | Mono`, `New | Overwrite | Append`). + - Mode switcher for 2 to 4 mutually exclusive states (e.g. `Stereo | Mono`, `New | Overwrite | Append`). 3. **`NumberStepper` (`src/lib/components/number_stepper.svelte`)**: - - Stepper with `+` / `-` buttons for discrete integers (channel count, snapshot limits). + - Stepper with `+` / `-` buttons for discrete integers (channel count, snapshot limits). 4. **`Combobox` + `RescanButton` (`src/lib/modules/form/ui/`)**: - - Dropdown with search for hardware devices, audio formats, or apps. Always place `RescanButton` adjacent when enumerating audio endpoints. + - Dropdown with search for hardware devices, audio formats, or apps. Always place `RescanButton` adjacent when enumerating audio endpoints. 5. **`PresetBar` (`src/lib/modules/preset/ui/preset_bar.svelte`)**: - - Placed at the bottom of effect nodes for loading factory and user presets. + - Placed at the bottom of effect nodes for loading factory and user presets. 6. **`Tooltip` (`src/lib/modules/overlay/ui/`)**: - - Hover tooltips for abbreviations, routing indicators, and warnings. + - Hover tooltips for abbreviations, routing indicators, and warnings. --- @@ -83,44 +75,46 @@ Do not hand-roll custom inputs or sliders. Reuse standard primitives: - **Tabular Monospace Numbers**: Every dB readout, frequency, latency figure, millisecond duration, or timer **must** use: - ```html - ... - ``` - This prevents layout jitter as numbers fluctuate in real time. + ```html + ... + ``` + This prevents layout jitter as numbers fluctuate in real time. - **Value Formatting**: Use format helpers from `src/lib/components/format.ts`: - - `formatHz(48000)` -> `"48 kHz"` - - `formatDuration(sec)` -> `"00:15"` - - `formatSize(bytes)` -> `"12.4 MB"` + - `formatHz(48000)` -> `"48 kHz"` + - `formatDuration(sec)` -> `"00:15"` + - `formatSize(bytes)` -> `"12.4 MB"` --- ## 5. Visualizers & Curves When displaying audio graphs, transfer curves, or level meters: + - Place the visualizer inside a recessed well for contrast: `rounded-lg border border-neutral-400/50 bg-neutral-100/60 p-1.5`. - SVG curve dimensions should be fixed (e.g. `130x60` for compressor transfer curves). - Line colors on SVG: - - Grid / axes: `stroke-neutral-400/40` - - Active response curve: category accent color (e.g. `stroke-violet-600 dark:stroke-violet-400`). + - Grid / axes: `stroke-neutral-400/40` + - Active response curve: category accent color (e.g. `stroke-violet-600 dark:stroke-violet-400`). --- ## 6. Settings Pages & Dialogs For standalone views outside the graph editor (`settings/+page.svelte`, `virtual-devices/+page.svelte`): + - **Section Layout**: - ```html -
-
-

Section Title

-

Brief explanation of setting.

-
- -
- ``` + ```html +
+
+

Section Title

+

Brief explanation of setting.

+
+ +
+ ``` - **Option Selection Cards (Grid buttons)**: - - Grid: `grid grid-cols-2 gap-2` or `grid-cols-3 gap-2`. - - Active state: `border-neutral-900 bg-neutral-200 text-theme`. - - Inactive state: `border-neutral-400 bg-neutral-100 text-neutral-1000 hover:bg-neutral-200`. + - Grid: `grid grid-cols-2 gap-2` or `grid-cols-3 gap-2`. + - Active state: `border-neutral-900 bg-neutral-200 text-theme`. + - Inactive state: `border-neutral-400 bg-neutral-100 text-neutral-1000 hover:bg-neutral-200`. diff --git a/src-tauri/src/audio/device/windows.rs b/src-tauri/src/audio/device/windows.rs index d1fac2b..e53b80c 100644 --- a/src-tauri/src/audio/device/windows.rs +++ b/src-tauri/src/audio/device/windows.rs @@ -23,7 +23,10 @@ pub fn list_inputs() -> AppResult> { let devices = host .input_devices() .map_err(|e| AppError::Host(e.to_string()))?; - Ok(super::unique_named(devices.filter_map(|d| d.name().ok()), DeviceKind::Input)) + Ok(super::unique_named( + devices.filter_map(|d| d.name().ok()), + DeviceKind::Input, + )) } pub fn list_outputs() -> AppResult> { @@ -31,7 +34,10 @@ pub fn list_outputs() -> AppResult> { let devices = host .output_devices() .map_err(|e| AppError::Host(e.to_string()))?; - Ok(super::unique_named(devices.filter_map(|d| d.name().ok()), DeviceKind::Output)) + Ok(super::unique_named( + devices.filter_map(|d| d.name().ok()), + DeviceKind::Output, + )) } pub fn find(kind: DeviceKind, id: &str) -> AppResult { diff --git a/src-tauri/src/audio/effects/biquad.rs b/src-tauri/src/audio/effects/biquad.rs index 3d4fa8c..c11f5b5 100644 --- a/src-tauri/src/audio/effects/biquad.rs +++ b/src-tauri/src/audio/effects/biquad.rs @@ -12,10 +12,23 @@ pub struct Biquad { } impl Biquad { + /// Identity pass-through filter (H(z) = 1). + pub fn identity() -> Self { + Self { + b0: 1.0, + b1: 0.0, + b2: 0.0, + a1: 0.0, + a2: 0.0, + z1: 0.0, + z2: 0.0, + } + } + /// Copy another biquad's coefficients while keeping this one's state — lets a /// filter be retuned live without a discontinuity. #[inline] - pub(super) fn retune(&mut self, c: Biquad) { + pub fn retune(&mut self, c: Biquad) { self.b0 = c.b0; self.b1 = c.b1; self.b2 = c.b2; @@ -24,7 +37,7 @@ impl Biquad { } #[inline] - pub(super) fn process(&mut self, x: f32) -> f32 { + pub fn process(&mut self, x: f32) -> f32 { let y = self.b0 * x + self.z1; self.z1 = self.b1 * x - self.a1 * y + self.z2; self.z2 = self.b2 * x - self.a2 * y; @@ -75,3 +88,35 @@ pub fn biquad_for(shape: BandShape, freq_hz: f32, q: f32, sample_rate: u32) -> B z2: 0.0, } } + +/// RBJ cookbook peaking EQ filter. +/// At 0 dB gain, acts as an exact identity pass-through. +pub fn biquad_peaking(freq_hz: f32, q: f32, gain_db: f32, sample_rate: u32) -> Biquad { + if gain_db.abs() < 1e-4 { + return Biquad::identity(); + } + let fs = sample_rate as f32; + let w0 = 2.0 * std::f32::consts::PI * (freq_hz.clamp(10.0, fs * 0.49) / fs); + let (sinw, cosw) = (w0.sin(), w0.cos()); + let a = 10.0_f32.powf(gain_db / 40.0); + let q = q.max(0.05); + let alpha = sinw / (2.0 * q); + + let b0 = 1.0 + alpha * a; + let b1 = -2.0 * cosw; + let b2 = 1.0 - alpha * a; + let a0 = 1.0 + alpha / a; + let a1 = -2.0 * cosw; + let a2 = 1.0 - alpha / a; + + let inv = 1.0 / a0; + Biquad { + b0: b0 * inv, + b1: b1 * inv, + b2: b2 * inv, + a1: a1 * inv, + a2: a2 * inv, + z1: 0.0, + z2: 0.0, + } +} diff --git a/src-tauri/src/audio/effects/eq.rs b/src-tauri/src/audio/effects/eq.rs index b40d940..ceb9795 100644 --- a/src-tauri/src/audio/effects/eq.rs +++ b/src-tauri/src/audio/effects/eq.rs @@ -3,74 +3,27 @@ use std::sync::Arc; use crate::audio::graph::EqData; -use super::biquad::{biquad_for, BandShape, Biquad}; -use super::util::{db_to_linear, load_f32}; +use super::biquad::{biquad_peaking, Biquad}; +use super::util::load_f32; use super::{Effect, EffectControl}; -/// Linkwitz-Riley 4th-order crossover points: geometric means between adjacent -/// band centres. LR4 = two cascaded 2nd-order Butterworth biquads; sum of -/// matched LPF/HPF at the same fc is allpass, so all 10 bands sum back to a -/// magnitude-flat output when their gains are unity. -const EQ_CROSSOVER_FREQS: [f32; 9] = [ - 45.2548, 89.4427, 176.7767, 353.5534, 707.1068, 1414.2136, 2828.4271, 5656.8542, 11313.7085, +/// Center frequencies for the 10 ISO 1-octave bands. +pub const EQ_FREQUENCIES_HZ: [f32; 10] = [ + 32.0, 64.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0, ]; -const BUTTER_Q: f32 = std::f32::consts::FRAC_1_SQRT_2; // 1/√2 ≈ 0.7071 - -/// Cascaded pair of Butterworth biquads — a 4th-order Linkwitz-Riley section. -#[derive(Clone, Copy, Default)] -struct Lr4 { - a: Biquad, - b: Biquad, -} - -impl Lr4 { - fn new(shape: BandShape, freq_hz: f32, sample_rate: u32) -> Self { - let c = biquad_for(shape, freq_hz, BUTTER_Q, sample_rate); - Lr4 { a: c, b: c } - } - #[inline] - fn process(&mut self, x: f32) -> f32 { - self.b.process(self.a.process(x)) - } -} - -/// Per-channel filter chain. The input cascades through 9 crossover splits: -/// each split peels off one band's slice via LPF and forwards the HPF residual -/// to the next stage. Band gains scale these slices and we sum. -struct ChannelChain { - lpfs: [Lr4; 9], - hpfs: [Lr4; 9], -} - -impl ChannelChain { - fn new(sample_rate: u32) -> Self { - Self { - lpfs: std::array::from_fn(|i| { - Lr4::new(BandShape::Lpf, EQ_CROSSOVER_FREQS[i], sample_rate) - }), - hpfs: std::array::from_fn(|i| { - Lr4::new(BandShape::Hpf, EQ_CROSSOVER_FREQS[i], sample_rate) - }), - } - } - - #[inline] - fn process(&mut self, x: f32, gains_linear: &[f32; 10]) -> f32 { - let mut residual = x; - let mut sum = 0.0; - for i in 0..9 { - let band = self.lpfs[i].process(residual); - residual = self.hpfs[i].process(residual); - sum += band * gains_linear[i]; - } - sum + residual * gains_linear[9] - } -} +/// Q factor for 1-octave bandwidth (BW = 1 octave -> Q = 1 / (2 * sinh(ln(2)/2)) ≈ 1.4142). +pub const EQ_Q: f32 = 1.4142135; +/// 10-band graphic equalizer using cascaded second-order peaking biquads. +/// Cascading peaking filters guarantees exact phase coherence and magnitude flatness +/// (identity pass-through at 0 dB gain across all bands) without the destructive +/// inter-band phase cancellations of crossover ladders. pub struct EqEffect { - channels: [ChannelChain; 2], + filters: [[Biquad; 10]; 2], + last_gains_db: [f32; 10], gains: [Arc; 10], + sample_rate: u32, } impl EqEffect { @@ -80,37 +33,57 @@ impl EqEffect { let control = EffectControl::Eq { gains: gains.clone(), }; - ( - Self { - channels: [ - ChannelChain::new(sample_rate), - ChannelChain::new(sample_rate), - ], - gains, - }, - control, - ) + let mut effect = Self { + filters: [[Biquad::identity(); 10]; 2], + last_gains_db: [0.0; 10], + gains, + sample_rate, + }; + effect.update_coefficients(d.gains_db); + (effect, control) } pub fn from_state(gains: [Arc; 10], sample_rate: u32) -> Self { - Self { - channels: [ - ChannelChain::new(sample_rate), - ChannelChain::new(sample_rate), - ], + let initial_gains = std::array::from_fn(|i| load_f32(&gains[i])); + let mut effect = Self { + filters: [[Biquad::identity(); 10]; 2], + last_gains_db: [0.0; 10], gains, + sample_rate, + }; + effect.update_coefficients(initial_gains); + effect + } + + #[inline] + fn update_coefficients(&mut self, gains_db: [f32; 10]) { + for i in 0..10 { + let gain = gains_db[i]; + if (gain - self.last_gains_db[i]).abs() > 1e-4 { + let coeff = biquad_peaking(EQ_FREQUENCIES_HZ[i], EQ_Q, gain, self.sample_rate); + self.filters[0][i].retune(coeff); + self.filters[1][i].retune(coeff); + self.last_gains_db[i] = gain; + } } } } impl Effect for EqEffect { fn process(&mut self, samples: &mut [f32], frames: usize) { - let gains_linear: [f32; 10] = - std::array::from_fn(|i| db_to_linear(load_f32(&self.gains[i]))); + let current_gains = std::array::from_fn(|i| load_f32(&self.gains[i])); + self.update_coefficients(current_gains); + let stereo = &mut samples[..frames * 2]; for frame in stereo.chunks_exact_mut(2) { - frame[0] = self.channels[0].process(frame[0], &gains_linear); - frame[1] = self.channels[1].process(frame[1], &gains_linear); + let mut l = frame[0]; + let mut r = frame[1]; + for i in 0..10 { + l = self.filters[0][i].process(l); + r = self.filters[1][i].process(r); + } + frame[0] = l; + frame[1] = r; } } } diff --git a/src-tauri/src/audio/effects/mod.rs b/src-tauri/src/audio/effects/mod.rs index ce42e4f..eddf63e 100644 --- a/src-tauri/src/audio/effects/mod.rs +++ b/src-tauri/src/audio/effects/mod.rs @@ -139,4 +139,3 @@ impl RuntimeEffect { } } } - diff --git a/src-tauri/src/audio/graph/tests.rs b/src-tauri/src/audio/graph/tests.rs index b609ba1..a2e291b 100644 --- a/src-tauri/src/audio/graph/tests.rs +++ b/src-tauri/src/audio/graph/tests.rs @@ -79,9 +79,7 @@ fn recv_only_collaborator_is_an_input() { let v = g.validate().expect("recv-only graph is valid"); assert_eq!(v.inputs.len(), 1); assert_eq!(v.inputs[0].id, "w#recv"); - assert!( - matches!(&v.inputs[0].spec, InputSpec::WebRtcRecv { node_id, .. } if node_id == "w") - ); + assert!(matches!(&v.inputs[0].spec, InputSpec::WebRtcRecv { node_id, .. } if node_id == "w")); assert!(!v .outputs .iter() diff --git a/src-tauri/src/audio/pipeline/dag/graph.rs b/src-tauri/src/audio/pipeline/dag/graph.rs index 8201bc4..1797b95 100644 --- a/src-tauri/src/audio/pipeline/dag/graph.rs +++ b/src-tauri/src/audio/pipeline/dag/graph.rs @@ -4,15 +4,13 @@ use std::sync::Arc; use rtrb::Producer; -use crate::audio::effects::{ - EffectControl, GrHandle, LufsHandle, MeterHandle, WaveformHandle, -}; +use crate::audio::effects::{EffectControl, GrHandle, LufsHandle, MeterHandle, WaveformHandle}; use crate::audio::health; use crate::audio::streams::bulk_push_counted; use super::nodes::{ - add_block_at, add_mapped, add_to_channel, parse_ch, parse_stereo, target_route, - DagNode, OutputMeta, SourceMeta, TerminalEdge, + add_block_at, add_mapped, add_to_channel, parse_ch, parse_stereo, target_route, DagNode, + OutputMeta, SourceMeta, TerminalEdge, }; use super::DSP_BLOCK_FRAMES; diff --git a/src-tauri/src/audio/pipeline/output/macos.rs b/src-tauri/src/audio/pipeline/output/macos.rs index 84ba3f8..9f021c2 100644 --- a/src-tauri/src/audio/pipeline/output/macos.rs +++ b/src-tauri/src/audio/pipeline/output/macos.rs @@ -20,7 +20,9 @@ use super::{spawn_speaker_worker, speaker_ring, SpeakerIo, StreamGuard}; const SPEAKER_MAX_ATTEMPTS: u32 = 3; const SPEAKER_RETRY_DELAY: Duration = Duration::from_millis(300); -pub(in crate::audio::pipeline) use super::cpal_speaker::{resolve_speaker, SpeakerHandle, SpeakerResolved}; +pub(in crate::audio::pipeline) use super::cpal_speaker::{ + resolve_speaker, SpeakerHandle, SpeakerResolved, +}; // Substring match on cpal's stable Display -- AppError flattens the variant. fn is_device_not_available(e: &AppError) -> bool { diff --git a/src-tauri/src/audio/pipeline/output/windows.rs b/src-tauri/src/audio/pipeline/output/windows.rs index 12185a4..b9de625 100644 --- a/src-tauri/src/audio/pipeline/output/windows.rs +++ b/src-tauri/src/audio/pipeline/output/windows.rs @@ -12,8 +12,10 @@ use crate::error::AppResult; use super::super::dag::OutputGraph; use super::super::worker::WorkerCtrl; +pub(in crate::audio::pipeline) use super::cpal_speaker::{ + resolve_speaker, SpeakerHandle, SpeakerResolved, +}; use super::{spawn_speaker_worker, speaker_ring, SpeakerIo}; -pub(in crate::audio::pipeline) use super::cpal_speaker::{resolve_speaker, SpeakerHandle, SpeakerResolved}; pub(in crate::audio::pipeline) fn start_speaker_stream( node_id: &str, diff --git a/src-tauri/src/audio/system_audio/macos.rs b/src-tauri/src/audio/system_audio/macos.rs index b984cd8..010208e 100644 --- a/src-tauri/src/audio/system_audio/macos.rs +++ b/src-tauri/src/audio/system_audio/macos.rs @@ -13,7 +13,6 @@ fn bundle_path_cache() -> &'static Mutex> { C.get_or_init(|| Mutex::new(HashMap::new())) } - pub fn list_audio_applications() -> AppResult> { let workspace = NSWorkspace::sharedWorkspace(); let apps = workspace.runningApplications(); diff --git a/src-tauri/src/audio/system_audio/windows.rs b/src-tauri/src/audio/system_audio/windows.rs index 852213e..aee1f2a 100644 --- a/src-tauri/src/audio/system_audio/windows.rs +++ b/src-tauri/src/audio/system_audio/windows.rs @@ -30,7 +30,6 @@ fn path_cache() -> &'static Mutex> { C.get_or_init(|| Mutex::new(HashMap::new())) } - fn ensure_com() { unsafe { let _ = CoInitializeEx(None, COINIT_MULTITHREADED); diff --git a/src-tauri/tests/audio_quality.rs b/src-tauri/tests/audio_quality.rs index 7878050..22a4420 100644 --- a/src-tauri/tests/audio_quality.rs +++ b/src-tauri/tests/audio_quality.rs @@ -25,7 +25,10 @@ use splitwave_lib::audio::resample::MultiResampler; /// introducing zero sample drift, zero noise, and bit-exact preservation. #[test] fn test_gain_unity_is_bit_exact() { - let (mut gain, _ctrl) = GainEffect::new(GainData { gain_db: 0.0, bypassed: false }); + let (mut gain, _ctrl) = GainEffect::new(GainData { + gain_db: 0.0, + bypassed: false, + }); let sample_rate = 48_000; let original = generators::sine_stereo(440.0, 880.0, sample_rate, 0.5, 0.7); @@ -51,7 +54,10 @@ fn test_gain_db_scaling_linearity() { let in_rms = metrics::rms(&original); // Test +6.02 dB boost (double amplitude) - let (mut boost, _ctrl) = GainEffect::new(GainData { gain_db: 6.0206, bypassed: false }); + let (mut boost, _ctrl) = GainEffect::new(GainData { + gain_db: 6.0206, + bypassed: false, + }); let mut boosted = original.clone(); let frames = boosted.len() / 2; boost.process(&mut boosted, frames); @@ -64,7 +70,10 @@ fn test_gain_db_scaling_linearity() { ); // Test -6.02 dB cut (half amplitude) - let (mut cut, _ctrl) = GainEffect::new(GainData { gain_db: -6.0206, bypassed: false }); + let (mut cut, _ctrl) = GainEffect::new(GainData { + gain_db: -6.0206, + bypassed: false, + }); let mut cut_sig = original.clone(); let frames = cut_sig.len() / 2; cut.process(&mut cut_sig, frames); @@ -93,10 +102,25 @@ fn test_channel_balance_hard_panning() { balance.process(&mut signal, frames); let left_rms = metrics::rms(&signal.iter().step_by(2).copied().collect::>()); - let right_rms = metrics::rms(&signal.iter().skip(1).step_by(2).copied().collect::>()); + let right_rms = metrics::rms( + &signal + .iter() + .skip(1) + .step_by(2) + .copied() + .collect::>(), + ); - assert!(left_rms > 0.3, "Left channel was attenuated unexpectedly: {}", left_rms); - assert!(right_rms < 1e-4, "Right channel was not muted by balance: {}", right_rms); + assert!( + left_rms > 0.3, + "Left channel was attenuated unexpectedly: {}", + left_rms + ); + assert!( + right_rms < 1e-4, + "Right channel was not muted by balance: {}", + right_rms + ); } /// Verifies mute toggling: when muted, output is completely silenced (all zeros), @@ -104,36 +128,46 @@ fn test_channel_balance_hard_panning() { #[test] fn test_mute_behavior_and_restoration() { let sample_rate = 48_000; - let (mut mute, _ctrl) = MuteEffect::new(MuteData { muted: true, bypassed: false }); + let (mut mute, _ctrl) = MuteEffect::new(MuteData { + muted: true, + bypassed: false, + }); let mut signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.2, 0.5); let frames = signal.len() / 2; mute.process(&mut signal, frames); let muted_peak = metrics::peak(&signal); - assert_eq!(muted_peak, 0.0, "Muted node did not output complete silence"); + assert_eq!( + muted_peak, 0.0, + "Muted node did not output complete silence" + ); } /// Verifies that the saturator introduces smooth soft-clipping on hot signals, /// rounding peaks gracefully without generating NaN or infinite floats. #[test] fn test_saturator_soft_clipping_and_harmonics() { - let (mut saturator, _ctrl) = SaturatorEffect::new( - SaturatorData { - threshold_db: -6.0, - drive_db: 12.0, - bypassed: false, - }, - ); + let (mut saturator, _ctrl) = SaturatorEffect::new(SaturatorData { + threshold_db: -6.0, + drive_db: 12.0, + bypassed: false, + }); let sample_rate = 48_000; let mut hot_signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 1.5); let frames = hot_signal.len() / 2; saturator.process(&mut hot_signal, frames); - assert!(hot_signal.iter().all(|s| s.is_finite()), "Saturator generated NaN or non-finite values"); + assert!( + hot_signal.iter().all(|s| s.is_finite()), + "Saturator generated NaN or non-finite values" + ); let peak = metrics::peak(&hot_signal); - assert!(peak < 1.6, "Saturator allowed uncontrolled signal expansion"); + assert!( + peak < 1.6, + "Saturator allowed uncontrolled signal expansion" + ); } /// Verifies that the brickwall limiter strictly clamps peaks at the defined ceiling, @@ -166,8 +200,15 @@ fn test_limiter_brickwall_ceiling_guarantee() { ceiling_linear ); - assert!(peak > 0.5, "Limiter output collapsed to silence: peak = {}", peak); - assert!(signal.iter().all(|s| s.is_finite()), "Limiter generated non-finite floats"); + assert!( + peak > 0.5, + "Limiter output collapsed to silence: peak = {}", + peak + ); + assert!( + signal.iter().all(|s| s.is_finite()), + "Limiter generated non-finite floats" + ); } /// Verifies compressor dynamics: quiet signals below threshold pass uncompressed, @@ -354,7 +395,9 @@ fn test_declick_impulse_spike_removal() { declick.process(&mut signal, frames); // Declick introduces a known latency lookahead; the repaired samples should not peak at 1.0 - let max_peak = signal[4000..4500].iter().fold(0.0f32, |acc, &s| acc.max(s.abs())); + let max_peak = signal[4000..4500] + .iter() + .fold(0.0f32, |acc, &s| acc.max(s.abs())); assert!( max_peak < 0.6, "Declick failed to attenuate extreme transient click spike: peak = {}", @@ -412,10 +455,16 @@ fn test_deesser_high_frequency_sibilance_reduction() { fn test_eq_frequency_band_isolation() { let sample_rate = 48_000; let mut gains = [0.0f32; 10]; - gains[5] = 12.0; // 1000 Hz boost (+12 dB) + gains[5] = 12.0; // 1000 Hz boost (+12 dB) gains[2] = -12.0; // 125 Hz cut (-12 dB) - let (mut eq, _ctrl) = EqEffect::new(EqData { gains_db: gains, bypassed: false }, sample_rate); + let (mut eq, _ctrl) = EqEffect::new( + EqData { + gains_db: gains, + bypassed: false, + }, + sample_rate, + ); let tone_1k = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.3, 0.2); let mut out_1k = tone_1k.clone(); @@ -444,6 +493,53 @@ fn test_eq_frequency_band_isolation() { ); } +/// Tests that when all 10 bands are set to 0 dB, the graphic equalizer guarantees an exactly flat +/// magnitude response (within ±0.05 dB) and zero phase cancellation across the entire spectrum. +#[test] +fn test_eq_flat_response_at_zero_db() { + let sample_rate = 48_000; + let gains = [0.0f32; 10]; + let (mut eq, _ctrl) = EqEffect::new( + EqData { + gains_db: gains, + bypassed: false, + }, + sample_rate, + ); + + let test_freqs = [ + 32.0, 64.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0, + ]; + for &freq in &test_freqs { + let signal = generators::sine_stereo(freq, freq, sample_rate, 0.5, 0.1); + let mut processed = signal.clone(); + let frames = processed.len() / 2; + eq.process(&mut processed, frames); + + let steady_range = (sample_rate as usize / 20)..frames * 2; + let in_rms = metrics::rms(&signal[steady_range.clone()]); + let out_rms = metrics::rms(&processed[steady_range.clone()]); + + let diff_db = 20.0 * (out_rms / in_rms).log10().abs(); + assert!( + diff_db < 0.05, + "EQ with 0 dB gains altered magnitude at {} Hz by {} dB (expected < 0.05 dB)", + freq, + diff_db + ); + + for i in steady_range { + let sample_diff = (signal[i] - processed[i]).abs(); + assert!( + sample_diff < 1e-4, + "EQ introduced phase delay or sample divergence at {} Hz: diff = {}", + freq, + sample_diff + ); + } + } +} + /// Verifies delay buffer timing and feedback decay: delayed repeats appear after the delay interval /// and decay exponentially based on the feedback factor. #[test] @@ -494,7 +590,9 @@ fn test_multiresampler_downsampling_fidelity() { while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; chunk_out.clear(); - resampler.process_chunk(chunk, &mut chunk_out).expect("resample chunk"); + resampler + .process_chunk(chunk, &mut chunk_out) + .expect("resample chunk"); output.extend_from_slice(&chunk_out); offset += chunk_size * channels; } @@ -542,7 +640,9 @@ fn test_multiresampler_upsampling_fidelity() { while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; chunk_out.clear(); - resampler.process_chunk(chunk, &mut chunk_out).expect("resample chunk"); + resampler + .process_chunk(chunk, &mut chunk_out) + .expect("resample chunk"); output.extend_from_slice(&chunk_out); offset += chunk_size * channels; } @@ -578,7 +678,9 @@ fn test_multiresampler_double_rate() { while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; chunk_out.clear(); - resampler.process_chunk(chunk, &mut chunk_out).expect("resample chunk"); + resampler + .process_chunk(chunk, &mut chunk_out) + .expect("resample chunk"); output.extend_from_slice(&chunk_out); offset += chunk_size * channels; } @@ -598,19 +700,35 @@ fn test_multiresampler_double_rate() { #[test] fn test_dc_offset_rejection_and_signal_integrity() { let sample_rate = 48_000; - let (mut eq, _ctrl) = EqEffect::new(EqData { gains_db: [0.0; 10], bypassed: false }, sample_rate); + let (mut eq, _ctrl) = EqEffect::new( + EqData { + gains_db: [0.0; 10], + bypassed: false, + }, + sample_rate, + ); let signal = generators::sine_stereo(100.0, 100.0, sample_rate, 0.5, 0.5); let mut processed = signal.clone(); let frames = processed.len() / 2; eq.process(&mut processed, frames); - // Evaluate DC offset after the initial filter impulse settling transient (< -66 dBFS tolerance) - let dc = metrics::dc_offset(&processed[2048..]); + // Evaluate DC offset over complete wave cycles after initial filter settling transient (< -80 dBFS tolerance) + let period_samples = (sample_rate / 100) as usize * 2; // stereo samples per 100 Hz cycle + let start = 4 * period_samples; // 3840 samples (4 cycles settling) + let end = (processed.len() / period_samples) * period_samples; // integer number of cycles + let in_dc = metrics::dc_offset(&signal[start..end]); + let out_dc = metrics::dc_offset(&processed[start..end]); assert!( - dc.abs() < 5e-4, + out_dc.abs() < 1e-4, "Processing introduced non-zero DC offset in steady state: {}", - dc + out_dc + ); + assert!( + (out_dc - in_dc).abs() < 1e-5, + "Processing drifted DC offset relative to input: in = {}, out = {}", + in_dc, + out_dc ); } @@ -620,7 +738,12 @@ fn test_dc_offset_rejection_and_signal_integrity() { fn test_limiter_recovery_after_overload() { let sample_rate = 48_000; let (mut limiter, _ctrl, _meter) = LimiterEffect::new( - LimiterData { ceiling_db: -1.0, release_ms: 20.0, lookahead_ms: 5.0, bypassed: false }, + LimiterData { + ceiling_db: -1.0, + release_ms: 20.0, + lookahead_ms: 5.0, + bypassed: false, + }, sample_rate, ); @@ -682,7 +805,13 @@ fn test_saturator_tanh_symmetry() { #[test] fn test_eq_runtime_gain_control_update() { let sample_rate = 48_000; - let (mut eq, ctrl) = EqEffect::new(EqData { gains_db: [0.0; 10], bypassed: false }, sample_rate); + let (mut eq, ctrl) = EqEffect::new( + EqData { + gains_db: [0.0; 10], + bypassed: false, + }, + sample_rate, + ); let mut signal = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.4, 0.2); let half_frames = (signal.len() / 4) & !1; @@ -845,7 +974,8 @@ fn test_multiresampler_stereo_phase_coherence() { let out_rate = 48_000; let channels = 2; let chunk_size = 1024; - let mut resampler = MultiResampler::new(in_rate, out_rate, channels, chunk_size).expect("resampler"); + let mut resampler = + MultiResampler::new(in_rate, out_rate, channels, chunk_size).expect("resampler"); // Identical in-phase mono tone sent to both left and right channels let input = generators::sine_stereo(1000.0, 1000.0, in_rate, 0.2, 0.8); @@ -855,7 +985,9 @@ fn test_multiresampler_stereo_phase_coherence() { let mut offset = 0; while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut chunk_out).expect("resample"); + resampler + .process_chunk(chunk, &mut chunk_out) + .expect("resample"); output.extend_from_slice(&chunk_out); offset += chunk_size * channels; } @@ -903,7 +1035,10 @@ fn test_channel_balance_center_is_unity() { /// Verifies that enabling mute instantly zeroes all audio samples without leaving trailing buffer artifacts. #[test] fn test_mute_immediate_silencing() { - let (mut mute, _ctrl) = MuteEffect::new(MuteData { muted: true, bypassed: false }); + let (mut mute, _ctrl) = MuteEffect::new(MuteData { + muted: true, + bypassed: false, + }); let sample_rate = 48_000; let mut signal = generators::sine_stereo(440.0, 880.0, sample_rate, 0.05, 0.9); @@ -912,7 +1047,10 @@ fn test_mute_immediate_silencing() { mute.process(&mut signal, frames); let peak = metrics::peak(&signal); - assert_eq!(peak, 0.0, "Mute must produce absolute digital silence (0.0)"); + assert_eq!( + peak, 0.0, + "Mute must produce absolute digital silence (0.0)" + ); } /// Verifies that the declicker leaves clean non-click audio untouched with minimal distortion. @@ -946,5 +1084,3 @@ fn test_declick_preserves_clean_music() { diff_db ); } - - diff --git a/src-tauri/tests/boundary_and_precision.rs b/src-tauri/tests/boundary_and_precision.rs index 019ca72..c6d0b92 100644 --- a/src-tauri/tests/boundary_and_precision.rs +++ b/src-tauri/tests/boundary_and_precision.rs @@ -25,7 +25,13 @@ use splitwave_lib::audio::resample::MultiResampler; #[test] fn test_bypass_is_bit_exact() { let sample_rate = 48_000; - let (mut eq, _ctrl) = EqEffect::new(EqData { gains_db: [6.0; 10], bypassed: true }, sample_rate); + let (mut eq, _ctrl) = EqEffect::new( + EqData { + gains_db: [6.0; 10], + bypassed: true, + }, + sample_rate, + ); let original = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.1, 0.5); let mut processed = original.clone(); @@ -49,10 +55,19 @@ fn test_bypass_is_bit_exact() { #[test] fn test_runtime_parameter_update() { let sample_rate = 48_000; - let (mut gain, ctrl) = GainEffect::new(GainData { gain_db: 0.0, bypassed: false }); + let (mut gain, ctrl) = GainEffect::new(GainData { + gain_db: 0.0, + bypassed: false, + }); let frames = 256; - let mut block = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.2); + let mut block = generators::sine_stereo( + 440.0, + 440.0, + sample_rate, + frames as f32 / sample_rate as f32, + 0.2, + ); // Initial processing at 0 dB gain.process(&mut block, frames); @@ -64,7 +79,13 @@ fn test_runtime_parameter_update() { gain.process(&mut block, frames); // Steady state block at new gain - let mut steady_block = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.2); + let mut steady_block = generators::sine_stereo( + 440.0, + 440.0, + sample_rate, + frames as f32 / sample_rate as f32, + 0.2, + ); gain.process(&mut steady_block, frames); let updated_rms = metrics::rms(&steady_block); @@ -81,17 +102,29 @@ fn test_runtime_parameter_update() { fn test_chunk_size_independence() { let sample_rate = 48_000; let total_frames = 1024; - let input = generators::sine_stereo(440.0, 880.0, sample_rate, total_frames as f32 / sample_rate as f32, 0.4); + let input = generators::sine_stereo( + 440.0, + 880.0, + sample_rate, + total_frames as f32 / sample_rate as f32, + 0.4, + ); // Stream A: Processed in 16 small chunks of 64 frames - let (mut gain_a, _ctrl_a) = GainEffect::new(GainData { gain_db: 3.0, bypassed: false }); + let (mut gain_a, _ctrl_a) = GainEffect::new(GainData { + gain_db: 3.0, + bypassed: false, + }); let mut stream_a = input.clone(); for chunk in stream_a.chunks_exact_mut(64 * 2) { gain_a.process(chunk, 64); } // Stream B: Processed in 2 large chunks of 512 frames - let (mut gain_b, _ctrl_b) = GainEffect::new(GainData { gain_db: 3.0, bypassed: false }); + let (mut gain_b, _ctrl_b) = GainEffect::new(GainData { + gain_db: 3.0, + bypassed: false, + }); let mut stream_b = input.clone(); for chunk in stream_b.chunks_exact_mut(512 * 2) { gain_b.process(chunk, 512); @@ -110,15 +143,29 @@ fn test_chunk_size_independence() { fn test_multiple_process_calls_match_single_process_call() { let sample_rate = 48_000; let total_frames = 512; - let input = generators::sine_stereo(1000.0, 1000.0, sample_rate, total_frames as f32 / sample_rate as f32, 0.5); + let input = generators::sine_stereo( + 1000.0, + 1000.0, + sample_rate, + total_frames as f32 / sample_rate as f32, + 0.5, + ); // Single call of 512 frames - let (mut sat_single, _ctrl1) = SaturatorEffect::new(SaturatorData { drive_db: 6.0, threshold_db: -3.0, bypassed: false }); + let (mut sat_single, _ctrl1) = SaturatorEffect::new(SaturatorData { + drive_db: 6.0, + threshold_db: -3.0, + bypassed: false, + }); let mut out_single = input.clone(); sat_single.process(&mut out_single, total_frames); // Two calls of 256 frames - let (mut sat_multi, _ctrl2) = SaturatorEffect::new(SaturatorData { drive_db: 6.0, threshold_db: -3.0, bypassed: false }); + let (mut sat_multi, _ctrl2) = SaturatorEffect::new(SaturatorData { + drive_db: 6.0, + threshold_db: -3.0, + bypassed: false, + }); let mut out_multi = input.clone(); sat_multi.process(&mut out_multi[..256 * 2], 256); sat_multi.process(&mut out_multi[256 * 2..], 256); @@ -138,17 +185,44 @@ fn test_stereo_channels_do_not_crosstalk() { let frames = 512; // Signal on Left channel ONLY, Right channel is digital silence (0.0) - let left_only = generators::sine_stereo(440.0, 0.0, sample_rate, frames as f32 / sample_rate as f32, 0.8); + let left_only = generators::sine_stereo( + 440.0, + 0.0, + sample_rate, + frames as f32 / sample_rate as f32, + 0.8, + ); let mut audio = left_only.clone(); for frame in audio.chunks_exact_mut(2) { frame[1] = 0.0; } // Process through Gain, Saturator, EQ, Limiter - let (mut gain, _g_c) = GainEffect::new(GainData { gain_db: 3.0, bypassed: false }); - let (mut sat, _s_c) = SaturatorEffect::new(SaturatorData { drive_db: 6.0, threshold_db: -2.0, bypassed: false }); - let (mut eq, _e_c) = EqEffect::new(EqData { gains_db: [2.0; 10], bypassed: false }, sample_rate); - let (mut limiter, _l_c, _l_m) = LimiterEffect::new(LimiterData { ceiling_db: -1.0, release_ms: 20.0, lookahead_ms: 5.0, bypassed: false }, sample_rate); + let (mut gain, _g_c) = GainEffect::new(GainData { + gain_db: 3.0, + bypassed: false, + }); + let (mut sat, _s_c) = SaturatorEffect::new(SaturatorData { + drive_db: 6.0, + threshold_db: -2.0, + bypassed: false, + }); + let (mut eq, _e_c) = EqEffect::new( + EqData { + gains_db: [2.0; 10], + bypassed: false, + }, + sample_rate, + ); + let (mut limiter, _l_c, _l_m) = LimiterEffect::new( + LimiterData { + ceiling_db: -1.0, + release_ms: 20.0, + lookahead_ms: 5.0, + bypassed: false, + }, + sample_rate, + ); gain.process(&mut audio, frames); sat.process(&mut audio, frames); @@ -174,8 +248,15 @@ fn test_no_nan_or_inf_for_extreme_parameters() { let sample_rate = 48_000; let frames = 256; - let (mut gain, _g) = GainEffect::new(GainData { gain_db: 60.0, bypassed: false }); - let (mut sat, _s) = SaturatorEffect::new(SaturatorData { drive_db: 48.0, threshold_db: -30.0, bypassed: false }); + let (mut gain, _g) = GainEffect::new(GainData { + gain_db: 60.0, + bypassed: false, + }); + let (mut sat, _s) = SaturatorEffect::new(SaturatorData { + drive_db: 48.0, + threshold_db: -30.0, + bypassed: false, + }); let (mut comp, _c, _cm) = CompressorEffect::new( CompressorData { threshold_db: -60.0, @@ -223,11 +304,20 @@ fn test_no_nan_or_inf_for_extreme_parameters() { /// Verifies that toggling mute at runtime zeroes audio instantly and restoring mute un-zeroes audio cleanly. #[test] fn test_mute_runtime_toggle_restores_audio() { - let (mut mute, ctrl) = MuteEffect::new(MuteData { muted: false, bypassed: false }); + let (mut mute, ctrl) = MuteEffect::new(MuteData { + muted: false, + bypassed: false, + }); let sample_rate = 48_000; let frames = 256; - let original = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.5); + let original = generators::sine_stereo( + 440.0, + 440.0, + sample_rate, + frames as f32 / sample_rate as f32, + 0.5, + ); // Block 1: Unmuted (normal signal) let mut block1 = original.clone(); @@ -243,7 +333,11 @@ fn test_mute_runtime_toggle_restores_audio() { // Block 3: Steady-state muted block (absolute digital silence) let mut block3 = original.clone(); mute.process(&mut block3, frames); - assert_eq!(metrics::peak(&block3), 0.0, "Muted block must be digital silence"); + assert_eq!( + metrics::peak(&block3), + 0.0, + "Muted block must be digital silence" + ); // Block 4: Toggle mute OFF -> ramps back up to full volume ctrl.apply_update(&json!({ "muted": false })); @@ -528,7 +622,10 @@ fn test_delay_exact_delay_time() { // Expected delay in frames: 50ms at 48 kHz = 2400 frames let expected_frame = (sample_rate as f32 * delay_ms * 0.001) as usize; - let peak_frame = buffer.chunks_exact(2).position(|f| f[0].abs() > 0.5).unwrap_or(0); + let peak_frame = buffer + .chunks_exact(2) + .position(|f| f[0].abs() > 0.5) + .unwrap_or(0); assert_eq!( peak_frame, expected_frame, @@ -629,7 +726,8 @@ fn test_resampler_identity_rate_is_transparent() { let sample_rate = 48_000; let channels = 2; let chunk_size = 512; - let mut resampler = MultiResampler::new(sample_rate, sample_rate, chunk_size, channels).expect("resampler"); + let mut resampler = + MultiResampler::new(sample_rate, sample_rate, chunk_size, channels).expect("resampler"); let input = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.15, 0.5); let mut output = Vec::new(); @@ -637,7 +735,9 @@ fn test_resampler_identity_rate_is_transparent() { let mut offset = 0; while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut output).expect("resample"); + resampler + .process_chunk(chunk, &mut output) + .expect("resample"); offset += chunk_size * channels; } @@ -660,7 +760,8 @@ fn test_resampler_chunk_boundary_continuity() { let out_rate = 48_000; let channels = 2; let chunk_size = 256; - let mut resampler = MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); + let mut resampler = + MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); let input = generators::sine_stereo(440.0, 440.0, in_rate, 0.15, 0.6); let mut output = Vec::new(); @@ -668,7 +769,9 @@ fn test_resampler_chunk_boundary_continuity() { let mut offset = 0; while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut output).expect("resample"); + resampler + .process_chunk(chunk, &mut output) + .expect("resample"); offset += chunk_size * channels; } @@ -698,14 +801,17 @@ fn test_resampler_alias_rejection() { // 1. Transition band attenuation: 48 kHz -> 44.1 kHz at 23.5 kHz (near Nyquist transition) { - let mut resampler = MultiResampler::new(48_000, 44_100, chunk_size, channels).expect("resampler"); + let mut resampler = + MultiResampler::new(48_000, 44_100, chunk_size, channels).expect("resampler"); let input = generators::sine_stereo(23_500.0, 23_500.0, 48_000, 0.15, 0.8); let mut output = Vec::new(); let mut offset = 0; while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut output).expect("resample"); + resampler + .process_chunk(chunk, &mut output) + .expect("resample"); offset += chunk_size * channels; } @@ -720,14 +826,17 @@ fn test_resampler_alias_rejection() { // 2. Deep stopband alias rejection: 96 kHz -> 44.1 kHz at 35 kHz (output Nyquist = 22.05 kHz) { - let mut resampler = MultiResampler::new(96_000, 44_100, chunk_size, channels).expect("resampler"); + let mut resampler = + MultiResampler::new(96_000, 44_100, chunk_size, channels).expect("resampler"); let input = generators::sine_stereo(35_000.0, 35_000.0, 96_000, 0.15, 0.8); let mut output = Vec::new(); let mut offset = 0; while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut output).expect("resample"); + resampler + .process_chunk(chunk, &mut output) + .expect("resample"); offset += chunk_size * channels; } @@ -748,7 +857,8 @@ fn test_resampler_frequency_preservation() { let out_rate = 48_000; let channels = 2; let chunk_size = 512; - let mut resampler = MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); + let mut resampler = + MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); // Pure 1000 Hz tone let freq = 1000.0f32; @@ -758,7 +868,9 @@ fn test_resampler_frequency_preservation() { let mut offset = 0; while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut output).expect("resample"); + resampler + .process_chunk(chunk, &mut output) + .expect("resample"); offset += chunk_size * channels; } @@ -791,7 +903,8 @@ fn test_resampler_long_stream_frame_count_accuracy() { let out_rate = 44_100; let channels = 2; let chunk_size = 512; - let mut resampler = MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); + let mut resampler = + MultiResampler::new(in_rate, out_rate, chunk_size, channels).expect("resampler"); // Exactly 1 second of audio at 48 kHz = 48,000 frames let in_frames = 48_000; @@ -801,12 +914,15 @@ fn test_resampler_long_stream_frame_count_accuracy() { let mut offset = 0; while offset + chunk_size * channels <= input.len() { let chunk = &input[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut output).expect("resample"); + resampler + .process_chunk(chunk, &mut output) + .expect("resample"); offset += chunk_size * channels; } let produced_frames = output.len() / channels; - let expected_frames = (offset as f64 / channels as f64 * (out_rate as f64 / in_rate as f64)) as usize; + let expected_frames = + (offset as f64 / channels as f64 * (out_rate as f64 / in_rate as f64)) as usize; let diff = (produced_frames as isize - expected_frames as isize).abs(); // The difference must be bounded within the sinc interpolation filter pipeline latency (128 frames) diff --git a/src-tauri/tests/common/mod.rs b/src-tauri/tests/common/mod.rs index 19cb6f4..00b82dd 100644 --- a/src-tauri/tests/common/mod.rs +++ b/src-tauri/tests/common/mod.rs @@ -19,7 +19,13 @@ pub mod generators { } /// Generates an interleaved stereo sine wave with independent left and right frequencies. - pub fn sine_stereo(freq_l: f32, freq_r: f32, sample_rate: u32, duration_secs: f32, amplitude: f32) -> Vec { + pub fn sine_stereo( + freq_l: f32, + freq_r: f32, + sample_rate: u32, + duration_secs: f32, + amplitude: f32, + ) -> Vec { let total_frames = (sample_rate as f32 * duration_secs) as usize; let mut out = Vec::with_capacity(total_frames * 2); let step_l = 2.0 * PI * freq_l / sample_rate as f32; @@ -32,7 +38,13 @@ pub mod generators { } /// Generates a logarithmic sine sweep across a given frequency range (Chirp). - pub fn sweep(start_hz: f32, end_hz: f32, sample_rate: u32, duration_secs: f32, amplitude: f32) -> Vec { + pub fn sweep( + start_hz: f32, + end_hz: f32, + sample_rate: u32, + duration_secs: f32, + amplitude: f32, + ) -> Vec { let total_frames = (sample_rate as f32 * duration_secs) as usize; let mut out = Vec::with_capacity(total_frames); let sr = sample_rate as f32; @@ -47,7 +59,12 @@ pub mod generators { } /// Generates a multi-tone signal composed of multiple harmonic frequencies. - pub fn multitone(freqs: &[f32], sample_rate: u32, duration_secs: f32, peak_amplitude: f32) -> Vec { + pub fn multitone( + freqs: &[f32], + sample_rate: u32, + duration_secs: f32, + peak_amplitude: f32, + ) -> Vec { let total_frames = (sample_rate as f32 * duration_secs) as usize; let mut out = vec![0.0f32; total_frames]; let num_tones = freqs.len().max(1) as f32; @@ -63,7 +80,14 @@ pub mod generators { } /// Generates a periodic tone burst (active tone alternating with silence). - pub fn tone_burst(freq_hz: f32, sample_rate: u32, active_ms: f32, silent_ms: f32, cycles: usize, amplitude: f32) -> Vec { + pub fn tone_burst( + freq_hz: f32, + sample_rate: u32, + active_ms: f32, + silent_ms: f32, + cycles: usize, + amplitude: f32, + ) -> Vec { let active_frames = (sample_rate as f32 * active_ms / 1000.0) as usize; let silent_frames = (sample_rate as f32 * silent_ms / 1000.0) as usize; let mut out = Vec::with_capacity((active_frames + silent_frames) * cycles); diff --git a/src-tauri/tests/pipeline_scenarios.rs b/src-tauri/tests/pipeline_scenarios.rs index 12b5764..6b79c09 100644 --- a/src-tauri/tests/pipeline_scenarios.rs +++ b/src-tauri/tests/pipeline_scenarios.rs @@ -18,8 +18,8 @@ use splitwave_lib::audio::effects::reverb::ReverbEffect; use splitwave_lib::audio::effects::saturator::SaturatorEffect; use splitwave_lib::audio::effects::Effect; use splitwave_lib::audio::graph::{ - CompressorData, DeEsserData, DeclickData, DelayData, EqData, GainData, - LevelMeterData, LimiterData, LufsMeterData, NoiseGateData, ReverbData, SaturatorData, + CompressorData, DeEsserData, DeclickData, DelayData, EqData, GainData, LevelMeterData, + LimiterData, LufsMeterData, NoiseGateData, ReverbData, SaturatorData, }; use splitwave_lib::audio::resample::MultiResampler; @@ -72,7 +72,13 @@ fn test_broadcast_vocal_chain() { // 4. EQ (adds gentle speech presence boost at 2 kHz) let mut eq_gains = [0.0f32; 10]; eq_gains[6] = 3.0; // 2 kHz presence boost - let (mut eq, _eq_ctrl) = EqEffect::new(EqData { gains_db: eq_gains, bypassed: false }, sample_rate); + let (mut eq, _eq_ctrl) = EqEffect::new( + EqData { + gains_db: eq_gains, + bypassed: false, + }, + sample_rate, + ); // 5. Compressor (evens dynamic speech volume) let (mut comp, _comp_ctrl, _comp_meter) = CompressorEffect::new( @@ -248,14 +254,17 @@ fn test_streamer_dual_sample_rate_mix() { let discord_audio = generators::sine_stereo(500.0, 500.0, in_rate, duration, 0.4); // Resample Discord voice from 44.1 kHz to 48 kHz - let mut resampler = MultiResampler::new(in_rate, out_rate, channels, chunk_size).expect("resampler"); + let mut resampler = + MultiResampler::new(in_rate, out_rate, channels, chunk_size).expect("resampler"); let mut discord_resampled = Vec::new(); let mut chunk_out = vec![0.0f32; chunk_size * 2 * channels]; let mut offset = 0; while offset + chunk_size * channels <= discord_audio.len() { let chunk = &discord_audio[offset..offset + chunk_size * channels]; - resampler.process_chunk(chunk, &mut chunk_out).expect("resample"); + resampler + .process_chunk(chunk, &mut chunk_out) + .expect("resample"); discord_resampled.extend_from_slice(&chunk_out); offset += chunk_size * channels; } @@ -372,7 +381,13 @@ fn test_dance_music_sidechain_pumping() { // Kick drum track: two 30ms kick bursts (at t=0ms and t=200ms) with silence in between let kick_burst = generators::sine_stereo(60.0, 60.0, sample_rate, 0.03, 1.0); let kick_pause = vec![0.0f32; (sample_rate as f32 * 0.17) as usize * 2]; - let kick_track = [kick_burst.clone(), kick_pause.clone(), kick_burst, kick_pause].concat(); + let kick_track = [ + kick_burst.clone(), + kick_pause.clone(), + kick_burst, + kick_pause, + ] + .concat(); let frames = synth_bass.len() / 2; comp.process_with_sidechain(&mut synth_bass, Some(&kick_track), frames); @@ -561,7 +576,8 @@ fn test_peak_metering_ballistics_and_decay() { #[test] fn test_lufs_metering_loudness_compliance() { let sample_rate = 48_000; - let (mut lufs, handle) = LufsMeterEffect::new(LufsMeterData {}, "lufs_test".into(), sample_rate); + let (mut lufs, handle) = + LufsMeterEffect::new(LufsMeterData {}, "lufs_test".into(), sample_rate); // Calibrated 1 kHz tone at -20 dBFS (amplitude = 0.1) for 400ms let mut tone = generators::sine_stereo(1000.0, 1000.0, sample_rate, 0.4, 0.1); @@ -595,8 +611,18 @@ fn test_long_running_pipeline_dsp_stability() { let block_size = 512; let num_blocks = 100; - let (mut eq, _eq_c) = EqEffect::new(EqData { gains_db: [1.0; 10], bypassed: false }, sample_rate); - let (mut saturator, _sat_c) = SaturatorEffect::new(SaturatorData { drive_db: 3.0, threshold_db: -3.0, bypassed: false }); + let (mut eq, _eq_c) = EqEffect::new( + EqData { + gains_db: [1.0; 10], + bypassed: false, + }, + sample_rate, + ); + let (mut saturator, _sat_c) = SaturatorEffect::new(SaturatorData { + drive_db: 3.0, + threshold_db: -3.0, + bypassed: false, + }); let (mut comp, _comp_c, _comp_m) = CompressorEffect::new( CompressorData { threshold_db: -12.0, @@ -609,11 +635,37 @@ fn test_long_running_pipeline_dsp_stability() { }, sample_rate, ); - let (mut deesser, _de_c) = DeEsserEffect::new(DeEsserData { frequency: 6000.0, threshold_db: -20.0, ratio: 3.0, bypassed: false }, sample_rate); - let (mut reverb, _rev_c) = ReverbEffect::new(ReverbData { room_size: 0.5, damping: 0.5, width: 1.0, mix: 0.3, bypassed: false }, sample_rate); - let (mut limiter, _lim_c, _lim_m) = LimiterEffect::new(LimiterData { ceiling_db: -1.0, release_ms: 20.0, lookahead_ms: 5.0, bypassed: false }, sample_rate); + let (mut deesser, _de_c) = DeEsserEffect::new( + DeEsserData { + frequency: 6000.0, + threshold_db: -20.0, + ratio: 3.0, + bypassed: false, + }, + sample_rate, + ); + let (mut reverb, _rev_c) = ReverbEffect::new( + ReverbData { + room_size: 0.5, + damping: 0.5, + width: 1.0, + mix: 0.3, + bypassed: false, + }, + sample_rate, + ); + let (mut limiter, _lim_c, _lim_m) = LimiterEffect::new( + LimiterData { + ceiling_db: -1.0, + release_ms: 20.0, + lookahead_ms: 5.0, + bypassed: false, + }, + sample_rate, + ); - let mut running_block = generators::sine_stereo(440.0, 880.0, sample_rate, 512.0 / 48000.0, 0.5); + let mut running_block = + generators::sine_stereo(440.0, 880.0, sample_rate, 512.0 / 48000.0, 0.5); for block_idx in 0..num_blocks { eq.process(&mut running_block, block_size); @@ -650,11 +702,20 @@ fn test_long_running_pipeline_dsp_stability() { /// This test verifies instant dynamic parameter response via EffectControl. #[test] fn test_dynamic_gain_slider_parameter_modulation() { - let (mut gain, ctrl) = GainEffect::new(GainData { gain_db: 0.0, bypassed: false }); + let (mut gain, ctrl) = GainEffect::new(GainData { + gain_db: 0.0, + bypassed: false, + }); let sample_rate = 48_000; let frames = 256; - let mut audio_block = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.3); + let mut audio_block = generators::sine_stereo( + 440.0, + 440.0, + sample_rate, + frames as f32 / sample_rate as f32, + 0.3, + ); // Step 1: Process at 0 dB (unity) gain.process(&mut audio_block, frames); @@ -665,7 +726,13 @@ fn test_dynamic_gain_slider_parameter_modulation() { // Block 1 ramps gain smoothly from 1.0x to 2.0x (anti-click smoothing) gain.process(&mut audio_block, frames); // Block 2 achieves steady-state 2.0x gain - let mut steady_boosted = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.3); + let mut steady_boosted = generators::sine_stereo( + 440.0, + 440.0, + sample_rate, + frames as f32 / sample_rate as f32, + 0.3, + ); gain.process(&mut steady_boosted, frames); let rms_boosted = metrics::rms(&steady_boosted); @@ -674,7 +741,13 @@ fn test_dynamic_gain_slider_parameter_modulation() { // Block 1 ramps down smoothly gain.process(&mut audio_block, frames); // Block 2 achieves steady-state 0.5x unity gain (0.25x of boosted) - let mut steady_cut = generators::sine_stereo(440.0, 440.0, sample_rate, frames as f32 / sample_rate as f32, 0.3); + let mut steady_cut = generators::sine_stereo( + 440.0, + 440.0, + sample_rate, + frames as f32 / sample_rate as f32, + 0.3, + ); gain.process(&mut steady_cut, frames); let rms_attenuated = metrics::rms(&steady_cut);