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:
co-authored by
Claude Opus 4.8
parent
eaae76d015
commit
033edd776f
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user