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; + }; + } + """; +} diff --git a/JoshHeaps.Net/.gitea/workflows/deploy.yml b/JoshHeaps.Net/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..0fff19e --- /dev/null +++ b/JoshHeaps.Net/.gitea/workflows/deploy.yml @@ -0,0 +1,75 @@ +# .gitea/workflows/deploy.yml +name: Build and Deploy + +on: + push: + branches: [ "master" ] + workflow_dispatch: {} + +concurrency: + group: deploy-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + + steps: + - name: Install deploy tools + run: | + apt-get update + apt-get install -y --no-install-recommends rsync openssh-client + + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore + + - name: Publish + run: dotnet publish -c Release -o ./publish + + - name: Prepare SSH + env: + SSH_KEY: ${{ secrets.SSH_KEY }} + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_PORT: ${{ secrets.SSH_PORT }} + run: | + set -euo pipefail + PORT="${SSH_PORT:-22}" + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Rsync to server + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_PORT: ${{ secrets.SSH_PORT }} + SSH_USER: ${{ secrets.SSH_USER }} + TARGET_DIR: ${{ secrets.TARGET_DIR }} + run: | + set -euo pipefail + PORT="${SSH_PORT:-22}" + rsync -az --delete \ + -e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \ + publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/" + + - name: Restart service + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_PORT: ${{ secrets.SSH_PORT }} + SSH_USER: ${{ secrets.SSH_USER }} + SERVICE_NAME: ${{ secrets.SERVICE_NAME }} + run: | + set -euo pipefail + PORT="${SSH_PORT:-22}" + ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ + "sudo systemctl daemon-reload \ + && sudo systemctl restart '$SERVICE_NAME' \ + && systemctl --no-pager status '$SERVICE_NAME' --lines=0" \ No newline at end of file diff --git a/JoshHeaps.Net/Hubs/EchoHub.cs b/JoshHeaps.Net/Hubs/EchoHub.cs new file mode 100644 index 0000000..c67c4e3 --- /dev/null +++ b/JoshHeaps.Net/Hubs/EchoHub.cs @@ -0,0 +1,52 @@ +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; +using Microsoft.AspNetCore.SignalR; + +namespace JoshHeaps.Net.Hubs; + +/// +/// Membership and peak reporting for acoustic ranging rooms. Nothing that arrives here is audio: +/// a report is a handful of sample indices, and the geometry is solved on the clients. +/// +public class EchoHub(IEchoRoomStore rooms) : Hub +{ + /// Join (or create) a room and receive an id plus the current roster. + public async Task JoinRoom(string roomCode, string displayName, int sampleRate) + { + var result = rooms.Join(roomCode, Context.ConnectionId, displayName, sampleRate); + await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(result.RoomCode)); + await Clients.Group(GroupFor(result.RoomCode)).SendAsync("RoomChanged", result.Room); + + return result; + } + + /// File this device's arrival indices for the room's open round. + public bool ReportRound(EchoPeakReport report) => rooms.Report(Context.ConnectionId, report); + + /// + /// Server clock, for estimating each device's offset from it. Only needs to be good to a few + /// tens of milliseconds: it decides which chirp is whose, never how far away anything is. + /// + public long ServerTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + public async Task LeaveRoom() => await RemoveFromRoom(); + + public override async Task OnDisconnectedAsync(Exception? exception) + { + await RemoveFromRoom(); + await base.OnDisconnectedAsync(exception); + } + + internal static string GroupFor(string roomCode) => $"echo:{roomCode.ToUpperInvariant()}"; + + private async Task RemoveFromRoom() + { + var (roomCode, room) = rooms.Leave(Context.ConnectionId); + if (roomCode is null) return false; + + await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupFor(roomCode)); + if (room is not null) await Clients.Group(GroupFor(roomCode)).SendAsync("RoomChanged", room); + + return true; + } +} diff --git a/JoshHeaps.Net/Models/EchoModels.cs b/JoshHeaps.Net/Models/EchoModels.cs new file mode 100644 index 0000000..d18a751 --- /dev/null +++ b/JoshHeaps.Net/Models/EchoModels.cs @@ -0,0 +1,84 @@ +namespace JoshHeaps.Net.Models; + +/// A device taking part in a ranging room. +public sealed class EchoDevice +{ + public required string DeviceId { get; init; } + public required string ConnectionId { get; init; } + public required string DisplayName { get; set; } + public int SampleRate { get; set; } +} + +/// +/// One chirp cycle: every device plays in turn, and every device listens to the whole thing. +/// +public sealed class EchoRound +{ + public required string RoundId { get; init; } + public required string[] SlotOrder { get; init; } + public int SlotMilliseconds { get; init; } + public DateTimeOffset Deadline { get; init; } + public Dictionary Reports { get; } = []; +} + +/// +/// What one device heard. Peaks are fractional sample indices into that device's own continuous +/// recording, one per slot, null where a chirp was not detected. Nothing else is ever uploaded. +/// +public sealed class EchoPeakReport +{ + public required string DeviceId { get; init; } + public required string RoundId { get; init; } + public int Slot { get; init; } + public int SampleRate { get; init; } + public double Epsilon { get; init; } + public double?[] Peaks { get; init; } = []; +} + +public sealed class EchoJoinResult +{ + public required string DeviceId { get; init; } + public required string RoomCode { get; init; } + public required EchoRoomSnapshot Room { get; init; } +} + +public sealed class EchoRoomSnapshot +{ + public required string RoomCode { get; init; } + public required EchoDeviceSnapshot[] Devices { get; init; } +} + +public sealed class EchoDeviceSnapshot +{ + public required string DeviceId { get; init; } + public required string DisplayName { get; init; } + public int SampleRate { get; init; } +} + +/// Tells every device when to chirp: its own slot index and how long a slot lasts. +public sealed class EchoRoundSchedule +{ + public required string RoundId { get; init; } + public required string[] SlotOrder { get; init; } + public int SlotMilliseconds { get; init; } + public int TailMilliseconds { get; init; } + + /// + /// When slot zero should sound, in server time, set far enough ahead that every device can + /// receive the message and book the playback before it arrives. Devices schedule against this + /// rather than against message arrival, so one slow client cannot drag its chirp into another + /// device's slot and invalidate the round for everybody. + /// + public long StartsAtUnixMs { get; init; } +} + +/// +/// Every device's peaks for one round, broadcast unchanged. Each client solves the geometry itself +/// so the server never needs the ranging maths. +/// +public sealed class EchoRoundResult +{ + public required string RoundId { get; init; } + public required string[] SlotOrder { get; init; } + public required EchoPeakReport[] Reports { get; init; } +} diff --git a/JoshHeaps.Net/Pages/Echo.cshtml b/JoshHeaps.Net/Pages/Echo.cshtml new file mode 100644 index 0000000..a29da33 --- /dev/null +++ b/JoshHeaps.Net/Pages/Echo.cshtml @@ -0,0 +1,70 @@ +@page +@model JoshHeaps.Net.Pages.EchoModel +@{ + Layout = "_Layout"; + ViewData["Title"] = "Echo"; +} + +
+

Echo

+

+ Two devices measure the distance between them by chirping at each other and timing the answer. + Each one records continuously and only ever compares arrivals inside its own recording, so no + clock synchronisation is needed and the unknown audio latency cancels out. +

+

Only sample numbers leave your device. The recording never does.

+
+ +
+ + + + + +
+ +

Pick a room, then open the same room on a second device.

+

Join link:

+
+ +
not measuring
+ +
+
+

Devices

+
    +
    +
    +

    Ranges

    +
    +
    +
    +

    Timing

    +

    How far each chirp landed from its slot. Steady means the arrivals are being matched to the right devices.

    +
    +
    +
    + +
    +

    Matched filter

    +

    Each spike is a chirp arriving. The bright one is this device hearing itself.

    + +
    + +← Back + +@section Scripts { + + + + + +} + +@section Styles { + + +} diff --git a/JoshHeaps.Net/Pages/Echo.cshtml.cs b/JoshHeaps.Net/Pages/Echo.cshtml.cs new file mode 100644 index 0000000..4949471 --- /dev/null +++ b/JoshHeaps.Net/Pages/Echo.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JoshHeaps.Net.Pages +{ + public class EchoModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index cd006d7..b665273 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -1,6 +1,7 @@ using JoshHeaps.Net.Hubs; using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Interfaces; +using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); @@ -30,6 +31,12 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.Configure(configuration.GetSection(EchoRoundSettings.SectionName)); +builder.Services.AddSingleton(provider => + provider.GetRequiredService>().Value); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + if (!builder.Environment.IsDevelopment()) { builder.Services.AddHostedService(); @@ -72,4 +79,6 @@ app.MapControllers(); app.MapHub("/chessHub"); +app.MapHub("/echoHub"); + app.Run(); \ No newline at end of file diff --git a/JoshHeaps.Net/Services/Implementations/EchoRoomStore.cs b/JoshHeaps.Net/Services/Implementations/EchoRoomStore.cs new file mode 100644 index 0000000..9ed5f48 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/EchoRoomStore.cs @@ -0,0 +1,189 @@ +using System.Collections.Concurrent; +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// In-memory ranging rooms. Singleton: rooms are process-wide and short-lived, and the site runs +/// as a single instance, so there is nothing to persist and no backplane to coordinate. +/// +public sealed class EchoRoomStore : IEchoRoomStore +{ + private sealed class Room + { + public required string Code { get; init; } + public List Devices { get; } = []; + public EchoRound? Round { get; set; } + public int RoundCounter { get; set; } + public DateTimeOffset LastActivity { get; set; } = DateTimeOffset.UtcNow; + } + + private readonly ConcurrentDictionary _rooms = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _roomsByConnection = []; + + public EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate) + { + var room = _rooms.GetOrAdd(roomCode, code => new Room { Code = code }); + var device = new EchoDevice + { + DeviceId = Guid.NewGuid().ToString("N")[..8], + ConnectionId = connectionId, + DisplayName = displayName, + SampleRate = sampleRate + }; + + lock (room) + { + room.Devices.RemoveAll(existing => existing.ConnectionId == connectionId); + room.Devices.Add(device); + room.LastActivity = DateTimeOffset.UtcNow; + _roomsByConnection[connectionId] = room.Code; + + return new EchoJoinResult { DeviceId = device.DeviceId, RoomCode = room.Code, Room = SnapshotLocked(room) }; + } + } + + public (string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId) + { + if (!_roomsByConnection.TryRemove(connectionId, out var roomCode)) return (null, null); + if (!_rooms.TryGetValue(roomCode, out var room)) return (roomCode, null); + + lock (room) + { + room.Devices.RemoveAll(device => device.ConnectionId == connectionId); + room.Round = null; + room.LastActivity = DateTimeOffset.UtcNow; + + if (room.Devices.Count == 0) _rooms.TryRemove(roomCode, out _); + + return (roomCode, SnapshotLocked(room)); + } + } + + public EchoRoomSnapshot? Snapshot(string roomCode) + { + if (!_rooms.TryGetValue(roomCode, out var room)) return null; + + lock (room) return SnapshotLocked(room); + } + + public IReadOnlyCollection MeasurableRooms => + [.. _rooms.Values.Where(room => room.Devices.Count >= 2).Select(room => room.Code)]; + + public EchoRoundSchedule? StartRound( + string roomCode, + int slotMilliseconds, + int tailMilliseconds, + TimeSpan lead, + TimeSpan grace) + { + if (!_rooms.TryGetValue(roomCode, out var room)) return null; + + lock (room) + { + if (room.Round is not null || room.Devices.Count < 2) return null; + + var slotOrder = RotatedSlotOrder(room); + var startsAt = DateTimeOffset.UtcNow + lead; + var duration = TimeSpan.FromMilliseconds(slotOrder.Length * slotMilliseconds + tailMilliseconds); + + room.Round = new EchoRound + { + RoundId = Guid.NewGuid().ToString("N")[..12], + SlotOrder = slotOrder, + SlotMilliseconds = slotMilliseconds, + Deadline = startsAt + duration + grace + }; + room.LastActivity = DateTimeOffset.UtcNow; + + return new EchoRoundSchedule + { + RoundId = room.Round.RoundId, + SlotOrder = slotOrder, + SlotMilliseconds = slotMilliseconds, + TailMilliseconds = tailMilliseconds, + StartsAtUnixMs = startsAt.ToUnixTimeMilliseconds() + }; + } + } + + public bool Report(string connectionId, EchoPeakReport report) + { + var device = FindDevice(connectionId, out var room); + if (device is null || room is null) return false; + + lock (room) + { + if (room.Round?.RoundId != report.RoundId) return false; + if (!room.Round.SlotOrder.Contains(device.DeviceId)) return false; + + room.Round.Reports[device.DeviceId] = report; + room.LastActivity = DateTimeOffset.UtcNow; + return true; + } + } + + public EchoRoundResult? TryCloseRound(string roomCode) + { + if (!_rooms.TryGetValue(roomCode, out var room)) return null; + + lock (room) + { + var round = room.Round; + if (round is null) return null; + + var everyoneReported = round.SlotOrder.All(round.Reports.ContainsKey); + if (!everyoneReported && DateTimeOffset.UtcNow < round.Deadline) return null; + + room.Round = null; + + return new EchoRoundResult + { + RoundId = round.RoundId, + SlotOrder = round.SlotOrder, + Reports = [.. round.SlotOrder.Where(round.Reports.ContainsKey).Select(id => round.Reports[id])] + }; + } + } + + public int PruneIdle(TimeSpan idleFor) + { + var cutoff = DateTimeOffset.UtcNow - idleFor; + var stale = _rooms.Values.Where(room => room.LastActivity < cutoff).Select(room => room.Code).ToList(); + + foreach (var code in stale) _rooms.TryRemove(code, out _); + + return stale.Count; + } + + private static EchoRoomSnapshot SnapshotLocked(Room room) => + new() + { + RoomCode = room.Code, + Devices = + [ + .. room.Devices.Select(device => new EchoDeviceSnapshot + { + DeviceId = device.DeviceId, + DisplayName = device.DisplayName, + SampleRate = device.SampleRate + }) + ] + }; + + private static string[] RotatedSlotOrder(Room room) + { + var offset = room.RoundCounter++ % room.Devices.Count; + return [.. room.Devices.Skip(offset).Concat(room.Devices.Take(offset)).Select(device => device.DeviceId)]; + } + + private EchoDevice? FindDevice(string connectionId, out Room? room) + { + room = null; + if (!_roomsByConnection.TryGetValue(connectionId, out var roomCode)) return null; + if (!_rooms.TryGetValue(roomCode, out room)) return null; + + lock (room) return room.Devices.FirstOrDefault(device => device.ConnectionId == connectionId); + } +} diff --git a/JoshHeaps.Net/Services/Implementations/EchoRoundService.cs b/JoshHeaps.Net/Services/Implementations/EchoRoundService.cs new file mode 100644 index 0000000..bef55c0 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/EchoRoundService.cs @@ -0,0 +1,107 @@ +using JoshHeaps.Net.Hubs; +using JoshHeaps.Net.Services.Interfaces; +using Microsoft.AspNetCore.SignalR; + +namespace JoshHeaps.Net.Services.Implementations; + +public sealed class EchoRoundSettings +{ + public const string SectionName = "Echo"; + + /// + /// Slot length. Wide enough that no device's chirp can land in another's search window once + /// unknown output latency (tens of milliseconds, different per device) and message jitter are + /// accounted for. + /// + public int SlotMilliseconds { get; set; } = 500; + + /// Time after the last chirp for propagation, detection and reporting. + public int TailMilliseconds { get; set; } = 700; + + public int GraceMilliseconds { get; set; } = 1500; + public int GapMilliseconds { get; set; } = 250; + public int IdleRoomMinutes { get; set; } = 10; + + /// + /// How far ahead a round is announced. Must exceed the worst message delivery plus client + /// stall, or a device will find its slot already gone and sit the round out. + /// + public int LeadMilliseconds { get; set; } = 600; +} + +/// +/// Drives continuous ranging: opens a round for every room that has two or more devices, closes it +/// once everyone has reported or the deadline passes, and broadcasts the raw peak table. +/// +public sealed class EchoRoundService( + IEchoRoomStore rooms, + IHubContext hub, + EchoRoundSettings settings, + ILogger logger) : BackgroundService +{ + private readonly Dictionary _nextRoundAllowedAt = []; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var lastPrune = DateTimeOffset.UtcNow; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await TickAsync(); + lastPrune = PruneIfDue(lastPrune); + } + catch (Exception error) + { + logger.LogError(error, "Echo round tick failed"); + } + + await Task.Delay(50, stoppingToken); + } + } + + private async Task TickAsync() + { + foreach (var roomCode in rooms.MeasurableRooms) + { + await CloseFinishedRoundAsync(roomCode); + await OpenRoundIfDueAsync(roomCode); + } + } + + private async Task CloseFinishedRoundAsync(string roomCode) + { + var result = rooms.TryCloseRound(roomCode); + if (result is null) return; + + _nextRoundAllowedAt[roomCode] = DateTimeOffset.UtcNow.AddMilliseconds(settings.GapMilliseconds); + await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundComplete", result); + } + + private async Task OpenRoundIfDueAsync(string roomCode) + { + if (_nextRoundAllowedAt.TryGetValue(roomCode, out var earliest) && DateTimeOffset.UtcNow < earliest) return; + + var schedule = rooms.StartRound( + roomCode, + settings.SlotMilliseconds, + settings.TailMilliseconds, + TimeSpan.FromMilliseconds(settings.LeadMilliseconds), + TimeSpan.FromMilliseconds(settings.GraceMilliseconds)); + + if (schedule is null) return; + + await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundStarting", schedule); + } + + private DateTimeOffset PruneIfDue(DateTimeOffset lastPrune) + { + if (DateTimeOffset.UtcNow - lastPrune < TimeSpan.FromMinutes(1)) return lastPrune; + + var pruned = rooms.PruneIdle(TimeSpan.FromMinutes(settings.IdleRoomMinutes)); + if (pruned > 0) logger.LogInformation("Pruned {Count} idle echo room(s)", pruned); + + return DateTimeOffset.UtcNow; + } +} diff --git a/JoshHeaps.Net/Services/Interfaces/IEchoRoomStore.cs b/JoshHeaps.Net/Services/Interfaces/IEchoRoomStore.cs new file mode 100644 index 0000000..8b90548 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IEchoRoomStore.cs @@ -0,0 +1,45 @@ +using JoshHeaps.Net.Models; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// Process-wide registry of ranging rooms. Holds only device identity and the sample indices each +/// device reported, so the server is a scheduler and a relay — it never sees audio. +/// +public interface IEchoRoomStore +{ + /// Add a device to a room, creating the room if this is the first arrival. + EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate); + + /// Remove whichever device owns this connection, returning the room it left. + (string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId); + + /// Snapshot of a room's roster, or null if the room is gone. + EchoRoomSnapshot? Snapshot(string roomCode); + + /// Room codes with at least two devices, which is the minimum for a measurement. + IReadOnlyCollection MeasurableRooms { get; } + + /// + /// Open a new round for a room, rotating which device chirps first so no single device is + /// permanently the slot-order anchor. + /// + EchoRoundSchedule? StartRound( + string roomCode, + int slotMilliseconds, + int tailMilliseconds, + TimeSpan lead, + TimeSpan grace); + + /// File a device's peaks against the room's open round. + bool Report(string connectionId, EchoPeakReport report); + + /// + /// Close the open round if every device has reported or its deadline has passed, returning the + /// reports to broadcast. + /// + EchoRoundResult? TryCloseRound(string roomCode); + + /// Drop rooms that have had no activity for longer than . + int PruneIdle(TimeSpan idleFor); +} diff --git a/JoshHeaps.Net/wwwroot/css/echo/echo.css b/JoshHeaps.Net/wwwroot/css/echo/echo.css new file mode 100644 index 0000000..ba6b7b1 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/css/echo/echo.css @@ -0,0 +1,287 @@ +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + margin: 0; + padding: 2rem 1.25rem 4rem; +} + +#echoHeader { + max-width: 46rem; + margin: 0 auto 1.5rem; +} + +#echoHeader h1 { + color: var(--color-heading); + font-size: 2.4rem; + letter-spacing: -0.02em; + margin: 0 0 0.6rem; +} + +.echo-blurb { + line-height: 1.6; + margin: 0 0 0.5rem; +} + +.echo-privacy { + color: var(--color-text-subtle); + font-family: var(--font-mono); + font-size: 0.8rem; + margin: 0; +} + +#echoControls { + align-items: flex-end; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0 auto 1rem; + max-width: 46rem; +} + +#echoControls label { + color: var(--color-text-muted); + display: flex; + flex-direction: column; + font-family: var(--font-mono); + font-size: 0.75rem; + gap: 0.3rem; + text-transform: lowercase; +} + +#echoControls input { + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-heading); + font-family: var(--font-mono); + font-size: 1rem; + padding: 0.5rem 0.6rem; + width: 7rem; +} + +#echoControls input:focus { + border-color: var(--color-accent-border); + outline: none; +} + +#echoControls button { + background: transparent; + border: 1px solid var(--color-accent-border); + border-radius: 6px; + color: var(--color-accent); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.9rem; + padding: 0.6rem 1.1rem; + transition: background 0.15s ease, color 0.15s ease; +} + +#echoControls button:hover:not(:disabled) { + background: var(--color-accent); + color: var(--color-bg); +} + +#echoControls button:disabled { + border-color: var(--color-border); + color: var(--color-text-subtle); + cursor: default; +} + +.echo-status, +.echo-share { + font-family: var(--font-mono); + font-size: 0.8rem; + margin: 0 auto 0.4rem; + max-width: 46rem; +} + +.echo-share a { + color: var(--color-accent); + text-decoration: none; + word-break: break-all; +} + +#echoWarnings { + margin: 0 auto; + max-width: 46rem; +} + +.echo-warning { + background: rgba(255, 170, 0, 0.08); + border: 1px solid rgba(255, 170, 0, 0.35); + border-radius: 6px; + color: #ffcc66; + font-size: 0.85rem; + line-height: 1.5; + margin: 0.5rem 0; + padding: 0.7rem 0.9rem; +} + +.echo-readout { + align-items: baseline; + display: flex; + flex-direction: column; + gap: 0.4rem; + margin: 2rem auto; + max-width: 46rem; + min-height: 6rem; +} + +.echo-metres { + color: var(--color-accent); + font-family: var(--font-mono); + font-size: clamp(3.5rem, 14vw, 7rem); + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.echo-metres small { + color: var(--color-text-subtle); + font-size: 0.28em; + margin-left: 0.2em; +} + +.echo-readout-detail, +.echo-readout-idle { + color: var(--color-text-subtle); + font-family: var(--font-mono); + font-size: 0.8rem; +} + +#echoPanels { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + margin: 0 auto; + max-width: 46rem; +} + +.echo-panel { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1rem 1.1rem; +} + +.echo-panel-wide { + margin: 1rem auto 0; + max-width: 46rem; +} + +.echo-panel h2 { + color: var(--color-heading-secondary); + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 500; + letter-spacing: 0.08em; + margin: 0 0 0.75rem; + text-transform: uppercase; +} + +.echo-hint { + color: var(--color-text-subtle); + font-size: 0.8rem; + margin: -0.4rem 0 0.75rem; +} + +#echoRoster { + list-style: none; + margin: 0; + padding: 0; +} + +.echo-device { + border-bottom: 1px solid var(--color-border); + font-family: var(--font-mono); + font-size: 0.85rem; + padding: 0.45rem 0; +} + +.echo-device:last-child { + border-bottom: none; +} + +.echo-device-self { + color: var(--color-accent); +} + +#echoPairs { + overflow-x: auto; +} + +#echoPairs table { + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 0.85rem; + width: 100%; +} + +#echoPairs th { + color: var(--color-text-subtle); + font-weight: 500; + padding: 0 0.6rem 0.4rem 0; + text-align: left; +} + +#echoPairs td { + border-top: 1px solid var(--color-border); + color: var(--color-heading-secondary); + font-variant-numeric: tabular-nums; + padding: 0.4rem 0.6rem 0.4rem 0; +} + +.echo-dropped td { + color: var(--color-text-subtle); + text-decoration: line-through; +} + +.echo-slots { + list-style: none; + margin: 0 0 0.6rem; + padding: 0; +} + +.echo-slots li { + color: var(--color-heading-secondary); + font-family: var(--font-mono); + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + padding: 0.2rem 0; +} + +.echo-slots .echo-missed { + color: #ff7676; +} + +.echo-diag-line { + color: var(--color-text-subtle); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.5; + margin: 0; +} + +.echo-diag-line strong { + color: #ff7676; +} + +#echoTrace { + display: block; + height: 160px; + width: 100%; +} + +.echo-back { + color: var(--color-text-subtle); + display: block; + font-family: var(--font-mono); + font-size: 0.8rem; + margin: 2rem auto 0; + max-width: 46rem; + text-decoration: none; +} + +.echo-back:hover { + color: var(--color-accent); +} diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoAudio.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoAudio.js new file mode 100644 index 0000000..0f816ad --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoAudio.js @@ -0,0 +1,160 @@ +/* + * Microphone capture and chirp playback, both indexed by AudioContext frame. + * + * Frames, not wall-clock: currentTime * sampleRate is exactly the frame number, so a playback + * scheduled at an audio-clock time converts directly into a position in the recording. Nothing here + * depends on a timer firing on schedule, which matters because a hidden tab's timers are throttled + * to about once a second and its chirp would land in another device's slot. + */ +const EchoAudio = { + TARGET_SAMPLE_RATE: 48000, + RING_SECONDS: 20, + BLUETOOTH_HINTS: /bluetooth|airpod|hands-?free|headset|\bbt\b|wireless/i, + + context: null, + stream: null, + capture: null, + ring: null, + highestFrame: 0, + peakAmplitude: 0, + warnings: [], + onSamples: null, + + async start() { + if (this.context) return this.describe(); + + this.stream = await navigator.mediaDevices.getUserMedia({ + // Echo cancellation exists to delete sounds this device just played, which is exactly + // the measurement. Gain control and noise suppression distort the chirp's envelope. + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + channelCount: 1 + } + }); + + this.context = new AudioContext({ sampleRate: this.TARGET_SAMPLE_RATE, latencyHint: "interactive" }); + await this.context.audioWorklet.addModule("/js/EchoScripts/EchoCaptureProcessor.js"); + await this.context.resume(); + + this.ring = new Float32Array(Math.ceil(this.context.sampleRate * this.RING_SECONDS)); + this.attachCapture(); + this.warnings = this.inspectDevice(); + + return this.describe(); + }, + + attachCapture() { + const source = this.context.createMediaStreamSource(this.stream); + this.capture = new AudioWorkletNode(this.context, "echo-capture", { numberOfOutputs: 0 }); + this.capture.port.onmessage = ({ data }) => this.write(data.frame, data.samples); + source.connect(this.capture); + return this.capture; + }, + + write(frame, samples) { + const capacity = this.ring.length; + let loudest = 0; + + for (let i = 0; i < samples.length; i++) { + this.ring[(frame + i) % capacity] = samples[i]; + loudest = Math.max(loudest, Math.abs(samples[i])); + } + + this.peakAmplitude = Math.max(this.peakAmplitude * 0.95, loudest); + this.highestFrame = Math.max(this.highestFrame, frame + samples.length); + this.onSamples?.(this.highestFrame); + return this.highestFrame; + }, + + /** The recording position for an AudioContext time. Exact: currentTime * sampleRate is a frame. */ + frameAt(contextTime) { + return Math.round(contextTime * this.context.sampleRate); + }, + + /** Copy an absolute frame range out of the ring, or null if it is not (or no longer) held. */ + read(startFrame, length) { + if (length <= 0) return null; + if (startFrame + length > this.highestFrame) return null; + if (startFrame < this.highestFrame - this.ring.length) return null; + + const capacity = this.ring.length; + const window = new Float32Array(length); + for (let i = 0; i < length; i++) window[i] = this.ring[((startFrame + i) % capacity + capacity) % capacity]; + + return window; + }, + + /** + * Schedule a chirp on the audio clock and return the frame it was scheduled for. When it + * actually leaves the speaker is later by an unknown output latency and does not need to be + * known — it is recovered from the recording of it. + */ + play(chirp, atContextTime, gain = 0.5) { + const when = Math.max(atContextTime, this.context.currentTime); + const buffer = this.context.createBuffer(1, chirp.length, this.context.sampleRate); + buffer.copyToChannel(chirp, 0); + + const source = this.context.createBufferSource(); + const volume = this.context.createGain(); + volume.gain.value = gain; + source.buffer = buffer; + source.connect(volume).connect(this.context.destination); + source.start(when); + + return this.frameAt(when); + }, + + /** How far behind the audio clock the main thread's view of the recording is running. */ + captureLagSeconds() { + return this.context.currentTime - this.highestFrame / this.context.sampleRate; + }, + + /** Reported output latency, which bounds how late a scheduled chirp can reach the microphone. */ + outputLatencySeconds() { + return (this.context.outputLatency || 0) + (this.context.baseLatency || 0); + }, + + inspectDevice() { + const warnings = []; + const track = this.stream.getAudioTracks()[0]; + const settings = track?.getSettings() ?? {}; + + if (this.context.sampleRate !== this.TARGET_SAMPLE_RATE) + warnings.push(`Running at ${this.context.sampleRate}Hz instead of 48000Hz — ranges stay correct but resolution drops.`); + + if (this.BLUETOOTH_HINTS.test(track?.label ?? "")) + warnings.push("This looks like a Bluetooth microphone. Bluetooth resamples and re-times the stream, which breaks the measurement — switch to the built-in speaker and microphone."); + + for (const [name, label] of [["echoCancellation", "Echo cancellation"], ["autoGainControl", "Auto gain"], ["noiseSuppression", "Noise suppression"]]) + if (settings[name] === true) warnings.push(`${label} could not be turned off on this device — the measurement will be unreliable.`); + + return warnings; + }, + + describe() { + const track = this.stream.getAudioTracks()[0]; + + return { + sampleRate: this.context.sampleRate, + label: track?.label ?? "microphone", + outputLatencyMs: Math.round(this.outputLatencySeconds() * 1000), + warnings: this.warnings + }; + }, + + async stop() { + this.stream?.getTracks().forEach(track => track.stop()); + await this.context?.close(); + this.context = null; + this.stream = null; + this.capture = null; + this.ring = null; + this.highestFrame = 0; + this.onSamples = null; + return true; + } +}; + +if (typeof window !== "undefined") window.EchoAudio = EchoAudio; diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoCaptureProcessor.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoCaptureProcessor.js new file mode 100644 index 0000000..fbcf20a --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoCaptureProcessor.js @@ -0,0 +1,39 @@ +/* + * Capture worklet. Every block is tagged with the AudioContext frame it started at, which makes the + * context's own clock the index for all captured audio: a scheduled playback time converts to a + * recording position exactly, with no dependence on when the main thread got round to noticing. + * + * A worklet rather than ScriptProcessorNode because a dropped buffer would break the continuity + * that the sample count depends on. + */ +const BLOCK_SAMPLES = 2048; + +class EchoCaptureProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.block = new Float32Array(BLOCK_SAMPLES); + this.filled = 0; + this.blockStartFrame = 0; + } + + process(inputs) { + const channel = inputs[0]?.[0]; + if (!channel) return true; + + for (let i = 0; i < channel.length; i++) { + if (this.filled === 0) this.blockStartFrame = currentFrame + i; + this.block[this.filled++] = channel[i]; + if (this.filled === BLOCK_SAMPLES) this.flush(); + } + + return true; + } + + flush() { + const samples = this.block.slice(0, this.filled); + this.port.postMessage({ frame: this.blockStartFrame, samples }, [samples.buffer]); + this.filled = 0; + } +} + +registerProcessor("echo-capture", EchoCaptureProcessor); diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoDsp.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoDsp.js new file mode 100644 index 0000000..1f71cf0 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoDsp.js @@ -0,0 +1,585 @@ +/* + * Pure signal-processing and geometry for acoustic ranging. No DOM, no Web Audio, no network: + * everything here is a function of its arguments so the simulator and the tests can drive the + * exact code the page runs. + */ +const EchoDsp = { + speedOfSound(temperatureCelsius = 20) { + return 331.3 + 0.606 * temperatureCelsius; + }, + + nextPowerOfTwo(value) { + let size = 1; + while (size < value) size <<= 1; + return size; + }, + + /** + * Linear frequency sweep, tapered at both ends. A sweep is used rather than a tone because a + * tone's autocorrelation peaks once per period, leaving no unambiguous arrival to measure. + */ + makeChirp({ sampleRate, durationSeconds, startHz, endHz, taperFraction = 0.15 }) { + const length = Math.round(sampleRate * durationSeconds); + const sweepRate = (endHz - startHz) / durationSeconds; + const chirp = new Float32Array(length); + + for (let i = 0; i < length; i++) { + const t = i / sampleRate; + chirp[i] = Math.sin(2 * Math.PI * (startHz * t + 0.5 * sweepRate * t * t)); + } + + return this.applyTaper(chirp, taperFraction); + }, + + applyTaper(signal, fraction) { + const edge = Math.max(1, Math.floor(signal.length * fraction)); + + for (let i = 0; i < edge; i++) { + const window = 0.5 - 0.5 * Math.cos((Math.PI * i) / edge); + signal[i] *= window; + signal[signal.length - 1 - i] *= window; + } + + return signal; + }, + + twiddles(size) { + this._twiddleCache ??= new Map(); + const cached = this._twiddleCache.get(size); + if (cached) return cached; + + const half = size >> 1; + const table = { cos: new Float64Array(half), sin: new Float64Array(half) }; + + for (let i = 0; i < half; i++) { + const angle = (-2 * Math.PI * i) / size; + table.cos[i] = Math.cos(angle); + table.sin[i] = Math.sin(angle); + } + + this._twiddleCache.set(size, table); + return table; + }, + + fft(real, imaginary, inverse = false) { + const size = real.length; + this.reverseBits(real, imaginary); + const { cos, sin } = this.twiddles(size); + + for (let span = 2; span <= size; span <<= 1) { + const half = span >> 1; + const stride = size / span; + + for (let base = 0; base < size; base += span) { + for (let k = 0; k < half; k++) { + const twiddle = k * stride; + const wReal = cos[twiddle]; + const wImaginary = inverse ? -sin[twiddle] : sin[twiddle]; + + const lower = base + k; + const upper = lower + half; + const productReal = real[upper] * wReal - imaginary[upper] * wImaginary; + const productImaginary = real[upper] * wImaginary + imaginary[upper] * wReal; + + real[upper] = real[lower] - productReal; + imaginary[upper] = imaginary[lower] - productImaginary; + real[lower] += productReal; + imaginary[lower] += productImaginary; + } + } + } + + if (!inverse) return { real, imaginary }; + + for (let i = 0; i < size; i++) { + real[i] /= size; + imaginary[i] /= size; + } + + return { real, imaginary }; + }, + + reverseBits(real, imaginary) { + const size = real.length; + + for (let i = 1, j = 0; i < size; i++) { + let bit = size >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i >= j) continue; + + [real[i], real[j]] = [real[j], real[i]]; + [imaginary[i], imaginary[j]] = [imaginary[j], imaginary[i]]; + } + + return { real, imaginary }; + }, + + /** + * Matched-filter envelope of a recording against a template, via FFT cross-correlation. + * Negative frequencies are dropped so the result is the analytic envelope rather than a burst + * oscillating at the sweep frequency — an oscillating peak defeats sub-sample interpolation + * and makes first-arrival detection jitter by half a carrier period. + */ + matchedFilterEnvelope(recording, template) { + const size = this.nextPowerOfTwo(recording.length + template.length); + const recordingReal = new Float64Array(size); + const recordingImaginary = new Float64Array(size); + const templateReal = new Float64Array(size); + const templateImaginary = new Float64Array(size); + + recordingReal.set(recording); + templateReal.set(template); + this.fft(recordingReal, recordingImaginary); + this.fft(templateReal, templateImaginary); + + const analyticReal = new Float64Array(size); + const analyticImaginary = new Float64Array(size); + const half = size >> 1; + + for (let i = 0; i <= half; i++) { + const gain = i === 0 || i === half ? 1 : 2; + analyticReal[i] = gain * (recordingReal[i] * templateReal[i] + recordingImaginary[i] * templateImaginary[i]); + analyticImaginary[i] = gain * (recordingImaginary[i] * templateReal[i] - recordingReal[i] * templateImaginary[i]); + } + + this.fft(analyticReal, analyticImaginary, true); + + const envelope = new Float32Array(recording.length); + for (let i = 0; i < envelope.length; i++) + envelope[i] = Math.sqrt(analyticReal[i] * analyticReal[i] + analyticImaginary[i] * analyticImaginary[i]); + + return envelope; + }, + + maxInRange(values, start, end) { + let index = start; + let value = -Infinity; + + for (let i = start; i < end; i++) { + if (values[i] <= value) continue; + value = values[i]; + index = i; + } + + return { index, value }; + }, + + medianInRange(values, start, end, sampleLimit = 2048) { + const span = end - start; + if (span <= 0) return 0; + + const stride = Math.max(1, Math.floor(span / sampleLimit)); + const sampled = []; + for (let i = start; i < end; i += stride) sampled.push(values[i]); + + sampled.sort((left, right) => left - right); + return sampled[sampled.length >> 1]; + }, + + /** + * Sub-sample peak position by fitting a parabola through the peak and its neighbours. One + * tenth of a sample is 0.7mm of range at 48kHz, so this is most of the accuracy for free. + */ + refinePeakIndex(envelope, index) { + if (index <= 0 || index >= envelope.length - 1) return index; + + const before = envelope[index - 1]; + const peak = envelope[index]; + const after = envelope[index + 1]; + const curvature = before - 2 * peak + after; + if (curvature === 0) return index; + + const offset = (0.5 * (before - after)) / curvature; + return index + Math.max(-1, Math.min(1, offset)); + }, + + /** + * First arrival in a window, not the loudest one. A reflection off a wall or table often + * comes back louder than the direct path, and only the direct path is the distance. + */ + findFirstPeak(envelope, options = {}) { + const start = Math.max(0, Math.floor(options.start ?? 0)); + const end = Math.min(envelope.length, Math.ceil(options.end ?? envelope.length)); + if (end - start < 8) return null; + + const loudest = this.maxInRange(envelope, start, end); + const noiseFloor = this.medianInRange(envelope, start, end); + const snr = noiseFloor > 0 ? loudest.value / noiseFloor : Infinity; + if (snr < (options.minSnr ?? 4)) return null; + + // Held above the noise floor so sidelobes cannot trigger it, but well below the loudest + // arrival so that a reflection several times louder than the direct path cannot mask it. + const threshold = Math.max( + noiseFloor * (options.noiseMultiple ?? 6), + loudest.value * (options.relativeThreshold ?? 0.15) + ); + + let crossing = start; + while (crossing < end && envelope[crossing] < threshold) crossing++; + if (crossing >= end) return null; + + const lobeEnd = Math.min(end, crossing + (options.lobeSamples ?? 64)); + const arrival = this.maxInRange(envelope, crossing, lobeEnd); + + return { index: this.refinePeakIndex(envelope, arrival.index), amplitude: arrival.value, snr }; + }, + + /** + * Locate every chirp of one round in a single device's recording. + * + * The device's own chirp is the anchor: it is always present and always the loudest thing in + * the recording, and its position absorbs this device's own output and input latency. Every + * other slot is then searched relative to that anchor, so no clock agreement between devices + * is required — only that the chirps stay in their slots. + */ + detectSlotPeaks({ + envelope, + slotCount, + slotSamples, + ownSlot, + ownSearchStart, + ownSearchSamples, + slotHints = null, + peakOptions = {} + }) { + const own = this.findFirstPeak(envelope, { + ...peakOptions, + start: ownSearchStart, + end: ownSearchStart + ownSearchSamples + }); + + if (!own) return null; + + const anchor = own.index - ownSlot * slotSamples; + const pad = Math.floor(slotSamples * 0.45); + const peaks = new Array(slotCount).fill(null); + peaks[ownSlot] = own; + + for (let slot = 0; slot < slotCount; slot++) { + if (slot === ownSlot) continue; + + const centre = anchor + slot * slotSamples + (slotHints?.[slot] ?? 0); + peaks[slot] = this.findFirstPeak(envelope, { + ...peakOptions, + start: centre - pad, + end: centre + pad + }); + } + + return { anchor, peaks }; + }, + + /** + * Distance between two devices from four arrival indices, each measured inside the recording + * of the device that made it. Clock offset and audio-pipeline latency appear once with each + * sign and cancel; the devices' own speaker-to-microphone spacing does not, and is added back. + */ + pairDistance({ a1, a2, b1, b2, sampleRate, sampleRateA, sampleRateB, speedOfSound, epsilonA = 0, epsilonB = 0 }) { + // Each interval is converted to seconds in its own device's sample rate before the two are + // subtracted: a device that hands back 44100 instead of 48000 would otherwise contribute + // its interval in the wrong unit. + const intervalA = (a2 - a1) / (sampleRateA ?? sampleRate); + const intervalB = (b2 - b1) / (sampleRateB ?? sampleRate); + return ((intervalA - intervalB) / 2) * speedOfSound + (epsilonA + epsilonB) / 2; + }, + + /** + * Symmetric distance matrix from one round of reports. Entries stay null where either device + * failed to hear one of the four chirps the pair needs. + */ + buildDistanceMatrix(reports, { speedOfSound = 343, maxDistance = 40 } = {}) { + const count = reports.length; + const matrix = Array.from({ length: count }, () => new Array(count).fill(null)); + + for (let i = 0; i < count; i++) { + matrix[i][i] = 0; + + for (let j = i + 1; j < count; j++) { + const distance = this.distanceBetween(reports[i], reports[j], speedOfSound); + if (distance === null || distance < -1 || distance > maxDistance) continue; + + matrix[i][j] = Math.max(0, distance); + matrix[j][i] = matrix[i][j]; + } + } + + return matrix; + }, + + distanceBetween(deviceA, deviceB, speedOfSound) { + const a1 = deviceA.peaks[deviceA.slot]; + const a2 = deviceA.peaks[deviceB.slot]; + const b1 = deviceB.peaks[deviceA.slot]; + const b2 = deviceB.peaks[deviceB.slot]; + if (a1 === null || a2 === null || b1 === null || b2 === null) return null; + + return this.pairDistance({ + a1, + a2, + b1, + b2, + sampleRateA: deviceA.sampleRate, + sampleRateB: deviceB.sampleRate, + speedOfSound, + epsilonA: deviceA.epsilon ?? 0, + epsilonB: deviceB.epsilon ?? 0 + }); + }, + + /** + * Largest set of devices linked by measured distances. Anything outside it cannot be placed + * relative to the others, and leaving it in makes the completed matrix infinite. + */ + largestConnectedComponent(matrix) { + const unvisited = new Set(matrix.map((_, index) => index)); + let largest = []; + + while (unvisited.size > 0) { + const component = []; + const queue = [unvisited.values().next().value]; + unvisited.delete(queue[0]); + + while (queue.length > 0) { + const current = queue.pop(); + component.push(current); + + for (const next of unvisited) + if (matrix[current][next] !== null) { + unvisited.delete(next); + queue.push(next); + } + } + + if (component.length > largest.length) largest = component; + } + + return largest.sort((left, right) => left - right); + }, + + /** + * Drop devices whose distances are geometrically impossible. One device reporting a bad peak + * distorts the whole layout, so the worst triangle-inequality offender is removed and the + * check repeated. Below four devices there is no redundancy left and nothing can be checked. + */ + rejectOutliers(matrix, candidates, tolerance = 0.5) { + const keep = [...candidates]; + + while (keep.length > 3) { + const violations = this.countTriangleViolations(matrix, keep, tolerance); + const worst = violations.reduce((best, count, index) => (count > violations[best] ? index : best), 0); + if (violations[worst] === 0) break; + + keep.splice(worst, 1); + } + + return keep; + }, + + submatrix(matrix, indices) { + return indices.map(row => indices.map(column => matrix[row][column])); + }, + + countTriangleViolations(matrix, keep, tolerance) { + const violations = new Array(keep.length).fill(0); + + for (let i = 0; i < keep.length; i++) { + for (let j = i + 1; j < keep.length; j++) { + for (let k = j + 1; k < keep.length; k++) { + const sides = [matrix[keep[i]][keep[j]], matrix[keep[j]][keep[k]], matrix[keep[i]][keep[k]]]; + if (sides.some(side => side === null)) continue; + + const longest = Math.max(...sides); + const perimeter = sides.reduce((sum, side) => sum + side, 0); + if (longest <= perimeter - longest + tolerance) continue; + + violations[i]++; + violations[j]++; + violations[k]++; + } + } + } + + return violations; + }, + + /** Fill gaps with the shortest known path between the two devices so MDS gets a full matrix. */ + completeMatrix(matrix) { + const count = matrix.length; + const filled = matrix.map(row => row.map(value => (value === null ? Infinity : value))); + + for (let via = 0; via < count; via++) + for (let i = 0; i < count; i++) + for (let j = 0; j < count; j++) + filled[i][j] = Math.min(filled[i][j], filled[i][via] + filled[via][j]); + + return filled; + }, + + /** Jacobi eigendecomposition of a symmetric matrix. Returns eigenvalues and column vectors. */ + symmetricEigen(matrix, maxSweeps = 100, tolerance = 1e-14) { + const count = matrix.length; + const working = matrix.map(row => Float64Array.from(row)); + const vectors = Array.from({ length: count }, (_, i) => { + const column = new Float64Array(count); + column[i] = 1; + return column; + }); + + for (let sweep = 0; sweep < maxSweeps; sweep++) { + if (this.offDiagonalMagnitude(working) < tolerance) break; + + for (let p = 0; p < count - 1; p++) + for (let q = p + 1; q < count; q++) + this.rotateOut(working, vectors, p, q); + } + + return { + values: working.map((row, i) => row[i]), + vectors + }; + }, + + offDiagonalMagnitude(matrix) { + let total = 0; + + for (let i = 0; i < matrix.length; i++) + for (let j = i + 1; j < matrix.length; j++) total += matrix[i][j] * matrix[i][j]; + + return total; + }, + + rotateOut(matrix, vectors, p, q) { + if (Math.abs(matrix[p][q]) < 1e-300) return matrix; + + const theta = (matrix[q][q] - matrix[p][p]) / (2 * matrix[p][q]); + const sign = theta >= 0 ? 1 : -1; + const tangent = sign / (Math.abs(theta) + Math.sqrt(theta * theta + 1)); + const cosine = 1 / Math.sqrt(tangent * tangent + 1); + const sine = tangent * cosine; + const count = matrix.length; + + for (let k = 0; k < count; k++) { + const left = matrix[k][p]; + const right = matrix[k][q]; + matrix[k][p] = cosine * left - sine * right; + matrix[k][q] = sine * left + cosine * right; + } + + for (let k = 0; k < count; k++) { + const left = matrix[p][k]; + const right = matrix[q][k]; + matrix[p][k] = cosine * left - sine * right; + matrix[q][k] = sine * left + cosine * right; + } + + for (let k = 0; k < count; k++) { + const left = vectors[k][p]; + const right = vectors[k][q]; + vectors[k][p] = cosine * left - sine * right; + vectors[k][q] = sine * left + cosine * right; + } + + return matrix; + }, + + /** + * Classical multidimensional scaling: coordinates whose pairwise distances best reproduce the + * matrix. The result is only defined up to rotation, translation and mirroring. + */ + classicalMds(distances, dimensions = 2) { + const count = distances.length; + const squared = distances.map(row => row.map(value => value * value)); + const rowMeans = squared.map(row => row.reduce((sum, value) => sum + value, 0) / count); + const grandMean = rowMeans.reduce((sum, value) => sum + value, 0) / count; + + const centred = squared.map((row, i) => row.map((value, j) => -0.5 * (value - rowMeans[i] - rowMeans[j] + grandMean))); + const { values, vectors } = this.symmetricEigen(centred); + const order = values + .map((value, index) => ({ value, index })) + .sort((left, right) => right.value - left.value) + .slice(0, dimensions); + + return Array.from({ length: count }, (_, i) => + order.map(({ value, index }) => vectors[i][index] * Math.sqrt(Math.max(0, value))) + ); + }, + + /** + * Rotate, mirror and translate a constellation onto a reference layout. Without this, every + * solve returns an arbitrary orientation and the display spins and flips between updates. + */ + alignToReference(points, reference) { + if (!reference || reference.length !== points.length || points.length === 0) return points; + + const pointCentre = this.centroid(points); + const referenceCentre = this.centroid(reference); + let best = null; + + for (const mirror of [1, -1]) { + const candidate = this.rotateOnto(points, reference, pointCentre, referenceCentre, mirror); + if (!best || candidate.residual < best.residual) best = candidate; + } + + return best.points; + }, + + centroid(points) { + const sum = points.reduce((total, [x, y]) => [total[0] + x, total[1] + y], [0, 0]); + return [sum[0] / points.length, sum[1] / points.length]; + }, + + rotateOnto(points, reference, pointCentre, referenceCentre, mirror) { + let sineTerm = 0; + let cosineTerm = 0; + + for (let i = 0; i < points.length; i++) { + const px = (points[i][0] - pointCentre[0]) * mirror; + const py = points[i][1] - pointCentre[1]; + const qx = reference[i][0] - referenceCentre[0]; + const qy = reference[i][1] - referenceCentre[1]; + sineTerm += px * qy - py * qx; + cosineTerm += px * qx + py * qy; + } + + const angle = Math.atan2(sineTerm, cosineTerm); + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + let residual = 0; + + const aligned = points.map((point, i) => { + const px = (point[0] - pointCentre[0]) * mirror; + const py = point[1] - pointCentre[1]; + const x = px * cosine - py * sine + referenceCentre[0]; + const y = px * sine + py * cosine + referenceCentre[1]; + residual += (x - reference[i][0]) ** 2 + (y - reference[i][1]) ** 2; + return [x, y]; + }); + + return { points: aligned, residual }; + }, + + /** + * Full solve for one round: distances, connectivity, outlier rejection, then a constellation + * aligned onto the previous frame. + * + * previousPoints is indexed by report position, with null for devices that were dropped + * last round, so alignment survives devices coming and going. + */ + solveRound(reports, { speedOfSound = 343, previousPoints = null, tolerance = 0.5 } = {}) { + const matrix = this.buildDistanceMatrix(reports, { speedOfSound }); + const connected = this.largestConnectedComponent(matrix); + const keep = this.rejectOutliers(matrix, connected, tolerance); + const points = keep.length >= 2 ? this.classicalMds(this.completeMatrix(this.submatrix(matrix, keep))) : []; + const reference = previousPoints ? keep.map(index => previousPoints[index]) : null; + const alignable = reference?.length === points.length && reference.every(Boolean); + + return { + matrix, + keep, + points: alignable ? this.alignToReference(points, reference) : points + }; + } +}; + +if (typeof window !== "undefined") window.EchoDsp = EchoDsp; diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSession.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSession.js new file mode 100644 index 0000000..44a1717 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSession.js @@ -0,0 +1,269 @@ +/* + * Round lifecycle: join a room, chirp when told to, measure every chirp in our own recording, and + * report the frame indices. Peaks are reported relative to the analysis window rather than to an + * absolute frame — every measurement is a difference within one device's own recording, so a + * constant per-device origin cancels. + * + * Both the chirp and the analysis are driven by the audio clock rather than by timers: a hidden tab + * has its timers throttled to roughly once a second, which is long enough to put its chirp in + * another device's slot and turn the whole round into nonsense. + */ +const EchoSession = { + LEAD_IN_SECONDS: 0.25, + TEMPERATURE_CELSIUS: 20, + MAX_OUTPUT_LATENCY_SECONDS: 0.35, + CHIRP: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 }, + + CLOCK_SAMPLES: 5, + LATE_TOLERANCE_SECONDS: 0.05, + + connection: null, + deviceId: null, + roomCode: null, + serverOffsetMs: 0, + skippedRounds: 0, + devices: [], + chirp: null, + pending: null, + lastRound: null, + hintsByDevice: {}, + previousPoints: null, + epsilon: 0.08, + onUpdate: () => {}, + + async join(roomCode, displayName, { onUpdate }) { + this.onUpdate = onUpdate ?? this.onUpdate; + const audio = await EchoAudio.start(); + this.chirp = EchoDsp.makeChirp({ sampleRate: audio.sampleRate, ...this.CHIRP }); + EchoAudio.onSamples = frame => this.analyseIfCaptured(frame); + + this.connection = new signalR.HubConnectionBuilder().withUrl("/echoHub").withAutomaticReconnect().build(); + this.connection.on("RoomChanged", room => this.handleRoomChanged(room)); + this.connection.on("RoundStarting", schedule => this.handleRoundStarting(schedule)); + this.connection.on("RoundComplete", result => this.handleRoundComplete(result)); + await this.connection.start(); + + const joined = await this.connection.invoke("JoinRoom", roomCode, displayName, audio.sampleRate); + this.deviceId = joined.deviceId; + this.roomCode = joined.roomCode; + this.devices = joined.room.devices; + this.serverOffsetMs = await this.estimateServerOffset(); + + return { ...joined, audio, serverOffsetMs: this.serverOffsetMs }; + }, + + /** + * Offset from the server clock, by the usual round-trip midpoint. Only needs to be good enough + * to identify which chirp belongs to which slot, which is tens of milliseconds — the ranging + * itself never uses a shared clock. + */ + async estimateServerOffset() { + const offsets = []; + + for (let attempt = 0; attempt < this.CLOCK_SAMPLES; attempt++) { + const sent = Date.now(); + const serverNow = await this.connection.invoke("ServerTime"); + const received = Date.now(); + offsets.push(serverNow - (sent + received) / 2); + } + + offsets.sort((left, right) => left - right); + return offsets[offsets.length >> 1]; + }, + + serverNowMs() { + return Date.now() + this.serverOffsetMs; + }, + + handleRoomChanged(room) { + this.devices = room.devices; + this.previousPoints = null; + this.hintsByDevice = {}; + this.onUpdate({ kind: "room", room }); + return room; + }, + + /** + * Schedule our chirp for our slot on the audio clock, and record which frames this round will + * occupy so the analysis can start the moment that audio has actually been captured. + */ + handleRoundStarting(schedule) { + const ownSlot = schedule.slotOrder.indexOf(this.deviceId); + if (ownSlot < 0 || !EchoAudio.context) return null; + + const sampleRate = EchoAudio.context.sampleRate; + const slotSeconds = schedule.slotMilliseconds / 1000; + const secondsUntilStart = (schedule.startsAtUnixMs - this.serverNowMs()) / 1000; + const roundStartTime = EchoAudio.context.currentTime + secondsUntilStart; + const playAtTime = roundStartTime + ownSlot * slotSeconds; + + // Sitting a round out is far better than chirping late: a chirp in the wrong slot is + // attributed to the wrong device and ruins the round for everyone, whereas a missing chirp + // costs only this pair, this round. + if (playAtTime < EchoAudio.context.currentTime - this.LATE_TOLERANCE_SECONDS) { + this.skippedRounds++; + this.onUpdate({ kind: "skipped", schedule, lateBySeconds: EchoAudio.context.currentTime - playAtTime }); + return null; + } + + const scheduledFrame = EchoAudio.play(this.chirp, playAtTime); + + const round = { + schedule, + ownSlot, + sampleRate, + scheduledFrame, + slotSamples: Math.round(slotSeconds * sampleRate), + leadInSamples: Math.round(this.LEAD_IN_SECONDS * sampleRate), + startFrame: EchoAudio.frameAt(roundStartTime), + outputLatencyMs: Math.round(EchoAudio.outputLatencySeconds() * 1000), + captureLagMs: Math.round(EchoAudio.captureLagSeconds() * 1000) + }; + + round.windowStart = round.startFrame - round.leadInSamples; + round.windowLength = + round.leadInSamples + + schedule.slotOrder.length * round.slotSamples + + Math.round((schedule.tailMilliseconds / 1000) * sampleRate); + + this.pending = round; + this.onUpdate({ kind: "roundStarting", round }); + return round; + }, + + /** + * Run as soon as the round's audio exists in the ring. Driven by captured audio rather than a + * timer so a throttled tab still analyses on time. + */ + analyseIfCaptured(highestFrame) { + const round = this.pending; + if (!round) return null; + if (highestFrame < round.windowStart + round.windowLength) return null; + + this.pending = null; + return this.analyse(round); + }, + + analyse(round) { + this.lastRound = round; + const recording = EchoAudio.read(round.windowStart, round.windowLength); + if (!recording) return null; + + const envelope = EchoDsp.matchedFilterEnvelope(recording, this.chirp); + const detected = EchoDsp.detectSlotPeaks({ + envelope, + slotCount: round.schedule.slotOrder.length, + slotSamples: round.slotSamples, + ownSlot: round.ownSlot, + // Anchored on the frame the chirp was scheduled for, so the only unknown left is how + // long the speaker takes to actually emit it. + ownSearchStart: round.scheduledFrame - round.windowStart, + ownSearchSamples: Math.round((this.MAX_OUTPUT_LATENCY_SECONDS + this.CHIRP.durationSeconds) * round.sampleRate), + slotHints: this.hintsFor(round.schedule) + }); + + const diagnostics = this.describeRound(round, detected); + this.onUpdate({ kind: "envelope", envelope, detected, round, diagnostics }); + if (!detected) return null; + + this.rememberHints(round.schedule, detected, round.slotSamples); + return this.report(round, detected); + }, + + describeRound(round, detected) { + const millisecondsPer = 1000 / round.sampleRate; + + return { + captureLagMs: round.captureLagMs, + outputLatencyMs: round.outputLatencyMs, + serverOffsetMs: Math.round(this.serverOffsetMs), + skippedRounds: this.skippedRounds, + clipping: EchoAudio.peakAmplitude >= 0.99, + inputPeak: Number(EchoAudio.peakAmplitude.toFixed(3)), + ownFound: Boolean(detected?.peaks[round.ownSlot]), + slots: round.schedule.slotOrder.map((deviceId, slot) => ({ + deviceId, + own: slot === round.ownSlot, + residualMs: detected?.peaks[slot] + ? Math.round((detected.peaks[slot].index - (detected.anchor + slot * round.slotSamples)) * millisecondsPer) + : null, + snr: detected?.peaks[slot] ? Math.round(detected.peaks[slot].snr) : null + })) + }; + }, + + report(round, detected) { + return this.connection.invoke("ReportRound", { + deviceId: this.deviceId, + roundId: round.schedule.roundId, + slot: round.ownSlot, + sampleRate: round.sampleRate, + epsilon: this.epsilon, + peaks: detected.peaks.map(peak => peak?.index ?? null) + }); + }, + + /** + * Where each device's chirp actually landed last round, relative to where the slot said it + * would. Output latency differs by tens of milliseconds per device and is stable, so carrying + * the residual forward keeps the search windows centred instead of merely wide. + */ + rememberHints(schedule, detected, slotSamples) { + schedule.slotOrder.forEach((deviceId, slot) => { + const peak = detected.peaks[slot]; + if (!peak) return; + this.hintsByDevice[deviceId] = peak.index - (detected.anchor + slot * slotSamples); + }); + + return this.hintsByDevice; + }, + + hintsFor(schedule) { + return schedule.slotOrder.map(deviceId => this.hintsByDevice[deviceId] ?? 0); + }, + + handleRoundComplete(result) { + const reports = result.reports.map(report => ({ ...report, peaks: report.peaks ?? [] })); + const solved = EchoDsp.solveRound(reports, { + speedOfSound: EchoDsp.speedOfSound(this.TEMPERATURE_CELSIUS), + previousPoints: this.previousPoints + }); + + this.previousPoints = this.pointsByReportIndex(reports, solved); + this.onUpdate({ kind: "solved", result, reports, solved, deviceId: this.deviceId }); + return solved; + }, + + pointsByReportIndex(reports, solved) { + const points = new Array(reports.length).fill(null); + solved.keep.forEach((reportIndex, i) => { + points[reportIndex] = solved.points[i]; + }); + return points; + }, + + setEpsilon(metres) { + this.epsilon = metres; + localStorage.setItem("echo.epsilon", String(metres)); + return this.epsilon; + }, + + loadEpsilon() { + const stored = Number(localStorage.getItem("echo.epsilon")); + this.epsilon = Number.isFinite(stored) && stored > 0 ? stored : 0.08; + return this.epsilon; + }, + + async leave() { + await this.connection?.invoke("LeaveRoom").catch(() => {}); + await this.connection?.stop(); + await EchoAudio.stop(); + this.connection = null; + this.pending = null; + this.previousPoints = null; + this.hintsByDevice = {}; + return true; + } +}; + +if (typeof window !== "undefined") window.EchoSession = EchoSession; diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSim.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSim.js new file mode 100644 index 0000000..7ce08f3 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSim.js @@ -0,0 +1,213 @@ +/* + * Virtual room for exercising the real ranging pipeline without microphones. Synthesizes what + * each device would have recorded — propagation delay, reflections, noise, per-device clock offset + * and unknown output latency — then runs the same EchoDsp code the page runs. + */ +const EchoSim = { + DEFAULTS: { + sampleRate: 48000, + slotSeconds: 0.4, + tailSeconds: 0.5, + speedOfSound: 343, + noiseAmplitude: 0.01, + referenceGain: 0.5, + minimumPathMetres: 0.25, + maximumOutputLatencySeconds: 0.2, + chirp: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 }, + reflectionsPerPath: 2, + reflectionExtraRange: [0.4, 4.0], + reflectionGainRange: [0.2, 0.8], + seed: 20260730 + }, + + randomGenerator(seed) { + let state = seed >>> 0; + + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let mixed = Math.imul(state ^ (state >>> 15), 1 | state); + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed; + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296; + }; + }, + + separation(first, second) { + return Math.hypot(first[0] - second[0], first[1] - second[1]); + }, + + buildConfiguration(overrides = {}) { + const config = { ...this.DEFAULTS, ...overrides }; + const count = config.positions.length; + + config.chirp = { ...this.DEFAULTS.chirp, ...(overrides.chirp ?? {}) }; + config.epsilon ??= new Array(count).fill(0.05); + config.clockOffsets ??= config.positions.map((_, i) => i * 7919); + config.outputLatencies ??= config.positions.map((_, i) => 0.02 + 0.03 * i); + config.scheduleJitter ??= config.positions.map((_, i) => 0.004 * i); + config.reflections ??= this.buildReflectionTable(config); + return config; + }, + + /** + * Multipath for every source-to-listener path independently. Giving every path the same echo + * would be worthless as a test: an identical bias on all four arrivals cancels out of the + * range formula, so a uniform echo model hides exactly the error it is supposed to expose. + */ + buildReflectionTable(config) { + const random = this.randomGenerator(config.seed ^ 0x5f3759df); + const spread = (range, value) => range[0] + value * (range[1] - range[0]); + + return config.positions.map(() => + config.positions.map(() => + Array.from({ length: config.reflectionsPerPath }, () => ({ + extraMetres: spread(config.reflectionExtraRange, random()), + gain: spread(config.reflectionGainRange, random()) + })) + ) + ); + }, + + reflectionsFor(config, source, listener) { + return Array.isArray(config.reflections[0]) ? config.reflections[source][listener] : config.reflections; + }, + + /** One recording per device, plus the index each device believes it started playing at. */ + synthesizeRound(config) { + const { positions, sampleRate, slotSeconds, tailSeconds } = config; + const random = this.randomGenerator(config.seed); + const template = EchoDsp.makeChirp({ sampleRate, ...config.chirp }); + const maximumOffset = Math.max(...config.clockOffsets); + const length = Math.ceil((positions.length * slotSeconds + tailSeconds) * sampleRate) + maximumOffset; + + const devices = positions.map((_, index) => ({ + recording: this.noiseBuffer(length, config.noiseAmplitude, random), + ownSearchStart: Math.round((index * slotSeconds + config.scheduleJitter[index]) * sampleRate) + config.clockOffsets[index] + })); + + for (let source = 0; source < positions.length; source++) + for (let listener = 0; listener < positions.length; listener++) + this.mixArrivals(devices[listener].recording, template, config, source, listener); + + return { devices, template }; + }, + + noiseBuffer(length, amplitude, random) { + const buffer = new Float32Array(length); + for (let i = 0; i < length; i++) buffer[i] = (random() * 2 - 1) * amplitude; + return buffer; + }, + + mixArrivals(recording, template, config, source, listener) { + const emissionSeconds = + source * config.slotSeconds + config.scheduleJitter[source] + config.outputLatencies[source]; + const directMetres = + source === listener ? config.epsilon[source] : this.separation(config.positions[source], config.positions[listener]); + + const paths = [ + { metres: directMetres, gain: 1 }, + ...this.reflectionsFor(config, source, listener).map(({ extraMetres, gain }) => ({ + metres: directMetres + extraMetres, + gain + })) + ]; + + for (const path of paths) { + const arrival = emissionSeconds + path.metres / config.speedOfSound; + const amplitude = + (config.referenceGain / Math.max(path.metres, config.minimumPathMetres)) * path.gain; + this.addAt(recording, template, Math.round(arrival * config.sampleRate) + config.clockOffsets[listener], amplitude); + } + + return recording; + }, + + addAt(recording, template, offset, amplitude) { + const start = Math.max(0, offset); + const end = Math.min(recording.length, offset + template.length); + + for (let i = start; i < end; i++) recording[i] += template[i - offset] * amplitude; + + return recording; + }, + + /** Run every device's recording through detection and return one report per device. */ + detectAll({ devices, template }, config) { + const slotSamples = Math.round(config.slotSeconds * config.sampleRate); + const searchSamples = Math.round( + (config.maximumOutputLatencySeconds + config.chirp.durationSeconds + 0.05) * config.sampleRate + ); + + return devices.map((device, slot) => { + const envelope = EchoDsp.matchedFilterEnvelope(device.recording, template); + const detected = EchoDsp.detectSlotPeaks({ + envelope, + slotCount: devices.length, + slotSamples, + ownSlot: slot, + ownSearchStart: device.ownSearchStart, + ownSearchSamples: searchSamples, + peakOptions: config.peakOptions ?? {} + }); + + return { + deviceId: `sim-${slot}`, + slot, + sampleRate: config.sampleRate, + epsilon: config.epsilon[slot], + peaks: (detected?.peaks ?? new Array(devices.length).fill(null)).map(peak => peak?.index ?? null) + }; + }); + }, + + /** Synthesize, detect and solve, reporting recovered geometry against the ground truth. */ + runRound(overrides = {}) { + const config = this.buildConfiguration(overrides); + const round = this.synthesizeRound(config); + const reports = this.detectAll(round, config); + const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound }); + + return { + config, + reports, + ...solved, + distanceErrors: this.distanceErrors(solved.matrix, config), + positionErrors: this.positionErrors(solved, config) + }; + }, + + distanceErrors(matrix, config) { + const errors = []; + + for (let i = 0; i < matrix.length; i++) + for (let j = i + 1; j < matrix.length; j++) { + const truth = this.separation(config.positions[i], config.positions[j]); + errors.push({ + pair: [i, j], + truth, + measured: matrix[i][j], + error: matrix[i][j] === null ? null : matrix[i][j] - truth + }); + } + + return errors; + }, + + positionErrors({ keep, points }, config) { + if (points.length !== keep.length || points.length < 2) return []; + + const truth = keep.map(index => config.positions[index]); + const aligned = EchoDsp.alignToReference(points, truth); + return aligned.map((point, i) => this.separation(point, truth[i])); + }, + + worstDistanceError(result) { + const magnitudes = result.distanceErrors.map(({ error }) => (error === null ? Infinity : Math.abs(error))); + return magnitudes.length === 0 ? 0 : Math.max(...magnitudes); + }, + + worstPositionError(result) { + return result.positionErrors.length === 0 ? Infinity : Math.max(...result.positionErrors); + } +}; + +if (typeof window !== "undefined") window.EchoSim = EchoSim; diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/echoMain.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/echoMain.js new file mode 100644 index 0000000..3ded793 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/echoMain.js @@ -0,0 +1,282 @@ +/* + * Page wiring for /echo. Renders what the session measures: the matched-filter trace with the + * arrivals it picked, the pairwise ranges, and — while only two devices are present — one large + * number, because that number is the whole measurement. + */ +const EchoPage = { + HISTORY_LENGTH: 12, + + elements: {}, + lastSolved: null, + lastDiagnostics: null, + history: [], + roundsSeen: 0, + + start() { + this.elements = { + room: document.getElementById("echoRoom"), + name: document.getElementById("echoName"), + join: document.getElementById("echoJoin"), + leave: document.getElementById("echoLeave"), + status: document.getElementById("echoStatus"), + warnings: document.getElementById("echoWarnings"), + readout: document.getElementById("echoReadout"), + pairs: document.getElementById("echoPairs"), + roster: document.getElementById("echoRoster"), + trace: document.getElementById("echoTrace"), + epsilon: document.getElementById("echoEpsilon"), + shareLink: document.getElementById("echoShareLink"), + diagnostics: document.getElementById("echoDiagnostics") + }; + + this.elements.room.value = new URLSearchParams(location.search).get("room") ?? this.randomCode(); + this.elements.epsilon.value = EchoSession.loadEpsilon(); + this.elements.epsilon.addEventListener("change", () => this.applyEpsilon()); + this.elements.join.addEventListener("click", () => this.join()); + this.elements.leave.addEventListener("click", () => this.leave()); + + return this; + }, + + randomCode() { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + return Array.from({ length: 4 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join(""); + }, + + applyEpsilon() { + const metres = Number(this.elements.epsilon.value); + if (!Number.isFinite(metres) || metres < 0) return null; + + EchoSession.setEpsilon(metres); + return metres; + }, + + async join() { + const roomCode = this.elements.room.value.trim().toUpperCase(); + if (!roomCode) return null; + + this.setStatus("Requesting the microphone…"); + this.elements.join.disabled = true; + + try { + const joined = await EchoSession.join(roomCode, this.deviceName(), { onUpdate: update => this.handle(update) }); + this.showJoined(joined); + return joined; + } catch (error) { + this.setStatus(`Could not start: ${error.message}`); + this.elements.join.disabled = false; + return null; + } + }, + + deviceName() { + const typed = this.elements.name.value.trim(); + if (typed) return typed; + + return /android|iphone|ipad|mobile/i.test(navigator.userAgent) ? "phone" : "laptop"; + }, + + showJoined(joined) { + this.elements.leave.hidden = false; + this.elements.room.disabled = true; + this.elements.name.disabled = true; + this.elements.shareLink.textContent = `${location.origin}/echo?room=${joined.roomCode}`; + this.elements.shareLink.href = `/echo?room=${joined.roomCode}`; + this.renderWarnings(joined.audio); + this.renderRoster(joined.room.devices); + this.setStatus(`Listening at ${joined.audio.sampleRate}Hz. Open the same room on another device.`); + return joined; + }, + + async leave() { + await EchoSession.leave(); + this.elements.leave.hidden = true; + this.elements.join.disabled = false; + this.elements.room.disabled = false; + this.elements.name.disabled = false; + this.setStatus("Left the room."); + return true; + }, + + handle(update) { + if (update.kind === "room") return this.renderRoster(update.room.devices); + if (update.kind === "envelope") { + this.renderDiagnostics(update.diagnostics); + return this.renderTrace(update); + } + if (update.kind === "solved") return this.renderSolved(update); + return null; + }, + + /** + * Per-slot residual is the diagnostic that matters: it is how far each chirp landed from where + * its slot said it would. Steady residuals mean the arrivals are being attributed correctly; + * residuals jumping by more than a slot mean they are not, and every range is then meaningless. + */ + renderDiagnostics(diagnostics) { + if (!diagnostics) return null; + this.lastDiagnostics = diagnostics; + + const slots = diagnostics.slots + .map(slot => { + const label = slot.own ? "self" : slot.deviceId; + const residual = slot.residualMs === null ? "missed" : `${slot.residualMs > 0 ? "+" : ""}${slot.residualMs}ms`; + return `
  • ${label} · ${residual}${slot.snr === null ? "" : ` · snr ${slot.snr}`}
  • `; + }) + .join(""); + + this.elements.diagnostics.innerHTML = ` +
      ${slots}
    +

    + capture lag ${diagnostics.captureLagMs}ms · output latency ${diagnostics.outputLatencyMs}ms · + clock offset ${diagnostics.serverOffsetMs}ms · rounds sat out ${diagnostics.skippedRounds} · + input peak ${diagnostics.inputPeak}${diagnostics.clipping ? " CLIPPING" : ""} +

    `; + + return diagnostics; + }, + + recordHistory(metres) { + this.history.push(metres); + if (this.history.length > this.HISTORY_LENGTH) this.history.shift(); + + const spread = Math.max(...this.history) - Math.min(...this.history); + return { spread, count: this.history.length }; + }, + + setStatus(message) { + this.elements.status.textContent = message; + return message; + }, + + renderWarnings(audio) { + this.elements.warnings.innerHTML = ""; + + for (const warning of audio.warnings) { + const item = document.createElement("p"); + item.className = "echo-warning"; + item.textContent = warning; + this.elements.warnings.appendChild(item); + } + + return audio.warnings.length; + }, + + renderRoster(devices) { + this.elements.roster.innerHTML = ""; + + for (const device of devices) { + const row = document.createElement("li"); + row.className = device.deviceId === EchoSession.deviceId ? "echo-device echo-device-self" : "echo-device"; + row.textContent = `${device.displayName} · ${device.sampleRate}Hz`; + this.elements.roster.appendChild(row); + } + + if (devices.length < 2) this.setStatus("Waiting for a second device to join this room."); + return devices.length; + }, + + renderSolved({ result, reports, solved }) { + this.roundsSeen++; + const raw = EchoDsp.buildDistanceMatrix( + reports.map(report => ({ ...report, epsilon: 0 })), + { speedOfSound: EchoDsp.speedOfSound(EchoSession.TEMPERATURE_CELSIUS) } + ); + + this.lastSolved = { result, reports, solved, raw }; + this.renderReadout(reports, solved, raw); + this.renderPairs(reports, solved, raw); + this.setStatus(`Round ${this.roundsSeen} · ${reports.length} of ${EchoSession.devices.length} devices reported`); + return solved; + }, + + renderReadout(reports, solved, raw) { + const readout = this.elements.readout; + + if (reports.length !== 2 || solved.matrix[0][1] === null) { + readout.innerHTML = `${reports.length < 2 ? "waiting for a pair" : "measuring…"}`; + return null; + } + + const corrected = solved.matrix[0][1]; + const { spread, count } = this.recordHistory(corrected); + readout.innerHTML = ` + ${corrected.toFixed(2)}m + + raw ${raw[0][1].toFixed(3)}m · calibration +${(corrected - raw[0][1]).toFixed(3)}m · + spread over last ${count} ${spread.toFixed(2)}m + `; + + return corrected; + }, + + renderPairs(reports, solved, raw) { + const rows = []; + + for (let i = 0; i < reports.length; i++) + for (let j = i + 1; j < reports.length; j++) { + const dropped = !solved.keep.includes(i) || !solved.keep.includes(j); + const measured = solved.matrix[i][j]; + rows.push(` + ${reports[i].deviceId} ↔ ${reports[j].deviceId} + ${measured === null ? "—" : measured.toFixed(3) + " m"} + ${raw[i][j] === null ? "—" : raw[i][j].toFixed(3) + " m"} + `); + } + + this.elements.pairs.innerHTML = rows.length + ? `${rows.join("")}
    pairrangeraw
    ` + : ""; + + return rows.length; + }, + + /** The matched-filter trace, with a marker on each arrival the detector accepted. */ + renderTrace({ envelope, detected, round }) { + const canvas = this.elements.trace; + const context = canvas.getContext("2d"); + const width = (canvas.width = canvas.clientWidth); + const height = (canvas.height = 160); + const peak = EchoDsp.maxInRange(envelope, 0, envelope.length).value || 1; + + context.clearRect(0, 0, width, height); + context.strokeStyle = "rgba(9, 255, 0, 0.75)"; + context.beginPath(); + + const bucket = envelope.length / width; + for (let x = 0; x < width; x++) { + const start = Math.floor(x * bucket); + const highest = EchoDsp.maxInRange(envelope, start, Math.min(envelope.length, Math.floor(start + bucket))).value; + const y = height - (highest / peak) * (height - 8) - 4; + x === 0 ? context.moveTo(x, y) : context.lineTo(x, y); + } + + context.stroke(); + this.drawMarkers(context, detected, round, envelope.length, width, height); + return canvas; + }, + + drawMarkers(context, detected, round, envelopeLength, width, height) { + if (!detected) return null; + + context.font = "11px 'Cascadia Code', monospace"; + + detected.peaks.forEach((peak, slot) => { + if (!peak) return; + + const x = (peak.index / envelopeLength) * width; + const own = slot === round.ownSlot; + context.strokeStyle = own ? "#5fff5f" : "rgba(255, 255, 255, 0.55)"; + context.fillStyle = context.strokeStyle; + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, height); + context.stroke(); + context.fillText(own ? `self (${slot})` : `slot ${slot}`, x + 4, 14 + slot * 13); + }); + + return detected.peaks.length; + } +}; + +document.addEventListener("DOMContentLoaded", () => EchoPage.start());