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
+12
View File
@@ -20,6 +20,18 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v5.0
A big one. Shape each person, record everyone separately, name your peers, and make the window your own.
Shape each peer. A new "Volume, pan and EQ for peers" tab lets you set how loud each connected person is, lean them left or right, and change their tone with an equaliser a simple 3-band, a 12-band graphic, or a full 16-band parametric where you place each band yourself. It's all live, adds no delay, and what you dial in is captured in your recordings too.
Record everyone on their own track. Recording can now save each connected peer to its own separate file, so you can take a jam or a chat away and mix the parts afterwards.
Name your peers. Give the people you connect to friendly names that stick to their machine for good, and a new details view shows who's connected, for how long, and what they're sending. Manage them all under Options, Manage named peers.
Make it yours. RemSound now follows your Windows light or dark theme, has a fresh look and its own icon, and a new Appearance tab in Preferences lets you reorder or hide the window's tabs. Jump straight to any tab with Ctrl and its number.
RemSound v4.9
Lock a profile to fixed addresses.
+38
View File
@@ -80,6 +80,10 @@ internal static class CommandLine
return WithConsole(PrintVersion);
case "--devices": case "--list-devices":
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
case "--profiles": case "--list-profiles":
return WithConsole(ListProfiles);
case "--named-peers": case "--list-named-peers":
return WithConsole(ListNamedPeers);
case "--selftest": case "--self-test": case "--smoke-test": case "--smoketest":
return WithConsole(() => SelfTest.Run(args));
case "--perftest": case "--perf-test":
@@ -139,6 +143,37 @@ internal static class CommandLine
return 0;
}
/// <summary>--list-profiles: print the saved profile titles (read-only).</summary>
private static int ListProfiles()
{
var cfg = AppConfig.Load();
var store = new ProfileStore(cfg.ProfilesDirectory ?? AppConfig.ProfilesBaseDirectory);
var titles = store.ListProfileTitles();
if (titles.Count == 0) { Console.WriteLine("No saved profiles."); return 0; }
Console.WriteLine($"Saved profiles ({titles.Count}):");
foreach (var t in titles) Console.WriteLine($" {t}");
return 0;
}
/// <summary>--list-named-peers: print the machine-wide named-peers book (read-only) — the friendly
/// names you've given peers, with where and when each was last seen.</summary>
private static int ListNamedPeers()
{
var named = AppConfig.Load().NamedPeers;
if (named.Count == 0) { Console.WriteLine("No named peers."); return 0; }
Console.WriteLine($"Named peers ({named.Count}):");
foreach (var kv in named.OrderBy(k => k.Value.FriendlyName, StringComparer.CurrentCultureIgnoreCase))
{
var np = kv.Value;
var seen = np.LastSeenUtc == default
? "not seen yet"
: $"last seen {np.LastSeenUtc.ToLocalTime():d MMM yyyy}"
+ (string.IsNullOrWhiteSpace(np.LastAddress) ? "" : $", {np.LastAddress}");
Console.WriteLine($" {np.FriendlyName} ({np.MachineName}) - {seen}");
}
return 0;
}
private static int PrintHelp()
{
Console.WriteLine($"RemSound {AppVersion} - command-line options");
@@ -150,6 +185,9 @@ internal static class CommandLine
Console.WriteLine(" --version Show the installed version.");
Console.WriteLine(" --devices List all microphones, outputs and ASIO drivers,");
Console.WriteLine(" with their formats and device ids.");
Console.WriteLine(" --list-profiles List your saved profile names.");
Console.WriteLine(" --list-named-peers List the friendly names you've given peers, with");
Console.WriteLine(" where and when each was last seen.");
Console.WriteLine(" --selftest [--seconds N] Run the built-in self-test - a localhost audio");
Console.WriteLine(" (or --smoke-test) round-trip plus checks of encryption, the wire format,");
Console.WriteLine(" settings, profiles, dialog accessibility and bundled files.");
+12 -2
View File
@@ -2599,6 +2599,8 @@ public sealed class MainForm : Form
// Appearance-tab changes (tab order, show pan/EQ tab, show discovered/remembered lists) apply now.
ApplyMainTabLayout();
RefreshConnectivityListVisibility();
var appearanceCfg = AppConfig.Load();
logFile.Event($"appearance applied: theme={appearanceCfg.ThemeMode}, tabs=[{string.Join(", ", mainTabControl.TabPages.Cast<TabPage>().Select(t => t.Text))}], discovered-list={appearanceCfg.ShowDiscoveredPeers}, remembered-list={appearanceCfg.ShowRememberedPeers}");
// 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
@@ -3260,7 +3262,7 @@ public sealed class MainForm : Form
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));
enableAllPeerShapingBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } };
enableAllPeerShapingBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { logFile.Event($"shaping: master switch {(enableAllPeerShapingBox.Checked ? "on" : "off")} ({peerShaping.Count} peer(s) with saved shaping)"); MarkProfileDirty(); ApplyAllPeerShaping(); } };
panEqPeerList.SelectedIndexChanged += (_, _) => OnPanEqPeerSelected();
panEqPeerList.ItemCheck += OnPeerShapeToggled;
// First-letter navigation on some Windows configs can accidentally toggle the checkbox; do the
@@ -3520,7 +3522,9 @@ public sealed class MainForm : Form
private void OnEqModeChanged()
{
if (loadingPanEqControls || selectedShapingKey is null) return;
GetOrCreateShaping(selectedShapingKey).EqMode = ModeForIndex(eqModeList.SelectedIndex);
var mode = ModeForIndex(eqModeList.SelectedIndex);
GetOrCreateShaping(selectedShapingKey).EqMode = mode;
logFile.Event($"shaping: EQ mode → {mode} for {selectedShapingKey}");
RebuildEqBandSliders();
ApplyPeerShaping(selectedShapingKey);
UpdateEqCurve();
@@ -3744,6 +3748,7 @@ public sealed class MainForm : Form
if (dlg.ShowDialog(this) == DialogResult.OK)
{
s.ParametricBands.Add(dlg.Result);
logFile.Event($"shaping: added parametric band {dlg.Result.StartHz:0}-{dlg.Result.EndHz:0} Hz {dlg.Result.GainDb:0.#} dB for {selectedShapingKey} ({s.ParametricBands.Count} band(s))");
RefreshParametricBandList();
MarkProfileDirty();
}
@@ -3759,6 +3764,7 @@ public sealed class MainForm : Form
int firstIdx = parametricBandList.SelectedIndex;
var toRemove = parametricBandList.SelectedItems.Cast<ParametricBandItem>().Select(x => x.Band).ToList();
foreach (var band in toRemove) s.ParametricBands.Remove(band);
logFile.Event($"shaping: deleted {toRemove.Count} parametric band(s) for {selectedShapingKey} ({s.ParametricBands.Count} left)");
RefreshParametricBandList();
// Put focus on whatever now occupies the first removed slot so NVDA announces it.
if (parametricBandList.Items.Count > 0)
@@ -4482,6 +4488,9 @@ public sealed class MainForm : Form
/// refresh every place a peer name shows. A blank/cleared name removes the entry entirely.</summary>
private void ApplyFriendlyName(string identityKey, string machineName, string? address, string? name)
{
logFile.Event(string.IsNullOrWhiteSpace(name)
? $"named peer: cleared name for {identityKey}"
: $"named peer: {identityKey} → \"{name.Trim()}\"");
if (string.IsNullOrWhiteSpace(name))
{
namedPeers.Remove(identityKey);
@@ -4597,6 +4606,7 @@ public sealed class MainForm : Form
void DeleteSelected()
{
if (list.SelectedItem is not NamedPeerItem it) return;
logFile.Event($"named peer: deleted \"{it.Peer.FriendlyName}\" ({it.Key})");
namedPeers.Remove(it.Key);
SaveNamedPeers();
lastPanEqPeerSignature = "";
+20 -11
View File
@@ -204,16 +204,21 @@ internal sealed class RecordingController
t.Len = span.Length;
}
// Audio thread. Once per render, flush each peer's summed block to their file and reset.
private void FlushPeerTracks()
// Audio thread. Once per render, flush each peer's summed block to their file. Every peer track gets
// EXACTLY one render block per callback: a peer that produced nothing this block — because it went
// quiet and its session was dropped, or it disconnected — is padded with silence to the same length.
// That keeps every track sample-locked to the single render clock, so the tracks can never drift
// apart from each other however long the recording runs, and they all end up the same length.
private void FlushPeerTracks(int floats)
{
var tracks = peerTracks;
if (tracks is null) return;
if (tracks is null || floats <= 0) return;
foreach (var t in tracks.Values)
{
if (t.Len <= 0) continue;
t.Recorder.WriteReceived(t.Accum.AsMemory(0, t.Len), RenderRoute.Mixed);
Array.Clear(t.Accum, 0, t.Len);
if (t.Accum.Length < floats) { var a = t.Accum; Array.Resize(ref a, floats); t.Accum = a; }
for (int i = t.Len; i < floats; i++) t.Accum[i] = 0f; // pad an empty/short block with silence
t.Recorder.WriteReceived(t.Accum.AsMemory(0, floats), RenderRoute.Mixed);
Array.Clear(t.Accum, 0, floats);
t.Len = 0;
}
}
@@ -227,13 +232,17 @@ internal sealed class RecordingController
rawMixLen = span.Length;
}
// Audio thread. Flush the summed raw mix for this render to the single recorder, then reset.
private void FlushRawMix()
// Audio thread. Flush the summed raw mix for this render to the single recorder, padding a silent
// block so the file stays aligned to real time even through total silence, then reset.
private void FlushRawMix(int floats)
{
var rec = active;
int n = rawMixLen;
if (rec is not null && n > 0) rec.WriteReceived(rawMixAccum.AsMemory(0, n), RenderRoute.Mixed);
if (n > 0) { Array.Clear(rawMixAccum, 0, n); rawMixLen = 0; }
if (rec is null || floats <= 0) return;
if (rawMixAccum.Length < floats) { var a = rawMixAccum; Array.Resize(ref a, floats); rawMixAccum = a; }
for (int i = rawMixLen; i < floats; i++) rawMixAccum[i] = 0f;
rec.WriteReceived(rawMixAccum.AsMemory(0, floats), RenderRoute.Mixed);
Array.Clear(rawMixAccum, 0, floats);
rawMixLen = 0;
}
private void StopRecorder(AudioRecorder? r)
+1 -1
View File
@@ -18,7 +18,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>4.9</Version>
<Version>5.0</Version>
</PropertyGroup>
<ItemGroup>
+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>