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
@@ -22,8 +22,10 @@ It works with a simple shared password. You and the person you're connecting to
|
||||
|
||||
* **Listen to one of your computers from another room.** Sit at your laptop and hear what's playing on your desktop. Walk around the house — the sound follows you.
|
||||
* **Play music together over the internet.** Two musicians at different houses can play along together with very low delay. Much faster than a video call, fast enough that timing-sensitive playing works.
|
||||
* **Mix everyone as you go.** With several people connected, set each person's volume, lean them left or right, and shape their tone with a built-in equaliser (simple 3-band, 12-band graphic, or a full 16-band parametric) — all live, and it's captured in your recordings.
|
||||
* **Send a finished mix to a producer or client** in real time, without uploading a file and waiting.
|
||||
* **Record what comes through the connection** to WAV, MP3, OGG-Opus, or FLAC. Save sessions for review later.
|
||||
* **Record what comes through the connection** to WAV, MP3, OGG-Opus, or FLAC — the whole mix, or **each person on their own separate track** to mix afterwards.
|
||||
* **Give the people you connect to names that stick**, and see who's connected and what they're sending at a glance.
|
||||
* **Drive it from the command line.** As well as its normal window, RemSound takes command-line options — list your audio devices, run a self-test of the whole capture-to-playback path, write a diagnostics report to send for help, or start straight into a profile from a shortcut or script. See [Command-line options](MANUAL.md#22-command-line-options) in the manual.
|
||||
|
||||
## Three quality settings, simple choice
|
||||
|
||||
+27
-11
@@ -1,33 +1,49 @@
|
||||
# RemSound v4.9
|
||||
# RemSound v5.0
|
||||
|
||||
Lock a profile to fixed addresses — for when you want one exact address and nothing else.
|
||||
A big release. Shape each person you're connected to, record everyone onto separate tracks, give your peers names that stick, and make the window your own with themes and reorderable tabs.
|
||||
|
||||
## Lock to exact peer addresses
|
||||
## Shape each peer — volume, pan and EQ
|
||||
|
||||
RemSound normally tries to be helpful about addresses: it finds the other computer by the name it advertises on your network, and if that computer turns up at a different address — a new IP after a reboot, or a second address over a VPN — it follows it there so your sound keeps flowing. Most of the time that's just what you want.
|
||||
A new **Volume, pan and EQ for peers** tab lets you treat every connected person as their own channel:
|
||||
|
||||
Sometimes it isn't. If a machine is reachable at two addresses at once and you only ever want one of them, there's now a tickbox on the **Connectivity** tab:
|
||||
- **Volume** — an individual level for each person, so you can balance everyone against each other.
|
||||
- **Pan** — lean someone to the left or right. It keeps their stereo image; it never folds them down to mono.
|
||||
- **EQ** — change each person's tone with one of three equalisers: a simple **3-band** tone control, a **12-band graphic** EQ (31 Hz up to 16 kHz), or a **16-band parametric** EQ where you place each band yourself by typing a start frequency, an end frequency and a gain. On the parametric bands list, left and right arrow nudge the selected band by half a dB, live.
|
||||
|
||||
**Lock to these exact peer addresses, no matter what (Alt+L)**
|
||||
Everything is live — you hear it as you move a control — and it adds no delay. One master switch turns it all on or off (there's a global shortcut for that too), and each person has a tick so you can bypass one without losing their settings. Crucially, **what you dial in is captured in your recordings**, so a shaped mix records the way it sounds (with a "record the raw audio" option if you'd rather keep it clean).
|
||||
|
||||
With it ticked, that profile uses only the exact addresses you set. RemSound won't look the other computer up by name, and won't switch to any other address it discovers. If the address you set stops working, the connection simply waits (or drops) until that exact address is reachable again, rather than wandering off to another.
|
||||
## Record everyone on their own track
|
||||
|
||||
It's off by default and saved with the profile, so you can lock one profile down while another keeps the automatic behaviour. When you use it, set your peers by their IP address (**Add peer by IP**) so there's no doubt which address is locked in.
|
||||
Recording can now **split into a separate file per peer** — each connected person on their own track — so you can take a jam or a conversation away and mix the parts afterwards. The default recording source is now "both sent and received".
|
||||
|
||||
## Name your peers, and see who's who
|
||||
|
||||
- **Rename peer (Alt+M or F2)** on the Connectivity tab gives someone a friendly name that sticks to their machine for good — across restarts, address changes and networks — and shows everywhere that peer appears.
|
||||
- **Peer details (Alt+E)** shows the highlighted peer's name, address, how long you've been connected, the link health and ping, what they're sending (how many devices, WASAPI or ASIO, sample rate and codec), and whether they're receiving your audio.
|
||||
- **Options → Manage named peers** lists everyone you've named, with where and when you last connected, so you can rename or delete them in one place.
|
||||
|
||||
## Make it yours — Appearance
|
||||
|
||||
Preferences has a new **Appearance** tab:
|
||||
|
||||
- **Colour theme** — Match Windows (the default), Light, or Dark. RemSound now follows your Windows light/dark setting, has a modern font and its own app icon.
|
||||
- **Tab order** — reorder the main window's tabs with Move up / Move down, and hide the ones you don't use (including the volume/pan/EQ tab and the Discovered / Remembered peer lists on the Connectivity tab).
|
||||
- **Ctrl and a number** jumps straight to a tab by its position, following whatever order you've set.
|
||||
|
||||
## Compatibility
|
||||
|
||||
**v4.9 talks to v3.3 through v4.8 with no trouble** — the over-the-network format is unchanged, so you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.)
|
||||
**v5.0 talks to v3.3 through v4.9 with no trouble** — the over-the-network format is unchanged, so you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.)
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `RemSound-v4.9.zip` from this release.
|
||||
1. Download `RemSound-v5.0.zip` from this release.
|
||||
2. Close RemSound.
|
||||
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your settings, profiles, logs or recordings.
|
||||
4. Run `RemSound.exe`.
|
||||
|
||||
## Upgrading
|
||||
|
||||
**From v3.6 or newer:** Help → Check for updates installs v4.9 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was.
|
||||
**From v3.6 or newer:** Help → Check for updates installs v5.0 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was.
|
||||
|
||||
**From v1.9–v3.5:** Check for updates works, but uses your current version's older updater for this one hop. If auto-update has been failing on your machine, install by hand using the steps above.
|
||||
|
||||
|
||||
@@ -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