Per-peer pan + EQ ("Pan and EQ" tab) — off by default, held for next release

New feature (Ed's jam-mixing request): pan and EQ each peer's signal independently.

Engine (zero added latency — per-sample, applied to each peer's isolated block just
before the mix): PeerDspChain (balance pan + RBJ biquad EQ) built on the UI thread and
swapped onto SessionPlayout via a volatile reference; PlayoutEngine remembers it per
address so a reconnecting peer keeps its shaping; AudioReceiver.SetPeerDsp facade.

Model: per-profile PeerShaping dict (keyed by peer address) + EnablePan/EnableEqForPeers
master switches; machine-wide AppConfig.ShowPanEqTab. Fixed band layouts in PeerEqBands
(3-band tone control: bass/mids/treble shelves+bell; 10-band ISO graphic EQ), +/-12 dB.

UI: a "Pan and EQ" tab (before Audio profile, shown only when ShowPanEqTab is on) with
the two enables, a connected-peer picker, a pan slider (balance, keeps stereo), a
"reset EQ" button (clears both modes' bands, leaves pan), a 3/10-band mode picker and its
band sliders — all TrackBars (arrow + page-up/down), updating in real time and saved per
profile. Sliders set a friendly AccessibleName on change (pan centre/left/right %, band
dB). "Show the Pan and EQ tab" checkbox added to Preferences > General.

Everything is off by default (tab hidden, both enables off), so this is dormant for all
users until switched on. Builds clean. Pending Ed's hands-on testing before release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-04 13:54:30 +01:00
co-authored by Claude Opus 4.8
parent d7f6d9fd9d
commit 1c1bf5a5cb
9 changed files with 558 additions and 1 deletions
+6
View File
@@ -68,6 +68,12 @@ public sealed class AppConfig
/// already running quietly". Default false.</summary>
public bool StartMinimised { get; set; }
/// <summary>If true, the main window shows a "Pan and EQ" tab (positioned before "Audio profile")
/// for setting per-peer panning and EQ. Off by default; toggled by "Show pan and EQ tab" on the
/// Preferences General tab. Machine-wide (a UI-visibility preference, like which tabs exist) — the
/// pan/EQ VALUES are saved per profile, in <see cref="Profile.PeerShaping"/>.</summary>
public bool ShowPanEqTab { get; set; }
/// <summary>If true (the default), RemSound plays the startup cue once, right after this
/// copy wins the single-instance takeover and before the profile loads. Machine-wide (not
/// per-<see cref="Profile"/>) because it fires before any profile — and its per-profile
+65
View File
@@ -0,0 +1,65 @@
namespace RemSound.Core;
/// <summary>Which EQ the user is shaping a peer with. The two modes are INDEPENDENT: switching
/// between them keeps each mode's own band gains — nothing is carried over. The active mode is the
/// one that's applied to the sound.</summary>
public enum PeerEqMode
{
Simple3Band = 0,
Advanced10Band = 1,
}
/// <summary>Per-peer pan + EQ, saved per profile (see <see cref="Profile.PeerShaping"/>). Keyed in
/// the profile by the same peer-entry string as <see cref="Profile.SelectedConnectedPeers"/>.</summary>
public sealed class PeerShaping
{
/// <summary>-1 (full left) .. 0 (centre) .. +1 (full right).</summary>
public float Pan { get; set; }
/// <summary>Which EQ mode is currently active for this peer.</summary>
public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band;
/// <summary>Gains in dB (-12..+12) for the 3 simple bands, in the order of <see cref="PeerEqBands.Simple"/>
/// (bass, mids, treble). Kept independently of the advanced bands.</summary>
public float[] SimpleBandsDb { get; set; } = new float[3];
/// <summary>Gains in dB (-12..+12) for the 10 advanced graphic-EQ bands, in the order of
/// <see cref="PeerEqBands.Advanced"/>. Kept independently of the simple bands.</summary>
public float[] AdvancedBandsDb { get; set; } = new float[10];
}
/// <summary>The fixed EQ band layouts. The user sets each band's GAIN; the centre frequencies are
/// fixed so the controls stay simple and predictable. Shared by the DSP (which builds the filters)
/// and the UI (which labels the sliders), so the two can never drift apart.</summary>
public static class PeerEqBands
{
/// <summary>RemSound's internal mix sample rate. All received audio is resampled to this stereo
/// float mix before per-peer shaping, so filter coefficients are computed against it.</summary>
public const int MixSampleRate = 48000;
/// <summary>Maximum boost/cut for any band, in dB. Sliders run -12..+12.</summary>
public const float MaxGainDb = 12f;
/// <summary>3-band "tone control": bass (low shelf), mids (peaking), treble (high shelf).</summary>
public static readonly (string Label, double Freq)[] Simple =
[
("Bass", 100.0),
("Mids", 1000.0),
("Treble", 8000.0),
];
/// <summary>10-band graphic EQ on ISO octave centres (all peaking).</summary>
public static readonly (string Label, double Freq)[] Advanced =
[
("31 Hz", 31.0),
("63 Hz", 63.0),
("125 Hz", 125.0),
("250 Hz", 250.0),
("500 Hz", 500.0),
("1 kHz", 1000.0),
("2 kHz", 2000.0),
("4 kHz", 4000.0),
("8 kHz", 8000.0),
("16 kHz", 16000.0),
];
}
+13
View File
@@ -183,6 +183,19 @@ public sealed class Profile
/// (the connection simply waits or drops rather than moving). Off by default. (#17)</summary>
public bool LockPeerAddresses { get; set; }
// === Per-peer pan and EQ (jam mixing) ===
/// <summary>Master switch for this profile: apply each connected peer's saved PAN. Off by default.
/// The pan values themselves live in <see cref="PeerShaping"/>.</summary>
public bool EnablePanForPeers { get; set; }
/// <summary>Master switch for this profile: apply each connected peer's saved EQ. Off by default.</summary>
public bool EnableEqForPeers { get; set; }
/// <summary>Per-peer pan + EQ, keyed by the SAME peer-entry string used in
/// <see cref="SelectedConnectedPeers"/> (user text / discovery label / address:port). A peer with
/// no entry gets centre pan and flat EQ. Empty on a fresh profile.</summary>
public Dictionary<string, PeerShaping> PeerShaping { get; set; } = new();
// === Hotkeys ===
public HotkeyRecord? ReceiveMuteHotkey { get; set; }
public HotkeyRecord? SendMuteHotkey { get; set; }