Test suite: richer diagnostics, headless accessibility audit, perf/leak sanity
Andre's three "bigger ideas" from RemSound-smoke-test-agent-brief.md: - Richer diagnostics: --diagnostics now includes a live localhost audio self-check (PCM + Opus, with packet/underrun/drop/buffer/latency counters), the most recent session snapshot parsed from the log (codec, send/receive state, buffer, drops, heartbeat), and a recent-warnings/errors digest from the log. BuildDiagnosticsReport gained a runLiveAudioProbe flag so the self-test's privacy check stays fast. - Headless accessibility audit: new --selftest step constructs the dialogs that can be built without hardware (Startup behaviour, Recording settings, Preferences) and checks every actionable control announces a name and that Alt-key mnemonics are unique within a container. MainForm is out of scope (its constructor opens audio/hotkeys/sockets). Dialogs that won't construct are skipped, not failed. Currently audits 3, no violations. - Perf/leak sanity: new --perftest command runs several audio-loopback cycles and reports whether handle/memory/thread counts stay bounded (handles ratcheting up cycle-on-cycle is the leak fingerprint, given RemSound's handle-leak history). Lenient thresholds; logs the numbers for build-to-build comparison. Wired into run-tests.ps1. - Shared AudioLoopback helper (used by the self-test, diagnostics and perf test) so all three exercise the identical real capture/encode/network/decode path on test port 47929. - csproj: the four previously-unconditional cue Content items are now Exists-guarded like the rest, so a mid-edit sounds\ folder doesn't break the dev build; the gate still enforces the required cues before release. Help + manual updated (--perftest, --smoke-test, --config-dir, richer --selftest). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
832ed40bf7
commit
ad66fe5364
@@ -1099,7 +1099,9 @@ Option| What it does
|
|||||||
`--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.
|
`--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`
|
`--selftest`
|
||||||
(or `--smoke-test`)| 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.
|
(or `--smoke-test`)| 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.
|
`--perftest`
|
||||||
|
[`--seconds N`]| Runs the audio path through several short cycles and reports whether RemSound's handle, memory and thread use stays bounded — a quick check for resource leaks. Prints the numbers each cycle so two builds can be compared. No sound is played out.
|
||||||
|
`--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, a quick live audio self-check, the most recent session snapshot and recent warnings from the log, 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
|
### Options that change a setting or control a running copy, then exit
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1155,7 +1155,8 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<tr><td><code>--version</code></td><td>Prints which version of RemSound is installed, for example “RemSound 3.9”.</td></tr>
|
<tr><td><code>--version</code></td><td>Prints which version of RemSound is installed, for example “RemSound 3.9”.</td></tr>
|
||||||
<tr><td><code>--devices</code></td><td>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.</td></tr>
|
<tr><td><code>--devices</code></td><td>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.</td></tr>
|
||||||
<tr><td><code>--selftest</code><br>(or <code>--smoke-test</code>)</td><td>Runs RemSound's built-in self-test and reports <strong>PASS</strong> or <strong>FAIL</strong>. 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 <code>--seconds N</code> to make the audio part run for longer than the default.</td></tr>
|
<tr><td><code>--selftest</code><br>(or <code>--smoke-test</code>)</td><td>Runs RemSound's built-in self-test and reports <strong>PASS</strong> or <strong>FAIL</strong>. 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 <code>--seconds N</code> to make the audio part run for longer than the default.</td></tr>
|
||||||
<tr><td><code>--diagnostics</code></td><td>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 <strong>user settings and logs</strong> folder and prints where it put it; you can also give a path, for example <code>--diagnostics C:\Users\you\Desktop\report.txt</code>. This is the file to send when asking for help — it answers most questions in one go.</td></tr>
|
<tr><td><code>--perftest</code><br>[<code>--seconds N</code>]</td><td>Runs the audio path through several short cycles and reports whether RemSound's handle, memory and thread use stays bounded — a quick check for resource leaks. Prints the numbers each cycle so two builds can be compared. No sound is played out.</td></tr>
|
||||||
|
<tr><td><code>--diagnostics</code></td><td>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, a quick live audio self-check, the most recent session snapshot and recent warnings from the log, and the tail of the most recent log. With no path it saves into the <strong>user settings and logs</strong> folder and prints where it put it; you can also give a path, for example <code>--diagnostics C:\Users\you\Desktop\report.txt</code>. This is the file to send when asking for help — it answers most questions in one go.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Options that change a setting or control a running copy, then exit</h3>
|
<h3>Options that change a setting or control a running copy, then exit</h3>
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ foreach ($line in ($st.Text -split "`r?`n")) {
|
|||||||
}
|
}
|
||||||
if ($st.Code -eq 0) { Pass "self-test passed (exit 0)" } else { Fail "self-test failed (exit $($st.Code))" }
|
if ($st.Code -eq 0) { Pass "self-test passed (exit 0)" } else { Fail "self-test failed (exit $($st.Code))" }
|
||||||
|
|
||||||
|
Write-Host "`nResource sanity (handle/memory leak check):" -ForegroundColor Cyan
|
||||||
|
$pf = Invoke-RsCli @('--perftest', '--seconds', '12')
|
||||||
|
foreach ($line in ($pf.Text -split "`r?`n")) {
|
||||||
|
if ($line -match 'baseline:|cycle \d|net change|^\s*RESULT:') { Write-Host " $($line.Trim())" }
|
||||||
|
}
|
||||||
|
if ($pf.Code -eq 0) { Pass "resources stayed bounded across cycles (or skipped - no audio device)" }
|
||||||
|
else { Fail "perf sanity flagged possible runaway (exit $($pf.Code))" }
|
||||||
|
|
||||||
# ---- 5. COLD START + CLEAN CLOSE, against an isolated --config-dir so the real settings are never
|
# ---- 5. COLD START + CLEAN CLOSE, against an isolated --config-dir so the real settings are never
|
||||||
# touched (smoke-test brief, safety rule 1 + baseline steps 3-4) ----
|
# touched (smoke-test brief, safety rule 1 + baseline steps 3-4) ----
|
||||||
Write-Host "`nCold start and clean close (isolated config):" -ForegroundColor Cyan
|
Write-Host "`nCold start and clean close (isolated config):" -ForegroundColor Cyan
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Net;
|
||||||
|
using RemSound.Core;
|
||||||
|
using RemSound.Receiver;
|
||||||
|
using RemSound.Sender;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A localhost audio loopback used by the self-test, the diagnostics report and the perf check:
|
||||||
|
/// 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 (so no sound is ever produced). Exposes the
|
||||||
|
/// runtime counters afterwards. Shared so all three callers exercise the identical real audio path.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AudioLoopback
|
||||||
|
{
|
||||||
|
/// <summary>A dedicated test port, separate from the live DefaultPort (47830), so a loopback
|
||||||
|
/// never clashes with a RemSound instance the user already has running.</summary>
|
||||||
|
internal const int TestPort = 47929;
|
||||||
|
|
||||||
|
internal sealed record Result(
|
||||||
|
bool Ran, string Codec,
|
||||||
|
long PacketsSent, long PacketsReceived, long BytesReceived,
|
||||||
|
long Underruns, long Drops, int BufferMs, int TargetLatencyMs,
|
||||||
|
string? SkipReason)
|
||||||
|
{
|
||||||
|
public bool Flowed => Ran && PacketsSent > 0 && PacketsReceived > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result Skipped(string codec, string why) =>
|
||||||
|
new(false, codec, 0, 0, 0, 0, 0, 0, 0, why);
|
||||||
|
|
||||||
|
/// <summary>Run the loopback for <paramref name="seconds"/> and return the counters. Never
|
||||||
|
/// throws for an environment reason (no output device, port busy) - it returns a Result with
|
||||||
|
/// <see cref="Result.Ran"/> = false and a <see cref="Result.SkipReason"/> instead, so callers
|
||||||
|
/// can treat "no audio hardware" as a skip rather than a failure.</summary>
|
||||||
|
internal static Result Run(bool opus, int seconds, int port = TestPort)
|
||||||
|
{
|
||||||
|
var codec = opus ? "Opus" : "PCM";
|
||||||
|
|
||||||
|
IReadOnlyList<AudioDeviceChoice> outputs;
|
||||||
|
try { outputs = AudioDeviceCatalog.LoadOutputs(); }
|
||||||
|
catch (Exception ex) { return Skipped(codec, "could not enumerate outputs: " + ex.Message); }
|
||||||
|
var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
|
||||||
|
if (dev?.DeviceId is not { } deviceId) return Skipped(codec, "no usable output device to capture from");
|
||||||
|
|
||||||
|
using var receiver = new AudioReceiver();
|
||||||
|
using var sender = new AudioSender();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
try { receiver.Start(port); }
|
||||||
|
catch (Exception ex) { return Skipped(codec, $"could not bind test port {port}: {ex.Message}"); }
|
||||||
|
receiver.SetOutputDevices(Array.Empty<string>()); // decode only - never make sound
|
||||||
|
sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
|
||||||
|
sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
|
||||||
|
sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, port) });
|
||||||
|
sender.Start();
|
||||||
|
Thread.Sleep(Math.Max(1, seconds) * 1000);
|
||||||
|
|
||||||
|
// Read the counters while still running (before the finally stops the engines).
|
||||||
|
return new Result(
|
||||||
|
Ran: true, Codec: codec,
|
||||||
|
PacketsSent: sender.PacketsSent,
|
||||||
|
PacketsReceived: receiver.PacketsReceived,
|
||||||
|
BytesReceived: receiver.BytesReceived,
|
||||||
|
Underruns: receiver.Underruns,
|
||||||
|
Drops: receiver.Drops,
|
||||||
|
BufferMs: receiver.CurrentBufferMs,
|
||||||
|
TargetLatencyMs: receiver.TargetLatencyMs,
|
||||||
|
SkipReason: null);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { sender.Stop(); } catch { /* ignore */ }
|
||||||
|
try { receiver.Stop(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ internal static class CommandLine
|
|||||||
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
|
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
|
||||||
case "--selftest": case "--self-test": case "--smoke-test": case "--smoketest":
|
case "--selftest": case "--self-test": case "--smoke-test": case "--smoketest":
|
||||||
return WithConsole(() => SelfTest.Run(args));
|
return WithConsole(() => SelfTest.Run(args));
|
||||||
|
case "--perftest": case "--perf-test":
|
||||||
|
return WithConsole(() => RunPerfTest(args));
|
||||||
case "--diagnostics": case "--diag":
|
case "--diagnostics": case "--diag":
|
||||||
return WithConsole(() => RunDiagnostics(ValueAfter(args, raw)));
|
return WithConsole(() => RunDiagnostics(ValueAfter(args, raw)));
|
||||||
case "--log":
|
case "--log":
|
||||||
@@ -150,7 +152,9 @@ internal static class CommandLine
|
|||||||
Console.WriteLine(" with their formats and device ids.");
|
Console.WriteLine(" with their formats and device ids.");
|
||||||
Console.WriteLine(" --selftest [--seconds N] Run the built-in self-test - a localhost audio");
|
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(" (or --smoke-test) round-trip plus checks of encryption, the wire format,");
|
||||||
Console.WriteLine(" settings, profiles and bundled files - and report PASS/FAIL.");
|
Console.WriteLine(" settings, profiles, dialog accessibility and bundled files.");
|
||||||
|
Console.WriteLine(" --perftest [--seconds N] Run repeated audio cycles and report whether handle,");
|
||||||
|
Console.WriteLine(" memory and thread counts stay bounded (leak sanity check).");
|
||||||
Console.WriteLine(" --diagnostics [path] Write a diagnostics report (version, config, profiles,");
|
Console.WriteLine(" --diagnostics [path] Write a diagnostics report (version, config, profiles,");
|
||||||
Console.WriteLine(" devices, mic-privacy check, recent log) and exit. With");
|
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.");
|
Console.WriteLine(" no path, it is saved in the user settings and logs folder.");
|
||||||
@@ -225,6 +229,62 @@ internal static class CommandLine
|
|||||||
w.WriteLine();
|
w.WriteLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Resource-sanity check: run several short audio-loopback cycles and watch this
|
||||||
|
/// process's handle / memory / thread counts. Each cycle builds and tears down the audio
|
||||||
|
/// engine, so a handle or thread count that ratchets up cycle on cycle is the fingerprint of a
|
||||||
|
/// leak (RemSound has a handle-leak history). Lenient thresholds - it flags obvious runaway, not
|
||||||
|
/// normal fluctuation - and logs the numbers so builds can be compared. Note: the loopback
|
||||||
|
/// renders to nothing (no real output device, to stay silent), so it exercises the
|
||||||
|
/// capture/encode/network/decode path, not the WASAPI render path.</summary>
|
||||||
|
private static int RunPerfTest(string[] args)
|
||||||
|
{
|
||||||
|
var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is >= 6 and <= 120 ? s : 15;
|
||||||
|
const int cycles = 3;
|
||||||
|
var perCycle = Math.Max(2, seconds / cycles);
|
||||||
|
var proc = System.Diagnostics.Process.GetCurrentProcess();
|
||||||
|
|
||||||
|
Console.WriteLine($"RemSound perf sanity: {cycles} x {perCycle}s audio loopback, watching handles/memory/threads...");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
static (int Handles, long WorkingSetMb, long PrivateMb, int Threads) Measure(System.Diagnostics.Process p)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
p.Refresh();
|
||||||
|
return (p.HandleCount, p.WorkingSet64 / (1024 * 1024), p.PrivateMemorySize64 / (1024 * 1024), p.Threads.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseline = Measure(proc);
|
||||||
|
Console.WriteLine($" baseline: handles={baseline.Handles} workingSet={baseline.WorkingSetMb}MB private={baseline.PrivateMb}MB threads={baseline.Threads}");
|
||||||
|
|
||||||
|
for (var i = 1; i <= cycles; i++)
|
||||||
|
{
|
||||||
|
var r = AudioLoopback.Run(opus: true, perCycle);
|
||||||
|
if (!r.Ran)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" RESULT: SKIP - {r.SkipReason} (no audio device to exercise).");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var m = Measure(proc);
|
||||||
|
Console.WriteLine($" cycle {i}/{cycles}: handles={m.Handles} workingSet={m.WorkingSetMb}MB private={m.PrivateMb}MB threads={m.Threads} (sent={r.PacketsSent}, received={r.PacketsReceived})");
|
||||||
|
}
|
||||||
|
|
||||||
|
var final = Measure(proc);
|
||||||
|
var handleGrowth = final.Handles - baseline.Handles;
|
||||||
|
var threadGrowth = final.Threads - baseline.Threads;
|
||||||
|
var memGrowth = final.WorkingSetMb - baseline.WorkingSetMb;
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($" net change over {cycles} cycles: handles {handleGrowth:+#;-#;0}, threads {threadGrowth:+#;-#;0}, workingSet {memGrowth:+#;-#;0}MB");
|
||||||
|
|
||||||
|
// Lenient: flag obvious runaway (e.g. a handle leak ratcheting up each cycle), not noise.
|
||||||
|
var runaway = handleGrowth > 1500 || threadGrowth > 100 || final.WorkingSetMb > 1500;
|
||||||
|
Console.WriteLine(runaway
|
||||||
|
? " RESULT: FAIL - resource use looks like it is running away (possible leak); compare the per-cycle trend above."
|
||||||
|
: " RESULT: PASS - handles, threads and memory stayed bounded across cycles.");
|
||||||
|
return runaway ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static int SetLogging(string? value)
|
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";
|
var on = value is not null && value.ToLowerInvariant() is "on" or "true" or "1" or "enable" or "enabled" or "yes";
|
||||||
@@ -256,7 +316,7 @@ internal static class CommandLine
|
|||||||
/// <summary>Build the support diagnostics report text for a given config (version, settings,
|
/// <summary>Build the support diagnostics report text for a given config (version, settings,
|
||||||
/// profiles, devices, mic-privacy, recent log). Shared by <c>--diagnostics</c> and the
|
/// profiles, devices, mic-privacy, recent log). Shared by <c>--diagnostics</c> and the
|
||||||
/// self-test's privacy check. Lists profile titles only - never their contents.</summary>
|
/// self-test's privacy check. Lists profile titles only - never their contents.</summary>
|
||||||
internal static string BuildDiagnosticsReport(AppConfig cfg)
|
internal static string BuildDiagnosticsReport(AppConfig cfg, bool runLiveAudioProbe = true)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine($"RemSound diagnostics");
|
sb.AppendLine($"RemSound diagnostics");
|
||||||
@@ -295,6 +355,21 @@ internal static class CommandLine
|
|||||||
sb.AppendLine($" {DescribeMicPrivacy()}");
|
sb.AppendLine($" {DescribeMicPrivacy()}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
|
if (runLiveAudioProbe)
|
||||||
|
{
|
||||||
|
sb.AppendLine("Live audio self-check (localhost loopback, no sound output):");
|
||||||
|
AppendLiveAudioCheck(sb);
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("Most recent session snapshot (from the log):");
|
||||||
|
sb.AppendLine($" {LastSessionSnapshot()}");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("Recent warnings and errors (from the log):");
|
||||||
|
sb.AppendLine(RecentLogProblems(15));
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
sb.AppendLine("Most recent log (tail):");
|
sb.AppendLine("Most recent log (tail):");
|
||||||
sb.AppendLine(TailNewestLog(40));
|
sb.AppendLine(TailNewestLog(40));
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
@@ -302,6 +377,70 @@ internal static class CommandLine
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Run a short localhost loopback for each codec and report the live counters - proves
|
||||||
|
/// this machine's capture/encode/network/decode path works, with real packet/underrun/drop
|
||||||
|
/// numbers. Skips (no failure) when there's no audio device.</summary>
|
||||||
|
private static void AppendLiveAudioCheck(StringBuilder sb)
|
||||||
|
{
|
||||||
|
foreach (var opus in new[] { false, true })
|
||||||
|
{
|
||||||
|
var r = AudioLoopback.Run(opus, 2);
|
||||||
|
if (!r.Ran) { sb.AppendLine($" {r.Codec}: skipped ({r.SkipReason})"); continue; }
|
||||||
|
sb.AppendLine($" {r.Codec}: sent={r.PacketsSent} pkts, received={r.PacketsReceived} pkts ({r.BytesReceived} bytes), "
|
||||||
|
+ $"underruns={r.Underruns}, drops={r.Drops}, buffer={r.BufferMs}ms, target latency={r.TargetLatencyMs}ms "
|
||||||
|
+ $"-> {(r.Flowed ? "OK" : "NO AUDIO")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The last one-second SNAP row from the newest log, as a readable line: the real
|
||||||
|
/// running session's codec, send/receive state, buffer, underruns, drops and heartbeat. These
|
||||||
|
/// are the live values a static report can't otherwise show. Empty when logging is off.</summary>
|
||||||
|
private static string LastSessionSnapshot()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = AppConfig.LogsDirectory;
|
||||||
|
if (!Directory.Exists(dir)) return "(no logs - logging may be off)";
|
||||||
|
var newest = new DirectoryInfo(dir).GetFiles("*.log").OrderByDescending(f => f.LastWriteTimeUtc).FirstOrDefault();
|
||||||
|
if (newest is null) return "(no log files - logging may be off)";
|
||||||
|
var lines = File.ReadAllLines(newest.FullName);
|
||||||
|
var header = lines.FirstOrDefault(l => l.StartsWith("Kind\t") || l.Contains("\tCodec\t"));
|
||||||
|
var lastSnap = lines.LastOrDefault(l => l.StartsWith("SNAP\t"));
|
||||||
|
if (header is null || lastSnap is null) return "(no session snapshots in the newest log yet)";
|
||||||
|
var cols = header.Split('\t');
|
||||||
|
var vals = lastSnap.Split('\t');
|
||||||
|
string Col(string name) { var i = Array.IndexOf(cols, name); return i >= 0 && i < vals.Length ? vals[i] : "?"; }
|
||||||
|
return $"Connected={Col("Connected")}, Send={Col("SendRunning")}, Receive={Col("ReceiveRunning")}, "
|
||||||
|
+ $"Codec={Col("Codec")}, Buffer={Col("BufferMs")}ms, Underruns={Col("Underruns")}, "
|
||||||
|
+ $"Drops={Col("Drops")}, Heartbeat={Col("Heartbeat")}, TargetLatency={Col("TargetLatencyMs")}ms";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { return $"(could not read snapshot: {ex.Message})"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The recent WARN/ERROR/exception-style lines from the newest log, so a support
|
||||||
|
/// reader sees the problems without scrolling the whole file. Last <paramref name="max"/>.</summary>
|
||||||
|
private static string RecentLogProblems(int max)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = AppConfig.LogsDirectory;
|
||||||
|
if (!Directory.Exists(dir)) return " (no logs - logging may be off)";
|
||||||
|
var newest = new DirectoryInfo(dir).GetFiles("*.log").OrderByDescending(f => f.LastWriteTimeUtc).FirstOrDefault();
|
||||||
|
if (newest is null) return " (no log files - logging may be off)";
|
||||||
|
var problems = File.ReadLines(newest.FullName)
|
||||||
|
.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| l.Contains("warn", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| l.Contains("exception", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| l.Contains("unreachable", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| l.Contains("failed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (problems.Count == 0) return " (none in the newest log)";
|
||||||
|
var tail = problems.Count <= max ? problems : problems.GetRange(problems.Count - max, max);
|
||||||
|
return string.Join(Environment.NewLine, tail.Select(l => " " + l));
|
||||||
|
}
|
||||||
|
catch (Exception ex) { return $" (could not scan log: {ex.Message})"; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Write a support-friendly diagnostics report and exit. Always writes a file so it
|
/// <summary>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.</summary>
|
/// works even when launched without a terminal; prints the path if a console is attached.</summary>
|
||||||
private static int RunDiagnostics(string? pathArg)
|
private static int RunDiagnostics(string? pathArg)
|
||||||
|
|||||||
@@ -62,19 +62,23 @@
|
|||||||
user-supplied custom cue paths set in Preferences override the defaults at runtime.
|
user-supplied custom cue paths set in Preferences override the defaults at runtime.
|
||||||
Filenames containing a space (e.g. "record start.wav") are preserved verbatim on
|
Filenames containing a space (e.g. "record start.wav") are preserved verbatim on
|
||||||
copy so the load-by-filename path in TryLoadCueSound finds them exactly as written. -->
|
copy so the load-by-filename path in TryLoadCueSound finds them exactly as written. -->
|
||||||
<Content Include="..\..\sounds\connect.wav">
|
<!-- Exists-guarded like the newer cues below: a missing source WAV (e.g. while the project owner
|
||||||
|
is swapping in new cue sounds) must not break the dev build. The release gate (run-tests.ps1
|
||||||
|
and the self-test "Bundled resources present" step) enforces that the required cues are
|
||||||
|
actually present before a release ships, so guarding here loses no safety. -->
|
||||||
|
<Content Include="..\..\sounds\connect.wav" Condition="Exists('..\..\sounds\connect.wav')">
|
||||||
<Link>sounds\connect.wav</Link>
|
<Link>sounds\connect.wav</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Include="..\..\sounds\disconnect.wav">
|
<Content Include="..\..\sounds\disconnect.wav" Condition="Exists('..\..\sounds\disconnect.wav')">
|
||||||
<Link>sounds\disconnect.wav</Link>
|
<Link>sounds\disconnect.wav</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Include="..\..\sounds\record start.wav">
|
<Content Include="..\..\sounds\record start.wav" Condition="Exists('..\..\sounds\record start.wav')">
|
||||||
<Link>sounds\record start.wav</Link>
|
<Link>sounds\record start.wav</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Include="..\..\sounds\record stop.wav">
|
<Content Include="..\..\sounds\record stop.wav" Condition="Exists('..\..\sounds\record stop.wav')">
|
||||||
<Link>sounds\record stop.wav</Link>
|
<Link>sounds\record stop.wav</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ using System.Diagnostics;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Windows.Forms;
|
||||||
using RemSound.Core;
|
using RemSound.Core;
|
||||||
using RemSound.Receiver;
|
|
||||||
using RemSound.Sender;
|
|
||||||
|
|
||||||
namespace RemSound.App;
|
namespace RemSound.App;
|
||||||
|
|
||||||
@@ -60,6 +59,7 @@ internal static class SelfTest
|
|||||||
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
||||||
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
|
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
|
||||||
RunStep(results, "Bundled resources present", ResourcesPresent);
|
RunStep(results, "Bundled resources present", ResourcesPresent);
|
||||||
|
RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit);
|
||||||
|
|
||||||
var failed = results.Count(r => r.Status == "FAIL");
|
var failed = results.Count(r => r.Status == "FAIL");
|
||||||
var skipped = results.Count(r => r.Status == "SKIP");
|
var skipped = results.Count(r => r.Status == "SKIP");
|
||||||
@@ -99,38 +99,10 @@ internal static class SelfTest
|
|||||||
/// box) so the suite stays green where there's simply nothing to capture.</summary>
|
/// box) so the suite stays green where there's simply nothing to capture.</summary>
|
||||||
private static string? AudioRoundTrip(bool opus, int seconds)
|
private static string? AudioRoundTrip(bool opus, int seconds)
|
||||||
{
|
{
|
||||||
IReadOnlyList<AudioDeviceChoice> outputs;
|
var r = AudioLoopback.Run(opus, seconds);
|
||||||
try { outputs = AudioDeviceCatalog.LoadOutputs(); }
|
if (!r.Ran) return Skip(r.SkipReason ?? "audio loopback unavailable");
|
||||||
catch (Exception ex) { return Skip("could not enumerate outputs: " + ex.Message); }
|
Check(r.Flowed, $"audio did not flow end-to-end (sent={r.PacketsSent}, received={r.PacketsReceived})");
|
||||||
var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
|
return $"sent={r.PacketsSent}, received={r.PacketsReceived}";
|
||||||
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<string>()); // 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}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Audio encryption: the right password decrypts to the original, the wrong one fails
|
/// <summary>Audio encryption: the right password decrypts to the original, the wrong one fails
|
||||||
@@ -301,7 +273,7 @@ internal static class SelfTest
|
|||||||
p.Password = canaryPassword;
|
p.Password = canaryPassword;
|
||||||
store.Save(p);
|
store.Save(p);
|
||||||
|
|
||||||
var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp });
|
var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp }, runLiveAudioProbe: false);
|
||||||
Check(report.Contains("RemSound diagnostics") && report.Contains(Environment.MachineName),
|
Check(report.Contains("RemSound diagnostics") && report.Contains(Environment.MachineName),
|
||||||
"the diagnostics report must contain its basic header");
|
"the diagnostics report must contain its basic header");
|
||||||
Check(report.Contains(canaryTitle), "the diagnostics report should list the profile title");
|
Check(report.Contains(canaryTitle), "the diagnostics report should list the profile title");
|
||||||
@@ -340,6 +312,89 @@ internal static class SelfTest
|
|||||||
return "manual, cue sounds, native Opus";
|
return "manual, cue sounds, native Opus";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Headless accessibility audit of the dialogs that can be built without hardware: every
|
||||||
|
/// actionable control announces a name to a screen reader, and the Alt-key mnemonic letters are
|
||||||
|
/// unique within a container so keyboard navigation is never ambiguous. The main window can't be
|
||||||
|
/// built headlessly (its constructor opens audio devices, registers hotkeys and binds sockets),
|
||||||
|
/// so it's out of scope here. A dialog that won't construct in this context is skipped, not
|
||||||
|
/// failed.</summary>
|
||||||
|
private static string? AccessibilityAudit()
|
||||||
|
{
|
||||||
|
var factories = new (string Name, Func<Form> Make)[]
|
||||||
|
{
|
||||||
|
("Startup behaviour", () => new StartupBehaviourDialog(null)),
|
||||||
|
("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())),
|
||||||
|
("Preferences", () => new PreferencesDialog(
|
||||||
|
new RemSoundSettingsStore("RemSound"), null,
|
||||||
|
() => false, _ => { }, () => { }, () => { }, () => { }, _ => { },
|
||||||
|
() => (default(RouterMappingStatus), (IPEndPoint?)null, ""),
|
||||||
|
_ => { }, _ => { })),
|
||||||
|
};
|
||||||
|
|
||||||
|
var audited = new List<string>();
|
||||||
|
var skipped = new List<string>();
|
||||||
|
var violations = new List<string>();
|
||||||
|
|
||||||
|
foreach (var (name, make) in factories)
|
||||||
|
{
|
||||||
|
Form? form = null;
|
||||||
|
try { form = make(); }
|
||||||
|
catch (Exception ex) { skipped.Add($"{name} ({ex.GetType().Name})"); continue; }
|
||||||
|
try { AuditForm(name, form, violations); audited.Add(name); }
|
||||||
|
finally { try { form.Dispose(); } catch { /* ignore */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audited.Count == 0) return Skip("no dialog could be constructed in this context");
|
||||||
|
Check(violations.Count == 0, string.Join("; ", violations));
|
||||||
|
var detail = $"audited {audited.Count} ({string.Join(", ", audited)})";
|
||||||
|
if (skipped.Count > 0) detail += $"; skipped {skipped.Count}";
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AuditForm(string formName, Form form, List<string> violations)
|
||||||
|
{
|
||||||
|
var all = new List<Control>();
|
||||||
|
void Walk(Control parent) { foreach (Control c in parent.Controls) { all.Add(c); Walk(c); } }
|
||||||
|
Walk(form);
|
||||||
|
|
||||||
|
// Mnemonic uniqueness, per immediate container (the practical Alt-key scope).
|
||||||
|
foreach (var group in all.Where(c => TryMnemonic(c.Text, out _)).GroupBy(c => c.Parent))
|
||||||
|
{
|
||||||
|
var counts = new Dictionary<char, int>();
|
||||||
|
foreach (var c in group)
|
||||||
|
{
|
||||||
|
if (TryMnemonic(c.Text, out var letter))
|
||||||
|
counts[letter] = counts.TryGetValue(letter, out var n) ? n + 1 : 1;
|
||||||
|
}
|
||||||
|
foreach (var dup in counts.Where(kv => kv.Value > 1))
|
||||||
|
violations.Add($"{formName}: Alt+{char.ToUpperInvariant(dup.Key)} is used by {dup.Value} controls in one group");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-labelling controls (buttons, check boxes, radio buttons) must announce something.
|
||||||
|
foreach (var c in all.Where(c => c is ButtonBase))
|
||||||
|
{
|
||||||
|
var name = !string.IsNullOrWhiteSpace(c.AccessibleName) ? c.AccessibleName : c.Text;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
violations.Add($"{formName}: a {c.GetType().Name} has no accessible name or text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extract the Alt mnemonic letter from a WinForms caption ('&X' marks X; '&&'
|
||||||
|
/// is a literal ampersand). Returns false when there is no mnemonic.</summary>
|
||||||
|
private static bool TryMnemonic(string? text, out char letter)
|
||||||
|
{
|
||||||
|
letter = '\0';
|
||||||
|
if (string.IsNullOrEmpty(text)) return false;
|
||||||
|
for (var i = 0; i < text.Length - 1; i++)
|
||||||
|
{
|
||||||
|
if (text[i] != '&') continue;
|
||||||
|
if (text[i + 1] == '&') { i++; continue; } // escaped "&&" is a literal ampersand
|
||||||
|
letter = char.ToLowerInvariant(text[i + 1]);
|
||||||
|
return char.IsLetterOrDigit(letter);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- helper ----------------
|
// ---------------- helper ----------------
|
||||||
|
|
||||||
private static string? ValueAfter(string[] args, string flag)
|
private static string? ValueAfter(string[] args, string flag)
|
||||||
|
|||||||
Reference in New Issue
Block a user