Volume/pan/EQ tab overhaul: one master switch, peer checklist, 16-band parametric EQ

Reworks the per-peer shaping tab (held for next release):

 * Renamed the tab to "Volume, pan and EQ for peers"; the Preferences toggle now defaults ON.
 * Collapsed the two master switches (Enable EQ / Enable pan) into ONE: "Enable volume, pan and
   EQ for all peers" (Alt+E). Volume now obeys it too. PeerDspChain.Build takes a single enabled
   flag; Profile.EnableAllPeerShaping replaces the two bools (old ones kept for load-migration).
 * Peer picker is now a CheckedListBox: ticking a peer shapes them (per-peer bypass via new
   PeerShaping.Enabled, default true); the focused row is the one the controls edit. Effective
   shaping = master switch AND that peer's tick. Letter-nav suppressed so keys never toggle a tick.
 * Three EQ modes, renamed: "3 band simple EQ", "12 band advanced graphic EQ", and the new
   "16 band parametric EQ" (PeerEqMode.Parametric16Band).
 * Parametric EQ: up to 16 user bands, each a boost/cut across a start->end range (PeerShaping
   .ParametricBands; ParametricToPeaking maps range -> peaking centre+Q, shared by DSP and curve).
   Add band dialog (spin-or-type, numeric-only, live preview, OK/Escape); Bands list sorted
   bass->treble reading "X Hz to Y Hz, plus/minus N dB"; Delete key / Delete button, multi-select.
   Set peer EQ to default clears the parametric list too.
 * dB now spoken as words ("plus 3 dB" / "minus 6 dB" / "flat") on the graphic sliders and the
   parametric list, since NVDA users typically have punctuation off and never hear a "+".
 * New unbound machine-wide global shortcut "Toggle volume, pan and EQ for all peers" (not stored
   in any profile) via the hotkey controller + settings store.
 * Renamed the Inputs/outputs "Set volume for all received audio" to "Master receive volume".
 * Added EqCurveControl: a purely-visual EQ response graph (not focusable, invisible to NVDA).
 * Full manual sweep (readme.html + regenerated MANUAL.md).

Build clean; --selftest passes. Deployed to both test folders. Held for next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-07 23:21:18 +01:00
co-authored by Claude Opus 4.8
parent eaae76d015
commit 033edd776f
102 changed files with 4658 additions and 135 deletions
+157
View File
@@ -0,0 +1,157 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>Modal dialog for adding one parametric EQ band. The user sets a start frequency, an end
/// frequency and a gain; all three are spin-or-type boxes that refuse non-numbers and clamp to range.
/// While the dialog is open it previews the in-progress band live (so the peer's sound changes as the
/// values move); OK keeps the band, Cancel/Escape drops it and reverts the preview.</summary>
internal sealed class AddBandDialog : Form
{
private readonly NumericUpDown startFreq = new()
{
Minimum = (decimal)PeerEqBands.ParametricMinHz,
Maximum = (decimal)PeerEqBands.ParametricMaxHz,
Value = 200,
Increment = 10,
DecimalPlaces = 0,
Width = 120,
TextAlign = HorizontalAlignment.Right,
AccessibleName = "Start frequency in Hertz (Alt+S)",
};
private readonly NumericUpDown endFreq = new()
{
Minimum = (decimal)PeerEqBands.ParametricMinHz,
Maximum = (decimal)PeerEqBands.ParametricMaxHz,
Value = 2000,
Increment = 10,
DecimalPlaces = 0,
Width = 120,
TextAlign = HorizontalAlignment.Right,
AccessibleName = "End frequency in Hertz (Alt+E)",
};
private readonly NumericUpDown gainDb = new()
{
Minimum = -(decimal)PeerEqBands.MaxGainDb,
Maximum = (decimal)PeerEqBands.MaxGainDb,
Value = 3,
Increment = 1,
DecimalPlaces = 0,
Width = 120,
TextAlign = HorizontalAlignment.Right,
AccessibleName = "Gain in dB (Alt+G)",
};
private readonly Action<ParametricBand?>? livePreview;
private bool accepted;
/// <summary>The band the user built, valid only when <see cref="Form.ShowDialog()"/> returned OK.</summary>
public ParametricBand Result => new()
{
StartHz = (float)startFreq.Value,
EndHz = (float)endFreq.Value,
GainDb = (float)gainDb.Value,
};
/// <param name="livePreview">Called with the in-progress band on every value change so the caller
/// can apply it to the peer in real time, and with null when the dialog is cancelled/closed so the
/// caller reverts to the saved shaping.</param>
public AddBandDialog(Action<ParametricBand?>? livePreview = null)
{
this.livePreview = livePreview;
Text = "Add EQ band";
StartPosition = FormStartPosition.CenterParent;
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
ClientSize = new Size(340, 200);
var grid = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 2,
RowCount = 4,
};
grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
AddRow(grid, 0, "&Start frequency in Hz (Alt+S)", startFreq);
AddRow(grid, 1, "&End frequency in Hz (Alt+E)", endFreq);
AddRow(grid, 2, "&Gain in dB (Alt+G)", gainDb);
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.None };
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
okButton.Click += (_, _) => TryAccept();
var buttonRow = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
};
buttonRow.Controls.Add(cancelButton);
buttonRow.Controls.Add(okButton);
grid.Controls.Add(buttonRow, 1, 3);
Controls.Add(grid);
AcceptButton = okButton;
CancelButton = cancelButton;
startFreq.ValueChanged += (_, _) => Preview();
endFreq.ValueChanged += (_, _) => Preview();
gainDb.ValueChanged += (_, _) => Preview();
Shown += (_, _) => { startFreq.Focus(); Preview(); };
}
// The mnemonic hint is embedded in each NumericUpDown's AccessibleName; the label carries the
// visible '&' so Alt+letter moves focus to the box (NumericUpDown has no '&' of its own).
private static void AddRow(TableLayoutPanel grid, int row, string labelText, NumericUpDown box)
{
var label = new Label { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 6, 8, 0) };
// A plain Label with '&' wires the mnemonic to the next control in tab order (the box).
grid.Controls.Add(label, 0, row);
grid.Controls.Add(box, 1, row);
}
private ParametricBand Current() => new()
{
StartHz = (float)startFreq.Value,
EndHz = (float)endFreq.Value,
GainDb = (float)gainDb.Value,
};
private void Preview() => livePreview?.Invoke(Current());
private void TryAccept()
{
if (endFreq.Value <= startFreq.Value)
{
var page = new TaskDialogPage
{
Caption = "Add EQ band",
Heading = "End frequency must be higher than start",
Text = $"The end frequency ({endFreq.Value:0} Hz) must be higher than the start frequency ({startFreq.Value:0} Hz). Adjust one of them and try again.",
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
};
TaskDialog.ShowDialog(this, page);
endFreq.Focus();
return;
}
accepted = true;
DialogResult = DialogResult.OK;
Close();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
base.OnFormClosing(e);
if (!accepted) livePreview?.Invoke(null); // revert the preview on cancel / close
}
}
+126
View File
@@ -0,0 +1,126 @@
using System.Drawing.Drawing2D;
using RemSound.Core;
namespace RemSound.App;
/// <summary>A purely-decorative frequency-response graph for the selected peer's EQ. Draws the dialled
/// EQ shape (all three modes feed the same curve) so a sighted onlooker sees the classic EQ picture.
/// It is NOT focusable and carries no accessible content — NVDA skips it entirely; every actual control
/// stays the sliders / list / buttons around it. RemSound has no sighted primary users, so this is a
/// low-cost nicety, not part of the interaction.</summary>
internal sealed class EqCurveControl : Panel
{
private PeerShaping? shaping;
// Log-frequency axis: 20 Hz .. 20 kHz.
private const double MinHz = 20.0;
private const double MaxHz = 20000.0;
private const float RangeDb = PeerEqBands.MaxGainDb; // curve spans -12..+12 dB
public EqCurveControl()
{
TabStop = false; // never in the keyboard tab order
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer
| ControlStyles.ResizeRedraw | ControlStyles.UserPaint, true);
// No accessible name/role — leave it invisible to screen readers.
AccessibleName = "";
}
/// <summary>Point the curve at a peer's shaping (or null for flat) and repaint.</summary>
public void SetResponse(PeerShaping? s)
{
shaping = s;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
int w = Math.Max(1, ClientSize.Width);
int h = Math.Max(1, ClientSize.Height);
bool dark = BackColor.GetBrightness() < 0.5f;
using var bg = new SolidBrush(dark ? Color.FromArgb(30, 30, 30) : Color.FromArgb(245, 245, 245));
g.FillRectangle(bg, 0, 0, w, h);
// Grid: 0 dB centre line plus ±half-range guides.
using var gridPen = new Pen(dark ? Color.FromArgb(70, 70, 70) : Color.FromArgb(210, 210, 210));
using var zeroPen = new Pen(dark ? Color.FromArgb(110, 110, 110) : Color.FromArgb(170, 170, 170));
float midY = h / 2f;
g.DrawLine(gridPen, 0, h * 0.25f, w, h * 0.25f);
g.DrawLine(gridPen, 0, h * 0.75f, w, h * 0.75f);
g.DrawLine(zeroPen, 0, midY, w, midY);
// Build the response polyline: one point per horizontal pixel.
var pts = new PointF[w];
double logMin = Math.Log10(MinHz);
double logMax = Math.Log10(MaxHz);
for (int x = 0; x < w; x++)
{
double frac = w <= 1 ? 0 : (double)x / (w - 1);
double freq = Math.Pow(10, logMin + frac * (logMax - logMin));
float db = ResponseDb(freq);
float clamped = Math.Clamp(db, -RangeDb, RangeDb);
// +db upward: y = mid - (db/range)*halfHeight
float y = midY - (clamped / RangeDb) * (h / 2f - 4f);
pts[x] = new PointF(x, y);
}
using var curvePen = new Pen(dark ? Color.FromArgb(90, 200, 255) : Color.FromArgb(0, 120, 200), 2f);
if (w >= 2) g.DrawLines(curvePen, pts);
}
/// <summary>Approximate combined EQ response in dB at one frequency. Cosmetic only — analytic bell /
/// shelf shapes summed in dB, close enough to the real biquad response for a picture.</summary>
private float ResponseDb(double freq)
{
var s = shaping;
if (s is null) return 0f;
double total = 0;
switch (s.EqMode)
{
case PeerEqMode.Parametric16Band:
foreach (var band in s.ParametricBands)
{
if (band is null || MathF.Abs(band.GainDb) < 0.05f) continue;
PeerEqBands.ParametricToPeaking(band.StartHz, band.EndHz, out float centre, out float q);
total += Bell(freq, centre, q) * band.GainDb;
}
break;
case PeerEqMode.Advanced10Band:
for (int i = 0; i < PeerEqBands.Advanced.Length; i++)
{
float gain = i < s.AdvancedBandsDb.Length ? s.AdvancedBandsDb[i] : 0f;
if (MathF.Abs(gain) < 0.05f) continue;
total += Bell(freq, PeerEqBands.Advanced[i].Freq, 1.4f) * gain;
}
break;
default: // Simple3Band: bass low-shelf, mids peak, treble high-shelf
float bass = s.SimpleBandsDb.Length > 0 ? s.SimpleBandsDb[0] : 0f;
float mids = s.SimpleBandsDb.Length > 1 ? s.SimpleBandsDb[1] : 0f;
float treble = s.SimpleBandsDb.Length > 2 ? s.SimpleBandsDb[2] : 0f;
total += LowShelf(freq, PeerEqBands.Simple[0].Freq) * bass;
total += Bell(freq, PeerEqBands.Simple[1].Freq, 0.9f) * mids;
total += HighShelf(freq, PeerEqBands.Simple[2].Freq) * treble;
break;
}
return (float)total;
}
// Gaussian bell in log-frequency, peak 1.0 at f0. Width from Q (higher Q = narrower).
private static double Bell(double f, double f0, double q)
{
double sigmaOct = 1.0 / (2.0 * Math.Max(0.1, q));
double sigmaLn = sigmaOct * Math.Log(2.0);
double x = Math.Log(f / f0) / Math.Max(1e-6, sigmaLn);
return Math.Exp(-0.5 * x * x);
}
private static double LowShelf(double f, double f0) => 1.0 / (1.0 + Math.Pow(f / f0, 2.0));
private static double HighShelf(double f, double f0) => 1.0 / (1.0 + Math.Pow(f0 / f, 2.0));
}
+275 -54
View File
@@ -152,16 +152,23 @@ public sealed class MainForm : Form
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" };
// === Volume, pan and EQ for peers tab — shown only when AppConfig.ShowPanEqTab is on. ===
private readonly TabPage panEqTabPage = new("Volume, pan and EQ for peers");
private readonly AccessibleCheckBox enableAllPeerShapingBox = new() { Text = "Enable volume, pan and &EQ for all peers (Alt+E)", AccessibleName = "Enable volume, pan and EQ for all peers", AutoSize = true };
// A checklist: ticking a peer applies your shaping to them (a per-peer bypass), and the peer the
// cursor is on is the one the controls below edit.
private readonly CheckedListBox panEqPeerList = new() { Width = 430, Height = 90, IntegralHeight = false, AccessibleName = "Peers (Alt+U)" };
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" };
private readonly ListBox eqModeList = new() { Width = 320, Height = 58, IntegralHeight = false, AccessibleName = "EQ mode" };
private readonly FlowLayoutPanel eqBandsPanel = new() { FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false, Margin = new Padding(0) };
// Parametric-mode controls (built into eqBandsPanel when the 16-band mode is active).
private readonly Button addBandButton = new() { Text = "&Add band (Alt+A)", AutoSize = true, AccessibleName = "Add band" };
private readonly Button deleteBandButton = new() { Text = "&Delete band (Alt+D)", AutoSize = true, AccessibleName = "Delete band" };
private readonly ListBox parametricBandList = new() { Width = 430, Height = 150, IntegralHeight = false, SelectionMode = SelectionMode.MultiExtended, AccessibleName = "Bands (Alt+B)" };
// Purely-visual EQ response graph (invisible to NVDA). See EqCurveControl.
private readonly EqCurveControl eqCurve = new() { Width = 430, Height = 110, Margin = new Padding(0, 8, 0, 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<string, PeerShaping> peerShaping = new();
@@ -778,7 +785,12 @@ public sealed class MainForm : Form
ShowQuickProfileSwitch,
// Speak the status line aloud through the active screen reader (issue #13). Screen-reader
// specific; the global hotkey is unset by default (the user binds it in Keyboard shortcuts).
SpeakStatusLine);
SpeakStatusLine,
// Toggle the "Enable volume, pan and EQ for all peers" master switch. Flipping .Checked
// routes through the CheckedChanged handler (re-applies shaping) and, being an
// AccessibleCheckBox, announces the new state to NVDA when the window is focused. Unset
// by default; the user binds it in Keyboard shortcuts.
() => enableAllPeerShapingBox.Checked = !enableAllPeerShapingBox.Checked);
// Pipe hotkey controller diagnostics into the main log so we can see, e.g.,
// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and
// "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409:
@@ -3139,26 +3151,35 @@ public sealed class MainForm : Form
public override string ToString() => Label;
}
/// <summary>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 <see cref="PeerDspChain"/> / <see cref="PeerShaping"/>.</summary>
/// <summary>Builds the "Volume, pan and EQ for peers" tab: one master switch, a checklist of
/// connected peers (tick = shape that peer), then the selected peer's volume + pan sliders, a
/// reset-EQ button, an EQ-mode picker (3-band simple / 12-band graphic / 16-band parametric) and
/// that mode's controls, plus a purely-visual response curve. Every control acts on the peer the
/// cursor is on, applies in real time, and is saved per profile. See <see cref="PeerDspChain"/> /
/// <see cref="PeerShaping"/>.</summary>
private void BuildPanEqTab()
{
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(); } };
enableAllPeerShapingBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
panEqPeerList.SelectedIndexChanged += (_, _) => OnPanEqPeerSelected();
panEqPeerList.ItemCheck += OnPeerShapeToggled;
// First-letter navigation on some Windows configs can accidentally toggle the checkbox; do the
// letter-nav ourselves and swallow the default (per the workspace CheckedListBox guidance).
panEqPeerList.KeyDown += OnPeerListKeyDown;
volumeSlider.ValueChanged += (_, _) => OnVolumeChanged();
panSlider.ValueChanged += (_, _) => OnPanChanged();
resetPeerEqButton.Click += (_, _) => OnResetPeerEq();
eqModeList.Items.Add("3 band basic EQ");
eqModeList.Items.Add("12 band advanced EQ");
addBandButton.Click += (_, _) => OnAddParametricBand();
deleteBandButton.Click += (_, _) => OnDeleteParametricBands();
parametricBandList.KeyDown += OnParametricBandListKeyDown;
eqModeList.Items.Add("3 band simple EQ");
eqModeList.Items.Add("12 band advanced graphic EQ");
eqModeList.Items.Add("16 band parametric EQ");
eqModeList.SelectedIndexChanged += (_, _) => OnEqModeChanged();
var peerLabel = new MnemonicLabel { Text = "Peer to shape (Alt+&U)", AutoSize = true, MnemonicTarget = panEqPeerList };
var peerLabel = new MnemonicLabel { Text = "Peers (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 };
@@ -3173,15 +3194,15 @@ public sealed class MainForm : Form
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(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);
panel.Controls.Add(enableAllPeerShapingBox, 0, 0);
panel.Controls.Add(peerLabel, 0, 1);
panel.Controls.Add(panEqPeerList, 0, 2);
panel.Controls.Add(volumeRow, 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(eqCurve, 0, 8);
panEqTabPage.Controls.Add(panel);
RefreshPanEqPeerList();
@@ -3226,17 +3247,25 @@ public sealed class MainForm : Form
lastPanEqPeerSignature = signature;
var prevKey = (panEqPeerList.SelectedItem as PanEqPeerItem)?.Key ?? selectedShapingKey;
panEqPeerList.BeginUpdate();
panEqPeerList.Items.Clear();
int idx = -1;
foreach (var d in desired)
loadingPanEqControls = true;
try
{
int i = panEqPeerList.Items.Add(d);
if (d.Key == prevKey) idx = i;
panEqPeerList.BeginUpdate();
panEqPeerList.Items.Clear();
int idx = -1;
foreach (var d in desired)
{
// Tick reflects the peer's saved Enabled flag (a per-peer bypass). Add(item, isChecked)
// sets the initial state without raising ItemCheck.
bool ticked = GetShaping(d.Key)?.Enabled ?? true;
int i = panEqPeerList.Items.Add(d, ticked);
if (d.Key == prevKey) idx = i;
}
if (idx < 0 && panEqPeerList.Items.Count > 0) idx = 0;
if (idx >= 0) panEqPeerList.SelectedIndex = idx;
panEqPeerList.EndUpdate();
}
if (idx < 0 && panEqPeerList.Items.Count > 0) idx = 0;
if (idx >= 0) panEqPeerList.SelectedIndex = idx;
panEqPeerList.EndUpdate();
finally { loadingPanEqControls = false; }
ApplyAllPeerShaping();
}
@@ -3254,16 +3283,60 @@ public sealed class MainForm : Form
UpdateVolumeAccessibleName();
panSlider.Value = Math.Clamp((int)Math.Round(s.Pan * 50f) + 50, 0, 100);
UpdatePanAccessibleName();
eqModeList.SelectedIndex = s.EqMode == PeerEqMode.Advanced10Band ? 1 : 0;
eqModeList.SelectedIndex = (int)s.EqMode; // enum values line up with the picker rows
RebuildEqBandSliders();
volumeSlider.Enabled = enabled;
panSlider.Enabled = enabled;
resetPeerEqButton.Enabled = enabled;
eqModeList.Enabled = enabled;
UpdateEqCurve();
}
finally { loadingPanEqControls = false; }
}
// The three EQ-mode picker rows map one-to-one onto the PeerEqMode enum values.
private static PeerEqMode ModeForIndex(int index) => index switch
{
2 => PeerEqMode.Parametric16Band,
1 => PeerEqMode.Advanced10Band,
_ => PeerEqMode.Simple3Band,
};
/// <summary>Fired when the user ticks/unticks a peer in the checklist — flips that peer's per-peer
/// bypass and re-applies its shaping immediately.</summary>
private void OnPeerShapeToggled(object? sender, ItemCheckEventArgs e)
{
if (loadingPanEqControls) return;
if (panEqPeerList.Items[e.Index] is not PanEqPeerItem item) return;
GetOrCreateShaping(item.Key).Enabled = e.NewValue == CheckState.Checked;
MarkProfileDirty();
// The check state isn't committed until after this event returns, so defer the re-apply.
BeginInvoke(() => ApplyPeerShaping(item.Key));
}
// Manual first-letter navigation so a letter key never toggles the tick (workspace guidance).
private void OnPeerListKeyDown(object? sender, KeyEventArgs e)
{
if (e.Modifiers != Keys.None) return;
char c = (char)e.KeyValue;
if (!char.IsLetterOrDigit(c)) return;
int count = panEqPeerList.Items.Count;
if (count == 0) return;
int start = panEqPeerList.SelectedIndex;
for (int step = 1; step <= count; step++)
{
int i = (start + step) % count;
if (panEqPeerList.Items[i]?.ToString() is string t && t.Length > 0
&& char.ToUpperInvariant(t[0]) == char.ToUpperInvariant(c))
{
panEqPeerList.SelectedIndex = i;
break;
}
}
e.Handled = true;
e.SuppressKeyPress = true;
}
private PeerShaping? GetShaping(string? key) => key is not null && peerShaping.TryGetValue(key, out var s) ? s : null;
private PeerShaping GetOrCreateShaping(string? key)
@@ -3318,9 +3391,10 @@ public sealed class MainForm : Form
private void OnEqModeChanged()
{
if (loadingPanEqControls || selectedShapingKey is null) return;
GetOrCreateShaping(selectedShapingKey).EqMode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band;
GetOrCreateShaping(selectedShapingKey).EqMode = ModeForIndex(eqModeList.SelectedIndex);
RebuildEqBandSliders();
ApplyPeerShaping(selectedShapingKey);
UpdateEqCurve();
MarkProfileDirty();
}
@@ -3330,8 +3404,10 @@ public sealed class MainForm : Form
var s = GetOrCreateShaping(selectedShapingKey);
Array.Clear(s.SimpleBandsDb);
Array.Clear(s.AdvancedBandsDb);
s.ParametricBands.Clear(); // reset clears the 16-band parametric list too
RebuildEqBandSliders();
ApplyPeerShaping(selectedShapingKey);
UpdateEqCurve();
MarkProfileDirty();
}
@@ -3342,16 +3418,27 @@ public sealed class MainForm : Form
try
{
eqBandsPanel.SuspendLayout();
// Clear the panel. The three parametric controls are persistent members reused across
// rebuilds — remove but never dispose them; everything else (slider rows, the bands label)
// is freshly built each time and disposed here.
var persistent = new Control[] { addBandButton, deleteBandButton, parametricBandList };
while (eqBandsPanel.Controls.Count > 0)
{
var c = eqBandsPanel.Controls[0];
eqBandsPanel.Controls.RemoveAt(0);
c.Dispose();
if (Array.IndexOf(persistent, c) < 0) c.Dispose();
}
eqBandSliders.Clear();
var s = GetShaping(selectedShapingKey);
var mode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band;
var mode = ModeForIndex(eqModeList.SelectedIndex);
if (mode == PeerEqMode.Parametric16Band)
{
BuildParametricPanel();
eqBandsPanel.ResumeLayout();
return;
}
var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple;
var gains = s is null ? null : (mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb);
@@ -3387,7 +3474,7 @@ public sealed class MainForm : Form
{
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 mode = ModeForIndex(eqModeList.SelectedIndex);
var gains = mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb;
var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple;
if (i >= 0 && i < gains.Length)
@@ -3396,42 +3483,175 @@ public sealed class MainForm : Form
UpdateBandAccessibleName(slider, bands[i].Label);
}
ApplyPeerShaping(selectedShapingKey);
UpdateEqCurve();
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}";
slider.AccessibleName = $"{label}: {FormatGainDb(db)}";
}
/// <summary>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.</summary>
// dB spoken as words — most NVDA users run with punctuation off and would never hear a "+" sign,
// so a boost must say "plus". ("minus" comes through on its own, but we spell both for symmetry.)
private static string FormatGainDb(float db)
{
if (MathF.Abs(db) < 0.5f) return "flat";
return db > 0 ? $"plus {db:0} dB" : $"minus {MathF.Abs(db):0} dB";
}
/// <summary>Whether a given peer is shaped right now: the profile-wide master switch AND that peer's
/// own tick (per-peer bypass) both have to be on.</summary>
private bool ShapingActiveFor(string? key)
=> enableAllPeerShapingBox.Checked && (GetShaping(key)?.Enabled ?? true);
/// <summary>Builds one peer's DSP chain (honouring the master switch and the peer's own tick) and
/// pushes it to the receiver, so the change is heard immediately. A null chain (nothing to do)
/// clears any prior one.</summary>
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);
var addr = ResolvePeerAddress(key);
if (addr is null) return;
var chain = PeerDspChain.Build(GetShaping(key), ShapingActiveFor(key));
receiver.SetPeerDsp(addr, chain);
}
/// <summary>Pushes shaping for every currently-connected peer. Used when a master enable flips or a
/// <summary>Pushes shaping for every currently-connected peer. Used when the master switch flips or a
/// profile loads / the connected set changes.</summary>
private void ApplyAllPeerShaping()
{
var seen = new HashSet<string>();
foreach (var (_, ep) in selectedPeerEndpoints)
{
if (!seen.Add(ep.Address.ToString())) continue;
var chain = PeerDspChain.Build(GetShaping(ep.Address.ToString()), enablePanForPeersBox.Checked, enableEqForPeersBox.Checked);
var key = ep.Address.ToString();
if (!seen.Add(key)) continue;
var chain = PeerDspChain.Build(GetShaping(key), ShapingActiveFor(key));
receiver.SetPeerDsp(ep.Address, chain);
}
}
private System.Net.IPAddress? ResolvePeerAddress(string? key)
{
if (key is null) return null;
foreach (var (_, ep) in selectedPeerEndpoints)
if (ep.Address.ToString() == key) return ep.Address;
return System.Net.IPAddress.TryParse(key, out var addr) ? addr : null;
}
// === 16-band parametric EQ ===
/// <summary>Populates <see cref="eqBandsPanel"/> with the parametric controls (Add band, the band
/// list, Delete band). The three controls are persistent members reused across rebuilds.</summary>
private void BuildParametricPanel()
{
var bandsLabel = new MnemonicLabel { Text = "Bands (Alt+&B)", AutoSize = true, MnemonicTarget = parametricBandList };
eqBandsPanel.Controls.Add(addBandButton);
eqBandsPanel.Controls.Add(bandsLabel);
eqBandsPanel.Controls.Add(parametricBandList);
eqBandsPanel.Controls.Add(deleteBandButton);
RefreshParametricBandList();
}
/// <summary>Rebuilds the band list from the selected peer, sorted bass→treble, each row spelling its
/// dB in words. Also enables/disables Add (capped at 16 bands) and Delete.</summary>
private void RefreshParametricBandList()
{
loadingPanEqControls = true;
try
{
parametricBandList.BeginUpdate();
parametricBandList.Items.Clear();
var s = GetShaping(selectedShapingKey);
if (s is not null)
{
foreach (var band in s.ParametricBands.OrderBy(b => b.StartHz).ThenBy(b => b.EndHz))
parametricBandList.Items.Add(new ParametricBandItem(band));
}
parametricBandList.EndUpdate();
int count = s?.ParametricBands.Count ?? 0;
bool havePeer = selectedShapingKey is not null;
parametricBandList.Enabled = havePeer;
addBandButton.Enabled = havePeer && count < PeerEqBands.ParametricMaxBands;
deleteBandButton.Enabled = havePeer && count > 0;
}
finally { loadingPanEqControls = false; }
}
private void OnAddParametricBand()
{
if (selectedShapingKey is null) return;
var s = GetOrCreateShaping(selectedShapingKey);
if (s.ParametricBands.Count >= PeerEqBands.ParametricMaxBands) return;
// Live preview: while the dialog is open, apply the in-progress band on top of the saved bands
// so the peer's sound changes as the user moves the values; null reverts to the saved shaping.
void Preview(ParametricBand? band)
{
var saved = GetShaping(selectedShapingKey);
var addr = ResolvePeerAddress(selectedShapingKey);
if (saved is null || addr is null) return;
if (band is null) { ApplyPeerShaping(selectedShapingKey); return; }
var temp = new PeerShaping
{
Enabled = saved.Enabled,
Pan = saved.Pan,
Volume = saved.Volume,
EqMode = PeerEqMode.Parametric16Band,
ParametricBands = new List<ParametricBand>(saved.ParametricBands) { band },
};
receiver.SetPeerDsp(addr, PeerDspChain.Build(temp, ShapingActiveFor(selectedShapingKey)));
}
using var dlg = new AddBandDialog(Preview);
if (dlg.ShowDialog(this) == DialogResult.OK)
{
s.ParametricBands.Add(dlg.Result);
RefreshParametricBandList();
MarkProfileDirty();
}
ApplyPeerShaping(selectedShapingKey); // settle on the real saved shaping either way
UpdateEqCurve();
}
private void OnDeleteParametricBands()
{
if (selectedShapingKey is null) return;
var s = GetShaping(selectedShapingKey);
if (s is null || parametricBandList.SelectedItems.Count == 0) return;
int firstIdx = parametricBandList.SelectedIndex;
var toRemove = parametricBandList.SelectedItems.Cast<ParametricBandItem>().Select(x => x.Band).ToList();
foreach (var band in toRemove) s.ParametricBands.Remove(band);
RefreshParametricBandList();
// Put focus on whatever now occupies the first removed slot so NVDA announces it.
if (parametricBandList.Items.Count > 0)
parametricBandList.SelectedIndex = Math.Clamp(firstIdx, 0, parametricBandList.Items.Count - 1);
ApplyPeerShaping(selectedShapingKey);
UpdateEqCurve();
MarkProfileDirty();
}
private void OnParametricBandListKeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Delete)
{
OnDeleteParametricBands();
e.Handled = true;
e.SuppressKeyPress = true;
}
}
private void UpdateEqCurve() => eqCurve.SetResponse(GetShaping(selectedShapingKey));
// One list row per parametric band, e.g. "200 Hz to 2000 Hz, plus 3 dB". Holds the band by
// reference so Delete can remove the exact object.
private sealed class ParametricBandItem(ParametricBand band)
{
public ParametricBand Band { get; } = band;
public override string ToString() => $"{Band.StartHz:0} Hz to {Band.EndHz:0} Hz, {FormatGainDb(Band.GainDb)}";
}
/// <summary>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-
@@ -6666,7 +6886,8 @@ public sealed class MainForm : Form
// 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; }
// Single master switch now. Migrate older profiles: either legacy flag being on turns it on.
try { enableAllPeerShapingBox.Checked = p.EnableAllPeerShaping || p.EnablePanForPeers || p.EnableEqForPeers; }
finally { loadingPanEqControls = false; }
lastPanEqPeerSignature = "";
}
@@ -6845,8 +7066,7 @@ 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.EnableAllPeerShaping = enableAllPeerShapingBox.Checked;
profile.PeerShaping = peerShaping;
return profile;
}
@@ -7671,6 +7891,7 @@ public sealed class MainForm : Form
{
sendMyAudioCheckbox.AccessibleDescription = DescribeHotkey(hotkeyController.SendMuteHotkey);
receiveAudioCheckbox.AccessibleDescription = DescribeHotkey(hotkeyController.ReceiveMuteHotkey);
enableAllPeerShapingBox.AccessibleDescription = DescribeHotkey(hotkeyController.ToggleAllPeerShapingHotkey);
if (startStopRecordingMenuItem is not null)
{
startStopRecordingMenuItem.AccessibleDescription = DescribeHotkey(hotkeyController.ToggleRecordingHotkey);
+29 -1
View File
@@ -35,6 +35,9 @@ internal sealed class MainFormHotkeyController : IDisposable
// specific (issue #13); unset by default. Global so it reads the status even when RemSound isn't
// focused — the case NVDA can't otherwise cover.
private readonly Action speakStatusLine;
// Toggle the "Enable volume, pan and EQ for all peers" master switch from anywhere. Unset by
// default; global so it works even when RemSound isn't focused.
private readonly Action toggleAllPeerShaping;
private Form? owner;
private HotkeyInfo sendMuteHotkey;
private HotkeyInfo receiveMuteHotkey;
@@ -50,6 +53,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private HotkeyInfo systemMuteToggleHotkey;
private HotkeyInfo quickProfileSwitchHotkey;
private HotkeyInfo speakStatusLineHotkey;
private HotkeyInfo toggleAllPeerShapingHotkey;
private GlobalHotkey? sendMuteGlobalHotkey;
private GlobalHotkey? receiveMuteGlobalHotkey;
private GlobalHotkey? trayGlobalHotkey;
@@ -64,6 +68,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private GlobalHotkey? systemMuteToggleGlobalHotkey;
private GlobalHotkey? quickProfileSwitchGlobalHotkey;
private GlobalHotkey? speakStatusLineGlobalHotkey;
private GlobalHotkey? toggleAllPeerShapingGlobalHotkey;
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> so each
/// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X
@@ -94,7 +99,8 @@ internal sealed class MainFormHotkeyController : IDisposable
Action sendSystemVolumeDown,
Action sendSystemMuteToggle,
Action quickProfileSwitch,
Action speakStatusLine)
Action speakStatusLine,
Action toggleAllPeerShaping)
{
this.settingsStore = settingsStore;
this.toggleSend = toggleSend;
@@ -111,6 +117,7 @@ internal sealed class MainFormHotkeyController : IDisposable
this.sendSystemMuteToggle = sendSystemMuteToggle;
this.quickProfileSwitch = quickProfileSwitch;
this.speakStatusLine = speakStatusLine;
this.toggleAllPeerShaping = toggleAllPeerShaping;
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
trayHotkey = settingsStore.LoadTrayHotkey();
@@ -125,6 +132,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey();
speakStatusLineHotkey = settingsStore.LoadSpeakStatusLineHotkey();
toggleAllPeerShapingHotkey = settingsStore.LoadToggleAllPeerShapingHotkey();
}
public void Initialize(Form ownerForm)
@@ -144,6 +152,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
quickProfileSwitchGlobalHotkey = new GlobalHotkey(ownerForm);
speakStatusLineGlobalHotkey = new GlobalHotkey(ownerForm);
toggleAllPeerShapingGlobalHotkey = new GlobalHotkey(ownerForm);
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
@@ -158,6 +167,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
quickProfileSwitchGlobalHotkey.Pressed += () => InvokeOnOwner(quickProfileSwitch);
speakStatusLineGlobalHotkey.Pressed += () => InvokeOnOwner(speakStatusLine);
toggleAllPeerShapingGlobalHotkey.Pressed += () => InvokeOnOwner(toggleAllPeerShaping);
RegisterSendMuteHotkey();
RegisterReceiveMuteHotkey();
RegisterTrayHotkey();
@@ -172,6 +182,7 @@ internal sealed class MainFormHotkeyController : IDisposable
RegisterSystemMuteToggleHotkey();
RegisterQuickProfileSwitchHotkey();
RegisterSpeakStatusLineHotkey();
RegisterToggleAllPeerShapingHotkey();
}
/// <summary>Re-read every hotkey from the (now machine-wide) settings store and re-register it.
@@ -193,6 +204,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey();
speakStatusLineHotkey = settingsStore.LoadSpeakStatusLineHotkey();
toggleAllPeerShapingHotkey = settingsStore.LoadToggleAllPeerShapingHotkey();
RegisterSendMuteHotkey();
RegisterReceiveMuteHotkey();
RegisterTrayHotkey();
@@ -207,6 +219,7 @@ internal sealed class MainFormHotkeyController : IDisposable
RegisterSystemMuteToggleHotkey();
RegisterQuickProfileSwitchHotkey();
RegisterSpeakStatusLineHotkey();
RegisterToggleAllPeerShapingHotkey();
}
public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner)
@@ -323,6 +336,7 @@ internal sealed class MainFormHotkeyController : IDisposable
list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}");
list.Items.Add($"Quick profile switch (open a list of all profiles): {quickProfileSwitchHotkey}");
list.Items.Add($"Speak the RemSound status information from anywhere (screen reader only): {speakStatusLineHotkey}");
list.Items.Add($"Toggle volume, pan and EQ for all peers: {toggleAllPeerShapingHotkey}");
if (prev >= 0 && prev < list.Items.Count)
{
list.SelectedIndex = prev;
@@ -361,6 +375,7 @@ internal sealed class MainFormHotkeyController : IDisposable
case 11: ChangeSystemMuteToggleHotkey(dialog); break;
case 12: ChangeQuickProfileSwitchHotkey(dialog); break;
case 13: ChangeSpeakStatusLineHotkey(dialog); break;
case 14: ChangeToggleAllPeerShapingHotkey(dialog); break;
default: return;
}
RefreshList();
@@ -394,6 +409,7 @@ internal sealed class MainFormHotkeyController : IDisposable
case 11: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break;
case 12: ApplyUnset("quick-profile-switch", h => quickProfileSwitchHotkey = h, RegisterQuickProfileSwitchHotkey, settingsStore.SaveQuickProfileSwitchHotkey); break;
case 13: ApplyUnset("speak-status-line", h => speakStatusLineHotkey = h, RegisterSpeakStatusLineHotkey, settingsStore.SaveSpeakStatusLineHotkey); break;
case 14: ApplyUnset("toggle-all-peer-shaping", h => toggleAllPeerShapingHotkey = h, RegisterToggleAllPeerShapingHotkey, settingsStore.SaveToggleAllPeerShapingHotkey); break;
default: return;
}
RefreshList();
@@ -475,6 +491,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemMuteToggleGlobalHotkey?.Dispose();
quickProfileSwitchGlobalHotkey?.Dispose();
speakStatusLineGlobalHotkey?.Dispose();
toggleAllPeerShapingGlobalHotkey?.Dispose();
}
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
@@ -491,6 +508,7 @@ internal sealed class MainFormHotkeyController : IDisposable
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
public HotkeyInfo QuickProfileSwitchHotkey => quickProfileSwitchHotkey;
public HotkeyInfo SpeakStatusLineHotkey => speakStatusLineHotkey;
public HotkeyInfo ToggleAllPeerShapingHotkey => toggleAllPeerShapingHotkey;
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
@@ -636,6 +654,13 @@ internal sealed class MainFormHotkeyController : IDisposable
settingsStore.SaveSpeakStatusLineHotkey(h);
});
private void ChangeToggleAllPeerShapingHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "toggle-all-peer-shaping", h =>
{
toggleAllPeerShapingHotkey = h;
RegisterToggleAllPeerShapingHotkey();
settingsStore.SaveToggleAllPeerShapingHotkey(h);
});
// Hotkeys come in two flavours and need different Windows-side registration:
// * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back
// and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire.
@@ -667,6 +692,9 @@ internal sealed class MainFormHotkeyController : IDisposable
// Speak status line is a one-shot (press → read the status aloud once); MOD_NOREPEAT (the default)
// keeps a held key from re-triggering the speech over and over.
private void RegisterSpeakStatusLineHotkey() => RegisterIfSet(speakStatusLineGlobalHotkey, speakStatusLineHotkey, "speak status line");
// Toggle all-peer shaping is a one-shot toggle; MOD_NOREPEAT (the default) stops a held key
// flipping the master switch on/off/on at auto-repeat rate.
private void RegisterToggleAllPeerShapingHotkey() => RegisterIfSet(toggleAllPeerShapingGlobalHotkey, toggleAllPeerShapingHotkey, "toggle all-peer shaping");
private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false)
{
+2 -2
View File
@@ -273,8 +273,8 @@ internal sealed class PreferencesDialog : Form
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",
Text = "Show the volume, pan and E&Q for peers tab (Alt+Q)",
AccessibleName = "Show the volume, pan and EQ for peers tab",
AutoSize = true,
};