From 6b1e65f4180fd5c011675c5b8d2628f580c9ee73 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:45:52 +0100 Subject: [PATCH] Pan/EQ: add a per-peer volume slider (before pan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ed's request — an individual level fader per peer, sitting just before the pan control on the Pan and EQ tab. New PeerShaping.Volume (0..1, default 1.0 = 100% = transparent), always applied (no master switch — unity does nothing). It folds into PeerDspChain's L/R gain alongside pan (gainL = panL*vol, gainR = panR*vol), so it's another per-sample multiply, zero added latency, and multiplies with the global volume (per-peer fader -> mix -> master). Slider is 0-100%, saved per profile, announces "Volume: N percent". Shaped recording captures it; raw (bypass) recording doesn't. Held for next release. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 31 ++++++++++++++++---- src/RemSound.Core/PeerShaping.cs | 4 +++ src/RemSound.Receiver/PeerDspChain.cs | 41 ++++++++++++++++----------- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 96ce0aa..5ca974c 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -157,6 +157,7 @@ public sealed class MainForm : Form 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 volumeSlider = new() { Minimum = 0, Maximum = 100, Value = 100, SmallChange = 1, LargeChange = 10, TickFrequency = 25, Width = 320 }; 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" }; @@ -3144,12 +3145,13 @@ public sealed class MainForm : Form /// 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 }; + var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 1, RowCount = 9, 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(); + volumeSlider.ValueChanged += (_, _) => OnVolumeChanged(); panSlider.ValueChanged += (_, _) => OnPanChanged(); resetPeerEqButton.Click += (_, _) => OnResetPeerEq(); eqModeList.Items.Add("3 band basic EQ"); @@ -3157,9 +3159,13 @@ public sealed class MainForm : Form eqModeList.SelectedIndexChanged += (_, _) => OnEqModeChanged(); var peerLabel = new MnemonicLabel { Text = "Peer to shape (Alt+&U)", AutoSize = true, MnemonicTarget = panEqPeerList }; + var volumeLabel = new MnemonicLabel { Text = "Vo&lume (Alt+L)", AutoSize = true, MnemonicTarget = volumeSlider }; 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 volumeRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; + volumeRow.Controls.Add(volumeLabel); + volumeRow.Controls.Add(volumeSlider); var panRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; panRow.Controls.Add(panLabel); panRow.Controls.Add(panSlider); @@ -3171,10 +3177,11 @@ public sealed class MainForm : Form 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); + panel.Controls.Add(volumeRow, 0, 4); + panel.Controls.Add(panRow, 0, 5); + panel.Controls.Add(resetPeerEqButton, 0, 6); + panel.Controls.Add(modeRow, 0, 7); + panel.Controls.Add(eqBandsPanel, 0, 8); panEqTabPage.Controls.Add(panel); RefreshPanEqPeerList(); @@ -3243,10 +3250,13 @@ public sealed class MainForm : Form try { var s = GetOrCreateShaping(selectedShapingKey); + volumeSlider.Value = Math.Clamp((int)Math.Round(s.Volume * 100f), 0, 100); + UpdateVolumeAccessibleName(); panSlider.Value = Math.Clamp((int)Math.Round(s.Pan * 50f) + 50, 0, 100); UpdatePanAccessibleName(); eqModeList.SelectedIndex = s.EqMode == PeerEqMode.Advanced10Band ? 1 : 0; RebuildEqBandSliders(); + volumeSlider.Enabled = enabled; panSlider.Enabled = enabled; resetPeerEqButton.Enabled = enabled; eqModeList.Enabled = enabled; @@ -3279,6 +3289,17 @@ public sealed class MainForm : Form panSlider.AccessibleName = $"Pan: {desc}"; } + private void OnVolumeChanged() + { + if (loadingPanEqControls || selectedShapingKey is null) return; + GetOrCreateShaping(selectedShapingKey).Volume = Math.Clamp(volumeSlider.Value / 100f, 0f, 1f); + UpdateVolumeAccessibleName(); + ApplyPeerShaping(selectedShapingKey); + MarkProfileDirty(); + } + + private void UpdateVolumeAccessibleName() => volumeSlider.AccessibleName = $"Volume: {volumeSlider.Value} percent"; + private void OnEqModeChanged() { if (loadingPanEqControls || selectedShapingKey is null) return; diff --git a/src/RemSound.Core/PeerShaping.cs b/src/RemSound.Core/PeerShaping.cs index 243e6ff..0595e15 100644 --- a/src/RemSound.Core/PeerShaping.cs +++ b/src/RemSound.Core/PeerShaping.cs @@ -16,6 +16,10 @@ public sealed class PeerShaping /// -1 (full left) .. 0 (centre) .. +1 (full right). public float Pan { get; set; } + /// Per-peer playback level, 0..1 (1 = 100%, unity). An individual fader for this peer, + /// on top of the global volume. Always applied (no master switch); 100% is transparent. + public float Volume { get; set; } = 1f; + /// Which EQ mode is currently active for this peer. public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band; diff --git a/src/RemSound.Receiver/PeerDspChain.cs b/src/RemSound.Receiver/PeerDspChain.cs index b5f881d..832d5d6 100644 --- a/src/RemSound.Receiver/PeerDspChain.cs +++ b/src/RemSound.Receiver/PeerDspChain.cs @@ -14,39 +14,46 @@ namespace RemSound.Receiver; /// public sealed class PeerDspChain { - private readonly float panL; - private readonly float panR; - private readonly bool hasPan; + // L/R output gains = pan (when enabled) folded together with the per-peer volume (always). + private readonly float gainL; + private readonly float gainR; + private readonly bool hasGain; // 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) + private PeerDspChain(float gainL, float gainR, bool hasGain, BiQuadFilter[] left, BiQuadFilter[] right) { - this.panL = panL; - this.panR = panR; - this.hasPan = hasPan; + this.gainL = gainL; + this.gainR = gainR; + this.hasGain = hasGain; 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; + /// True when this chain would do nothing (unity gain — pan off/centre, volume 100% — 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 => !hasGain && 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 (only when enabled) and the per-peer volume (always applied) fold into one L/R gain. // 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; + // at the extreme. Volume then scales both sides. So a stereo signal leans left/right and sits + // at the level you set, without ever collapsing to mono. + float pan = applyPan && shaping is not null ? Math.Clamp(shaping.Pan, -1f, 1f) : 0f; float panL = pan > 0f ? 1f - pan : 1f; float panR = pan < 0f ? 1f + pan : 1f; + float vol = shaping is null ? 1f : Math.Clamp(shaping.Volume, 0f, 1f); + float gainL = panL * vol; + float gainR = panR * vol; + bool hasGain = gainL != 1f || gainR != 1f; var l = new List(); var r = new List(); @@ -64,7 +71,7 @@ public sealed class PeerDspChain } } - var chain = new PeerDspChain(panL, panR, hasPan, [.. l], [.. r]); + var chain = new PeerDspChain(gainL, gainR, hasGain, [.. l], [.. r]); return chain.IsNoOp ? null : chain; } @@ -101,12 +108,12 @@ public sealed class PeerDspChain output[2 * f + 1] = sr; } } - if (hasPan) + if (hasGain) { for (int f = 0; f < frames; f++) { - output[2 * f] *= panL; - output[2 * f + 1] *= panR; + output[2 * f] *= gainL; + output[2 * f + 1] *= gainR; } } }