diff --git a/src/RemSound.App/RecordingController.cs b/src/RemSound.App/RecordingController.cs index 1ef1c89..c489617 100644 --- a/src/RemSound.App/RecordingController.cs +++ b/src/RemSound.App/RecordingController.cs @@ -44,6 +44,11 @@ internal sealed class RecordingController /// recording starts, so one track file is created per peer. Set by MainForm. public Func>? ConnectedPeersProvider { get; set; } + /// Test seam: overrides where reads the recording settings from, so a + /// self-test can drive a split (multi-track) recording without writing to the real shared settings + /// store. Null (the default) = read from the store as normal. + internal Func? SettingsSourceForTest { get; set; } + public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action diagnostic) { this.sender = sender; @@ -71,7 +76,7 @@ internal sealed class RecordingController public void Start() { if (IsRecording) return; - var s = settings.LoadRecordingSettings(); + var s = (SettingsSourceForTest ?? settings.LoadRecordingSettings)(); var now = DateTime.Now; try { diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 9b61380..5bb63ba 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -66,6 +66,7 @@ internal static class SelfTest 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, "Recording split tracks (per-peer + own)", RecordingSplitTracks); RunStep(results, "Recording churn / soak", RecordingChurn); RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip); RunStep(results, "Profile save and reload", ProfileRoundTrip); @@ -534,6 +535,63 @@ internal static class SelfTest finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } } } + /// Split-track (multi-track) recording: with SplitTracks on and one connected peer, the + /// recorder must write a FOLDER of tracks — one per peer plus your own send — not a single mixed file. + /// Drives the real RecordingController via a settings-injection seam (so it never touches the shared + /// settings store), feeds the "your send" track through the tap the controller wires onto the sender, + /// and asserts the track files land with content. + private static string? RecordingSplitTracks() + { + var temp = Path.Combine(Path.GetTempPath(), "remsound-split-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temp); + using var receiver = new AudioReceiver(); + using var sender = new RemSound.Sender.AudioSender(); + try + { + var controller = new RecordingController(sender, receiver, new RemSoundSettingsStore("RemSound"), _ => { }) + { + SettingsSourceForTest = () => new RecordingSettings + { + SplitTracks = true, + Source = RecordingSource.Both, + FileFormat = RecordingFileFormat.Wav, + Folder = temp, + }, + ConnectedPeersProvider = () => new[] { (IPAddress.Loopback, "TestPeer") }, + }; + + controller.Start(); + Check(controller.IsRecording, "split recording should be running after Start"); + + // Feed the "your send" track through the tap Start wired onto the sender. + var tap = sender.OnSentSamples; + if (tap is not null) + { + var chunk = new float[480 * 2]; + var phase = 0.0; + for (var c = 0; c < 60; c++) + { + for (var i = 0; i < chunk.Length; i += 2) + { + var s = (float)(0.2 * Math.Sin(phase)); + phase += 2 * Math.PI * 440 / 48000; + chunk[i] = s; chunk[i + 1] = s; + } + tap(chunk.AsMemory(), RenderRoute.Mixed); + Thread.Sleep(2); + } + } + controller.Stop(); + for (var i = 0; i < 40 && Directory.GetFiles(temp, "*.wav", SearchOption.AllDirectories).Length == 0; i++) Thread.Sleep(25); + + var files = Directory.GetFiles(temp, "*.wav", SearchOption.AllDirectories); + Check(files.Length >= 2, $"split recording must make one file per peer plus your own (found {files.Length})"); + Check(files.Any(f => new FileInfo(f).Length > 200), "at least one split track (your own send) must have real content"); + return $"split recording made {files.Length} track files, one with content"; + } + finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } } + } + /// Load/soak: rapidly start, feed, stop and dispose recordings across every format, checking /// nothing leaks handles across the churn — catches recorder/encoder lifecycle leaks and races that a /// single recording wouldn't surface. Set the env var REMSOUND_TEST_SOAK=<seconds> to keep