diff --git a/MANUAL.md b/MANUAL.md
index 7117d34..bd99086 100644
--- a/MANUAL.md
+++ b/MANUAL.md
@@ -1097,7 +1097,7 @@ Option| What it does
`--help` or `-h`| Lists every option, the same as this section in short form.
`--version`| Prints which version of RemSound is installed, for example “RemSound 3.9”.
`--devices`| Lists every microphone and line-in, every speaker and headphone output, and every ASIO driver on the machine — each with its sample rate, channel count and the exact device id RemSound uses internally. This is the quickest way to confirm an interface is actually present and seen by Windows.
-`--selftest`| Runs a complete round-trip on the machine on its own: it captures sound, encodes it, sends it across the network layer to itself, receives it and decodes it, then reports **PASS** or **FAIL**. No sound is played out, so it is safe to run silently. A PASS proves capture, encoding, the network code and decoding are all working on that computer. Add `--opus` to test the Opus codec path, or `--seconds N` to run it for longer than the default.
+`--selftest`| Runs RemSound's built-in self-test and reports **PASS** or **FAIL**. It works through a list of named checks: a full audio round-trip on the machine on its own (capture → encode → send across the network layer to itself → receive → decode, for both quality settings), the audio encryption, the network packet format, saving and reloading settings and a profile, that a diagnostics report never leaks a password, and that the bundled sounds and manual are present. No sound is played out, so it is safe to run silently. Add `--seconds N` to make the audio part run for longer than the default.
`--diagnostics`| Writes a single plain-text report file holding the version, the operating system, the current settings, the list of profiles, the full device list, a check of the Windows microphone-privacy permission, and the tail of the most recent log. With no path it saves into the **user settings and logs** folder and prints where it put it; you can also give a path, for example `--diagnostics C:\Users\you\Desktop\report.txt`. This is the file to send when asking for help — it answers most questions in one go.
### Options that change a setting or control a running copy, then exit
diff --git a/build-release.ps1 b/build-release.ps1
index 1d2d007..6bf1593 100644
--- a/build-release.ps1
+++ b/build-release.ps1
@@ -100,6 +100,24 @@ if (Test-Path $syncScript) {
Write-Host "Note: sync-manual.py not found - skipping MANUAL.md sync." -ForegroundColor DarkGray
}
+# Gate: build RemSound and run the full test suite (run-tests.ps1) before packaging anything. A
+# failing gate means no release - these are the same checks that would have caught the v3.9
+# missing-cue-sounds bug. run-tests publishes and tests its OWN throwaway copy, so it never runs
+# the app against (and never pollutes) the clean staging folder built below.
+$gate = Join-Path $repo 'run-tests.ps1'
+if (Test-Path $gate) {
+ Write-Host ""
+ Write-Host "Running the build-and-test gate (run-tests.ps1)..." -ForegroundColor Cyan
+ & $gate
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host ""
+ Write-Host "RELEASE ABORTED - the build-and-test gate failed. Fix the failures above and re-run." -ForegroundColor Red
+ exit 1
+ }
+} else {
+ Write-Host "WARNING: run-tests.ps1 not found - packaging WITHOUT the test gate." -ForegroundColor Yellow
+}
+
# Anything matching these must NEVER appear in a release. Folders by name; files by
# extension / exact name. RemSound.deps.json and RemSound.runtimeconfig.json are
# legitimate app files and are deliberately NOT matched (different names).
diff --git a/readme.html b/readme.html
index 0f395c1..5610f79 100644
--- a/readme.html
+++ b/readme.html
@@ -1154,7 +1154,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
--help or -h | Lists every option, the same as this section in short form. |
--version | Prints which version of RemSound is installed, for example “RemSound 3.9”. |
--devices | Lists every microphone and line-in, every speaker and headphone output, and every ASIO driver on the machine — each with its sample rate, channel count and the exact device id RemSound uses internally. This is the quickest way to confirm an interface is actually present and seen by Windows. |
---selftest | Runs a complete round-trip on the machine on its own: it captures sound, encodes it, sends it across the network layer to itself, receives it and decodes it, then reports PASS or FAIL. No sound is played out, so it is safe to run silently. A PASS proves capture, encoding, the network code and decoding are all working on that computer. Add --opus to test the Opus codec path, or --seconds N to run it for longer than the default. |
+--selftest | Runs RemSound's built-in self-test and reports PASS or FAIL. It works through a list of named checks: a full audio round-trip on the machine on its own (capture → encode → send across the network layer to itself → receive → decode, for both quality settings), the audio encryption, the network packet format, saving and reloading settings and a profile, that a diagnostics report never leaks a password, and that the bundled sounds and manual are present. No sound is played out, so it is safe to run silently. Add --seconds N to make the audio part run for longer than the default. |
--diagnostics | Writes a single plain-text report file holding the version, the operating system, the current settings, the list of profiles, the full device list, a check of the Windows microphone-privacy permission, and the tail of the most recent log. With no path it saves into the user settings and logs folder and prints where it put it; you can also give a path, for example --diagnostics C:\Users\you\Desktop\report.txt. This is the file to send when asking for help — it answers most questions in one go. |
diff --git a/run-tests.ps1 b/run-tests.ps1
new file mode 100644
index 0000000..a0178b2
--- /dev/null
+++ b/run-tests.ps1
@@ -0,0 +1,125 @@
+# run-tests.ps1 - the RemSound build-and-test gate.
+#
+# One command that BUILDS RemSound, then runs every check that can be made from outside a single
+# running copy, and returns 0 only if they all pass. Pairs with the in-app self-test (RemSound.exe
+# --selftest), which it invokes: the app's --selftest covers the audio path, encryption, wire format,
+# settings, profiles and bundled files from the inside; this script covers the build, the CLI
+# surface, the published package layout, the About-box changelog, and the client<->server wire
+# contract from the outside.
+#
+# build-release.ps1 calls this first and refuses to package a release if it fails, so "build and
+# test" is one step before every publish. Run it by hand any time: powershell -File run-tests.ps1
+#
+# Modelled on Andre's Sensor Readout (Build.ps1 + an in-app self-test), the pattern that inspired
+# RemSound's command line in the first place.
+
+$ErrorActionPreference = 'Stop'
+$repo = $PSScriptRoot
+$proj = Join-Path $repo 'src\RemSound.App\RemSound.App.csproj'
+
+$script:failures = @()
+function Fail($m) { $script:failures += $m; Write-Host " [FAIL] $m" -ForegroundColor Red }
+function Pass($m) { Write-Host " [PASS] $m" -ForegroundColor Green }
+
+# ---- expected version, read from the one source of truth (the csproj) ----
+$csprojText = Get-Content -LiteralPath $proj -Raw
+$expectedVersion = if ($csprojText -match '([^<]+)') { $Matches[1].Trim() } else { '' }
+$expectedMM = ($expectedVersion -split '\.')[0..1] -join '.' # major.minor, e.g. 3.9
+
+# ---- 1. BUILD: publish to a throwaway folder (the app is never run here yet, so the bundled
+# sounds\ folder stays intact for the package checks below) ----
+$publishDir = Join-Path ([System.IO.Path]::GetTempPath()) ("rs-runtests-" + [guid]::NewGuid().ToString('N'))
+Write-Host "Building (publish to $publishDir) ..." -ForegroundColor Cyan
+& dotnet publish $proj -c Release -o $publishDir --nologo | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "RESULT: FAIL - build/publish failed (warnings are errors)." -ForegroundColor Red
+ exit 1
+}
+$exe = Join-Path $publishDir 'RemSound.exe'
+if (-not (Test-Path -LiteralPath $exe)) {
+ Write-Host "RESULT: FAIL - no RemSound.exe was produced." -ForegroundColor Red
+ exit 1
+}
+
+# ---- 2. PACKAGE CONTENTS (must run BEFORE any CLI call: every RemSound launch consolidates the
+# bundled sounds\ into 'user settings and logs\sounds\', emptying sounds\) ----
+Write-Host "`nPackage contents:" -ForegroundColor Cyan
+$wavCount = @(Get-ChildItem -LiteralPath (Join-Path $publishDir 'sounds') -Filter *.wav -ErrorAction SilentlyContinue).Count
+if ($wavCount -ge 3) { Pass "cue sounds bundled ($wavCount .wav)" } else { Fail "cue sounds missing (found $wavCount) - this is the bug that shipped v3.9 with no sounds" }
+foreach ($cue in @('connect.wav', 'disconnect.wav', 'start up.wav')) {
+ if (Test-Path -LiteralPath (Join-Path $publishDir "sounds\$cue")) { Pass "cue '$cue' present" } else { Fail "cue '$cue' missing from the published sounds\ folder" }
+}
+if (Test-Path -LiteralPath (Join-Path $publishDir 'readme.html')) { Pass "readme.html (F1 manual) bundled" } else { Fail "readme.html missing" }
+if (Test-Path -LiteralPath (Join-Path $publishDir 'runtimes\win-x64\native\opus.dll')) { Pass "native opus.dll bundled" } else { Fail "native opus.dll missing (runtimes\win-x64\native\)" }
+if (Test-Path -LiteralPath (Join-Path $publishDir 'coreclr.dll')) { Fail "self-contained build (coreclr.dll present) - releases must be framework-dependent" } else { Pass "framework-dependent (no coreclr.dll)" }
+
+# built assembly version must match the csproj
+try {
+ $dllVer = [System.Reflection.AssemblyName]::GetAssemblyName((Join-Path $publishDir 'RemSound.dll')).Version
+ $dllMM = "$($dllVer.Major).$($dllVer.Minor)"
+ if ($dllMM -eq $expectedMM) { Pass "built RemSound.dll version $dllVer matches csproj $expectedVersion" }
+ else { Fail "RemSound.dll version $dllVer does not match csproj $expectedVersion" }
+} catch { Fail "could not read RemSound.dll version: $($_.Exception.Message)" }
+
+# ---- 3. SOURCE CHECKS (no app run needed) ----
+Write-Host "`nRelease readiness:" -ForegroundColor Cyan
+$about = Get-Content -LiteralPath (Join-Path $repo 'src\RemSound.App\AboutDialog.cs') -Raw
+if ($about -match [regex]::Escape("RemSound v$expectedMM")) { Pass "About-box changelog has a 'RemSound v$expectedMM' entry" }
+else { Fail "About-box changelog has no 'RemSound v$expectedMM' entry - add the release notes before shipping" }
+
+# Client <-> server wire contract. The Pi relay forwards by reading the wire header only; if the
+# client's header drifts from what the relay parses, the relay must be updated and re-released.
+# Ideally we never touch the server - this proves we don't need to.
+Write-Host "`nClient/server wire compatibility (no server change should be needed):" -ForegroundColor Cyan
+$relay = Get-Content -LiteralPath (Join-Path $repo 'server\remsound-relay.py') -Raw
+$packet = Get-Content -LiteralPath (Join-Path $repo 'src\RemSound.Core\RemPacket.cs') -Raw
+$wireOk = $true
+if ($relay -notmatch 'RMND') { Fail "relay no longer references the 'RMND' magic"; $wireOk = $false }
+if ($relay -match 'V1_VERSION\s*=\s*(\d+)') { if ($Matches[1] -ne '1') { Fail "relay V1_VERSION=$($Matches[1]) but the client writes version 1"; $wireOk = $false } }
+else { Fail "could not find V1_VERSION in the relay"; $wireOk = $false }
+if ($packet -match 'HeaderSize\s*=\s*(\d+)') { if ($Matches[1] -ne '12') { Fail "client RemPacket.HeaderSize=$($Matches[1]) but the relay reads a 12-byte header"; $wireOk = $false } }
+else { Fail "could not find RemPacket.HeaderSize"; $wireOk = $false }
+if ($packet -match 'DefaultPort\s*=\s*(\d+)') { if ($Matches[1] -ne '47830') { Fail "client DefaultPort=$($Matches[1]) but the relay listens on 47830"; $wireOk = $false } }
+if ($relay -notmatch '47830') { Fail "relay no longer references port 47830"; $wireOk = $false }
+if ($wireOk) { Pass "relay magic / version / port still match the client header - no server change needed" }
+
+# ---- 4. CLI SURFACE + IN-APP SELF-TEST (these launch the app, which consolidates sounds away;
+# that's why the package checks ran first) ----
+function Invoke-RsCli([string[]]$cliArgs) {
+ $out = Join-Path $env:TEMP ("rs-cli-" + [guid]::NewGuid().ToString('N') + ".txt")
+ $p = Start-Process -FilePath $exe -ArgumentList $cliArgs -Wait -NoNewWindow -RedirectStandardOutput $out -PassThru
+ $text = if (Test-Path $out) { Get-Content -LiteralPath $out -Raw -Encoding UTF8 } else { '' }
+ Remove-Item $out -Force -ErrorAction SilentlyContinue
+ [pscustomobject]@{ Code = $p.ExitCode; Text = ([string]$text) }
+}
+
+Write-Host "`nCLI surface:" -ForegroundColor Cyan
+$v = Invoke-RsCli @('--version')
+if ($v.Code -eq 0 -and $v.Text.Trim() -like 'RemSound *') { Pass "--version: $($v.Text.Trim())" } else { Fail "--version returned '$($v.Text.Trim())' (exit $($v.Code))" }
+if ($v.Text -match "$([regex]::Escape($expectedMM))(\D|$)") { Pass "--version matches csproj $expectedMM" } else { Fail "--version '$($v.Text.Trim())' does not match csproj $expectedVersion" }
+
+$h = Invoke-RsCli @('--help')
+$needed = @('--devices', '--selftest', '--diagnostics', '--connect', '--profile', '--minimized', '--log', '--close', '--version')
+$missing = @($needed | Where-Object { $h.Text -notlike "*$_*" })
+if ($h.Code -eq 0 -and $missing.Count -eq 0) { Pass "--help documents every option" } else { Fail "--help missing or errored: $($missing -join ', ') (exit $($h.Code))" }
+
+$dev = Invoke-RsCli @('--devices')
+if ($dev.Code -eq 0 -and $dev.Text.Length -gt 0) { Pass "--devices ran and produced output" } else { Fail "--devices exit $($dev.Code)" }
+
+Write-Host "`nIn-app self-test:" -ForegroundColor Cyan
+$st = Invoke-RsCli @('--selftest')
+foreach ($line in ($st.Text -split "`r?`n")) {
+ if ($line -match '\[(PASS|FAIL|SKIP)\]|^RESULT:') { Write-Host " $($line.Trim())" }
+}
+if ($st.Code -eq 0) { Pass "self-test passed (exit 0)" } else { Fail "self-test failed (exit $($st.Code))" }
+
+# ---- summary ----
+Remove-Item -LiteralPath $publishDir -Recurse -Force -ErrorAction SilentlyContinue
+Write-Host ""
+if ($script:failures.Count -eq 0) {
+ Write-Host "RESULT: PASS - all gate checks passed. Safe to publish." -ForegroundColor Green
+ exit 0
+}
+Write-Host "RESULT: FAIL - $($script:failures.Count) check(s) failed:" -ForegroundColor Red
+$script:failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
+exit 1
diff --git a/src/RemSound.App/CommandLine.cs b/src/RemSound.App/CommandLine.cs
index 6339426..82d2e8c 100644
--- a/src/RemSound.App/CommandLine.cs
+++ b/src/RemSound.App/CommandLine.cs
@@ -62,7 +62,7 @@ internal static class CommandLine
case "--devices": case "--list-devices":
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
case "--selftest": case "--self-test":
- return WithConsole(() => RunSelfTest(args));
+ return WithConsole(() => SelfTest.Run(args));
case "--diagnostics": case "--diag":
return WithConsole(() => RunDiagnostics(ValueAfter(args, raw)));
case "--log":
@@ -109,7 +109,7 @@ internal static class CommandLine
// ---------------- commands ----------------
- private static string AppVersion =>
+ internal static string AppVersion =>
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
private static int PrintVersion()
@@ -129,8 +129,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(" --selftest [--opus] Run a localhost audio round-trip (capture -> encode ->");
- Console.WriteLine(" [--seconds N] network -> decode) and report PASS or FAIL.");
+ Console.WriteLine(" --selftest [--seconds N] Run the built-in self-test - a localhost audio");
+ Console.WriteLine(" round-trip plus checks of encryption, the wire format,");
+ Console.WriteLine(" settings, profiles and bundled files - and report PASS/FAIL.");
Console.WriteLine(" --diagnostics [path] Write a diagnostics report (version, config, profiles,");
Console.WriteLine(" devices, mic-privacy check, recent log) and exit. With");
Console.WriteLine(" no path, it is saved in the user settings and logs folder.");
@@ -201,56 +202,6 @@ internal static class CommandLine
w.WriteLine();
}
- /// Localhost audio round-trip: capture the default output (as loopback) → encode →
- /// send to 127.0.0.1 → receive → decode. The receiver renders to nothing (no sound), so this is
- /// safe to run any time. PASS when packets flow end-to-end; exit code 0 = PASS, 1 = FAIL.
- private static int RunSelfTest(string[] args)
- {
- var opus = args.Any(a => a.Equals("--opus", StringComparison.OrdinalIgnoreCase));
- var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is > 0 and <= 60 ? s : 5;
-
- Console.WriteLine($"RemSound self-test: localhost {(opus ? "Opus" : "PCM")} round-trip for {seconds}s...");
-
- IReadOnlyList outputs;
- try { outputs = AudioDeviceCatalog.LoadOutputs(); }
- catch (Exception ex) { Console.WriteLine($" could not enumerate outputs: {ex.Message}"); return 1; }
- var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
- var deviceId = dev?.DeviceId;
- if (dev is null || deviceId is null)
- {
- Console.WriteLine(" RESULT: SKIP - no usable output device to capture from.");
- return 1;
- }
-
- using var receiver = new AudioReceiver();
- using var sender = new AudioSender();
- try
- {
- receiver.Start();
- receiver.SetOutputDevices(Array.Empty()); // decode only - never make sound during a test
- sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
- sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
- sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, RemPacket.DefaultPort) });
- sender.Start();
- Console.WriteLine($" capturing \"{dev.Name}\" -> 127.0.0.1:{RemPacket.DefaultPort}");
- Thread.Sleep(seconds * 1000);
- }
- finally
- {
- try { sender.Stop(); } catch { /* ignore */ }
- try { receiver.Stop(); } catch { /* ignore */ }
- }
-
- var sent = sender.PacketsSent;
- var got = receiver.PacketsReceived;
- Console.WriteLine($" packets sent={sent} received={got} bytes received={receiver.BytesReceived}");
- var pass = sent > 0 && got > 0;
- Console.WriteLine(pass
- ? " RESULT: PASS - capture, encode, network and decode are all working."
- : " RESULT: FAIL - audio did not flow end-to-end (sent or received was zero).");
- return pass ? 0 : 1;
- }
-
private static int SetLogging(string? value)
{
var on = value is not null && value.ToLowerInvariant() is "on" or "true" or "1" or "enable" or "enabled" or "yes";
@@ -279,9 +230,10 @@ internal static class CommandLine
return 0;
}
- /// Write a support-friendly diagnostics report and exit. Always writes a file so it
- /// works even when launched without a terminal; prints the path if a console is attached.
- private static int RunDiagnostics(string? pathArg)
+ /// Build the support diagnostics report text for a given config (version, settings,
+ /// profiles, devices, mic-privacy, recent log). Shared by --diagnostics and the
+ /// self-test's privacy check. Lists profile titles only - never their contents.
+ internal static string BuildDiagnosticsReport(AppConfig cfg)
{
var sb = new StringBuilder();
sb.AppendLine($"RemSound diagnostics");
@@ -293,8 +245,6 @@ internal static class CommandLine
sb.AppendLine($"Exe: {Environment.ProcessPath}");
sb.AppendLine();
- AppConfig cfg;
- try { cfg = AppConfig.Load(); } catch { cfg = new AppConfig(); }
sb.AppendLine("Settings:");
sb.AppendLine($" Logging enabled: {cfg.LoggingEnabled}");
sb.AppendLine($" Start minimised: {cfg.StartMinimised}");
@@ -326,6 +276,17 @@ internal static class CommandLine
sb.AppendLine(TailNewestLog(40));
sb.AppendLine();
+ return sb.ToString();
+ }
+
+ /// Write a support-friendly diagnostics report and exit. Always writes a file so it
+ /// works even when launched without a terminal; prints the path if a console is attached.
+ private static int RunDiagnostics(string? pathArg)
+ {
+ AppConfig cfg;
+ try { cfg = AppConfig.Load(); } catch { cfg = new AppConfig(); }
+ var report = BuildDiagnosticsReport(cfg);
+
var path = !string.IsNullOrWhiteSpace(pathArg)
? pathArg!
: Path.Combine(AppConfig.UserDataDirectory,
@@ -333,15 +294,15 @@ internal static class CommandLine
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
- File.WriteAllText(path, sb.ToString());
- Console.WriteLine($"Diagnostics written to:");
+ File.WriteAllText(path, report);
+ Console.WriteLine("Diagnostics written to:");
Console.WriteLine($" {path}");
}
catch (Exception ex)
{
Console.WriteLine($"Could not write the diagnostics file: {ex.Message}");
Console.WriteLine();
- Console.Write(sb.ToString()); // last resort - dump to the terminal
+ Console.Write(report); // last resort - dump to the terminal
return 1;
}
return 0;
diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj
index 979b3ef..2483884 100644
--- a/src/RemSound.App/RemSound.App.csproj
+++ b/src/RemSound.App/RemSound.App.csproj
@@ -119,4 +119,21 @@
PreserveNewest
+
+
+
+
+ <_CueWavs Include="..\..\sounds\*.wav" />
+
+
+
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
new file mode 100644
index 0000000..023a700
--- /dev/null
+++ b/src/RemSound.App/SelfTest.cs
@@ -0,0 +1,354 @@
+using System.Buffers.Binary;
+using System.Diagnostics;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using RemSound.Core;
+using RemSound.Receiver;
+using RemSound.Sender;
+
+namespace RemSound.App;
+
+///
+/// The in-app self-test, run by --selftest. Modelled on Andre's Sensor Readout: a list of
+/// named steps, each timed and reported PASS / FAIL / SKIP, a one-line summary, and an exit code
+/// (0 = every step passed or skipped, 1 = at least one failed) so a build-and-publish script can
+/// gate on it.
+///
+/// The steps run INSIDE a real RemSound process on purpose — that's the only way to exercise the
+/// genuine audio path, encryption, wire format and config/profile code rather than a stand-in.
+/// Everything here is read-only or temp-folder-scoped: a self-test never touches the user's real
+/// settings, profiles or logs, and never makes a sound.
+///
+internal static class SelfTest
+{
+ private sealed class Result
+ {
+ public string Name = "";
+ public string Status = ""; // PASS | FAIL | SKIP
+ public string Message = "";
+ public long Ms;
+ }
+
+ /// A step asserts with (failure) or bails with
+ /// (not applicable on this machine, e.g. no audio device). Both are signalled by exception so a
+ /// step body reads as straight-line code.
+ private sealed class CheckFailed : Exception { public CheckFailed(string m) : base(m) { } }
+ private sealed class StepSkipped : Exception { public StepSkipped(string m) : base(m) { } }
+
+ private static void Check(bool condition, string failMessage)
+ {
+ if (!condition) throw new CheckFailed(failMessage);
+ }
+
+ private static string Skip(string why) => throw new StepSkipped(why);
+
+ public static int Run(string[] args)
+ {
+ var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is > 0 and <= 30 ? s : 3;
+
+ Console.WriteLine($"RemSound self-test {CommandLine.AppVersion} ({DateTime.Now:yyyy-MM-dd HH:mm:ss})");
+ Console.WriteLine();
+
+ var results = new List();
+ RunStep(results, "Audio round-trip (PCM)", () => AudioRoundTrip(opus: false, seconds));
+ RunStep(results, "Audio round-trip (Opus)", () => AudioRoundTrip(opus: true, seconds));
+ RunStep(results, "Encryption round-trip", Encryption);
+ RunStep(results, "Packet framing and rejection", PacketFraming);
+ RunStep(results, "Server wire-format compatibility", ServerWireCompat);
+ RunStep(results, "App settings save and reload", SettingsRoundTrip);
+ RunStep(results, "Profile save and reload", ProfileRoundTrip);
+ RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
+ RunStep(results, "Bundled resources present", ResourcesPresent);
+
+ var failed = results.Count(r => r.Status == "FAIL");
+ var skipped = results.Count(r => r.Status == "SKIP");
+ var passed = results.Count(r => r.Status == "PASS");
+
+ Console.WriteLine();
+ if (failed == 0)
+ {
+ Console.WriteLine($"RESULT: PASS - {passed} passed{(skipped > 0 ? $", {skipped} skipped" : "")} of {results.Count}.");
+ return 0;
+ }
+ var names = string.Join(", ", results.Where(r => r.Status == "FAIL").Select(r => r.Name));
+ Console.WriteLine($"RESULT: FAIL - {failed} failed, {passed} passed{(skipped > 0 ? $", {skipped} skipped" : "")} of {results.Count}.");
+ Console.WriteLine($" Failed: {names}");
+ return 1;
+ }
+
+ private static void RunStep(List results, string name, Func body)
+ {
+ var sw = Stopwatch.StartNew();
+ var r = new Result { Name = name };
+ try { r.Message = body() ?? ""; r.Status = "PASS"; }
+ catch (StepSkipped sk) { r.Status = "SKIP"; r.Message = sk.Message; }
+ catch (CheckFailed cf) { r.Status = "FAIL"; r.Message = cf.Message; }
+ catch (Exception ex) { r.Status = "FAIL"; r.Message = $"{ex.GetType().Name}: {ex.Message}"; }
+ sw.Stop();
+ r.Ms = sw.ElapsedMilliseconds;
+ results.Add(r);
+ Console.WriteLine($" [{r.Status}] {name} ({r.Ms} ms){(r.Message.Length > 0 ? " - " + r.Message : "")}");
+ }
+
+ // ---------------- steps ----------------
+
+ /// Capture the default output as loopback → encode → send to 127.0.0.1 → receive →
+ /// decode, for a few seconds, with the receiver rendering to nothing (no sound). PASS when
+ /// packets flow both ways. SKIP on a machine with no usable output device (e.g. a headless CI
+ /// box) so the suite stays green where there's simply nothing to capture.
+ private static string? AudioRoundTrip(bool opus, int seconds)
+ {
+ IReadOnlyList outputs;
+ try { outputs = AudioDeviceCatalog.LoadOutputs(); }
+ catch (Exception ex) { return Skip("could not enumerate outputs: " + ex.Message); }
+ var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
+ if (dev?.DeviceId is not { } deviceId) return Skip("no usable output device to capture from");
+
+ // A dedicated test port, separate from the live DefaultPort (47830), so the self-test
+ // doesn't clash with a RemSound instance the user already has running.
+ const int testPort = 47929;
+ using var receiver = new AudioReceiver();
+ using var sender = new AudioSender();
+ try
+ {
+ try { receiver.Start(testPort); }
+ catch (Exception ex) { return Skip($"could not bind test port {testPort}: {ex.Message}"); }
+ receiver.SetOutputDevices(Array.Empty()); // decode only - never make sound during a test
+ sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
+ sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
+ sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, testPort) });
+ sender.Start();
+ Thread.Sleep(seconds * 1000);
+ }
+ finally
+ {
+ try { sender.Stop(); } catch { /* ignore */ }
+ try { receiver.Stop(); } catch { /* ignore */ }
+ }
+
+ var sent = sender.PacketsSent;
+ var got = receiver.PacketsReceived;
+ Check(sent > 0 && got > 0, $"audio did not flow end-to-end (sent={sent}, received={got})");
+ return $"sent={sent}, received={got}";
+ }
+
+ /// Audio encryption: the right password decrypts to the original, the wrong one fails
+ /// (silence, never garbage), fingerprints match/differ correctly, and the on-disk password
+ /// scramble round-trips without leaving the password in plain text.
+ private static string? Encryption()
+ {
+ var message = Encoding.UTF8.GetBytes("RemSound self-test payload 0123456789 the quick brown fox");
+ var keyA = RemSoundCrypto.DeriveKey("correct horse battery staple");
+ var keyB = RemSoundCrypto.DeriveKey("a different password entirely");
+
+ var cipher = RemSoundCrypto.Encrypt(keyA, message);
+ Check(RemSoundCrypto.TryDecrypt(keyA, cipher, out var plain) && plain.AsSpan().SequenceEqual(message),
+ "the right password must decrypt to the original bytes");
+ Check(!RemSoundCrypto.TryDecrypt(keyB, cipher, out _),
+ "the wrong password must fail to decrypt (silence, not garbage)");
+
+ Check(RemSoundCrypto.FingerprintsEqual(RemSoundCrypto.Fingerprint("shared"), RemSoundCrypto.Fingerprint("shared")),
+ "the same password must produce the same fingerprint");
+ Check(!RemSoundCrypto.FingerprintsEqual(RemSoundCrypto.Fingerprint("shared"), RemSoundCrypto.Fingerprint("other")),
+ "different passwords must produce different fingerprints");
+
+ const string pw = "p@ss w0rd!";
+ Check(RemSoundCrypto.Obfuscate(pw) != pw, "a stored password must not be plain text");
+ Check(RemSoundCrypto.Deobfuscate(RemSoundCrypto.Obfuscate(pw)) == pw, "the stored-password scramble must round-trip");
+ return "AES-256-GCM, PBKDF2 fingerprint, on-disk scramble";
+ }
+
+ /// The packet header writes and reads back for every type, and malformed packets
+ /// (too short, bad magic, wrong version) are rejected rather than mis-parsed. Plus the PCM
+ /// multi-part sub-header round-trips.
+ private static string? PacketFraming()
+ {
+ Span header = stackalloc byte[RemPacket.HeaderSize];
+ foreach (var type in new[] { RemPacketType.Format, RemPacketType.Audio, RemPacketType.Heartbeat, RemPacketType.Control })
+ {
+ RemPacket.WriteHeader(header, type, streamId: 7, sequence: 42);
+ Check(RemPacket.TryReadHeader(header, out var t, out var sid, out var seq) && t == type && sid == 7 && seq == 42,
+ $"header round-trip failed for {type}");
+ }
+
+ Check(!RemPacket.TryReadHeader(new byte[5], out _, out _, out _), "a too-short packet must be rejected");
+ Check(!RemPacket.TryReadHeader(new byte[RemPacket.HeaderSize], out _, out _, out _), "a zero/bad-magic packet must be rejected");
+
+ var wrongVersion = new byte[RemPacket.HeaderSize];
+ RemPacket.WriteHeader(wrongVersion, RemPacketType.Audio, 1, 1);
+ wrongVersion[4] = 99;
+ Check(!RemPacket.TryReadHeader(wrongVersion, out _, out _, out _), "a wrong-version packet must be rejected");
+
+ Span sub = stackalloc byte[RemPcmFrame.SubHeaderSize];
+ RemPcmFrame.WriteSubHeader(sub, frameId: 12345, partIndex: 1, totalParts: 3);
+ Check(RemPcmFrame.TryReadSubHeader(sub, out var fid, out var pi, out var tp) && fid == 12345 && pi == 1 && tp == 3,
+ "PCM sub-header round-trip failed");
+ return "header + PCM sub-header round-trip, malformed rejected";
+ }
+
+ ///
+ /// Client-to-server compatibility guard. The Pi relay (server/remsound-relay.py)
+ /// forwards packets by reading ONLY the wire header at fixed byte offsets — it never looks at
+ /// the audio. These are the exact field positions and values it assumes. If RemSound's header
+ /// ever changes shape, this step FAILS, which is the reminder that the relay must be updated
+ /// and a new server-* release cut before shipping. Ideally we never touch the server —
+ /// this check is how we keep proving that, in case the network stack changes underneath us.
+ ///
+ private static string? ServerWireCompat()
+ {
+ // Golden contract the relay parses (see remsound-relay.py: MAGIC, V1_VERSION, header offsets).
+ Check(RemPacket.HeaderSize == 12, "the relay reads a 12-byte header; RemPacket.HeaderSize must stay 12");
+ Check(RemPacket.Version == 1, "the relay matches version byte 1 (V1_VERSION); RemPacket.Version must stay 1");
+ Check(RemPacket.DefaultPort == 47830, "the relay listens on UDP 47830; RemPacket.DefaultPort must stay 47830");
+
+ // Packet-type values both ends agree on — changing any breaks interop with the relay/peers.
+ Check((byte)RemPacketType.Format == 1 && (byte)RemPacketType.Audio == 2
+ && (byte)RemPacketType.KeepAlive == 3 && (byte)RemPacketType.Heartbeat == 4
+ && (byte)RemPacketType.Control == 5,
+ "packet type values must stay Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5");
+
+ // Build a real header and assert the byte-level layout the relay reads.
+ Span h = stackalloc byte[RemPacket.HeaderSize];
+ RemPacket.WriteHeader(h, RemPacketType.Audio, streamId: 0x1234, sequence: 0xAABBCCDD);
+ Check(h[0] == (byte)'R' && h[1] == (byte)'M' && h[2] == (byte)'N' && h[3] == (byte)'D',
+ "magic must be ASCII 'RMND' at offset 0 (the relay's first-four-byte check)");
+ Check(h[4] == 1, "version byte must be at offset 4");
+ Check(h[5] == (byte)RemPacketType.Audio, "type byte must be at offset 5");
+ Check(BinaryPrimitives.ReadUInt16LittleEndian(h.Slice(6, 2)) == 0x1234,
+ "streamId must be a little-endian uint16 at offset 6 (the relay's pairing key)");
+ Check(BinaryPrimitives.ReadUInt32LittleEndian(h.Slice(8, 4)) == 0xAABBCCDD,
+ "sequence must be a little-endian uint32 at offset 8");
+ return "12-byte 'RMND' header; relay-visible fields unchanged";
+ }
+
+ /// App settings survive a save-and-reload (the same JSON serialisation
+ /// / use) without touching the real
+ /// config on disk.
+ private static string? SettingsRoundTrip()
+ {
+ var original = new AppConfig
+ {
+ LoggingEnabled = true,
+ StartMinimised = true,
+ EnableStartupCue = false,
+ UpdateCheckFrequency = UpdateCheckFrequency.EveryHour,
+ StartWithProfileTitle = "Studio link",
+ ProfilesDirectory = @"X:\some\profiles\folder",
+ };
+ var json = JsonSerializer.Serialize(original, new JsonSerializerOptions { WriteIndented = true });
+ var loaded = JsonSerializer.Deserialize(json);
+ Check(loaded is not null, "config must deserialise");
+ Check(loaded!.LoggingEnabled == original.LoggingEnabled
+ && loaded.StartMinimised == original.StartMinimised
+ && loaded.EnableStartupCue == original.EnableStartupCue
+ && loaded.UpdateCheckFrequency == original.UpdateCheckFrequency
+ && loaded.StartWithProfileTitle == original.StartWithProfileTitle
+ && loaded.ProfilesDirectory == original.ProfilesDirectory,
+ "settings must survive a save/reload unchanged");
+ return null;
+ }
+
+ /// A profile saved through reloads with its fields intact.
+ /// Runs entirely inside a throwaway temp folder — the user's real profiles are never touched.
+ private static string? ProfileRoundTrip()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), "remsound-selftest-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var store = new ProfileStore(temp);
+ var p = Profile.NewBlank();
+ p.Title = "selftest roundtrip";
+ p.ReceiveAudioOn = true;
+ p.SendAudioOn = false;
+ p.Volume = 73;
+ p.AudioPort = 47830;
+ p.AsioDriverName = "Some ASIO Driver";
+ p.SelectedWasapiSendInputs.Add("device-id-abc");
+ store.Save(p);
+
+ var back = store.Load("selftest roundtrip");
+ Check(back is not null, "the profile must load back from disk");
+ Check(back!.Title == p.Title
+ && back.Volume == 73
+ && back.ReceiveAudioOn && !back.SendAudioOn
+ && back.AudioPort == 47830
+ && back.AsioDriverName == "Some ASIO Driver"
+ && back.SelectedWasapiSendInputs.Contains("device-id-abc"),
+ "profile fields must survive a save/reload");
+ return null;
+ }
+ finally
+ {
+ try { Directory.Delete(temp, recursive: true); } catch { /* best-effort temp cleanup */ }
+ }
+ }
+
+ /// The diagnostics report lists a profile's title but never its password (plain or
+ /// scrambled). Guards against a future change accidentally dumping profile contents into a
+ /// support bundle. Uses a throwaway temp profiles folder with a known canary password.
+ private static string? DiagnosticsPrivacy()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), "remsound-selftest-priv-" + Guid.NewGuid().ToString("N"));
+ const string canaryTitle = "PrivacyCanaryProfile";
+ const string canaryPassword = "SENTINEL-PW-DO-NOT-LEAK-7f3a91";
+ try
+ {
+ Directory.CreateDirectory(temp);
+ var store = new ProfileStore(temp);
+ var p = Profile.NewBlank();
+ p.Title = canaryTitle;
+ p.Password = canaryPassword;
+ store.Save(p);
+
+ var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp });
+ Check(report.Contains("RemSound diagnostics") && report.Contains(Environment.MachineName),
+ "the diagnostics report must contain its basic header");
+ Check(report.Contains(canaryTitle), "the diagnostics report should list the profile title");
+ Check(!report.Contains(canaryPassword), "the diagnostics report must NOT contain a profile password (plain text)");
+ Check(!report.Contains(RemSoundCrypto.Obfuscate(canaryPassword)),
+ "the diagnostics report must NOT contain a profile password (scrambled form)");
+ return "title listed, password withheld";
+ }
+ finally
+ {
+ try { Directory.Delete(temp, recursive: true); } catch { /* best-effort temp cleanup */ }
+ }
+ }
+
+ /// The files a shipped RemSound needs at runtime are actually next to the exe: the
+ /// bundled manual, the cue sounds, and the native Opus library.
+ private static string? ResourcesPresent()
+ {
+ var root = AppContext.BaseDirectory;
+ Check(File.Exists(Path.Combine(root, "readme.html")), "readme.html (the F1 manual) must ship next to the exe");
+
+ // Cues are consolidated from the shipped sounds\ folder into the runtime sounds folder at
+ // startup (Program.ConsolidateSounds), so by the time the self-test runs they live here.
+ // An empty runtime folder means the shipped build had no sounds to seed from - exactly the
+ // bug that shipped the v3.9 zip with no cue sounds.
+ var soundsDir = AppConfig.SoundsDirectory;
+ Check(Directory.Exists(soundsDir), "the runtime sounds folder must exist (cues are consolidated at startup)");
+ foreach (var cue in new[] { "connect.wav", "disconnect.wav", "start up.wav" })
+ {
+ Check(File.Exists(Path.Combine(soundsDir, cue)), $"cue sound '{cue}' must be present (was the shipped sounds\\ folder empty?)");
+ }
+
+ // Native Opus (Concentus.Native) keeps the encoder off the allocation-heavy managed fallback.
+ var nativeOpus = Path.Combine(root, "runtimes", "win-x64", "native", "opus.dll");
+ Check(File.Exists(nativeOpus), "native opus.dll must ship under runtimes\\win-x64\\native\\");
+ return "manual, cue sounds, native Opus";
+ }
+
+ // ---------------- helper ----------------
+
+ private static string? ValueAfter(string[] args, string flag)
+ {
+ for (var i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i].Equals(flag, StringComparison.OrdinalIgnoreCase) && !args[i + 1].StartsWith('-'))
+ return args[i + 1];
+ }
+ return null;
+ }
+}