Author SHA1 Message Date
Josh-HeapsandClaude Opus 5 5501c84e9b Test the ranging pipeline against a simulated room
The DSP tests drive the same files the page loads, so there is no second
implementation to drift. The room tests put two real browsers through a
real room with a synthetic microphone, which covers everything except the
acoustics: slot rotation, leave and rejoin, and the stalled-device case
where a chirp must be withheld rather than sent late.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 14:42:16 -06:00
Josh-HeapsandClaude Opus 5 d78251c928 Add the /echo page, capture and playback
Worklet capture into a ring buffer indexed by AudioContext frame, with
the chirp scheduled on the audio clock. Timers are avoided throughout:
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 misattribute
every arrival in the round. A device that cannot reach its slot in time
sits the round out rather than chirping late.

Echo cancellation, noise suppression and auto gain are all requested off
and warned about if the device refuses, since echo cancellation exists to
delete exactly the sound being measured.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 14:42:05 -06:00
Josh-HeapsandClaude Opus 5 94054c0f1b Add echo rooms, round scheduling and peak relay
In-memory rooms with a background service that opens a round per room,
rotates which device chirps first, and broadcasts the raw peak table once
everyone has reported or the deadline passes. Rounds are announced ahead
of time with a server timestamp so devices schedule against a shared
reference rather than against message arrival.

The server only ever holds sample indices; the geometry is solved on the
clients and no audio is uploaded.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 14:41:52 -06:00
Josh-HeapsandClaude Opus 5 183a53234f Add acoustic ranging maths and a room simulator
Chirp generation, FFT matched filter, first-arrival detection with
sub-sample peak fitting, the BeepBeep pair solve, triangle-inequality
outlier rejection and classical MDS, plus a virtual room that
synthesizes what each device would have recorded so the pipeline can be
exercised without microphones.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 14:41:39 -06:00
Josh Heaps f956f4bffa Merge pull request #29 from JoshHeaps/chess-ui-overhaul
Build and Deploy / build (push) Failing after 1m22s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 58s
Bug fixes
2026-06-13 22:03:35 -06:00
Josh Heaps edc4d70678 Merge pull request #28 from JoshHeaps/chess-ui-overhaul
change deploy
2026-06-13 21:34:22 -06:00
Josh Heaps 59b318b75c Merge pull request #27 from JoshHeaps/chess-ui-overhaul
revert everything, these changes were a mistake
2026-06-13 20:30:57 -06:00
Josh Heaps 3947bd0daf Merge pull request #26 from JoshHeaps/chess-ui-overhaul
bug fixes
2026-06-13 20:14:20 -06:00
Josh Heaps a0b8c2602a Merge pull request #25 from JoshHeaps/chess-ui-overhaul
fix deploy
2026-06-13 19:44:26 -06:00
Josh Heaps 02163a4fc4 Merge pull request #24 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 18:47:56 -06:00
Josh Heaps 26b4c0e59b Merge pull request #23 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 17:32:16 -06:00
Josh Heaps d09a067377 Merge pull request #22 from JoshHeaps/chess-ui-overhaul
Change data location
2026-06-12 19:53:34 -06:00
Josh Heaps d452ee414b Merge pull request #21 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-12 18:02:44 -06:00
Josh Heaps 62e146648c Merge pull request #20 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-10 17:01:22 -06:00
17 changed files with 2942 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
/// <summary>
/// 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.
/// </summary>
[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<double>("""
() => {
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<double[]>("""
() => {
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<double>("""
() => {
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<double>("""
() => {
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<double[]>("""
() => {
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<double[]>("""
() => {
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<double[]>("""
() => {
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<double[]>("""
() => {
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<double[]>("""
() => {
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<double[]>("""
() => {
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<double>("""
() => {
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");
}
}
+244
View File
@@ -0,0 +1,244 @@
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
/// <summary>
/// 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.
/// </summary>
[TestFixture]
public class EchoRoomTests : PlaywrightTest
{
private const double TargetMetres = 2.5;
private IPlaywright? _playwright;
private IBrowser? _browser;
private readonly List<IBrowserContext> _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));
}
/// <summary>
/// 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.
/// </summary>
[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<double[]>("() => window.__seen");
var satOut = await second.EvaluateAsync<int>("() => 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<double[]>("""
() => [
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");
}
/// <summary>
/// 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.
/// </summary>
private async Task<IPage> 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;
}
/// <summary>Blocks the main thread on every round announcement, the way a throttled tab does.</summary>
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<double> WaitForMeasurementAsync(IPage page)
{
await page.WaitForFunctionAsync(
"() => EchoPage.lastSolved?.solved?.matrix?.[0]?.[1] != null",
null,
new PageWaitForFunctionOptions { Timeout = 30000, PollingInterval = 250 });
return await page.EvaluateAsync<double>("() => EchoPage.lastSolved.solved.matrix[0][1]");
}
/// <summary>
/// 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.
/// </summary>
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;
};
}
""";
}
+52
View File
@@ -0,0 +1,52 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Hubs;
/// <summary>
/// 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.
/// </summary>
public class EchoHub(IEchoRoomStore rooms) : Hub
{
/// <summary>Join (or create) a room and receive an id plus the current roster.</summary>
public async Task<EchoJoinResult> 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;
}
/// <summary>File this device's arrival indices for the room's open round.</summary>
public bool ReportRound(EchoPeakReport report) => rooms.Report(Context.ConnectionId, report);
/// <summary>
/// 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.
/// </summary>
public long ServerTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
public async Task<bool> 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<bool> 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;
}
}
+84
View File
@@ -0,0 +1,84 @@
namespace JoshHeaps.Net.Models;
/// <summary>A device taking part in a ranging room.</summary>
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; }
}
/// <summary>
/// One chirp cycle: every device plays in turn, and every device listens to the whole thing.
/// </summary>
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<string, EchoPeakReport> Reports { get; } = [];
}
/// <summary>
/// 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.
/// </summary>
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; }
}
/// <summary>Tells every device when to chirp: its own slot index and how long a slot lasts.</summary>
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; }
/// <summary>
/// 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.
/// </summary>
public long StartsAtUnixMs { get; init; }
}
/// <summary>
/// Every device's peaks for one round, broadcast unchanged. Each client solves the geometry itself
/// so the server never needs the ranging maths.
/// </summary>
public sealed class EchoRoundResult
{
public required string RoundId { get; init; }
public required string[] SlotOrder { get; init; }
public required EchoPeakReport[] Reports { get; init; }
}
+70
View File
@@ -0,0 +1,70 @@
@page
@model JoshHeaps.Net.Pages.EchoModel
@{
Layout = "_Layout";
ViewData["Title"] = "Echo";
}
<div id="echoHeader">
<h1>Echo</h1>
<p class="echo-blurb">
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.
</p>
<p class="echo-privacy">Only sample numbers leave your device. The recording never does.</p>
</div>
<div id="echoControls">
<label>Room <input id="echoRoom" type="text" maxlength="6" autocomplete="off" spellcheck="false" /></label>
<label>Name <input id="echoName" type="text" maxlength="16" placeholder="this device" autocomplete="off" /></label>
<label>
Speaker to mic (m)
<input id="echoEpsilon" type="number" min="0" max="0.5" step="0.01" />
</label>
<button id="echoJoin">Join and listen</button>
<button id="echoLeave" hidden>Leave</button>
</div>
<p id="echoStatus" class="echo-status">Pick a room, then open the same room on a second device.</p>
<p class="echo-share">Join link: <a id="echoShareLink" href="#"></a></p>
<div id="echoWarnings"></div>
<div id="echoReadout" class="echo-readout"><span class="echo-readout-idle">not measuring</span></div>
<div id="echoPanels">
<section class="echo-panel">
<h2>Devices</h2>
<ul id="echoRoster"></ul>
</section>
<section class="echo-panel">
<h2>Ranges</h2>
<div id="echoPairs"></div>
</section>
<section class="echo-panel">
<h2>Timing</h2>
<p class="echo-hint">How far each chirp landed from its slot. Steady means the arrivals are being matched to the right devices.</p>
<div id="echoDiagnostics"></div>
</section>
</div>
<section class="echo-panel echo-panel-wide">
<h2>Matched filter</h2>
<p class="echo-hint">Each spike is a chirp arriving. The bright one is this device hearing itself.</p>
<canvas id="echoTrace"></canvas>
</section>
<a class="echo-back" href="/">&larr; Back</a>
@section Scripts {
<script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/EchoScripts/EchoDsp.js"></script>
<script src="~/js/EchoScripts/EchoAudio.js"></script>
<script src="~/js/EchoScripts/EchoSession.js"></script>
<script src="~/js/EchoScripts/echoMain.js"></script>
}
@section Styles {
<link rel="stylesheet" href="~/css/variables.css?v=@ViewData["cssVersion"]" />
<link rel="stylesheet" href="~/css/echo/echo.css?v=@ViewData["cssVersion"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class EchoModel : PageModel
{
public void OnGet()
{
}
}
}
+9
View File
@@ -1,6 +1,7 @@
using JoshHeaps.Net.Hubs; using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -30,6 +31,12 @@ builder.Services.AddSingleton<IGameStore, GameStore>();
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>(); builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
builder.Services.AddSingleton<AutoTrainingSettings>(); builder.Services.AddSingleton<AutoTrainingSettings>();
builder.Services.Configure<EchoRoundSettings>(configuration.GetSection(EchoRoundSettings.SectionName));
builder.Services.AddSingleton(provider =>
provider.GetRequiredService<IOptions<EchoRoundSettings>>().Value);
builder.Services.AddSingleton<IEchoRoomStore, EchoRoomStore>();
builder.Services.AddHostedService<EchoRoundService>();
if (!builder.Environment.IsDevelopment()) if (!builder.Environment.IsDevelopment())
{ {
builder.Services.AddHostedService<AutoIpUpdateService>(); builder.Services.AddHostedService<AutoIpUpdateService>();
@@ -72,4 +79,6 @@ app.MapControllers();
app.MapHub<ChessHub>("/chessHub"); app.MapHub<ChessHub>("/chessHub");
app.MapHub<EchoHub>("/echoHub");
app.Run(); app.Run();
@@ -0,0 +1,189 @@
using System.Collections.Concurrent;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// 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.
/// </summary>
public sealed class EchoRoomStore : IEchoRoomStore
{
private sealed class Room
{
public required string Code { get; init; }
public List<EchoDevice> Devices { get; } = [];
public EchoRound? Round { get; set; }
public int RoundCounter { get; set; }
public DateTimeOffset LastActivity { get; set; } = DateTimeOffset.UtcNow;
}
private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _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<string> 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);
}
}
@@ -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";
/// <summary>
/// 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.
/// </summary>
public int SlotMilliseconds { get; set; } = 500;
/// <summary>Time after the last chirp for propagation, detection and reporting.</summary>
public int TailMilliseconds { get; set; } = 700;
public int GraceMilliseconds { get; set; } = 1500;
public int GapMilliseconds { get; set; } = 250;
public int IdleRoomMinutes { get; set; } = 10;
/// <summary>
/// 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.
/// </summary>
public int LeadMilliseconds { get; set; } = 600;
}
/// <summary>
/// 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.
/// </summary>
public sealed class EchoRoundService(
IEchoRoomStore rooms,
IHubContext<EchoHub> hub,
EchoRoundSettings settings,
ILogger<EchoRoundService> logger) : BackgroundService
{
private readonly Dictionary<string, DateTimeOffset> _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;
}
}
@@ -0,0 +1,45 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// 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.
/// </summary>
public interface IEchoRoomStore
{
/// <summary>Add a device to a room, creating the room if this is the first arrival.</summary>
EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate);
/// <summary>Remove whichever device owns this connection, returning the room it left.</summary>
(string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId);
/// <summary>Snapshot of a room's roster, or null if the room is gone.</summary>
EchoRoomSnapshot? Snapshot(string roomCode);
/// <summary>Room codes with at least two devices, which is the minimum for a measurement.</summary>
IReadOnlyCollection<string> MeasurableRooms { get; }
/// <summary>
/// Open a new round for a room, rotating which device chirps first so no single device is
/// permanently the slot-order anchor.
/// </summary>
EchoRoundSchedule? StartRound(
string roomCode,
int slotMilliseconds,
int tailMilliseconds,
TimeSpan lead,
TimeSpan grace);
/// <summary>File a device's peaks against the room's open round.</summary>
bool Report(string connectionId, EchoPeakReport report);
/// <summary>
/// Close the open round if every device has reported or its deadline has passed, returning the
/// reports to broadcast.
/// </summary>
EchoRoundResult? TryCloseRound(string roomCode);
/// <summary>Drop rooms that have had no activity for longer than <paramref name="idleFor"/>.</summary>
int PruneIdle(TimeSpan idleFor);
}
+287
View File
@@ -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);
}
@@ -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;
@@ -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);
@@ -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.
*
* <c>previousPoints</c> 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;
@@ -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;
@@ -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;
@@ -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 `<li class="${slot.residualMs === null ? "echo-missed" : ""}">${label} · ${residual}${slot.snr === null ? "" : ` · snr ${slot.snr}`}</li>`;
})
.join("");
this.elements.diagnostics.innerHTML = `
<ul class="echo-slots">${slots}</ul>
<p class="echo-diag-line">
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 ? " <strong>CLIPPING</strong>" : ""}
</p>`;
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 = `<span class="echo-readout-idle">${reports.length < 2 ? "waiting for a pair" : "measuring…"}</span>`;
return null;
}
const corrected = solved.matrix[0][1];
const { spread, count } = this.recordHistory(corrected);
readout.innerHTML = `
<span class="echo-metres">${corrected.toFixed(2)}<small>m</small></span>
<span class="echo-readout-detail">
raw ${raw[0][1].toFixed(3)}m · calibration +${(corrected - raw[0][1]).toFixed(3)}m ·
spread over last ${count} ${spread.toFixed(2)}m
</span>`;
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(`<tr class="${dropped ? "echo-dropped" : ""}">
<td>${reports[i].deviceId}${reports[j].deviceId}</td>
<td>${measured === null ? "—" : measured.toFixed(3) + " m"}</td>
<td>${raw[i][j] === null ? "—" : raw[i][j].toFixed(3) + " m"}</td>
</tr>`);
}
this.elements.pairs.innerHTML = rows.length
? `<table><thead><tr><th>pair</th><th>range</th><th>raw</th></tr></thead><tbody>${rows.join("")}</tbody></table>`
: "";
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());