v5 pre-release: multi-track drift fix, more self-tests, logging, CLI, version bump

Multi-track recording drift fix (Ed's question — can the separate tracks drift over an hour?):
 * Root: FlushPeerTracks skipped a peer that produced no samples in a render block, so a peer that
   went quiet long enough for its session to be pruned (>4 s idle) would have its track fall behind
   and desync. Now every peer track is padded to a full render block each cycle (silence when the
   peer produced nothing), so all peer tracks stay sample-locked to the single render clock — they
   can't drift apart however long the recording runs, and all end the same length. Same padding for
   the single-file bypass path. OnRecordBlockComplete now carries the block's float count.
   (The peer tracks are already resampled to the render clock per peer, so this makes peer-to-peer
   sync exact; your own "me" track is capture-clocked — same soundcard for capture+playback = same
   clock = no drift, different interfaces can drift slightly.)

 * Self-test: two new steps — "Per-peer shaping DSP" (PeerDspChain unity/master-off/volume/parametric
   + ParametricToPeaking) and "v5 settings and shaping round-trip" (AppConfig defaults, NamedPeers,
   MainTabOrder, parametric PeerShaping, recording default = Both).
 * Logging (gated by the logging checkbox): master shaping switch, EQ-mode change, parametric band
   add/delete, peer rename/clear/delete, and the applied Appearance settings after Preferences close.
 * CLI: --list-profiles and --list-named-peers (read-only), in --help.
 * Version bumped to 5.0; About-box changelog, RELEASE_NOTES.md and README updated for v5.

Build clean; --selftest passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-08 18:19:01 +01:00
co-authored by Claude Opus 4.8
parent fcc3cdc793
commit aeca24ae17
10 changed files with 193 additions and 31 deletions
+73
View File
@@ -5,6 +5,7 @@ using System.Text;
using System.Text.Json;
using System.Windows.Forms;
using RemSound.Core;
using RemSound.Receiver;
namespace RemSound.App;
@@ -56,6 +57,8 @@ internal static class SelfTest
RunStep(results, "Packet framing and rejection", PacketFraming);
RunStep(results, "Server wire-format compatibility", ServerWireCompat);
RunStep(results, "App settings save and reload", SettingsRoundTrip);
RunStep(results, "Per-peer shaping DSP", PeerShapingDsp);
RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip);
RunStep(results, "Profile save and reload", ProfileRoundTrip);
RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip);
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
@@ -218,6 +221,76 @@ internal static class SelfTest
return "12-byte 'RMND' header; relay-visible fields unchanged";
}
/// <summary>Per-peer volume/pan/EQ DSP: nothing-to-do builds a null chain, the master-off state
/// bypasses, a real volume actually attenuates the signal, and the parametric range→peaking maths
/// is sane. This is the receive-side shaping that also feeds recordings.</summary>
private static string? PeerShapingDsp()
{
Check(PeerDspChain.Build(null, enabled: true) is null, "no shaping must build a null (do-nothing) chain");
Check(PeerDspChain.Build(new PeerShaping(), enabled: true) is null, "default (unity) shaping must build a null chain");
var half = new PeerShaping { Volume = 0.5f };
Check(PeerDspChain.Build(half, enabled: false) is null, "master switch off must bypass shaping (null chain)");
var chain = PeerDspChain.Build(half, enabled: true);
Check(chain is { IsNoOp: false }, "a 50% volume must build a real chain");
var buf = new float[8];
Array.Fill(buf, 1.0f);
chain!.Process(buf, buf.Length / 2); // 4 stereo frames
Check(buf.All(v => Math.Abs(v - 0.5f) < 0.001f), $"volume 50% must halve the signal (got {buf[0]:0.000})");
var para = new PeerShaping { EqMode = PeerEqMode.Parametric16Band };
para.ParametricBands.Add(new ParametricBand { StartHz = 200, EndHz = 800, GainDb = 6 });
Check(PeerDspChain.Build(para, enabled: true) is { IsNoOp: false }, "a parametric band must build a real chain");
PeerEqBands.ParametricToPeaking(200, 800, out var centre, out var q);
Check(centre > 200 && centre < 800 && q is > 0.1f and < 12f,
$"parametric range→peaking must give a sane centre ({centre:0} Hz) and Q ({q:0.00})");
return "unity→null, master-off→null, volume, parametric";
}
/// <summary>The v5 machine-wide settings and per-peer shaping survive a JSON save/reload: new
/// AppConfig defaults, the named-peers book, the main tab order, per-peer shaping with parametric
/// bands, and the new recording default. All in-memory — the real config/profiles aren't touched.</summary>
private static string? V5ConfigRoundTrip()
{
var fresh = new AppConfig();
Check(fresh.ShowPanEqTab, "ShowPanEqTab must default to true");
Check(fresh.ThemeMode == "system", "ThemeMode must default to 'system'");
Check(fresh.ShowDiscoveredPeers && fresh.ShowRememberedPeers, "the peer lists must default to shown");
var cfg = new AppConfig
{
ThemeMode = "dark",
MainTabOrder = ["audioio", "connectivity", "paneq", "audioprofile"],
ShowDiscoveredPeers = false,
};
cfg.NamedPeers["ANDRE-PC"] = new NamedPeer
{
MachineName = "ANDRE-PC",
FriendlyName = "Andre's desktop",
LastAddress = "100.72.4.13",
LastSeenUtc = new DateTime(2026, 7, 8, 12, 0, 0, DateTimeKind.Utc),
};
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
var back = JsonSerializer.Deserialize<AppConfig>(json);
Check(back is not null, "config must deserialise");
Check(back!.ThemeMode == "dark" && !back.ShowDiscoveredPeers, "theme and list toggles must round-trip");
Check(back.MainTabOrder is { Count: 4 } && back.MainTabOrder[0] == "audioio", "tab order must round-trip");
Check(back.NamedPeers.TryGetValue("ANDRE-PC", out var np)
&& np.FriendlyName == "Andre's desktop" && np.LastAddress == "100.72.4.13",
"named peers must round-trip");
var shaping = new PeerShaping { Volume = 0.7f, Pan = -0.5f, EqMode = PeerEqMode.Parametric16Band };
shaping.ParametricBands.Add(new ParametricBand { StartHz = 100, EndHz = 500, GainDb = 3.5f });
var sback = JsonSerializer.Deserialize<PeerShaping>(JsonSerializer.Serialize(shaping));
Check(sback is not null && sback.EqMode == PeerEqMode.Parametric16Band
&& sback.ParametricBands.Count == 1 && Math.Abs(sback.ParametricBands[0].GainDb - 3.5f) < 0.001f,
"peer shaping (with parametric bands) must round-trip");
Check(new RecordingSettings().Source == RecordingSource.Both, "recording source must default to Both");
return "config defaults, named peers, tab order, parametric shaping, recording default";
}
/// <summary>App settings survive a save-and-reload (the same JSON serialisation
/// <see cref="AppConfig.Save"/> / <see cref="AppConfig.Load"/> use) without touching the real
/// config on disk.</summary>