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:
co-authored by
Claude Opus 4.8
parent
fcc3cdc793
commit
aeca24ae17
@@ -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.
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -206,7 +206,7 @@ public sealed class AudioReceiver : IDisposable
|
||||
|
||||
/// <summary>Fired once per rendered block, right after <see cref="OnReceivedSamples"/> — the block
|
||||
/// boundary a single-file bypass recording uses to flush its per-peer sum.</summary>
|
||||
public Action? OnRecordBlockComplete
|
||||
public Action<int>? OnRecordBlockComplete
|
||||
{
|
||||
get => playoutEngine.OnRecordBlockComplete;
|
||||
set => playoutEngine.OnRecordBlockComplete = value;
|
||||
|
||||
@@ -170,8 +170,10 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
}
|
||||
|
||||
/// <summary>Fired once per rendered block, right after the mixed received tap — the block boundary a
|
||||
/// single-file "bypass" recording uses to flush its per-peer sum.</summary>
|
||||
public Action? OnRecordBlockComplete { get; set; }
|
||||
/// split / "bypass" recording uses to flush its per-peer sum. The argument is the block's interleaved
|
||||
/// float count (frames × channels), so the recorder can pad any peer that produced nothing this block
|
||||
/// with silence and keep every track sample-locked to the render clock.</summary>
|
||||
public Action<int>? OnRecordBlockComplete { get; set; }
|
||||
|
||||
public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||
|
||||
@@ -822,7 +824,7 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
// BothIndependent; without the tag both ended up in one recorder ring, doubling the
|
||||
// file's effective sample rate).
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
|
||||
OnRecordBlockComplete?.Invoke();
|
||||
OnRecordBlockComplete?.Invoke(outFloats);
|
||||
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
@@ -899,7 +901,7 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
// wasapi-slot ring (canonical single-lane slot in classic modes), so this fires
|
||||
// exactly once per real-time second.
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), RenderRoute.Mixed);
|
||||
OnRecordBlockComplete?.Invoke();
|
||||
OnRecordBlockComplete?.Invoke(outFloats);
|
||||
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
|
||||
Reference in New Issue
Block a user