Per-peer pan + EQ ("Pan and EQ" tab) — off by default, held for next release
New feature (Ed's jam-mixing request): pan and EQ each peer's signal independently. Engine (zero added latency — per-sample, applied to each peer's isolated block just before the mix): PeerDspChain (balance pan + RBJ biquad EQ) built on the UI thread and swapped onto SessionPlayout via a volatile reference; PlayoutEngine remembers it per address so a reconnecting peer keeps its shaping; AudioReceiver.SetPeerDsp facade. Model: per-profile PeerShaping dict (keyed by peer address) + EnablePan/EnableEqForPeers master switches; machine-wide AppConfig.ShowPanEqTab. Fixed band layouts in PeerEqBands (3-band tone control: bass/mids/treble shelves+bell; 10-band ISO graphic EQ), +/-12 dB. UI: a "Pan and EQ" tab (before Audio profile, shown only when ShowPanEqTab is on) with the two enables, a connected-peer picker, a pan slider (balance, keeps stereo), a "reset EQ" button (clears both modes' bands, leaves pan), a 3/10-band mode picker and its band sliders — all TrackBars (arrow + page-up/down), updating in real time and saved per profile. Sliders set a friendly AccessibleName on change (pan centre/left/right %, band dB). "Show the Pan and EQ tab" checkbox added to Preferences > General. Everything is off by default (tab hidden, both enables off), so this is dormant for all users until switched on. Builds clean. Pending Ed's hands-on testing before release. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d7f6d9fd9d
commit
1c1bf5a5cb
@@ -151,6 +151,28 @@ public sealed class MainForm : Form
|
||||
private readonly TabPage connectivityTabPage = new("Connectivity");
|
||||
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" };
|
||||
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 FlowLayoutPanel eqBandsPanel = new() { FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false, Margin = new Padding(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();
|
||||
// Address string of the peer currently selected in panEqPeerList (the one the controls edit).
|
||||
private string? selectedShapingKey;
|
||||
// The band-gain sliders currently shown; rebuilt when the mode or selected peer changes.
|
||||
private readonly List<TrackBar> eqBandSliders = new();
|
||||
// True while we're pushing a peer's saved values INTO the controls, so those programmatic changes
|
||||
// don't fire the apply/dirty handlers back at us.
|
||||
private bool loadingPanEqControls;
|
||||
// Signature of the peer list last rendered, so the 1 Hz refresh only rebuilds it on a real change.
|
||||
private string lastPanEqPeerSignature = "";
|
||||
// profilesPrefsTabPage retired 2026-05-08 — its contents now live on the File menu.
|
||||
|
||||
// connectivityTransportButton + ShowConnectivityTransportDialog removed in Phase 2/3 of
|
||||
@@ -1679,10 +1701,13 @@ public sealed class MainForm : Form
|
||||
BuildConnectivityTab();
|
||||
BuildAudioIOTab();
|
||||
BuildAudioProfileTab();
|
||||
BuildPanEqTab();
|
||||
|
||||
mainTabControl.TabPages.Add(connectivityTabPage);
|
||||
mainTabControl.TabPages.Add(audioIOTabPage);
|
||||
mainTabControl.TabPages.Add(audioProfileTabPage);
|
||||
// Insert the optional Pan-and-EQ tab (just before Audio profile) if the preference is on.
|
||||
RefreshPanEqTabVisibility();
|
||||
// No SelectedIndexChanged handler. No focus management on tab change. Andre's
|
||||
// accessible app does ZERO event hooking on TabControl — relies entirely on
|
||||
// default WinForms + NVDA behaviour. Per Ed's repeated request: arrow keys cycle
|
||||
@@ -2515,6 +2540,8 @@ public sealed class MainForm : Form
|
||||
unsubscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged -= handler);
|
||||
dialog.ShowDialog(this);
|
||||
if (dialog.ChangedAnyProfileSetting) MarkProfileDirty();
|
||||
// The "Show pan and EQ tab" preference may have been toggled — add/remove the tab to match.
|
||||
RefreshPanEqTabVisibility();
|
||||
// The Preferences dialog includes per-cue Browse buttons that can change custom
|
||||
// WAV paths in AppConfig.CustomCuePaths. Reload the cached SoundPlayer instances
|
||||
// here unconditionally — cheap, only six small files, and guarantees the next
|
||||
@@ -3096,6 +3123,274 @@ public sealed class MainForm : Form
|
||||
audioProfileTabPage.Controls.Add(outerPanel);
|
||||
}
|
||||
|
||||
/// <summary>An item in the Pan-and-EQ peer picker. Keyed by peer address string (the same key the
|
||||
/// per-profile <see cref="PeerShaping"/> dictionary uses, and what the receiver routes DSP by).</summary>
|
||||
private sealed class PanEqPeerItem(string label, System.Net.IPAddress address, string key)
|
||||
{
|
||||
public string Label { get; } = label;
|
||||
public System.Net.IPAddress Address { get; } = address;
|
||||
public string Key { get; } = key;
|
||||
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>
|
||||
private void BuildPanEqTab()
|
||||
{
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 1, RowCount = 8, AutoScroll = true };
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
|
||||
enableEqForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
|
||||
enablePanForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
|
||||
panEqPeerList.SelectedIndexChanged += (_, _) => OnPanEqPeerSelected();
|
||||
panSlider.ValueChanged += (_, _) => OnPanChanged();
|
||||
resetPeerEqButton.Click += (_, _) => OnResetPeerEq();
|
||||
eqModeList.Items.Add("3 band basic EQ");
|
||||
eqModeList.Items.Add("10 band advanced EQ");
|
||||
eqModeList.SelectedIndexChanged += (_, _) => OnEqModeChanged();
|
||||
|
||||
var peerLabel = new MnemonicLabel { Text = "Peer to shape (Alt+&U)", AutoSize = true, MnemonicTarget = panEqPeerList };
|
||||
var panLabel = new MnemonicLabel { Text = "Pa&n (Alt+N)", AutoSize = true, MnemonicTarget = panSlider };
|
||||
var modeLabel = new MnemonicLabel { Text = "EQ &mode (Alt+M)", AutoSize = true, MnemonicTarget = eqModeList };
|
||||
|
||||
var panRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) };
|
||||
panRow.Controls.Add(panLabel);
|
||||
panRow.Controls.Add(panSlider);
|
||||
var modeRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) };
|
||||
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(panRow, 0, 4);
|
||||
panel.Controls.Add(resetPeerEqButton, 0, 5);
|
||||
panel.Controls.Add(modeRow, 0, 6);
|
||||
panel.Controls.Add(eqBandsPanel, 0, 7);
|
||||
panEqTabPage.Controls.Add(panel);
|
||||
|
||||
RefreshPanEqPeerList();
|
||||
OnPanEqPeerSelected();
|
||||
}
|
||||
|
||||
/// <summary>Adds (or removes) the Pan-and-EQ tab to match the machine-wide "Show pan and EQ tab"
|
||||
/// preference. Placed just before the Audio profile tab. Called at build and after Preferences close.</summary>
|
||||
private void RefreshPanEqTabVisibility()
|
||||
{
|
||||
bool show = AppConfig.Load().ShowPanEqTab;
|
||||
bool present = mainTabControl.TabPages.Contains(panEqTabPage);
|
||||
if (show && !present)
|
||||
{
|
||||
int idx = mainTabControl.TabPages.IndexOf(audioProfileTabPage);
|
||||
if (idx < 0) idx = mainTabControl.TabPages.Count;
|
||||
mainTabControl.TabPages.Insert(idx, panEqTabPage);
|
||||
}
|
||||
else if (!show && present)
|
||||
{
|
||||
mainTabControl.TabPages.Remove(panEqTabPage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the peer picker from the currently-connected peers, one row per address. Runs
|
||||
/// each tick but only rebuilds on a real change (so NVDA focus survives). On a change it also
|
||||
/// re-pushes every connected peer's shaping, so a freshly-connected peer picks up its saved pan/EQ.</summary>
|
||||
private void RefreshPanEqPeerList()
|
||||
{
|
||||
var desired = new List<PanEqPeerItem>();
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var (id, ep) in selectedPeerEndpoints)
|
||||
{
|
||||
var key = ep.Address.ToString();
|
||||
if (!seen.Add(key)) continue;
|
||||
var label = selectedPeerLabels.GetValueOrDefault(id, key);
|
||||
desired.Add(new PanEqPeerItem(label, ep.Address, key));
|
||||
}
|
||||
desired = desired.OrderBy(d => d.Label).ThenBy(d => d.Key).ToList();
|
||||
var signature = string.Join("|", desired.Select(d => d.Key + "=" + d.Label));
|
||||
if (signature == lastPanEqPeerSignature) return;
|
||||
lastPanEqPeerSignature = signature;
|
||||
|
||||
var prevKey = (panEqPeerList.SelectedItem as PanEqPeerItem)?.Key ?? selectedShapingKey;
|
||||
panEqPeerList.BeginUpdate();
|
||||
panEqPeerList.Items.Clear();
|
||||
int idx = -1;
|
||||
foreach (var d in desired)
|
||||
{
|
||||
int i = panEqPeerList.Items.Add(d);
|
||||
if (d.Key == prevKey) idx = i;
|
||||
}
|
||||
if (idx < 0 && panEqPeerList.Items.Count > 0) idx = 0;
|
||||
if (idx >= 0) panEqPeerList.SelectedIndex = idx;
|
||||
panEqPeerList.EndUpdate();
|
||||
|
||||
ApplyAllPeerShaping();
|
||||
}
|
||||
|
||||
/// <summary>Loads the peer selected in the picker into the pan / mode / band controls.</summary>
|
||||
private void OnPanEqPeerSelected()
|
||||
{
|
||||
selectedShapingKey = (panEqPeerList.SelectedItem as PanEqPeerItem)?.Key;
|
||||
bool enabled = selectedShapingKey is not null;
|
||||
loadingPanEqControls = true;
|
||||
try
|
||||
{
|
||||
var s = GetOrCreateShaping(selectedShapingKey);
|
||||
panSlider.Value = Math.Clamp((int)Math.Round(s.Pan * 50f) + 50, 0, 100);
|
||||
UpdatePanAccessibleName();
|
||||
eqModeList.SelectedIndex = s.EqMode == PeerEqMode.Advanced10Band ? 1 : 0;
|
||||
RebuildEqBandSliders();
|
||||
panSlider.Enabled = enabled;
|
||||
resetPeerEqButton.Enabled = enabled;
|
||||
eqModeList.Enabled = enabled;
|
||||
}
|
||||
finally { loadingPanEqControls = false; }
|
||||
}
|
||||
|
||||
private PeerShaping? GetShaping(string? key) => key is not null && peerShaping.TryGetValue(key, out var s) ? s : null;
|
||||
|
||||
private PeerShaping GetOrCreateShaping(string? key)
|
||||
{
|
||||
if (key is null) return new PeerShaping();
|
||||
if (!peerShaping.TryGetValue(key, out var s)) { s = new PeerShaping(); peerShaping[key] = s; }
|
||||
return s;
|
||||
}
|
||||
|
||||
private void OnPanChanged()
|
||||
{
|
||||
if (loadingPanEqControls || selectedShapingKey is null) return;
|
||||
GetOrCreateShaping(selectedShapingKey).Pan = Math.Clamp((panSlider.Value - 50) / 50f, -1f, 1f);
|
||||
UpdatePanAccessibleName();
|
||||
ApplyPeerShaping(selectedShapingKey);
|
||||
MarkProfileDirty();
|
||||
}
|
||||
|
||||
private void UpdatePanAccessibleName()
|
||||
{
|
||||
int v = panSlider.Value;
|
||||
string desc = v == 50 ? "centre" : v < 50 ? $"{(50 - v) * 2} percent left" : $"{(v - 50) * 2} percent right";
|
||||
panSlider.AccessibleName = $"Pan: {desc}";
|
||||
}
|
||||
|
||||
private void OnEqModeChanged()
|
||||
{
|
||||
if (loadingPanEqControls || selectedShapingKey is null) return;
|
||||
GetOrCreateShaping(selectedShapingKey).EqMode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band;
|
||||
RebuildEqBandSliders();
|
||||
ApplyPeerShaping(selectedShapingKey);
|
||||
MarkProfileDirty();
|
||||
}
|
||||
|
||||
private void OnResetPeerEq()
|
||||
{
|
||||
if (selectedShapingKey is null) return;
|
||||
var s = GetOrCreateShaping(selectedShapingKey);
|
||||
Array.Clear(s.SimpleBandsDb);
|
||||
Array.Clear(s.AdvancedBandsDb);
|
||||
RebuildEqBandSliders();
|
||||
ApplyPeerShaping(selectedShapingKey);
|
||||
MarkProfileDirty();
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the band sliders for the current EQ mode + selected peer, loading their gains.</summary>
|
||||
private void RebuildEqBandSliders()
|
||||
{
|
||||
loadingPanEqControls = true;
|
||||
try
|
||||
{
|
||||
eqBandsPanel.SuspendLayout();
|
||||
while (eqBandsPanel.Controls.Count > 0)
|
||||
{
|
||||
var c = eqBandsPanel.Controls[0];
|
||||
eqBandsPanel.Controls.RemoveAt(0);
|
||||
c.Dispose();
|
||||
}
|
||||
eqBandSliders.Clear();
|
||||
|
||||
var s = GetShaping(selectedShapingKey);
|
||||
var mode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band;
|
||||
var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple;
|
||||
var gains = s is null ? null : (mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb);
|
||||
|
||||
for (int i = 0; i < bands.Length; i++)
|
||||
{
|
||||
float gainDb = gains is not null && i < gains.Length ? gains[i] : 0f;
|
||||
var slider = new TrackBar
|
||||
{
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
Value = Math.Clamp((int)Math.Round(gainDb / PeerEqBands.MaxGainDb * 50f) + 50, 0, 100),
|
||||
SmallChange = 1,
|
||||
LargeChange = 10,
|
||||
TickFrequency = 25,
|
||||
Width = 300,
|
||||
Tag = i,
|
||||
Enabled = selectedShapingKey is not null,
|
||||
};
|
||||
UpdateBandAccessibleName(slider, bands[i].Label);
|
||||
slider.ValueChanged += (_, _) => OnBandChanged(slider);
|
||||
var row = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) };
|
||||
row.Controls.Add(new MnemonicLabel { Text = bands[i].Label, AutoSize = true, MnemonicTarget = slider });
|
||||
row.Controls.Add(slider);
|
||||
eqBandsPanel.Controls.Add(row);
|
||||
eqBandSliders.Add(slider);
|
||||
}
|
||||
eqBandsPanel.ResumeLayout();
|
||||
}
|
||||
finally { loadingPanEqControls = false; }
|
||||
}
|
||||
|
||||
private void OnBandChanged(TrackBar slider)
|
||||
{
|
||||
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 gains = mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb;
|
||||
var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple;
|
||||
if (i >= 0 && i < gains.Length)
|
||||
{
|
||||
gains[i] = (slider.Value - 50) / 50f * PeerEqBands.MaxGainDb;
|
||||
UpdateBandAccessibleName(slider, bands[i].Label);
|
||||
}
|
||||
ApplyPeerShaping(selectedShapingKey);
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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);
|
||||
receiver.SetPeerDsp(addr, chain);
|
||||
}
|
||||
|
||||
/// <summary>Pushes shaping for every currently-connected peer. Used when a master enable 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);
|
||||
receiver.SetPeerDsp(ep.Address, chain);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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-
|
||||
@@ -3360,6 +3655,7 @@ public sealed class MainForm : Form
|
||||
SyncConnectedList();
|
||||
SyncDiscoveredList();
|
||||
SyncRememberedList();
|
||||
RefreshPanEqPeerList();
|
||||
RefreshStatusReadout();
|
||||
}
|
||||
|
||||
@@ -6310,6 +6606,14 @@ public sealed class MainForm : Form
|
||||
// exactly as if the user had typed it into the manual-peer field. Discovered peers
|
||||
// (no longer reachable / different IP) just fail gracefully — no popup.
|
||||
ReconnectSavedPeers(p.SelectedConnectedPeers);
|
||||
// Per-peer pan/EQ: adopt this profile's saved shaping + the two master enables. The peer
|
||||
// picker and the DSP re-apply on the next tick (once the peers above have reconnected);
|
||||
// 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; }
|
||||
finally { loadingPanEqControls = false; }
|
||||
lastPanEqPeerSignature = "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -6486,6 +6790,9 @@ 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.PeerShaping = peerShaping;
|
||||
return profile;
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,13 @@ internal sealed class PreferencesDialog : Form
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
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",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly Label upnpStatusLabel = new()
|
||||
{
|
||||
Text = "",
|
||||
@@ -565,6 +572,7 @@ internal sealed class PreferencesDialog : Form
|
||||
updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency;
|
||||
silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates;
|
||||
upnpEnabledBox.Checked = cfgForLoad.UpnpEnabled;
|
||||
showPanEqTabBox.Checked = cfgForLoad.ShowPanEqTab;
|
||||
|
||||
checkForUpdatesOnStartupBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
@@ -605,6 +613,13 @@ internal sealed class PreferencesDialog : Form
|
||||
RefreshUpnpStatusLabel();
|
||||
};
|
||||
|
||||
showPanEqTabBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.ShowPanEqTab = showPanEqTabBox.Checked;
|
||||
try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ }
|
||||
};
|
||||
|
||||
// Live UPnP status — the RouterPortMapper raises StatusChanged from a thread-pool
|
||||
// thread, so marshal back onto the UI thread before touching the label. Subscribe
|
||||
// on show and unsubscribe on close to avoid leaking the handler past the dialog.
|
||||
@@ -798,7 +813,7 @@ internal sealed class PreferencesDialog : Form
|
||||
// is a field (declared above) so OnShown can focus it when the dialog opens. Logging is its
|
||||
// own tab (2026-06-19); the two logging controls moved off the General tab to lead it.
|
||||
tabs.TabPages.Add(MakeTab("General",
|
||||
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel));
|
||||
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, showPanEqTabBox));
|
||||
tabs.TabPages.Add(MakeTab("Audio cues", cueGroup));
|
||||
tabs.TabPages.Add(MakeTab("Startup behaviour",
|
||||
startMinimisedBox, startWithUserBox, startWithProfileBox, startupListPanel));
|
||||
|
||||
@@ -68,6 +68,12 @@ public sealed class AppConfig
|
||||
/// already running quietly". Default false.</summary>
|
||||
public bool StartMinimised { get; set; }
|
||||
|
||||
/// <summary>If true, the main window shows a "Pan and EQ" tab (positioned before "Audio profile")
|
||||
/// for setting per-peer panning and EQ. Off by default; toggled by "Show pan and EQ tab" on the
|
||||
/// Preferences General tab. Machine-wide (a UI-visibility preference, like which tabs exist) — the
|
||||
/// pan/EQ VALUES are saved per profile, in <see cref="Profile.PeerShaping"/>.</summary>
|
||||
public bool ShowPanEqTab { get; set; }
|
||||
|
||||
/// <summary>If true (the default), RemSound plays the startup cue once, right after this
|
||||
/// copy wins the single-instance takeover and before the profile loads. Machine-wide (not
|
||||
/// per-<see cref="Profile"/>) because it fires before any profile — and its per-profile
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>Which EQ the user is shaping a peer with. The two modes are INDEPENDENT: switching
|
||||
/// between them keeps each mode's own band gains — nothing is carried over. The active mode is the
|
||||
/// one that's applied to the sound.</summary>
|
||||
public enum PeerEqMode
|
||||
{
|
||||
Simple3Band = 0,
|
||||
Advanced10Band = 1,
|
||||
}
|
||||
|
||||
/// <summary>Per-peer pan + EQ, saved per profile (see <see cref="Profile.PeerShaping"/>). Keyed in
|
||||
/// the profile by the same peer-entry string as <see cref="Profile.SelectedConnectedPeers"/>.</summary>
|
||||
public sealed class PeerShaping
|
||||
{
|
||||
/// <summary>-1 (full left) .. 0 (centre) .. +1 (full right).</summary>
|
||||
public float Pan { get; set; }
|
||||
|
||||
/// <summary>Which EQ mode is currently active for this peer.</summary>
|
||||
public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band;
|
||||
|
||||
/// <summary>Gains in dB (-12..+12) for the 3 simple bands, in the order of <see cref="PeerEqBands.Simple"/>
|
||||
/// (bass, mids, treble). Kept independently of the advanced bands.</summary>
|
||||
public float[] SimpleBandsDb { get; set; } = new float[3];
|
||||
|
||||
/// <summary>Gains in dB (-12..+12) for the 10 advanced graphic-EQ bands, in the order of
|
||||
/// <see cref="PeerEqBands.Advanced"/>. Kept independently of the simple bands.</summary>
|
||||
public float[] AdvancedBandsDb { get; set; } = new float[10];
|
||||
}
|
||||
|
||||
/// <summary>The fixed EQ band layouts. The user sets each band's GAIN; the centre frequencies are
|
||||
/// fixed so the controls stay simple and predictable. Shared by the DSP (which builds the filters)
|
||||
/// and the UI (which labels the sliders), so the two can never drift apart.</summary>
|
||||
public static class PeerEqBands
|
||||
{
|
||||
/// <summary>RemSound's internal mix sample rate. All received audio is resampled to this stereo
|
||||
/// float mix before per-peer shaping, so filter coefficients are computed against it.</summary>
|
||||
public const int MixSampleRate = 48000;
|
||||
|
||||
/// <summary>Maximum boost/cut for any band, in dB. Sliders run -12..+12.</summary>
|
||||
public const float MaxGainDb = 12f;
|
||||
|
||||
/// <summary>3-band "tone control": bass (low shelf), mids (peaking), treble (high shelf).</summary>
|
||||
public static readonly (string Label, double Freq)[] Simple =
|
||||
[
|
||||
("Bass", 100.0),
|
||||
("Mids", 1000.0),
|
||||
("Treble", 8000.0),
|
||||
];
|
||||
|
||||
/// <summary>10-band graphic EQ on ISO octave centres (all peaking).</summary>
|
||||
public static readonly (string Label, double Freq)[] Advanced =
|
||||
[
|
||||
("31 Hz", 31.0),
|
||||
("63 Hz", 63.0),
|
||||
("125 Hz", 125.0),
|
||||
("250 Hz", 250.0),
|
||||
("500 Hz", 500.0),
|
||||
("1 kHz", 1000.0),
|
||||
("2 kHz", 2000.0),
|
||||
("4 kHz", 4000.0),
|
||||
("8 kHz", 8000.0),
|
||||
("16 kHz", 16000.0),
|
||||
];
|
||||
}
|
||||
@@ -183,6 +183,19 @@ public sealed class Profile
|
||||
/// (the connection simply waits or drops rather than moving). Off by default. (#17)</summary>
|
||||
public bool LockPeerAddresses { get; set; }
|
||||
|
||||
// === Per-peer pan and EQ (jam mixing) ===
|
||||
/// <summary>Master switch for this profile: apply each connected peer's saved PAN. Off by default.
|
||||
/// The pan values themselves live in <see cref="PeerShaping"/>.</summary>
|
||||
public bool EnablePanForPeers { get; set; }
|
||||
|
||||
/// <summary>Master switch for this profile: apply each connected peer's saved EQ. Off by default.</summary>
|
||||
public bool EnableEqForPeers { get; set; }
|
||||
|
||||
/// <summary>Per-peer pan + EQ, keyed by the SAME peer-entry string used in
|
||||
/// <see cref="SelectedConnectedPeers"/> (user text / discovery label / address:port). A peer with
|
||||
/// no entry gets centre pan and flat EQ. Empty on a fresh profile.</summary>
|
||||
public Dictionary<string, PeerShaping> PeerShaping { get; set; } = new();
|
||||
|
||||
// === Hotkeys ===
|
||||
public HotkeyRecord? ReceiveMuteHotkey { get; set; }
|
||||
public HotkeyRecord? SendMuteHotkey { get; set; }
|
||||
|
||||
@@ -193,6 +193,11 @@ public sealed class AudioReceiver : IDisposable
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact artifact) =>
|
||||
playoutEngine.SetConcealmentArtifact(artifact);
|
||||
|
||||
/// <summary>Sets (or clears with null) the per-peer pan+EQ chain for the given peer address.
|
||||
/// Applies to that peer's current and future sessions. Called from the UI thread.</summary>
|
||||
public void SetPeerDsp(IPAddress address, PeerDspChain? chain) =>
|
||||
playoutEngine.SetPeerDsp(address, chain);
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked when the engine produces fully-processed mixed received
|
||||
/// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using NAudio.Dsp;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Per-peer pan + EQ, applied to that one peer's decoded stereo block just before it is summed into
|
||||
/// the mix (see <see cref="SessionPlayout"/>.ReadFloats). Immutable once built: when the user changes
|
||||
/// a setting the UI thread builds a fresh <see cref="PeerDspChain"/> and swaps the reference on the
|
||||
/// SessionPlayout in a single assignment; the audio thread reads that reference once per block. No
|
||||
/// locks and no allocation on the audio thread — the same build-new-and-swap idiom RemSound already
|
||||
/// uses for its other audio-thread parameters. Every operation is per-sample (a balance-style pan
|
||||
/// plus RBJ biquad IIR EQ), so it adds ZERO buffering latency to the mix — only CPU, and very little.
|
||||
/// </summary>
|
||||
public sealed class PeerDspChain
|
||||
{
|
||||
private readonly float panL;
|
||||
private readonly float panR;
|
||||
private readonly bool hasPan;
|
||||
// Same coefficients on both channels, but each needs its own filter instance because a biquad
|
||||
// carries per-channel state. left.Length == right.Length always.
|
||||
private readonly BiQuadFilter[] left;
|
||||
private readonly BiQuadFilter[] right;
|
||||
|
||||
private PeerDspChain(float panL, float panR, bool hasPan, BiQuadFilter[] left, BiQuadFilter[] right)
|
||||
{
|
||||
this.panL = panL;
|
||||
this.panR = panR;
|
||||
this.hasPan = hasPan;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
}
|
||||
|
||||
/// <summary>True when this chain would do nothing (pan off/centre and EQ off/flat). Build returns
|
||||
/// null in that case so an unshaped peer's <c>dsp</c> reference is null and it pays nothing.</summary>
|
||||
public bool IsNoOp => !hasPan && left.Length == 0;
|
||||
|
||||
/// <summary>Builds a chain for one peer from its saved shaping and the profile's two master
|
||||
/// switches. Returns null if there's nothing to do — pan disabled or centred, and EQ disabled or
|
||||
/// completely flat. Runs on the UI thread; the result is swapped onto the audio thread atomically.</summary>
|
||||
public static PeerDspChain? Build(PeerShaping? shaping, bool applyPan, bool applyEq)
|
||||
{
|
||||
// Pan is a balance control: it keeps the peer's stereo image (never sums to mono). Centre is
|
||||
// unity on both sides; panning toward one side attenuates the OPPOSITE channel, reaching zero
|
||||
// at the extreme. So a stereo signal just leans left or right rather than collapsing.
|
||||
float pan = shaping is null ? 0f : Math.Clamp(shaping.Pan, -1f, 1f);
|
||||
bool hasPan = applyPan && pan != 0f;
|
||||
float panL = pan > 0f ? 1f - pan : 1f;
|
||||
float panR = pan < 0f ? 1f + pan : 1f;
|
||||
|
||||
var l = new List<BiQuadFilter>();
|
||||
var r = new List<BiQuadFilter>();
|
||||
if (applyEq && shaping is not null)
|
||||
{
|
||||
var mode = shaping.EqMode;
|
||||
var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple;
|
||||
var gains = mode == PeerEqMode.Advanced10Band ? shaping.AdvancedBandsDb : shaping.SimpleBandsDb;
|
||||
for (int i = 0; i < bands.Length; i++)
|
||||
{
|
||||
float gainDb = gains is not null && i < gains.Length ? gains[i] : 0f;
|
||||
if (MathF.Abs(gainDb) < 0.05f) continue; // flat band — no filter needed, skip it
|
||||
l.Add(MakeBand(mode, i, bands.Length, (float)bands[i].Freq, gainDb));
|
||||
r.Add(MakeBand(mode, i, bands.Length, (float)bands[i].Freq, gainDb));
|
||||
}
|
||||
}
|
||||
|
||||
var chain = new PeerDspChain(panL, panR, hasPan, [.. l], [.. r]);
|
||||
return chain.IsNoOp ? null : chain;
|
||||
}
|
||||
|
||||
private static BiQuadFilter MakeBand(PeerEqMode mode, int index, int count, float freq, float gainDb)
|
||||
{
|
||||
// 3-band tone control: bass is a low shelf, treble a high shelf, mids a peaking band — the
|
||||
// natural shape for a simple bass/mid/treble control. 10-band graphic EQ: peaking throughout,
|
||||
// with a Q suited to roughly one-octave band spacing.
|
||||
if (mode == PeerEqMode.Simple3Band && index == 0)
|
||||
return BiQuadFilter.LowShelf(PeerEqBands.MixSampleRate, freq, 0.7f, gainDb);
|
||||
if (mode == PeerEqMode.Simple3Band && index == count - 1)
|
||||
return BiQuadFilter.HighShelf(PeerEqBands.MixSampleRate, freq, 0.7f, gainDb);
|
||||
return BiQuadFilter.PeakingEQ(PeerEqBands.MixSampleRate, freq, mode == PeerEqMode.Advanced10Band ? 1.4f : 0.9f, gainDb);
|
||||
}
|
||||
|
||||
/// <summary>Process one interleaved stereo block IN PLACE. <paramref name="frames"/> is the number
|
||||
/// of stereo frames (the used span length is frames*2). Per-sample, no allocation, no locks —
|
||||
/// safe to call on the audio render thread.</summary>
|
||||
public void Process(Span<float> output, int frames)
|
||||
{
|
||||
int n = left.Length;
|
||||
if (n > 0)
|
||||
{
|
||||
for (int f = 0; f < frames; f++)
|
||||
{
|
||||
float sl = output[2 * f];
|
||||
float sr = output[2 * f + 1];
|
||||
for (int b = 0; b < n; b++)
|
||||
{
|
||||
sl = left[b].Transform(sl);
|
||||
sr = right[b].Transform(sr);
|
||||
}
|
||||
output[2 * f] = sl;
|
||||
output[2 * f + 1] = sr;
|
||||
}
|
||||
}
|
||||
if (hasPan)
|
||||
{
|
||||
for (int f = 0; f < frames; f++)
|
||||
{
|
||||
output[2 * f] *= panL;
|
||||
output[2 * f + 1] *= panR;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
// Both) the sender emits a single streamId so the dict still has one entry per peer,
|
||||
// identical to the pre-refactor behaviour. The new mode adds a second entry per peer.
|
||||
private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), SessionPlayout> sessions = new();
|
||||
// Per-peer pan+EQ, keyed by peer address, so a session created later (a reconnect) inherits its
|
||||
// peer's shaping from frame zero — the same "applies to future sessions too" idea as the
|
||||
// concealment artifact. Guarded by sessionsLock. A null value means explicitly cleared.
|
||||
private readonly Dictionary<IPAddress, PeerDspChain?> peerDspByAddress = new();
|
||||
private SessionPlayout[] sessionsSnapshot = [];
|
||||
// Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on
|
||||
// its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render
|
||||
@@ -135,6 +139,20 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
foreach (var s in snap) s.SetConcealmentArtifact(artifact);
|
||||
}
|
||||
|
||||
/// <summary>Sets (or clears with null) the pan+EQ chain for every current and future session from
|
||||
/// this peer address. Live-updates existing sessions and remembers it so a session created later
|
||||
/// (a reconnect, or a peer that starts streaming after you set it) inherits it from frame zero.</summary>
|
||||
public void SetPeerDsp(IPAddress address, PeerDspChain? chain)
|
||||
{
|
||||
lock (sessionsLock)
|
||||
{
|
||||
if (chain is null) peerDspByAddress.Remove(address);
|
||||
else peerDspByAddress[address] = chain;
|
||||
foreach (var s in sessions.Values)
|
||||
if (s.Endpoint.Address.Equals(address)) s.SetDsp(chain);
|
||||
}
|
||||
}
|
||||
|
||||
public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||
|
||||
/// <summary>Legacy property returning the Mixed route's target. Used by code paths that
|
||||
@@ -305,6 +323,9 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
// gets the right artifact from frame zero (rather than the SessionPlayout
|
||||
// default, which would only get overridden on the next SetConcealmentArtifact).
|
||||
sp.SetConcealmentArtifact((ConcealmentArtifact)concealmentArtifactRaw);
|
||||
// Inherit this peer's pan+EQ too, so a mid-stream / reconnect session is shaped from
|
||||
// frame zero rather than only on the next SetPeerDsp call.
|
||||
if (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain);
|
||||
sessions[key] = sp;
|
||||
sessionsSnapshot = sessions.Values.ToArray();
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ internal sealed class SessionPlayout : IDisposable
|
||||
private float lastConcealSampleL;
|
||||
private float lastConcealSampleR;
|
||||
private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst;
|
||||
// Per-peer pan + EQ, or null when this peer isn't shaped. Built on the UI thread and swapped in
|
||||
// atomically (volatile reference); the audio thread reads it once per block. See PeerDspChain.
|
||||
private volatile PeerDspChain? dsp;
|
||||
// Per-session RNG for noise concealment. Seeded from process-level Shared so each session
|
||||
// gets a different sequence — but we don't care about reproducibility, just character.
|
||||
private readonly Random concealRng = new(Random.Shared.Next());
|
||||
@@ -331,6 +334,10 @@ internal sealed class SessionPlayout : IDisposable
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact value) =>
|
||||
concealmentArtifactRaw = (int)value;
|
||||
|
||||
/// <summary>Sets (or clears with null) this session's per-peer pan+EQ chain. Takes effect on the
|
||||
/// next block. Called from the UI thread; the audio thread reads the reference lock-free.</summary>
|
||||
public void SetDsp(PeerDspChain? value) => dsp = value;
|
||||
|
||||
/// <summary>UTC time of the most recent successful audio write. Used by <see cref="AudioReceiver"/>
|
||||
/// to prune long-idle sessions so the dictionary doesn't grow unboundedly.</summary>
|
||||
public DateTime LastWriteUtc { get; private set; } = DateTime.UtcNow;
|
||||
@@ -621,6 +628,11 @@ internal sealed class SessionPlayout : IDisposable
|
||||
|
||||
// Read through the resampler and apply concealment on full underruns.
|
||||
ReadThroughResampler(output, outFrames);
|
||||
// Per-peer pan + EQ: this peer's block is fully decoded and isolated here, immediately before
|
||||
// PlayoutEngine sums it into the mix — so shaping is per-peer and pre-mix, and being per-sample
|
||||
// it adds no buffering latency. Null (unshaped peer) skips the whole stage.
|
||||
var d = dsp;
|
||||
d?.Process(output, outFrames);
|
||||
return outFrames;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user