From b258bec9a23a1ca05995d28affb82f388bff0d8a Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:58:46 +0100 Subject: [PATCH] Tests: recording engine coverage (all formats + source gate + mono) Local checkpoint - NOT for public release. First test-suite increment. New self-test "Recording engine" drives the real AudioRecorder headless by feeding its audio-thread taps a synthetic tone, then asserting the output files: - every format written with real content: WAV / MP3 (LAME) / OGG-Opus (Concentus) / FLAC (FLAKE), native encoders and all. - source gate: a SentOnly recorder fed only received audio stays (near) empty. - mono downmix produces a valid smaller WAV. This is the recording pain Ed flagged - now proven on every build instead of by ear. Follow-up: split-track (per-peer) recording is orchestrated by RecordingController, which reads settings from the shared store; testing it without mutating real config needs a settings-injection seam. Deferred, noted. Gate 21/21. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/SelfTest.cs | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 8344eb7..d66ebca 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -65,6 +65,7 @@ internal static class SelfTest RunStep(results, "Service app-yield token", ServiceInteractivePresence); RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream); RunStep(results, "Service registration args", ServiceRegistrationArgs); + RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip); RunStep(results, "Profile save and reload", ProfileRoundTrip); RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip); @@ -458,6 +459,88 @@ internal static class SelfTest catch { return 0; } } + /// Records a short synthetic tone to disk in every output format and checks each file is + /// written with real content — the thing Ed can't face testing by ear on every change. Drives the + /// real writer (WAV / MP3 / OGG-Opus / FLAC encoders and their native + /// bits) headlessly by feeding its audio-thread taps directly, then asserting the file exists and is + /// non-trivial. Also covers the received/sent source gate and mono downmix. + private static string? RecordingEngine() + { + var temp = Path.Combine(Path.GetTempPath(), "remsound-rec-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temp); + try + { + var summary = new List(); + + // 1. Every format, Both source, stereo — the file must exist with real content. + foreach (var (fmt, ext) in new[] + { + (RecordingFileFormat.Wav, "wav"), (RecordingFileFormat.Mp3, "mp3"), + (RecordingFileFormat.Ogg, "ogg"), (RecordingFileFormat.Flac, "flac"), + }) + { + var path = Path.Combine(temp, $"both.{ext}"); + var len = RecordTone(temp, path, + new RecordingSettings { FileFormat = fmt, Source = RecordingSource.Both, ChannelMode = RecordingChannelMode.Stereo }, + feedReceived: true, feedSent: true); + Check(len > 200, $"{ext.ToUpperInvariant()} recording must have real content (got {len} bytes)"); + summary.Add($"{ext}={len}B"); + } + + // 2. Source gate: a SentOnly recorder fed only RECEIVED audio must stay (near) empty. + var sentOnlyPath = Path.Combine(temp, "gate.wav"); + var gateLen = RecordTone(temp, sentOnlyPath, + new RecordingSettings { FileFormat = RecordingFileFormat.Wav, Source = RecordingSource.SentOnly }, + feedReceived: true, feedSent: false); + var fullLen = RecordTone(temp, Path.Combine(temp, "full.wav"), + new RecordingSettings { FileFormat = RecordingFileFormat.Wav, Source = RecordingSource.SentOnly }, + feedReceived: false, feedSent: true); + Check(gateLen < fullLen / 2, $"a SentOnly recorder must ignore received audio (gate={gateLen}B vs full={fullLen}B)"); + + // 3. Mono downmix produces a valid (smaller) WAV. + var monoLen = RecordTone(temp, Path.Combine(temp, "mono.wav"), + new RecordingSettings { FileFormat = RecordingFileFormat.Wav, Source = RecordingSource.Both, ChannelMode = RecordingChannelMode.Mono }, + feedReceived: true, feedSent: false); + Check(monoLen > 200, $"mono WAV must have real content (got {monoLen} bytes)"); + + return string.Join(", ", summary) + $"; gate ok; mono={monoLen}B"; + } + finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } } + } + + // Records ~0.4s of a 440 Hz tone with the given settings to an explicit path and returns the file + // size. Feeds the recorder's audio-thread taps directly, pacing so the writer thread drains the ring. + private static long RecordTone(string temp, string path, RecordingSettings settings, bool feedReceived, bool feedSent) + { + long finishedBytes = -1; + using (var rec = new AudioRecorder(settings, null, (_, b) => finishedBytes = b, path)) + { + const int rate = 48000; + var totalFrames = (int)(rate * 0.4); + var chunk = new float[480 * 2]; + var phase = 0.0; + var done = 0; + while (done < totalFrames) + { + var frames = Math.Min(chunk.Length / 2, totalFrames - done); + for (var i = 0; i < frames; i++) + { + var s = (float)(0.2 * Math.Sin(phase)); + phase += 2 * Math.PI * 440 / rate; + chunk[i * 2] = s; chunk[i * 2 + 1] = s; + } + var mem = chunk.AsMemory(0, frames * 2); + if (feedReceived) rec.WriteReceived(mem, RenderRoute.Mixed); + if (feedSent) rec.WriteSent(mem, RenderRoute.Mixed); + done += frames; + Thread.Sleep(2); + } + rec.Stop(); + } + for (var i = 0; i < 60 && !File.Exists(path); i++) Thread.Sleep(25); + return File.Exists(path) ? new FileInfo(path).Length : 0; + } + /// The sc.exe "create" argument string quotes a spaced exe path correctly — a real footgun /// (a broken binPath silently installs a service that can't start). Pure/side-effect-free, so it /// never touches the SCM or needs admin.