diff --git a/JoshHeaps.Net.UiTests/EchoDspTests.cs b/JoshHeaps.Net.UiTests/EchoDspTests.cs
new file mode 100644
index 0000000..4ad5f6e
--- /dev/null
+++ b/JoshHeaps.Net.UiTests/EchoDspTests.cs
@@ -0,0 +1,296 @@
+using Microsoft.Playwright;
+using Microsoft.Playwright.NUnit;
+using NUnit.Framework;
+
+namespace JoshHeaps.Net.UiTests;
+
+///
+/// Exercises the acoustic-ranging pipeline against a simulated room. The DSP runs in the browser
+/// because that is where it runs in production; these tests drive the same files the page loads,
+/// so there is no second implementation to drift.
+///
+[TestFixture]
+public class EchoDspTests : PageTest
+{
+ private TestConfiguration Config => TestConfiguration.Instance;
+
+ public override BrowserNewContextOptions ContextOptions() => Config.GetBrowserContextOptions();
+
+ [SetUp]
+ public async Task LoadPipeline()
+ {
+ await Page.GotoAsync(Config.Test.BaseUrl);
+ await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoDsp.js" });
+ await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoSim.js" });
+ }
+
+ [Test]
+ public async Task Matched_Filter_Finds_The_Chirp_Within_One_Sample()
+ {
+ var error = await Page.EvaluateAsync("""
+ () => {
+ const sampleRate = 48000;
+ const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
+ const recording = new Float32Array(sampleRate);
+ for (let i = 0; i < recording.length; i++) recording[i] = (Math.random() * 2 - 1) * 0.02;
+
+ const offset = 12345;
+ for (let i = 0; i < chirp.length; i++) recording[offset + i] += chirp[i] * 0.3;
+
+ const peak = EchoDsp.findFirstPeak(EchoDsp.matchedFilterEnvelope(recording, chirp));
+ return Math.abs(peak.index - offset);
+ }
+ """);
+
+ Assert.That(error, Is.LessThan(1.0), "arrival should be located to within a sample");
+ }
+
+ [Test]
+ public async Task A_Tone_Cannot_Be_Located_But_A_Chirp_Can()
+ {
+ var ratios = await Page.EvaluateAsync("""
+ () => {
+ const sampleRate = 48000;
+ const sidelobeRatio = template => {
+ const recording = new Float32Array(sampleRate / 2);
+ const offset = 8000;
+ for (let i = 0; i < template.length; i++) recording[offset + i] += template[i];
+
+ const envelope = EchoDsp.matchedFilterEnvelope(recording, template);
+ const peak = EchoDsp.maxInRange(envelope, 0, envelope.length);
+ let highest = 0;
+ for (let i = 0; i < envelope.length; i++) {
+ if (Math.abs(i - peak.index) < 200) continue;
+ highest = Math.max(highest, envelope[i]);
+ }
+ return highest / peak.value;
+ };
+
+ const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
+ const tone = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 5000, endHz: 5000 });
+ return [sidelobeRatio(chirp), sidelobeRatio(tone)];
+ }
+ """);
+
+ Assert.That(ratios[1], Is.GreaterThan(0.5), "a tone should correlate almost as well far from the true arrival");
+ Assert.That(ratios[0], Is.LessThan(0.25), "a chirp should give one unambiguous arrival");
+ }
+
+ [Test]
+ public async Task Clock_Offset_And_Pipeline_Latency_Cancel()
+ {
+ var distance = await Page.EvaluateAsync("""
+ () => {
+ const sampleRate = 48000;
+ const speedOfSound = 343;
+ const flight = (4.2 / speedOfSound) * sampleRate;
+ const slot = 0.4 * sampleRate;
+ const offsetB = 987654;
+ const latencyA = 0.031 * sampleRate;
+ const latencyB = 0.128 * sampleRate;
+
+ return EchoDsp.pairDistance({
+ a1: latencyA,
+ a2: slot + latencyB + flight,
+ b1: offsetB + latencyA + flight,
+ b2: offsetB + slot + latencyB,
+ sampleRate,
+ speedOfSound
+ });
+ }
+ """);
+
+ Assert.That(distance, Is.EqualTo(4.2).Within(0.001));
+ }
+
+ [Test]
+ public async Task Collocated_Devices_Read_Zero_Before_Calibration()
+ {
+ var distance = await Page.EvaluateAsync("""
+ () => {
+ const sampleRate = 48000;
+ const speedOfSound = 343;
+ const spacing = (0.19 / speedOfSound) * sampleRate;
+ const slot = 0.4 * sampleRate;
+ const latencyA = 0.04 * sampleRate;
+ const latencyB = 0.11 * sampleRate;
+
+ // Two tabs on one machine: one speaker, one microphone, so the self path and the
+ // cross path are the same physical distance.
+ return EchoDsp.pairDistance({
+ a1: latencyA + spacing,
+ a2: slot + latencyB + spacing,
+ b1: latencyA + spacing,
+ b2: slot + latencyB + spacing,
+ sampleRate,
+ speedOfSound
+ });
+ }
+ """);
+
+ Assert.That(distance, Is.EqualTo(0).Within(0.001));
+ }
+
+ [Test]
+ public async Task Speaker_To_Microphone_Spacing_Is_Added_Back()
+ {
+ var distances = await Page.EvaluateAsync("""
+ () => {
+ const sampleRate = 48000;
+ const speedOfSound = 343;
+ const truth = 3.0;
+ const epsilonA = 0.18;
+ const epsilonB = 0.04;
+ const samples = metres => (metres / speedOfSound) * sampleRate;
+ const slot = 0.4 * sampleRate;
+
+ const peaks = {
+ a1: samples(epsilonA),
+ a2: slot + samples(truth),
+ b1: samples(truth),
+ b2: slot + samples(epsilonB),
+ sampleRate,
+ speedOfSound
+ };
+
+ return [
+ EchoDsp.pairDistance(peaks),
+ EchoDsp.pairDistance({ ...peaks, epsilonA, epsilonB })
+ ];
+ }
+ """);
+
+ Assert.That(distances[0], Is.EqualTo(3.0 - 0.11).Within(0.005), "uncorrected range reads short");
+ Assert.That(distances[1], Is.EqualTo(3.0).Within(0.005), "correcting for spacing recovers the true range");
+ }
+
+ [Test]
+ public async Task Simulated_Room_Recovers_Distances_And_Layout()
+ {
+ var errors = await Page.EvaluateAsync("""
+ () => {
+ const result = EchoSim.runRound({
+ positions: [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]]
+ });
+ return [EchoSim.worstDistanceError(result), EchoSim.worstPositionError(result), result.keep.length];
+ }
+ """);
+
+ Assert.That(errors[2], Is.EqualTo(5), "every device should survive a clean round");
+ Assert.That(errors[0], Is.LessThan(0.05), "worst pairwise range error");
+ Assert.That(errors[1], Is.LessThan(0.15), "worst recovered position error");
+ }
+
+ [Test]
+ public async Task A_Reflection_Louder_Than_The_Direct_Path_Does_Not_Win()
+ {
+ var errors = await Page.EvaluateAsync("""
+ () => {
+ const measure = relativeThreshold => EchoSim.worstDistanceError(EchoSim.runRound({
+ positions: [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]],
+ reflections: [{ extraMetres: 1.8, gain: 5 }],
+ peakOptions: { relativeThreshold }
+ }));
+
+ return [measure(undefined), measure(0.5)];
+ }
+ """);
+
+ Assert.That(errors[0], Is.LessThan(0.05), "the first arrival is the distance, not the loudest one");
+ Assert.That(errors[1], Is.GreaterThan(1.5),
+ "a threshold high enough to miss the direct path must measure the reflection instead — this is what the default guards against");
+ }
+
+ [Test]
+ public async Task Echoes_Inside_The_Correlation_Lobe_Bound_The_Accuracy()
+ {
+ var errors = await Page.EvaluateAsync("""
+ () => {
+ const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]];
+ const worst = reflectionExtraRange =>
+ EchoSim.worstDistanceError(EchoSim.runRound({ positions, reflectionExtraRange }));
+
+ return [worst([0.4, 4.0]), worst([0.08, 0.4])];
+ }
+ """);
+
+ Assert.That(errors[0], Is.LessThan(0.01), "echoes well clear of the direct arrival are rejected outright");
+ Assert.That(errors[1], Is.LessThan(0.15),
+ "echoes arriving inside the correlation lobe cannot be separated and bias the range — this bounds what a device resting on a hard surface can achieve");
+ }
+
+ [Test]
+ public async Task A_Bad_Measurement_Is_Rejected_And_The_Layout_Survives()
+ {
+ var outcome = await Page.EvaluateAsync("""
+ () => {
+ const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.6, 1.3]];
+ const config = EchoSim.buildConfiguration({ positions });
+ const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
+
+ reports[3].peaks[0] += 9000;
+
+ const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
+ const truth = solved.keep.map(index => positions[index]);
+ const aligned = EchoDsp.alignToReference(solved.points, truth);
+ const worst = Math.max(...aligned.map((point, i) => EchoSim.separation(point, truth[i])));
+ const brokenPairSurvived = solved.keep.includes(0) && solved.keep.includes(3);
+
+ return [solved.keep.length, brokenPairSurvived ? 1 : 0, worst];
+ }
+ """);
+
+ Assert.That(outcome[0], Is.EqualTo(4), "exactly one endpoint of the bad pair should be dropped");
+ Assert.That(outcome[1], Is.EqualTo(0), "the impossible pair must not survive");
+ Assert.That(outcome[2], Is.LessThan(0.2), "the remaining layout should be unpoisoned");
+ }
+
+ [Test]
+ public async Task Alignment_Undoes_An_Arbitrary_Rotation_And_Mirror()
+ {
+ var errors = await Page.EvaluateAsync("""
+ () => {
+ const reference = [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]];
+ const scramble = (points, angle, mirror) => points.map(([x, y]) => {
+ const mx = x * mirror;
+ return [mx * Math.cos(angle) - y * Math.sin(angle) + 11, mx * Math.sin(angle) + y * Math.cos(angle) - 4];
+ });
+
+ const worst = mirror => {
+ const aligned = EchoDsp.alignToReference(scramble(reference, 0.9, mirror), reference);
+ return Math.max(...aligned.map((point, i) => EchoSim.separation(point, reference[i])));
+ };
+
+ return [worst(1), worst(-1)];
+ }
+ """);
+
+ Assert.That(errors[0], Is.LessThan(1e-9), "rotation and translation should be recovered exactly");
+ Assert.That(errors[1], Is.LessThan(1e-9), "a mirrored solve should be un-mirrored onto the reference");
+ }
+
+ [Test]
+ public async Task Consecutive_Frames_Do_Not_Rotate_Or_Flip()
+ {
+ var drift = await Page.EvaluateAsync("""
+ () => {
+ const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9]];
+ const first = EchoSim.runRound({ positions, seed: 11 });
+
+ const previous = new Array(positions.length).fill(null);
+ first.keep.forEach((device, i) => { previous[device] = first.points[i]; });
+
+ const config = EchoSim.buildConfiguration({ positions, seed: 22 });
+ const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
+ const second = EchoDsp.solveRound(reports, {
+ speedOfSound: config.speedOfSound,
+ previousPoints: previous
+ });
+
+ return Math.max(...second.keep.map((device, i) => EchoSim.separation(second.points[i], previous[device])));
+ }
+ """);
+
+ Assert.That(drift, Is.LessThan(0.3), "a stationary room should not move between frames");
+ }
+}
diff --git a/JoshHeaps.Net.UiTests/EchoRoomTests.cs b/JoshHeaps.Net.UiTests/EchoRoomTests.cs
new file mode 100644
index 0000000..c8a94fc
--- /dev/null
+++ b/JoshHeaps.Net.UiTests/EchoRoomTests.cs
@@ -0,0 +1,244 @@
+using Microsoft.Playwright;
+using Microsoft.Playwright.NUnit;
+using NUnit.Framework;
+
+namespace JoshHeaps.Net.UiTests;
+
+///
+/// Drives two real browsers through a real room: the hub, the round scheduler, slot rotation,
+/// detection and the solve all run unchanged. Only the microphone is synthetic, so the answer is
+/// known in advance — this is everything except the acoustics.
+///
+[TestFixture]
+public class EchoRoomTests : PlaywrightTest
+{
+ private const double TargetMetres = 2.5;
+ private IPlaywright? _playwright;
+ private IBrowser? _browser;
+ private readonly List _contexts = [];
+ private TestConfiguration Config => TestConfiguration.Instance;
+
+ [OneTimeSetUp]
+ public async Task LaunchBrowser()
+ {
+ // Its own Playwright instance and browser: the fake-media launch flags have to be set at
+ // launch time, and the fixture-managed browser is already running by the time tests start.
+ _playwright = await Microsoft.Playwright.Playwright.CreateAsync();
+ _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
+ {
+ Headless = true,
+ Args =
+ [
+ "--use-fake-ui-for-media-stream",
+ "--use-fake-device-for-media-stream",
+ "--autoplay-policy=no-user-gesture-required"
+ ]
+ });
+ }
+
+ [OneTimeTearDown]
+ public async Task CloseBrowser()
+ {
+ if (_browser is not null) await _browser.CloseAsync();
+ _playwright?.Dispose();
+ }
+
+ [TearDown]
+ public async Task CloseContexts()
+ {
+ foreach (var context in _contexts) await context.CloseAsync();
+ _contexts.Clear();
+ }
+
+ [Test]
+ public async Task Two_Devices_Measure_The_Distance_Between_Them()
+ {
+ var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
+ var first = await NewDeviceAsync(roomCode, "laptop");
+ var second = await NewDeviceAsync(roomCode, "phone");
+
+ await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
+
+ var measured = await WaitForMeasurementAsync(first);
+ var alsoMeasured = await WaitForMeasurementAsync(second);
+
+ Assert.That(measured, Is.EqualTo(TargetMetres).Within(0.05), "the first device's range");
+ Assert.That(alsoMeasured, Is.EqualTo(TargetMetres).Within(0.05), "both devices should agree");
+ }
+
+ [Test]
+ public async Task A_Device_Leaving_Stops_The_Rounds_And_Rejoining_Resumes_Them()
+ {
+ var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
+ var first = await NewDeviceAsync(roomCode, "laptop");
+ var second = await NewDeviceAsync(roomCode, "phone");
+
+ await WaitForMeasurementAsync(first);
+ await second.ClickAsync("#echoLeave");
+
+ await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(1);
+ await Expect(first.Locator("#echoStatus")).ToContainTextAsync("Waiting for a second device");
+
+ await second.ClickAsync("#echoJoin");
+ await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
+ Assert.That(await WaitForMeasurementAsync(first), Is.EqualTo(TargetMetres).Within(0.05));
+ }
+
+ ///
+ /// A stalled main thread is the two-tabs-on-one-machine failure: only one tab is visible, so the
+ /// other gets throttled and its round handling runs late. A late chirp attributed to the wrong
+ /// slot yields a plausible-looking but completely wrong range, so the requirement is not "always
+ /// measures" — it is "never reports a wrong answer". A round it cannot hit must be sat out.
+ ///
+ [Test]
+ public async Task A_Stalled_Device_Sits_Rounds_Out_Instead_Of_Reporting_Nonsense()
+ {
+ var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
+ var first = await NewDeviceAsync(roomCode, "laptop");
+ var second = await NewDeviceAsync(roomCode, "phone", stallMilliseconds: 900);
+
+ await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
+ await first.EvaluateAsync("() => { window.__seen = []; }");
+ await first.EvaluateAsync("""
+ () => {
+ const original = EchoPage.renderSolved.bind(EchoPage);
+ EchoPage.renderSolved = update => {
+ const range = update.solved?.matrix?.[0]?.[1];
+ if (range != null) window.__seen.push(range);
+ return original(update);
+ };
+ }
+ """);
+
+ await first.WaitForTimeoutAsync(20000);
+ var seen = await first.EvaluateAsync("() => window.__seen");
+ var satOut = await second.EvaluateAsync("() => EchoSession.skippedRounds");
+
+ TestContext.Out.WriteLine($"reported ranges: {string.Join(", ", seen.Select(r => r.ToString("0.000")))}, sat out: {satOut}");
+ Assert.That(satOut, Is.GreaterThan(0),
+ "the stall must actually have cost the device some slots, or this test proves nothing");
+ Assert.That(seen, Is.Not.Empty, "a stalled peer should still let some rounds through");
+ Assert.That(seen, Is.All.EqualTo(TargetMetres).Within(0.05),
+ "every range that gets reported must be right — a stalled device must sit the round out, not chirp late");
+ }
+
+ [Test]
+ public async Task The_Capture_Worklet_Keeps_A_Continuous_Readable_Stream()
+ {
+ var page = await NewDeviceAsync($"T{Random.Shared.Next(1000, 9999)}", "laptop", fakeMicrophone: false);
+
+ await page.WaitForFunctionAsync(
+ "() => EchoAudio.highestFrame > 48000",
+ null,
+ new PageWaitForFunctionOptions { Timeout = 15000, PollingInterval = 100 });
+
+ var capture = await page.EvaluateAsync("""
+ () => [
+ EchoAudio.context.sampleRate,
+ EchoAudio.warnings.length,
+ EchoAudio.read(EchoAudio.highestFrame - 24000, 24000)?.length ?? 0,
+ EchoAudio.read(EchoAudio.highestFrame + 1000, 100) === null ? 1 : 0,
+ Math.abs(EchoAudio.frameAt(EchoAudio.context.currentTime) - EchoAudio.highestFrame)
+ ]
+ """);
+
+ Assert.That(capture[0], Is.EqualTo(48000), "the pipeline assumes it got the rate it asked for");
+ Assert.That(capture[1], Is.EqualTo(0), "a clean fake device should raise no capture warnings");
+ Assert.That(capture[2], Is.EqualTo(24000), "recent audio must be readable out of the ring");
+ Assert.That(capture[3], Is.EqualTo(1), "reads past the captured end must fail rather than return silence");
+ Assert.That(capture[4], Is.LessThan(48000),
+ "the frame index and the context clock must stay in the same domain — a scheduled playback time is converted straight into a recording position");
+ }
+
+ ///
+ /// A page joined to the room with its microphone replaced by a synthesizer. Every slot's chirp
+ /// is placed where a room of this geometry would put it, including a different unknown output
+ /// latency per slot so the cancellation is actually exercised.
+ ///
+ private async Task NewDeviceAsync(
+ string roomCode,
+ string name,
+ bool fakeMicrophone = true,
+ int stallMilliseconds = 0)
+ {
+ var context = await _browser!.NewContextAsync(new BrowserNewContextOptions
+ {
+ IgnoreHTTPSErrors = true,
+ Permissions = ["microphone"]
+ });
+
+ _contexts.Add(context);
+ var page = await context.NewPageAsync();
+ page.Console += (_, message) =>
+ {
+ if (message.Type == "error") TestContext.Out.WriteLine($"[{name} console] {message.Text}");
+ };
+
+ await page.GotoAsync($"{Config.Test.BaseUrl}/echo?room={roomCode}");
+ await page.FillAsync("#echoName", name);
+ if (fakeMicrophone) await page.EvaluateAsync(FakeMicrophoneScript, TargetMetres);
+ if (stallMilliseconds > 0) await page.EvaluateAsync(StallScript, stallMilliseconds);
+ await page.ClickAsync("#echoJoin");
+
+ return page;
+ }
+
+ /// Blocks the main thread on every round announcement, the way a throttled tab does.
+ private const string StallScript = """
+ stallMs => {
+ const original = EchoSession.handleRoundStarting.bind(EchoSession);
+ EchoSession.handleRoundStarting = schedule => {
+ const until = performance.now() + stallMs;
+ while (performance.now() < until) { /* hold the thread */ }
+ return original(schedule);
+ };
+ }
+ """;
+
+ private static async Task WaitForMeasurementAsync(IPage page)
+ {
+ await page.WaitForFunctionAsync(
+ "() => EchoPage.lastSolved?.solved?.matrix?.[0]?.[1] != null",
+ null,
+ new PageWaitForFunctionOptions { Timeout = 30000, PollingInterval = 250 });
+
+ return await page.EvaluateAsync("() => EchoPage.lastSolved.solved.matrix[0][1]");
+ }
+
+ ///
+ /// Synthesizes what the microphone would have heard for this round. The device's own chirp is
+ /// placed at the frame it was actually scheduled for rather than at its nominal slot position,
+ /// so any drift between "when the round said to play" and "when playback was really booked"
+ /// reaches the detector instead of being papered over by the harness.
+ ///
+ private const string FakeMicrophoneScript = """
+ targetMetres => {
+ const speedOfSound = 343;
+ const epsilonMetres = 0.08;
+ const latencyBySlot = [1400, 5200, 2600, 7100, 900, 4300, 3300, 6000];
+
+ EchoAudio.read = (startFrame, length) => {
+ const round = EchoSession.pending ?? EchoSession.lastRound;
+ if (!round) return null;
+
+ const chirp = EchoSession.chirp;
+ const toSamples = metres => Math.round((metres / speedOfSound) * round.sampleRate);
+ const recording = new Float32Array(length);
+
+ round.schedule.slotOrder.forEach((_, slot) => {
+ const own = slot === round.ownSlot;
+ const origin = own
+ ? round.scheduledFrame - round.windowStart
+ : round.leadInSamples + slot * round.slotSamples;
+ const at = origin + latencyBySlot[slot] + toSamples(own ? epsilonMetres : targetMetres);
+ const amplitude = own ? 1.0 : 0.25;
+
+ for (let i = 0; i < chirp.length && at + i < length; i++) recording[at + i] += chirp[i] * amplitude;
+ });
+
+ for (let i = 0; i < length; i++) recording[i] += (Math.random() * 2 - 1) * 0.01;
+ return recording;
+ };
+ }
+ """;
+}