Skip to content

feat: N-channel audio support — per-channel EQ + routing (Linux)#1

Merged
ealtun21 merged 4 commits into
masterfrom
feat/n-channel
Jun 27, 2026
Merged

feat: N-channel audio support — per-channel EQ + routing (Linux)#1
ealtun21 merged 4 commits into
masterfrom
feat/n-channel

Conversation

@ealtun21

Copy link
Copy Markdown
Owner

N-channel audio support — per-channel EQ + routing (backlog item 16)

Removes the hard stereo assumption end-to-end on Linux: Resonance can now process an arbitrary channel count, target EQ bands at individual channels, and remap channels.

What landed

DSP core (resonance-dsp)

  • ChannelMask — per-band channel targeting (bitset, default ALL = global, fully back-compatible).
  • ChannelMatrixout×in routing/remap (identity / swap / per-channel gain), allocation-free apply, frame loop clamped to both buffers (panic-free for any matrix).
  • ProcessorChain: mask-aware band cascade, routing stage + route()/out_channels(), set_channels(n) (resizes filter state, rebuilds effects). Surround is gated to exactly stereo.

Data model (resonance-ipc, resonance-preset, daemon)

  • Wire types ChannelMask (i64 bit-cast serde so the all-ones default survives TOML's i64 range) + RoutingMatrix; BandState.channels; DaemonState.{out_channels, channel_layout, routing}.
  • Commands SetBandChannels / SetChannelRouting / SwapChannels / ClearRouting (appended at the end of the enum to keep postcard ordinals stable).
  • APO .txt Channel: directive parse + write (named and 1-based numeric positions); .toml profiles round-trip masks; presets predating per-channel EQ load as global.

PipeWire backend (resonance-daemon)

  • audio::target_channels() — single source of truth (env RESONANCE_CHANNELS, default stereo), used by both the backend ports and the daemon's shadow chain so a ReplaceChain can never hand the RT thread a width-mismatched chain.
  • Virtual sink + filter built with N dynamic ports from the channel layout; links paired by audio.channel label with positional fallback; N-channel RT interleave/deinterleave + square routing.

CLI

  • resonance channel info | swap | clear | band + per-channel tags and a routing line in status. band resolves channel names against the live layout (correct on 4/5/7ch) and range-checks indices.

Verification

  • make check green (fmt + clippy -D warnings + test --all); offline N-channel harness in channel_tests.rs.
  • Live (Linux/PipeWire): ran a 6-channel pipeline against a module-null-sink channels=6 device — virtual sink + 6-in/6-out filter, all ports linked FL/FR/FC/LFE/RL/RR by label.
  • Live CLI↔daemon: channel info, per-band targeting ([FL]), swap/clear routing display, out-of-range index/channel rejection, bare channel → info.
  • Adversarial review of the full diff (find → refute) surfaced 15 confirmed issues, all fixed in 2740714 (routing in_ch validation + RT guard + apply clamp; mid-enum variant insertion; APO numeric channels + lossless write; CLI layout-aware names + range checks; preset-load wrong-rate glitch on non-48k graphs).

Not in this PR (follow-ups)

  • TUI + GUI channel controls (routing-matrix editor, per-channel band editor).
  • macOS (CoreAudio N-ch demux/upmix) + Windows (APO per-channel spectrum) parity — need the respective machines to verify.
  • Non-square up/downmix routing (changes channel count) — the in-graph PipeWire filter only applies square remaps; full up/downmix is a daemon-path feature for the cpal/CoreAudio backends.

ealtun21 added 4 commits June 27, 2026 04:48
…g matrix

Foundational N-channel support (backlog item 16), platform-independent layer.

- dsp: ChannelMask (per-band channel targeting) + ChannelMatrix (out×in remap);
  mask-aware band loop; ProcessorChain.routing + route()/out_channels();
  set_channels() resizes filter state and rebuilds effects; Surround gated to
  exactly stereo. Offline N-channel test harness (channel_tests.rs).
- ipc: ChannelMask/RoutingMatrix wire types (i64 bit-cast serde so the all-ones
  default survives TOML's i64 range), BandState.channels, and the
  SetBandChannels/SetChannelRouting/SwapChannels/ClearRouting commands;
  DaemonState gains out_channels, channel_layout, routing.
- daemon: AudioCommand SetBandChannels/SetRouting on both RT and shadow paths;
  build_band preserves a band's channel mask across param edits; snapshot
  reports channel state.
- preset: EqBand.channels; APO Channel: directive parse + write; .toml profiles
  round-trip masks; presets predating per-channel EQ load as global bands.

Backends still stereo-locked (Phase 3). make check is green.
- Single channel-count source of truth: audio::target_channels() (env
  RESONANCE_CHANNELS, default stereo), used by both the backend ports and the
  daemon's shadow chain so a ReplaceChain can never hand the RT thread a chain
  whose width disagrees with the live ports.
- Virtual sink + pw_filter are built with N ports from the channel layout
  (FL,FR,FC,LFE,RL,RR,... for <=8, AUX0.. beyond); audio.position follows.
- create_links pairs ports by audio.channel label with a positional fallback
  (was a hardcoded FL/FR pair).
- RT callback interleaves/deinterleaves N channels, applies the square routing
  matrix (swap / per-channel gain), N-channel peak/RMS meters + mono spectrum.

make check green. Live multichannel verification pending.
… status

resonance channel {info,swap,clear,band} drives the N-channel IPC commands;
status now shows a route line and per-band channel tags. Maps directly onto
the SwapChannels/ClearRouting/SetBandChannels commands (wire-tested in ipc).
…der, APO numerics

Adversarial review of the diff surfaced these (all confirmed, several reproduced):

- RT safety: SetChannelRouting never checked the matrix in_ch against the live
  channel count; a non-square matrix from IPC could panic or garble the RT audio
  thread. Now the daemon rejects a non-square/mismatched matrix, the PipeWire
  guard requires in_ch==out_ch==channels, and ChannelMatrix::apply clamps its
  frame loop to both buffers (panic-free for any matrix/buffer pair).
- Wire compat: the four new Command variants were inserted mid-enum, shifting
  every later postcard ordinal, so a version-skewed client silently misdecodes.
  Moved them to the end of the enum.
- APO: parse numeric Channel: positions (1-based, exact for any layout) and emit
  a numeric token for channels with no WAVE name, so per-channel bands round-trip
  losslessly instead of collapsing to ALL or dropping bits.
- CLI: 'channel band' resolves names against the live layout (correct on 4/5/7ch)
  and rejects out-of-range indices; an out-of-range band index now errors; bare
  'channel' runs info; '[none]' instead of '[]' for a degenerate mask.
- Daemon: rebuild_chain / apply_profile build from the live RT rate, not the
  frozen shadow rate (a transient wrong-rate glitch on preset load on non-48k
  graphs, e.g. a 96 kHz DAC).
- Doc: corrected the serde(default) comment (TOML/JSON disk only, not postcard).

New tests: ChannelMatrix::apply clamp, APO numeric + high-channel round-trip.
@ealtun21 ealtun21 merged commit 29375f8 into master Jun 27, 2026
3 checks passed
@ealtun21 ealtun21 deleted the feat/n-channel branch June 27, 2026 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant