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
+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));
}