Pan/EQ: add a per-peer volume slider (before pan)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
efa9435da7
commit
6b1e65f418
@@ -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 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 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 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 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 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 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 <see cref="PeerDspChain"/> / <see cref="PeerShaping"/>.</summary>
|
/// and is saved per profile. See <see cref="PeerDspChain"/> / <see cref="PeerShaping"/>.</summary>
|
||||||
private void BuildPanEqTab()
|
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));
|
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||||
|
|
||||||
enableEqForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
|
enableEqForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
|
||||||
enablePanForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
|
enablePanForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
|
||||||
panEqPeerList.SelectedIndexChanged += (_, _) => OnPanEqPeerSelected();
|
panEqPeerList.SelectedIndexChanged += (_, _) => OnPanEqPeerSelected();
|
||||||
|
volumeSlider.ValueChanged += (_, _) => OnVolumeChanged();
|
||||||
panSlider.ValueChanged += (_, _) => OnPanChanged();
|
panSlider.ValueChanged += (_, _) => OnPanChanged();
|
||||||
resetPeerEqButton.Click += (_, _) => OnResetPeerEq();
|
resetPeerEqButton.Click += (_, _) => OnResetPeerEq();
|
||||||
eqModeList.Items.Add("3 band basic EQ");
|
eqModeList.Items.Add("3 band basic EQ");
|
||||||
@@ -3157,9 +3159,13 @@ public sealed class MainForm : Form
|
|||||||
eqModeList.SelectedIndexChanged += (_, _) => OnEqModeChanged();
|
eqModeList.SelectedIndexChanged += (_, _) => OnEqModeChanged();
|
||||||
|
|
||||||
var peerLabel = new MnemonicLabel { Text = "Peer to shape (Alt+&U)", AutoSize = true, MnemonicTarget = panEqPeerList };
|
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 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 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) };
|
var panRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) };
|
||||||
panRow.Controls.Add(panLabel);
|
panRow.Controls.Add(panLabel);
|
||||||
panRow.Controls.Add(panSlider);
|
panRow.Controls.Add(panSlider);
|
||||||
@@ -3171,10 +3177,11 @@ public sealed class MainForm : Form
|
|||||||
panel.Controls.Add(enablePanForPeersBox, 0, 1);
|
panel.Controls.Add(enablePanForPeersBox, 0, 1);
|
||||||
panel.Controls.Add(peerLabel, 0, 2);
|
panel.Controls.Add(peerLabel, 0, 2);
|
||||||
panel.Controls.Add(panEqPeerList, 0, 3);
|
panel.Controls.Add(panEqPeerList, 0, 3);
|
||||||
panel.Controls.Add(panRow, 0, 4);
|
panel.Controls.Add(volumeRow, 0, 4);
|
||||||
panel.Controls.Add(resetPeerEqButton, 0, 5);
|
panel.Controls.Add(panRow, 0, 5);
|
||||||
panel.Controls.Add(modeRow, 0, 6);
|
panel.Controls.Add(resetPeerEqButton, 0, 6);
|
||||||
panel.Controls.Add(eqBandsPanel, 0, 7);
|
panel.Controls.Add(modeRow, 0, 7);
|
||||||
|
panel.Controls.Add(eqBandsPanel, 0, 8);
|
||||||
panEqTabPage.Controls.Add(panel);
|
panEqTabPage.Controls.Add(panel);
|
||||||
|
|
||||||
RefreshPanEqPeerList();
|
RefreshPanEqPeerList();
|
||||||
@@ -3243,10 +3250,13 @@ public sealed class MainForm : Form
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var s = GetOrCreateShaping(selectedShapingKey);
|
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);
|
panSlider.Value = Math.Clamp((int)Math.Round(s.Pan * 50f) + 50, 0, 100);
|
||||||
UpdatePanAccessibleName();
|
UpdatePanAccessibleName();
|
||||||
eqModeList.SelectedIndex = s.EqMode == PeerEqMode.Advanced10Band ? 1 : 0;
|
eqModeList.SelectedIndex = s.EqMode == PeerEqMode.Advanced10Band ? 1 : 0;
|
||||||
RebuildEqBandSliders();
|
RebuildEqBandSliders();
|
||||||
|
volumeSlider.Enabled = enabled;
|
||||||
panSlider.Enabled = enabled;
|
panSlider.Enabled = enabled;
|
||||||
resetPeerEqButton.Enabled = enabled;
|
resetPeerEqButton.Enabled = enabled;
|
||||||
eqModeList.Enabled = enabled;
|
eqModeList.Enabled = enabled;
|
||||||
@@ -3279,6 +3289,17 @@ public sealed class MainForm : Form
|
|||||||
panSlider.AccessibleName = $"Pan: {desc}";
|
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()
|
private void OnEqModeChanged()
|
||||||
{
|
{
|
||||||
if (loadingPanEqControls || selectedShapingKey is null) return;
|
if (loadingPanEqControls || selectedShapingKey is null) return;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public sealed class PeerShaping
|
|||||||
/// <summary>-1 (full left) .. 0 (centre) .. +1 (full right).</summary>
|
/// <summary>-1 (full left) .. 0 (centre) .. +1 (full right).</summary>
|
||||||
public float Pan { get; set; }
|
public float Pan { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public float Volume { get; set; } = 1f;
|
||||||
|
|
||||||
/// <summary>Which EQ mode is currently active for this peer.</summary>
|
/// <summary>Which EQ mode is currently active for this peer.</summary>
|
||||||
public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band;
|
public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band;
|
||||||
|
|
||||||
|
|||||||
@@ -14,39 +14,46 @@ namespace RemSound.Receiver;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PeerDspChain
|
public sealed class PeerDspChain
|
||||||
{
|
{
|
||||||
private readonly float panL;
|
// L/R output gains = pan (when enabled) folded together with the per-peer volume (always).
|
||||||
private readonly float panR;
|
private readonly float gainL;
|
||||||
private readonly bool hasPan;
|
private readonly float gainR;
|
||||||
|
private readonly bool hasGain;
|
||||||
// Same coefficients on both channels, but each needs its own filter instance because a biquad
|
// Same coefficients on both channels, but each needs its own filter instance because a biquad
|
||||||
// carries per-channel state. left.Length == right.Length always.
|
// carries per-channel state. left.Length == right.Length always.
|
||||||
private readonly BiQuadFilter[] left;
|
private readonly BiQuadFilter[] left;
|
||||||
private readonly BiQuadFilter[] right;
|
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.gainL = gainL;
|
||||||
this.panR = panR;
|
this.gainR = gainR;
|
||||||
this.hasPan = hasPan;
|
this.hasGain = hasGain;
|
||||||
this.left = left;
|
this.left = left;
|
||||||
this.right = right;
|
this.right = right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True when this chain would do nothing (pan off/centre and EQ off/flat). Build returns
|
/// <summary>True when this chain would do nothing (unity gain — pan off/centre, volume 100% — and
|
||||||
/// null in that case so an unshaped peer's <c>dsp</c> reference is null and it pays nothing.</summary>
|
/// EQ off/flat). Build returns null in that case so an unshaped peer's <c>dsp</c> reference is null
|
||||||
public bool IsNoOp => !hasPan && left.Length == 0;
|
/// and it pays nothing.</summary>
|
||||||
|
public bool IsNoOp => !hasGain && left.Length == 0;
|
||||||
|
|
||||||
/// <summary>Builds a chain for one peer from its saved shaping and the profile's two master
|
/// <summary>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
|
/// 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.</summary>
|
/// completely flat. Runs on the UI thread; the result is swapped onto the audio thread atomically.</summary>
|
||||||
public static PeerDspChain? Build(PeerShaping? shaping, bool applyPan, bool applyEq)
|
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
|
// 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
|
// 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.
|
// at the extreme. Volume then scales both sides. So a stereo signal leans left/right and sits
|
||||||
float pan = shaping is null ? 0f : Math.Clamp(shaping.Pan, -1f, 1f);
|
// at the level you set, without ever collapsing to mono.
|
||||||
bool hasPan = applyPan && pan != 0f;
|
float pan = applyPan && shaping is not null ? Math.Clamp(shaping.Pan, -1f, 1f) : 0f;
|
||||||
float panL = pan > 0f ? 1f - pan : 1f;
|
float panL = pan > 0f ? 1f - pan : 1f;
|
||||||
float panR = 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<BiQuadFilter>();
|
var l = new List<BiQuadFilter>();
|
||||||
var r = new List<BiQuadFilter>();
|
var r = new List<BiQuadFilter>();
|
||||||
@@ -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;
|
return chain.IsNoOp ? null : chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,12 +108,12 @@ public sealed class PeerDspChain
|
|||||||
output[2 * f + 1] = sr;
|
output[2 * f + 1] = sr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasPan)
|
if (hasGain)
|
||||||
{
|
{
|
||||||
for (int f = 0; f < frames; f++)
|
for (int f = 0; f < frames; f++)
|
||||||
{
|
{
|
||||||
output[2 * f] *= panL;
|
output[2 * f] *= gainL;
|
||||||
output[2 * f + 1] *= panR;
|
output[2 * f + 1] *= gainR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user