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
@@ -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