diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 91a709c..f036782 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -151,6 +151,28 @@ public sealed class MainForm : Form private readonly TabPage connectivityTabPage = new("Connectivity"); private readonly TabPage audioIOTabPage = new("Audio inputs and outputs"); private readonly TabPage audioProfileTabPage = new("Audio profile"); + + // === Pan and EQ tab (per-peer shaping) — shown only when AppConfig.ShowPanEqTab is on. === + private readonly TabPage panEqTabPage = new("Pan and EQ"); + private readonly AccessibleCheckBox enableEqForPeersBox = new() { Text = "Enable &EQ for peers (Alt+E)", AccessibleName = "Enable EQ for peers", AutoSize = true }; + private readonly AccessibleCheckBox enablePanForPeersBox = new() { Text = "Enable &pan for peers (Alt+P)", AccessibleName = "Enable pan for peers", AutoSize = true }; + private readonly ListBox panEqPeerList = new() { Width = 430, Height = 90, AccessibleName = "Peer to shape" }; + private readonly TrackBar panSlider = new() { Minimum = 0, Maximum = 100, Value = 50, SmallChange = 1, LargeChange = 10, TickFrequency = 25, Width = 320 }; + private readonly Button resetPeerEqButton = new() { Text = "Set peer E&Q to default (Alt+Q)", AutoSize = true, AccessibleName = "Set peer EQ to default" }; + private readonly ListBox eqModeList = new() { Width = 320, Height = 40, IntegralHeight = false, AccessibleName = "EQ mode" }; + private readonly FlowLayoutPanel eqBandsPanel = new() { FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false, Margin = new Padding(0) }; + // Working copy of the active profile's per-peer shaping, keyed by peer address string. Loaded on + // profile apply, saved by BuildCurrentProfile, mutated live as the user moves the controls. + private Dictionary peerShaping = new(); + // Address string of the peer currently selected in panEqPeerList (the one the controls edit). + private string? selectedShapingKey; + // The band-gain sliders currently shown; rebuilt when the mode or selected peer changes. + private readonly List eqBandSliders = new(); + // True while we're pushing a peer's saved values INTO the controls, so those programmatic changes + // don't fire the apply/dirty handlers back at us. + private bool loadingPanEqControls; + // Signature of the peer list last rendered, so the 1 Hz refresh only rebuilds it on a real change. + private string lastPanEqPeerSignature = ""; // profilesPrefsTabPage retired 2026-05-08 — its contents now live on the File menu. // connectivityTransportButton + ShowConnectivityTransportDialog removed in Phase 2/3 of @@ -1679,10 +1701,13 @@ public sealed class MainForm : Form BuildConnectivityTab(); BuildAudioIOTab(); BuildAudioProfileTab(); + BuildPanEqTab(); mainTabControl.TabPages.Add(connectivityTabPage); mainTabControl.TabPages.Add(audioIOTabPage); mainTabControl.TabPages.Add(audioProfileTabPage); + // Insert the optional Pan-and-EQ tab (just before Audio profile) if the preference is on. + RefreshPanEqTabVisibility(); // No SelectedIndexChanged handler. No focus management on tab change. Andre's // accessible app does ZERO event hooking on TabControl — relies entirely on // default WinForms + NVDA behaviour. Per Ed's repeated request: arrow keys cycle @@ -2515,6 +2540,8 @@ public sealed class MainForm : Form unsubscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged -= handler); dialog.ShowDialog(this); if (dialog.ChangedAnyProfileSetting) MarkProfileDirty(); + // The "Show pan and EQ tab" preference may have been toggled — add/remove the tab to match. + RefreshPanEqTabVisibility(); // The Preferences dialog includes per-cue Browse buttons that can change custom // WAV paths in AppConfig.CustomCuePaths. Reload the cached SoundPlayer instances // here unconditionally — cheap, only six small files, and guarantees the next @@ -3096,6 +3123,274 @@ public sealed class MainForm : Form audioProfileTabPage.Controls.Add(outerPanel); } + /// An item in the Pan-and-EQ peer picker. Keyed by peer address string (the same key the + /// per-profile dictionary uses, and what the receiver routes DSP by). + private sealed class PanEqPeerItem(string label, System.Net.IPAddress address, string key) + { + public string Label { get; } = label; + public System.Net.IPAddress Address { get; } = address; + public string Key { get; } = key; + public override string ToString() => Label; + } + + /// Builds the "Pan and EQ" tab: two master enables, a connected-peer picker, then the + /// selected peer's pan slider, a reset-EQ button, an EQ-mode picker (3-band / 10-band) and that + /// mode's band sliders. Every control acts on the peer selected in the list, applies in real time, + /// and is saved per profile. See / . + private void BuildPanEqTab() + { + var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 1, RowCount = 8, AutoScroll = true }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + enableEqForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } }; + enablePanForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } }; + panEqPeerList.SelectedIndexChanged += (_, _) => OnPanEqPeerSelected(); + panSlider.ValueChanged += (_, _) => OnPanChanged(); + resetPeerEqButton.Click += (_, _) => OnResetPeerEq(); + eqModeList.Items.Add("3 band basic EQ"); + eqModeList.Items.Add("10 band advanced EQ"); + eqModeList.SelectedIndexChanged += (_, _) => OnEqModeChanged(); + + var peerLabel = new MnemonicLabel { Text = "Peer to shape (Alt+&U)", AutoSize = true, MnemonicTarget = panEqPeerList }; + var panLabel = new MnemonicLabel { Text = "Pa&n (Alt+N)", AutoSize = true, MnemonicTarget = panSlider }; + var modeLabel = new MnemonicLabel { Text = "EQ &mode (Alt+M)", AutoSize = true, MnemonicTarget = eqModeList }; + + var panRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; + panRow.Controls.Add(panLabel); + panRow.Controls.Add(panSlider); + var modeRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; + modeRow.Controls.Add(modeLabel); + modeRow.Controls.Add(eqModeList); + + panel.Controls.Add(enableEqForPeersBox, 0, 0); + panel.Controls.Add(enablePanForPeersBox, 0, 1); + panel.Controls.Add(peerLabel, 0, 2); + panel.Controls.Add(panEqPeerList, 0, 3); + panel.Controls.Add(panRow, 0, 4); + panel.Controls.Add(resetPeerEqButton, 0, 5); + panel.Controls.Add(modeRow, 0, 6); + panel.Controls.Add(eqBandsPanel, 0, 7); + panEqTabPage.Controls.Add(panel); + + RefreshPanEqPeerList(); + OnPanEqPeerSelected(); + } + + /// Adds (or removes) the Pan-and-EQ tab to match the machine-wide "Show pan and EQ tab" + /// preference. Placed just before the Audio profile tab. Called at build and after Preferences close. + private void RefreshPanEqTabVisibility() + { + bool show = AppConfig.Load().ShowPanEqTab; + bool present = mainTabControl.TabPages.Contains(panEqTabPage); + if (show && !present) + { + int idx = mainTabControl.TabPages.IndexOf(audioProfileTabPage); + if (idx < 0) idx = mainTabControl.TabPages.Count; + mainTabControl.TabPages.Insert(idx, panEqTabPage); + } + else if (!show && present) + { + mainTabControl.TabPages.Remove(panEqTabPage); + } + } + + /// Rebuilds the peer picker from the currently-connected peers, one row per address. Runs + /// each tick but only rebuilds on a real change (so NVDA focus survives). On a change it also + /// re-pushes every connected peer's shaping, so a freshly-connected peer picks up its saved pan/EQ. + private void RefreshPanEqPeerList() + { + var desired = new List(); + var seen = new HashSet(); + foreach (var (id, ep) in selectedPeerEndpoints) + { + var key = ep.Address.ToString(); + if (!seen.Add(key)) continue; + var label = selectedPeerLabels.GetValueOrDefault(id, key); + desired.Add(new PanEqPeerItem(label, ep.Address, key)); + } + desired = desired.OrderBy(d => d.Label).ThenBy(d => d.Key).ToList(); + var signature = string.Join("|", desired.Select(d => d.Key + "=" + d.Label)); + if (signature == lastPanEqPeerSignature) return; + lastPanEqPeerSignature = signature; + + var prevKey = (panEqPeerList.SelectedItem as PanEqPeerItem)?.Key ?? selectedShapingKey; + panEqPeerList.BeginUpdate(); + panEqPeerList.Items.Clear(); + int idx = -1; + foreach (var d in desired) + { + int i = panEqPeerList.Items.Add(d); + if (d.Key == prevKey) idx = i; + } + if (idx < 0 && panEqPeerList.Items.Count > 0) idx = 0; + if (idx >= 0) panEqPeerList.SelectedIndex = idx; + panEqPeerList.EndUpdate(); + + ApplyAllPeerShaping(); + } + + /// Loads the peer selected in the picker into the pan / mode / band controls. + private void OnPanEqPeerSelected() + { + selectedShapingKey = (panEqPeerList.SelectedItem as PanEqPeerItem)?.Key; + bool enabled = selectedShapingKey is not null; + loadingPanEqControls = true; + try + { + var s = GetOrCreateShaping(selectedShapingKey); + panSlider.Value = Math.Clamp((int)Math.Round(s.Pan * 50f) + 50, 0, 100); + UpdatePanAccessibleName(); + eqModeList.SelectedIndex = s.EqMode == PeerEqMode.Advanced10Band ? 1 : 0; + RebuildEqBandSliders(); + panSlider.Enabled = enabled; + resetPeerEqButton.Enabled = enabled; + eqModeList.Enabled = enabled; + } + finally { loadingPanEqControls = false; } + } + + private PeerShaping? GetShaping(string? key) => key is not null && peerShaping.TryGetValue(key, out var s) ? s : null; + + private PeerShaping GetOrCreateShaping(string? key) + { + if (key is null) return new PeerShaping(); + if (!peerShaping.TryGetValue(key, out var s)) { s = new PeerShaping(); peerShaping[key] = s; } + return s; + } + + private void OnPanChanged() + { + if (loadingPanEqControls || selectedShapingKey is null) return; + GetOrCreateShaping(selectedShapingKey).Pan = Math.Clamp((panSlider.Value - 50) / 50f, -1f, 1f); + UpdatePanAccessibleName(); + ApplyPeerShaping(selectedShapingKey); + MarkProfileDirty(); + } + + private void UpdatePanAccessibleName() + { + int v = panSlider.Value; + string desc = v == 50 ? "centre" : v < 50 ? $"{(50 - v) * 2} percent left" : $"{(v - 50) * 2} percent right"; + panSlider.AccessibleName = $"Pan: {desc}"; + } + + private void OnEqModeChanged() + { + if (loadingPanEqControls || selectedShapingKey is null) return; + GetOrCreateShaping(selectedShapingKey).EqMode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band; + RebuildEqBandSliders(); + ApplyPeerShaping(selectedShapingKey); + MarkProfileDirty(); + } + + private void OnResetPeerEq() + { + if (selectedShapingKey is null) return; + var s = GetOrCreateShaping(selectedShapingKey); + Array.Clear(s.SimpleBandsDb); + Array.Clear(s.AdvancedBandsDb); + RebuildEqBandSliders(); + ApplyPeerShaping(selectedShapingKey); + MarkProfileDirty(); + } + + /// Rebuilds the band sliders for the current EQ mode + selected peer, loading their gains. + private void RebuildEqBandSliders() + { + loadingPanEqControls = true; + try + { + eqBandsPanel.SuspendLayout(); + while (eqBandsPanel.Controls.Count > 0) + { + var c = eqBandsPanel.Controls[0]; + eqBandsPanel.Controls.RemoveAt(0); + c.Dispose(); + } + eqBandSliders.Clear(); + + var s = GetShaping(selectedShapingKey); + var mode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band; + var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple; + var gains = s is null ? null : (mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb); + + for (int i = 0; i < bands.Length; i++) + { + float gainDb = gains is not null && i < gains.Length ? gains[i] : 0f; + var slider = new TrackBar + { + Minimum = 0, + Maximum = 100, + Value = Math.Clamp((int)Math.Round(gainDb / PeerEqBands.MaxGainDb * 50f) + 50, 0, 100), + SmallChange = 1, + LargeChange = 10, + TickFrequency = 25, + Width = 300, + Tag = i, + Enabled = selectedShapingKey is not null, + }; + UpdateBandAccessibleName(slider, bands[i].Label); + slider.ValueChanged += (_, _) => OnBandChanged(slider); + var row = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; + row.Controls.Add(new MnemonicLabel { Text = bands[i].Label, AutoSize = true, MnemonicTarget = slider }); + row.Controls.Add(slider); + eqBandsPanel.Controls.Add(row); + eqBandSliders.Add(slider); + } + eqBandsPanel.ResumeLayout(); + } + finally { loadingPanEqControls = false; } + } + + private void OnBandChanged(TrackBar slider) + { + if (loadingPanEqControls || selectedShapingKey is null || slider.Tag is not int i) return; + var s = GetOrCreateShaping(selectedShapingKey); + var mode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band; + var gains = mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb; + var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple; + if (i >= 0 && i < gains.Length) + { + gains[i] = (slider.Value - 50) / 50f * PeerEqBands.MaxGainDb; + UpdateBandAccessibleName(slider, bands[i].Label); + } + ApplyPeerShaping(selectedShapingKey); + MarkProfileDirty(); + } + + private static void UpdateBandAccessibleName(TrackBar slider, string label) + { + float db = (slider.Value - 50) / 50f * PeerEqBands.MaxGainDb; + string desc = MathF.Abs(db) < 0.5f ? "flat" : $"{(db > 0 ? "+" : "")}{db:0} dB"; + slider.AccessibleName = $"{label}: {desc}"; + } + + /// Builds one peer's DSP chain (honouring the two master enables) and pushes it to the + /// receiver, so the change is heard immediately. A null chain (nothing to do) clears any prior one. + private void ApplyPeerShaping(string? key) + { + if (key is null) return; + System.Net.IPAddress? addr = null; + foreach (var (_, ep) in selectedPeerEndpoints) + if (ep.Address.ToString() == key) { addr = ep.Address; break; } + if (addr is null && !System.Net.IPAddress.TryParse(key, out addr)) return; + var chain = PeerDspChain.Build(GetShaping(key), enablePanForPeersBox.Checked, enableEqForPeersBox.Checked); + receiver.SetPeerDsp(addr, chain); + } + + /// Pushes shaping for every currently-connected peer. Used when a master enable flips or a + /// profile loads / the connected set changes. + private void ApplyAllPeerShaping() + { + var seen = new HashSet(); + foreach (var (_, ep) in selectedPeerEndpoints) + { + if (!seen.Add(ep.Address.ToString())) continue; + var chain = PeerDspChain.Build(GetShaping(ep.Address.ToString()), enablePanForPeersBox.Checked, enableEqForPeersBox.Checked); + receiver.SetPeerDsp(ep.Address, chain); + } + } + /// Send-side controls: codec + packet size on row 0, lock-to-audio-clock on /// row 1. The codec and packet-size combo share a row because they're tightly coupled /// (changing the codec resets the meaningful packet sizes). Lock-to-clock is a sender- @@ -3360,6 +3655,7 @@ public sealed class MainForm : Form SyncConnectedList(); SyncDiscoveredList(); SyncRememberedList(); + RefreshPanEqPeerList(); RefreshStatusReadout(); } @@ -6310,6 +6606,14 @@ public sealed class MainForm : Form // exactly as if the user had typed it into the manual-peer field. Discovered peers // (no longer reachable / different IP) just fail gracefully — no popup. ReconnectSavedPeers(p.SelectedConnectedPeers); + // Per-peer pan/EQ: adopt this profile's saved shaping + the two master enables. The peer + // picker and the DSP re-apply on the next tick (once the peers above have reconnected); + // clearing the signature makes RefreshPanEqPeerList rebuild and re-push for this profile. + peerShaping = p.PeerShaping is null ? new() : new(p.PeerShaping); + loadingPanEqControls = true; + try { enablePanForPeersBox.Checked = p.EnablePanForPeers; enableEqForPeersBox.Checked = p.EnableEqForPeers; } + finally { loadingPanEqControls = false; } + lastPanEqPeerSignature = ""; } catch (Exception ex) { @@ -6486,6 +6790,9 @@ public sealed class MainForm : Form profile.SelectedWasapiSendInputs = ExtractCheckedDeviceIds(sendInputDevicesList); profile.SelectedAsioSendInputs = ExtractCheckedDeviceIds(asioSendDevicesList); profile.SelectedConnectedPeers = GatherSelectedPeerEntries(); + profile.EnablePanForPeers = enablePanForPeersBox.Checked; + profile.EnableEqForPeers = enableEqForPeersBox.Checked; + profile.PeerShaping = peerShaping; return profile; } diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index 2a86406..2a94e50 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -271,6 +271,13 @@ internal sealed class PreferencesDialog : Form AutoSize = true, }; + private readonly AccessibleCheckBox showPanEqTabBox = new() + { + Text = "Show the Pan and E&Q tab, for per-peer pan and EQ (Alt+Q)", + AccessibleName = "Show the Pan and EQ tab", + AutoSize = true, + }; + private readonly Label upnpStatusLabel = new() { Text = "", @@ -565,6 +572,7 @@ internal sealed class PreferencesDialog : Form updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency; silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates; upnpEnabledBox.Checked = cfgForLoad.UpnpEnabled; + showPanEqTabBox.Checked = cfgForLoad.ShowPanEqTab; checkForUpdatesOnStartupBox.CheckedChanged += (_, _) => { @@ -605,6 +613,13 @@ internal sealed class PreferencesDialog : Form RefreshUpnpStatusLabel(); }; + showPanEqTabBox.CheckedChanged += (_, _) => + { + var cfg = AppConfig.Load(); + cfg.ShowPanEqTab = showPanEqTabBox.Checked; + try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ } + }; + // Live UPnP status — the RouterPortMapper raises StatusChanged from a thread-pool // thread, so marshal back onto the UI thread before touching the label. Subscribe // on show and unsubscribe on close to avoid leaking the handler past the dialog. @@ -798,7 +813,7 @@ internal sealed class PreferencesDialog : Form // is a field (declared above) so OnShown can focus it when the dialog opens. Logging is its // own tab (2026-06-19); the two logging controls moved off the General tab to lead it. tabs.TabPages.Add(MakeTab("General", - browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel)); + browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, showPanEqTabBox)); tabs.TabPages.Add(MakeTab("Audio cues", cueGroup)); tabs.TabPages.Add(MakeTab("Startup behaviour", startMinimisedBox, startWithUserBox, startWithProfileBox, startupListPanel)); diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index ef064dc..6751a12 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -68,6 +68,12 @@ public sealed class AppConfig /// already running quietly". Default false. public bool StartMinimised { get; set; } + /// 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 . + public bool ShowPanEqTab { get; set; } + /// 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-) because it fires before any profile — and its per-profile diff --git a/src/RemSound.Core/PeerShaping.cs b/src/RemSound.Core/PeerShaping.cs new file mode 100644 index 0000000..243e6ff --- /dev/null +++ b/src/RemSound.Core/PeerShaping.cs @@ -0,0 +1,65 @@ +namespace RemSound.Core; + +/// 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. +public enum PeerEqMode +{ + Simple3Band = 0, + Advanced10Band = 1, +} + +/// Per-peer pan + EQ, saved per profile (see ). Keyed in +/// the profile by the same peer-entry string as . +public sealed class PeerShaping +{ + /// -1 (full left) .. 0 (centre) .. +1 (full right). + public float Pan { get; set; } + + /// Which EQ mode is currently active for this peer. + public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band; + + /// Gains in dB (-12..+12) for the 3 simple bands, in the order of + /// (bass, mids, treble). Kept independently of the advanced bands. + public float[] SimpleBandsDb { get; set; } = new float[3]; + + /// Gains in dB (-12..+12) for the 10 advanced graphic-EQ bands, in the order of + /// . Kept independently of the simple bands. + public float[] AdvancedBandsDb { get; set; } = new float[10]; +} + +/// 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. +public static class PeerEqBands +{ + /// 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. + public const int MixSampleRate = 48000; + + /// Maximum boost/cut for any band, in dB. Sliders run -12..+12. + public const float MaxGainDb = 12f; + + /// 3-band "tone control": bass (low shelf), mids (peaking), treble (high shelf). + public static readonly (string Label, double Freq)[] Simple = + [ + ("Bass", 100.0), + ("Mids", 1000.0), + ("Treble", 8000.0), + ]; + + /// 10-band graphic EQ on ISO octave centres (all peaking). + 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), + ]; +} diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index 550b8c3..6f5f14c 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -183,6 +183,19 @@ public sealed class Profile /// (the connection simply waits or drops rather than moving). Off by default. (#17) public bool LockPeerAddresses { get; set; } + // === Per-peer pan and EQ (jam mixing) === + /// Master switch for this profile: apply each connected peer's saved PAN. Off by default. + /// The pan values themselves live in . + public bool EnablePanForPeers { get; set; } + + /// Master switch for this profile: apply each connected peer's saved EQ. Off by default. + public bool EnableEqForPeers { get; set; } + + /// Per-peer pan + EQ, keyed by the SAME peer-entry string used in + /// (user text / discovery label / address:port). A peer with + /// no entry gets centre pan and flat EQ. Empty on a fresh profile. + public Dictionary PeerShaping { get; set; } = new(); + // === Hotkeys === public HotkeyRecord? ReceiveMuteHotkey { get; set; } public HotkeyRecord? SendMuteHotkey { get; set; } diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs index 7ae8af8..6ac819b 100644 --- a/src/RemSound.Receiver/AudioReceiver.cs +++ b/src/RemSound.Receiver/AudioReceiver.cs @@ -193,6 +193,11 @@ public sealed class AudioReceiver : IDisposable public void SetConcealmentArtifact(ConcealmentArtifact artifact) => playoutEngine.SetConcealmentArtifact(artifact); + /// Sets (or clears with null) the per-peer pan+EQ chain for the given peer address. + /// Applies to that peer's current and future sessions. Called from the UI thread. + public void SetPeerDsp(IPAddress address, PeerDspChain? chain) => + playoutEngine.SetPeerDsp(address, chain); + /// /// Optional callback invoked when the engine produces fully-processed mixed received /// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo diff --git a/src/RemSound.Receiver/PeerDspChain.cs b/src/RemSound.Receiver/PeerDspChain.cs new file mode 100644 index 0000000..b5f881d --- /dev/null +++ b/src/RemSound.Receiver/PeerDspChain.cs @@ -0,0 +1,113 @@ +using NAudio.Dsp; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Per-peer pan + EQ, applied to that one peer's decoded stereo block just before it is summed into +/// the mix (see .ReadFloats). Immutable once built: when the user changes +/// a setting the UI thread builds a fresh and swaps the reference on the +/// SessionPlayout in a single assignment; the audio thread reads that reference once per block. No +/// locks and no allocation on the audio thread — the same build-new-and-swap idiom RemSound already +/// uses for its other audio-thread parameters. Every operation is per-sample (a balance-style pan +/// plus RBJ biquad IIR EQ), so it adds ZERO buffering latency to the mix — only CPU, and very little. +/// +public sealed class PeerDspChain +{ + private readonly float panL; + private readonly float panR; + private readonly bool hasPan; + // Same coefficients on both channels, but each needs its own filter instance because a biquad + // carries per-channel state. left.Length == right.Length always. + private readonly BiQuadFilter[] left; + private readonly BiQuadFilter[] right; + + private PeerDspChain(float panL, float panR, bool hasPan, BiQuadFilter[] left, BiQuadFilter[] right) + { + this.panL = panL; + this.panR = panR; + this.hasPan = hasPan; + this.left = left; + this.right = right; + } + + /// True when this chain would do nothing (pan off/centre and EQ off/flat). Build returns + /// null in that case so an unshaped peer's dsp reference is null and it pays nothing. + public bool IsNoOp => !hasPan && left.Length == 0; + + /// Builds a chain for one peer from its saved shaping and the profile's two master + /// switches. Returns null if there's nothing to do — pan disabled or centred, and EQ disabled or + /// completely flat. Runs on the UI thread; the result is swapped onto the audio thread atomically. + public static PeerDspChain? Build(PeerShaping? shaping, bool applyPan, bool applyEq) + { + // Pan is a balance control: it keeps the peer's stereo image (never sums to mono). Centre is + // unity on both sides; panning toward one side attenuates the OPPOSITE channel, reaching zero + // at the extreme. So a stereo signal just leans left or right rather than collapsing. + float pan = shaping is null ? 0f : Math.Clamp(shaping.Pan, -1f, 1f); + bool hasPan = applyPan && pan != 0f; + float panL = pan > 0f ? 1f - pan : 1f; + float panR = pan < 0f ? 1f + pan : 1f; + + var l = new List(); + var r = new List(); + if (applyEq && shaping is not null) + { + var mode = shaping.EqMode; + var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple; + var gains = mode == PeerEqMode.Advanced10Band ? shaping.AdvancedBandsDb : shaping.SimpleBandsDb; + for (int i = 0; i < bands.Length; i++) + { + float gainDb = gains is not null && i < gains.Length ? gains[i] : 0f; + if (MathF.Abs(gainDb) < 0.05f) continue; // flat band — no filter needed, skip it + l.Add(MakeBand(mode, i, bands.Length, (float)bands[i].Freq, gainDb)); + r.Add(MakeBand(mode, i, bands.Length, (float)bands[i].Freq, gainDb)); + } + } + + var chain = new PeerDspChain(panL, panR, hasPan, [.. l], [.. r]); + return chain.IsNoOp ? null : chain; + } + + private static BiQuadFilter MakeBand(PeerEqMode mode, int index, int count, float freq, float gainDb) + { + // 3-band tone control: bass is a low shelf, treble a high shelf, mids a peaking band — the + // natural shape for a simple bass/mid/treble control. 10-band graphic EQ: peaking throughout, + // with a Q suited to roughly one-octave band spacing. + if (mode == PeerEqMode.Simple3Band && index == 0) + return BiQuadFilter.LowShelf(PeerEqBands.MixSampleRate, freq, 0.7f, gainDb); + if (mode == PeerEqMode.Simple3Band && index == count - 1) + return BiQuadFilter.HighShelf(PeerEqBands.MixSampleRate, freq, 0.7f, gainDb); + return BiQuadFilter.PeakingEQ(PeerEqBands.MixSampleRate, freq, mode == PeerEqMode.Advanced10Band ? 1.4f : 0.9f, gainDb); + } + + /// Process one interleaved stereo block IN PLACE. is the number + /// of stereo frames (the used span length is frames*2). Per-sample, no allocation, no locks — + /// safe to call on the audio render thread. + public void Process(Span output, int frames) + { + int n = left.Length; + if (n > 0) + { + for (int f = 0; f < frames; f++) + { + float sl = output[2 * f]; + float sr = output[2 * f + 1]; + for (int b = 0; b < n; b++) + { + sl = left[b].Transform(sl); + sr = right[b].Transform(sr); + } + output[2 * f] = sl; + output[2 * f + 1] = sr; + } + } + if (hasPan) + { + for (int f = 0; f < frames; f++) + { + output[2 * f] *= panL; + output[2 * f + 1] *= panR; + } + } + } +} diff --git a/src/RemSound.Receiver/PlayoutEngine.cs b/src/RemSound.Receiver/PlayoutEngine.cs index 3f5fca1..c0ab1ff 100644 --- a/src/RemSound.Receiver/PlayoutEngine.cs +++ b/src/RemSound.Receiver/PlayoutEngine.cs @@ -45,6 +45,10 @@ internal sealed class PlayoutEngine : IWaveProvider // Both) the sender emits a single streamId so the dict still has one entry per peer, // identical to the pre-refactor behaviour. The new mode adds a second entry per peer. private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), SessionPlayout> sessions = new(); + // Per-peer pan+EQ, keyed by peer address, so a session created later (a reconnect) inherits its + // peer's shaping from frame zero — the same "applies to future sessions too" idea as the + // concealment artifact. Guarded by sessionsLock. A null value means explicitly cleared. + private readonly Dictionary peerDspByAddress = new(); private SessionPlayout[] sessionsSnapshot = []; // Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on // its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render @@ -135,6 +139,20 @@ internal sealed class PlayoutEngine : IWaveProvider foreach (var s in snap) s.SetConcealmentArtifact(artifact); } + /// Sets (or clears with null) the pan+EQ chain for every current and future session from + /// this peer address. Live-updates existing sessions and remembers it so a session created later + /// (a reconnect, or a peer that starts streaming after you set it) inherits it from frame zero. + public void SetPeerDsp(IPAddress address, PeerDspChain? chain) + { + lock (sessionsLock) + { + if (chain is null) peerDspByAddress.Remove(address); + else peerDspByAddress[address] = chain; + foreach (var s in sessions.Values) + if (s.Endpoint.Address.Equals(address)) s.SetDsp(chain); + } + } + public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels); /// Legacy property returning the Mixed route's target. Used by code paths that @@ -305,6 +323,9 @@ internal sealed class PlayoutEngine : IWaveProvider // gets the right artifact from frame zero (rather than the SessionPlayout // default, which would only get overridden on the next SetConcealmentArtifact). sp.SetConcealmentArtifact((ConcealmentArtifact)concealmentArtifactRaw); + // Inherit this peer's pan+EQ too, so a mid-stream / reconnect session is shaped from + // frame zero rather than only on the next SetPeerDsp call. + if (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain); sessions[key] = sp; sessionsSnapshot = sessions.Values.ToArray(); } diff --git a/src/RemSound.Receiver/SessionPlayout.cs b/src/RemSound.Receiver/SessionPlayout.cs index b92a436..1b42ef5 100644 --- a/src/RemSound.Receiver/SessionPlayout.cs +++ b/src/RemSound.Receiver/SessionPlayout.cs @@ -98,6 +98,9 @@ internal sealed class SessionPlayout : IDisposable private float lastConcealSampleL; private float lastConcealSampleR; private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst; + // Per-peer pan + EQ, or null when this peer isn't shaped. Built on the UI thread and swapped in + // atomically (volatile reference); the audio thread reads it once per block. See PeerDspChain. + private volatile PeerDspChain? dsp; // Per-session RNG for noise concealment. Seeded from process-level Shared so each session // gets a different sequence — but we don't care about reproducibility, just character. private readonly Random concealRng = new(Random.Shared.Next()); @@ -331,6 +334,10 @@ internal sealed class SessionPlayout : IDisposable public void SetConcealmentArtifact(ConcealmentArtifact value) => concealmentArtifactRaw = (int)value; + /// Sets (or clears with null) this session's per-peer pan+EQ chain. Takes effect on the + /// next block. Called from the UI thread; the audio thread reads the reference lock-free. + public void SetDsp(PeerDspChain? value) => dsp = value; + /// UTC time of the most recent successful audio write. Used by /// to prune long-idle sessions so the dictionary doesn't grow unboundedly. public DateTime LastWriteUtc { get; private set; } = DateTime.UtcNow; @@ -621,6 +628,11 @@ internal sealed class SessionPlayout : IDisposable // Read through the resampler and apply concealment on full underruns. ReadThroughResampler(output, outFrames); + // Per-peer pan + EQ: this peer's block is fully decoded and isolated here, immediately before + // PlayoutEngine sums it into the mix — so shaping is per-peer and pre-mix, and being per-sample + // it adds no buffering latency. Null (unshaped peer) skips the whole stage. + var d = dsp; + d?.Process(output, outFrames); return outFrames; }