+ Two devices measure the distance between them by chirping at each other and timing the answer.
+ Each one records continuously and only ever compares arrivals inside its own recording, so no
+ clock synchronisation is needed and the unknown audio latency cancels out.
+
+
Only sample numbers leave your device. The recording never does.
+
+
+
+
+
+
+
+
+
+
+
Pick a room, then open the same room on a second device.
+
Join link:
+
+
+
not measuring
+
+
+
+
Devices
+
+
+
+
Ranges
+
+
+
+
Timing
+
How far each chirp landed from its slot. Steady means the arrivals are being matched to the right devices.
+
+
+
+
+
+
Matched filter
+
Each spike is a chirp arriving. The bright one is this device hearing itself.
+
+
+
+← Back
+
+@section Scripts {
+
+
+
+
+
+}
+
+@section Styles {
+
+
+}
diff --git a/JoshHeaps.Net/Pages/Echo.cshtml.cs b/JoshHeaps.Net/Pages/Echo.cshtml.cs
new file mode 100644
index 0000000..4949471
--- /dev/null
+++ b/JoshHeaps.Net/Pages/Echo.cshtml.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace JoshHeaps.Net.Pages
+{
+ public class EchoModel : PageModel
+ {
+ public void OnGet()
+ {
+ }
+ }
+}
diff --git a/JoshHeaps.Net/wwwroot/css/echo/echo.css b/JoshHeaps.Net/wwwroot/css/echo/echo.css
new file mode 100644
index 0000000..ba6b7b1
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/css/echo/echo.css
@@ -0,0 +1,287 @@
+body {
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ margin: 0;
+ padding: 2rem 1.25rem 4rem;
+}
+
+#echoHeader {
+ max-width: 46rem;
+ margin: 0 auto 1.5rem;
+}
+
+#echoHeader h1 {
+ color: var(--color-heading);
+ font-size: 2.4rem;
+ letter-spacing: -0.02em;
+ margin: 0 0 0.6rem;
+}
+
+.echo-blurb {
+ line-height: 1.6;
+ margin: 0 0 0.5rem;
+}
+
+.echo-privacy {
+ color: var(--color-text-subtle);
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ margin: 0;
+}
+
+#echoControls {
+ align-items: flex-end;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin: 0 auto 1rem;
+ max-width: 46rem;
+}
+
+#echoControls label {
+ color: var(--color-text-muted);
+ display: flex;
+ flex-direction: column;
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ gap: 0.3rem;
+ text-transform: lowercase;
+}
+
+#echoControls input {
+ background: var(--color-surface-raised);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ color: var(--color-heading);
+ font-family: var(--font-mono);
+ font-size: 1rem;
+ padding: 0.5rem 0.6rem;
+ width: 7rem;
+}
+
+#echoControls input:focus {
+ border-color: var(--color-accent-border);
+ outline: none;
+}
+
+#echoControls button {
+ background: transparent;
+ border: 1px solid var(--color-accent-border);
+ border-radius: 6px;
+ color: var(--color-accent);
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ padding: 0.6rem 1.1rem;
+ transition: background 0.15s ease, color 0.15s ease;
+}
+
+#echoControls button:hover:not(:disabled) {
+ background: var(--color-accent);
+ color: var(--color-bg);
+}
+
+#echoControls button:disabled {
+ border-color: var(--color-border);
+ color: var(--color-text-subtle);
+ cursor: default;
+}
+
+.echo-status,
+.echo-share {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ margin: 0 auto 0.4rem;
+ max-width: 46rem;
+}
+
+.echo-share a {
+ color: var(--color-accent);
+ text-decoration: none;
+ word-break: break-all;
+}
+
+#echoWarnings {
+ margin: 0 auto;
+ max-width: 46rem;
+}
+
+.echo-warning {
+ background: rgba(255, 170, 0, 0.08);
+ border: 1px solid rgba(255, 170, 0, 0.35);
+ border-radius: 6px;
+ color: #ffcc66;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ margin: 0.5rem 0;
+ padding: 0.7rem 0.9rem;
+}
+
+.echo-readout {
+ align-items: baseline;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin: 2rem auto;
+ max-width: 46rem;
+ min-height: 6rem;
+}
+
+.echo-metres {
+ color: var(--color-accent);
+ font-family: var(--font-mono);
+ font-size: clamp(3.5rem, 14vw, 7rem);
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+}
+
+.echo-metres small {
+ color: var(--color-text-subtle);
+ font-size: 0.28em;
+ margin-left: 0.2em;
+}
+
+.echo-readout-detail,
+.echo-readout-idle {
+ color: var(--color-text-subtle);
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+}
+
+#echoPanels {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
+ margin: 0 auto;
+ max-width: 46rem;
+}
+
+.echo-panel {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ padding: 1rem 1.1rem;
+}
+
+.echo-panel-wide {
+ margin: 1rem auto 0;
+ max-width: 46rem;
+}
+
+.echo-panel h2 {
+ color: var(--color-heading-secondary);
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ margin: 0 0 0.75rem;
+ text-transform: uppercase;
+}
+
+.echo-hint {
+ color: var(--color-text-subtle);
+ font-size: 0.8rem;
+ margin: -0.4rem 0 0.75rem;
+}
+
+#echoRoster {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.echo-device {
+ border-bottom: 1px solid var(--color-border);
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+ padding: 0.45rem 0;
+}
+
+.echo-device:last-child {
+ border-bottom: none;
+}
+
+.echo-device-self {
+ color: var(--color-accent);
+}
+
+#echoPairs {
+ overflow-x: auto;
+}
+
+#echoPairs table {
+ border-collapse: collapse;
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+ width: 100%;
+}
+
+#echoPairs th {
+ color: var(--color-text-subtle);
+ font-weight: 500;
+ padding: 0 0.6rem 0.4rem 0;
+ text-align: left;
+}
+
+#echoPairs td {
+ border-top: 1px solid var(--color-border);
+ color: var(--color-heading-secondary);
+ font-variant-numeric: tabular-nums;
+ padding: 0.4rem 0.6rem 0.4rem 0;
+}
+
+.echo-dropped td {
+ color: var(--color-text-subtle);
+ text-decoration: line-through;
+}
+
+.echo-slots {
+ list-style: none;
+ margin: 0 0 0.6rem;
+ padding: 0;
+}
+
+.echo-slots li {
+ color: var(--color-heading-secondary);
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+ font-variant-numeric: tabular-nums;
+ padding: 0.2rem 0;
+}
+
+.echo-slots .echo-missed {
+ color: #ff7676;
+}
+
+.echo-diag-line {
+ color: var(--color-text-subtle);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ line-height: 1.5;
+ margin: 0;
+}
+
+.echo-diag-line strong {
+ color: #ff7676;
+}
+
+#echoTrace {
+ display: block;
+ height: 160px;
+ width: 100%;
+}
+
+.echo-back {
+ color: var(--color-text-subtle);
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ margin: 2rem auto 0;
+ max-width: 46rem;
+ text-decoration: none;
+}
+
+.echo-back:hover {
+ color: var(--color-accent);
+}
diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoAudio.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoAudio.js
new file mode 100644
index 0000000..0f816ad
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoAudio.js
@@ -0,0 +1,160 @@
+/*
+ * Microphone capture and chirp playback, both indexed by AudioContext frame.
+ *
+ * Frames, not wall-clock: currentTime * sampleRate is exactly the frame number, so a playback
+ * scheduled at an audio-clock time converts directly into a position in the recording. Nothing here
+ * depends on a timer firing on schedule, which matters because a hidden tab's timers are throttled
+ * to about once a second and its chirp would land in another device's slot.
+ */
+const EchoAudio = {
+ TARGET_SAMPLE_RATE: 48000,
+ RING_SECONDS: 20,
+ BLUETOOTH_HINTS: /bluetooth|airpod|hands-?free|headset|\bbt\b|wireless/i,
+
+ context: null,
+ stream: null,
+ capture: null,
+ ring: null,
+ highestFrame: 0,
+ peakAmplitude: 0,
+ warnings: [],
+ onSamples: null,
+
+ async start() {
+ if (this.context) return this.describe();
+
+ this.stream = await navigator.mediaDevices.getUserMedia({
+ // Echo cancellation exists to delete sounds this device just played, which is exactly
+ // the measurement. Gain control and noise suppression distort the chirp's envelope.
+ audio: {
+ echoCancellation: false,
+ noiseSuppression: false,
+ autoGainControl: false,
+ channelCount: 1
+ }
+ });
+
+ this.context = new AudioContext({ sampleRate: this.TARGET_SAMPLE_RATE, latencyHint: "interactive" });
+ await this.context.audioWorklet.addModule("/js/EchoScripts/EchoCaptureProcessor.js");
+ await this.context.resume();
+
+ this.ring = new Float32Array(Math.ceil(this.context.sampleRate * this.RING_SECONDS));
+ this.attachCapture();
+ this.warnings = this.inspectDevice();
+
+ return this.describe();
+ },
+
+ attachCapture() {
+ const source = this.context.createMediaStreamSource(this.stream);
+ this.capture = new AudioWorkletNode(this.context, "echo-capture", { numberOfOutputs: 0 });
+ this.capture.port.onmessage = ({ data }) => this.write(data.frame, data.samples);
+ source.connect(this.capture);
+ return this.capture;
+ },
+
+ write(frame, samples) {
+ const capacity = this.ring.length;
+ let loudest = 0;
+
+ for (let i = 0; i < samples.length; i++) {
+ this.ring[(frame + i) % capacity] = samples[i];
+ loudest = Math.max(loudest, Math.abs(samples[i]));
+ }
+
+ this.peakAmplitude = Math.max(this.peakAmplitude * 0.95, loudest);
+ this.highestFrame = Math.max(this.highestFrame, frame + samples.length);
+ this.onSamples?.(this.highestFrame);
+ return this.highestFrame;
+ },
+
+ /** The recording position for an AudioContext time. Exact: currentTime * sampleRate is a frame. */
+ frameAt(contextTime) {
+ return Math.round(contextTime * this.context.sampleRate);
+ },
+
+ /** Copy an absolute frame range out of the ring, or null if it is not (or no longer) held. */
+ read(startFrame, length) {
+ if (length <= 0) return null;
+ if (startFrame + length > this.highestFrame) return null;
+ if (startFrame < this.highestFrame - this.ring.length) return null;
+
+ const capacity = this.ring.length;
+ const window = new Float32Array(length);
+ for (let i = 0; i < length; i++) window[i] = this.ring[((startFrame + i) % capacity + capacity) % capacity];
+
+ return window;
+ },
+
+ /**
+ * Schedule a chirp on the audio clock and return the frame it was scheduled for. When it
+ * actually leaves the speaker is later by an unknown output latency and does not need to be
+ * known — it is recovered from the recording of it.
+ */
+ play(chirp, atContextTime, gain = 0.5) {
+ const when = Math.max(atContextTime, this.context.currentTime);
+ const buffer = this.context.createBuffer(1, chirp.length, this.context.sampleRate);
+ buffer.copyToChannel(chirp, 0);
+
+ const source = this.context.createBufferSource();
+ const volume = this.context.createGain();
+ volume.gain.value = gain;
+ source.buffer = buffer;
+ source.connect(volume).connect(this.context.destination);
+ source.start(when);
+
+ return this.frameAt(when);
+ },
+
+ /** How far behind the audio clock the main thread's view of the recording is running. */
+ captureLagSeconds() {
+ return this.context.currentTime - this.highestFrame / this.context.sampleRate;
+ },
+
+ /** Reported output latency, which bounds how late a scheduled chirp can reach the microphone. */
+ outputLatencySeconds() {
+ return (this.context.outputLatency || 0) + (this.context.baseLatency || 0);
+ },
+
+ inspectDevice() {
+ const warnings = [];
+ const track = this.stream.getAudioTracks()[0];
+ const settings = track?.getSettings() ?? {};
+
+ if (this.context.sampleRate !== this.TARGET_SAMPLE_RATE)
+ warnings.push(`Running at ${this.context.sampleRate}Hz instead of 48000Hz — ranges stay correct but resolution drops.`);
+
+ if (this.BLUETOOTH_HINTS.test(track?.label ?? ""))
+ warnings.push("This looks like a Bluetooth microphone. Bluetooth resamples and re-times the stream, which breaks the measurement — switch to the built-in speaker and microphone.");
+
+ for (const [name, label] of [["echoCancellation", "Echo cancellation"], ["autoGainControl", "Auto gain"], ["noiseSuppression", "Noise suppression"]])
+ if (settings[name] === true) warnings.push(`${label} could not be turned off on this device — the measurement will be unreliable.`);
+
+ return warnings;
+ },
+
+ describe() {
+ const track = this.stream.getAudioTracks()[0];
+
+ return {
+ sampleRate: this.context.sampleRate,
+ label: track?.label ?? "microphone",
+ outputLatencyMs: Math.round(this.outputLatencySeconds() * 1000),
+ warnings: this.warnings
+ };
+ },
+
+ async stop() {
+ this.stream?.getTracks().forEach(track => track.stop());
+ await this.context?.close();
+ this.context = null;
+ this.stream = null;
+ this.capture = null;
+ this.ring = null;
+ this.highestFrame = 0;
+ this.onSamples = null;
+ return true;
+ }
+};
+
+if (typeof window !== "undefined") window.EchoAudio = EchoAudio;
diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoCaptureProcessor.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoCaptureProcessor.js
new file mode 100644
index 0000000..fbcf20a
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoCaptureProcessor.js
@@ -0,0 +1,39 @@
+/*
+ * Capture worklet. Every block is tagged with the AudioContext frame it started at, which makes the
+ * context's own clock the index for all captured audio: a scheduled playback time converts to a
+ * recording position exactly, with no dependence on when the main thread got round to noticing.
+ *
+ * A worklet rather than ScriptProcessorNode because a dropped buffer would break the continuity
+ * that the sample count depends on.
+ */
+const BLOCK_SAMPLES = 2048;
+
+class EchoCaptureProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.block = new Float32Array(BLOCK_SAMPLES);
+ this.filled = 0;
+ this.blockStartFrame = 0;
+ }
+
+ process(inputs) {
+ const channel = inputs[0]?.[0];
+ if (!channel) return true;
+
+ for (let i = 0; i < channel.length; i++) {
+ if (this.filled === 0) this.blockStartFrame = currentFrame + i;
+ this.block[this.filled++] = channel[i];
+ if (this.filled === BLOCK_SAMPLES) this.flush();
+ }
+
+ return true;
+ }
+
+ flush() {
+ const samples = this.block.slice(0, this.filled);
+ this.port.postMessage({ frame: this.blockStartFrame, samples }, [samples.buffer]);
+ this.filled = 0;
+ }
+}
+
+registerProcessor("echo-capture", EchoCaptureProcessor);
diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSession.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSession.js
new file mode 100644
index 0000000..44a1717
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/EchoSession.js
@@ -0,0 +1,269 @@
+/*
+ * Round lifecycle: join a room, chirp when told to, measure every chirp in our own recording, and
+ * report the frame indices. Peaks are reported relative to the analysis window rather than to an
+ * absolute frame — every measurement is a difference within one device's own recording, so a
+ * constant per-device origin cancels.
+ *
+ * Both the chirp and the analysis are driven by the audio clock rather than by timers: a hidden tab
+ * has its timers throttled to roughly once a second, which is long enough to put its chirp in
+ * another device's slot and turn the whole round into nonsense.
+ */
+const EchoSession = {
+ LEAD_IN_SECONDS: 0.25,
+ TEMPERATURE_CELSIUS: 20,
+ MAX_OUTPUT_LATENCY_SECONDS: 0.35,
+ CHIRP: { durationSeconds: 0.05, startHz: 2000, endHz: 8000 },
+
+ CLOCK_SAMPLES: 5,
+ LATE_TOLERANCE_SECONDS: 0.05,
+
+ connection: null,
+ deviceId: null,
+ roomCode: null,
+ serverOffsetMs: 0,
+ skippedRounds: 0,
+ devices: [],
+ chirp: null,
+ pending: null,
+ lastRound: null,
+ hintsByDevice: {},
+ previousPoints: null,
+ epsilon: 0.08,
+ onUpdate: () => {},
+
+ async join(roomCode, displayName, { onUpdate }) {
+ this.onUpdate = onUpdate ?? this.onUpdate;
+ const audio = await EchoAudio.start();
+ this.chirp = EchoDsp.makeChirp({ sampleRate: audio.sampleRate, ...this.CHIRP });
+ EchoAudio.onSamples = frame => this.analyseIfCaptured(frame);
+
+ this.connection = new signalR.HubConnectionBuilder().withUrl("/echoHub").withAutomaticReconnect().build();
+ this.connection.on("RoomChanged", room => this.handleRoomChanged(room));
+ this.connection.on("RoundStarting", schedule => this.handleRoundStarting(schedule));
+ this.connection.on("RoundComplete", result => this.handleRoundComplete(result));
+ await this.connection.start();
+
+ const joined = await this.connection.invoke("JoinRoom", roomCode, displayName, audio.sampleRate);
+ this.deviceId = joined.deviceId;
+ this.roomCode = joined.roomCode;
+ this.devices = joined.room.devices;
+ this.serverOffsetMs = await this.estimateServerOffset();
+
+ return { ...joined, audio, serverOffsetMs: this.serverOffsetMs };
+ },
+
+ /**
+ * Offset from the server clock, by the usual round-trip midpoint. Only needs to be good enough
+ * to identify which chirp belongs to which slot, which is tens of milliseconds — the ranging
+ * itself never uses a shared clock.
+ */
+ async estimateServerOffset() {
+ const offsets = [];
+
+ for (let attempt = 0; attempt < this.CLOCK_SAMPLES; attempt++) {
+ const sent = Date.now();
+ const serverNow = await this.connection.invoke("ServerTime");
+ const received = Date.now();
+ offsets.push(serverNow - (sent + received) / 2);
+ }
+
+ offsets.sort((left, right) => left - right);
+ return offsets[offsets.length >> 1];
+ },
+
+ serverNowMs() {
+ return Date.now() + this.serverOffsetMs;
+ },
+
+ handleRoomChanged(room) {
+ this.devices = room.devices;
+ this.previousPoints = null;
+ this.hintsByDevice = {};
+ this.onUpdate({ kind: "room", room });
+ return room;
+ },
+
+ /**
+ * Schedule our chirp for our slot on the audio clock, and record which frames this round will
+ * occupy so the analysis can start the moment that audio has actually been captured.
+ */
+ handleRoundStarting(schedule) {
+ const ownSlot = schedule.slotOrder.indexOf(this.deviceId);
+ if (ownSlot < 0 || !EchoAudio.context) return null;
+
+ const sampleRate = EchoAudio.context.sampleRate;
+ const slotSeconds = schedule.slotMilliseconds / 1000;
+ const secondsUntilStart = (schedule.startsAtUnixMs - this.serverNowMs()) / 1000;
+ const roundStartTime = EchoAudio.context.currentTime + secondsUntilStart;
+ const playAtTime = roundStartTime + ownSlot * slotSeconds;
+
+ // Sitting a round out is far better than chirping late: a chirp in the wrong slot is
+ // attributed to the wrong device and ruins the round for everyone, whereas a missing chirp
+ // costs only this pair, this round.
+ if (playAtTime < EchoAudio.context.currentTime - this.LATE_TOLERANCE_SECONDS) {
+ this.skippedRounds++;
+ this.onUpdate({ kind: "skipped", schedule, lateBySeconds: EchoAudio.context.currentTime - playAtTime });
+ return null;
+ }
+
+ const scheduledFrame = EchoAudio.play(this.chirp, playAtTime);
+
+ const round = {
+ schedule,
+ ownSlot,
+ sampleRate,
+ scheduledFrame,
+ slotSamples: Math.round(slotSeconds * sampleRate),
+ leadInSamples: Math.round(this.LEAD_IN_SECONDS * sampleRate),
+ startFrame: EchoAudio.frameAt(roundStartTime),
+ outputLatencyMs: Math.round(EchoAudio.outputLatencySeconds() * 1000),
+ captureLagMs: Math.round(EchoAudio.captureLagSeconds() * 1000)
+ };
+
+ round.windowStart = round.startFrame - round.leadInSamples;
+ round.windowLength =
+ round.leadInSamples +
+ schedule.slotOrder.length * round.slotSamples +
+ Math.round((schedule.tailMilliseconds / 1000) * sampleRate);
+
+ this.pending = round;
+ this.onUpdate({ kind: "roundStarting", round });
+ return round;
+ },
+
+ /**
+ * Run as soon as the round's audio exists in the ring. Driven by captured audio rather than a
+ * timer so a throttled tab still analyses on time.
+ */
+ analyseIfCaptured(highestFrame) {
+ const round = this.pending;
+ if (!round) return null;
+ if (highestFrame < round.windowStart + round.windowLength) return null;
+
+ this.pending = null;
+ return this.analyse(round);
+ },
+
+ analyse(round) {
+ this.lastRound = round;
+ const recording = EchoAudio.read(round.windowStart, round.windowLength);
+ if (!recording) return null;
+
+ const envelope = EchoDsp.matchedFilterEnvelope(recording, this.chirp);
+ const detected = EchoDsp.detectSlotPeaks({
+ envelope,
+ slotCount: round.schedule.slotOrder.length,
+ slotSamples: round.slotSamples,
+ ownSlot: round.ownSlot,
+ // Anchored on the frame the chirp was scheduled for, so the only unknown left is how
+ // long the speaker takes to actually emit it.
+ ownSearchStart: round.scheduledFrame - round.windowStart,
+ ownSearchSamples: Math.round((this.MAX_OUTPUT_LATENCY_SECONDS + this.CHIRP.durationSeconds) * round.sampleRate),
+ slotHints: this.hintsFor(round.schedule)
+ });
+
+ const diagnostics = this.describeRound(round, detected);
+ this.onUpdate({ kind: "envelope", envelope, detected, round, diagnostics });
+ if (!detected) return null;
+
+ this.rememberHints(round.schedule, detected, round.slotSamples);
+ return this.report(round, detected);
+ },
+
+ describeRound(round, detected) {
+ const millisecondsPer = 1000 / round.sampleRate;
+
+ return {
+ captureLagMs: round.captureLagMs,
+ outputLatencyMs: round.outputLatencyMs,
+ serverOffsetMs: Math.round(this.serverOffsetMs),
+ skippedRounds: this.skippedRounds,
+ clipping: EchoAudio.peakAmplitude >= 0.99,
+ inputPeak: Number(EchoAudio.peakAmplitude.toFixed(3)),
+ ownFound: Boolean(detected?.peaks[round.ownSlot]),
+ slots: round.schedule.slotOrder.map((deviceId, slot) => ({
+ deviceId,
+ own: slot === round.ownSlot,
+ residualMs: detected?.peaks[slot]
+ ? Math.round((detected.peaks[slot].index - (detected.anchor + slot * round.slotSamples)) * millisecondsPer)
+ : null,
+ snr: detected?.peaks[slot] ? Math.round(detected.peaks[slot].snr) : null
+ }))
+ };
+ },
+
+ report(round, detected) {
+ return this.connection.invoke("ReportRound", {
+ deviceId: this.deviceId,
+ roundId: round.schedule.roundId,
+ slot: round.ownSlot,
+ sampleRate: round.sampleRate,
+ epsilon: this.epsilon,
+ peaks: detected.peaks.map(peak => peak?.index ?? null)
+ });
+ },
+
+ /**
+ * Where each device's chirp actually landed last round, relative to where the slot said it
+ * would. Output latency differs by tens of milliseconds per device and is stable, so carrying
+ * the residual forward keeps the search windows centred instead of merely wide.
+ */
+ rememberHints(schedule, detected, slotSamples) {
+ schedule.slotOrder.forEach((deviceId, slot) => {
+ const peak = detected.peaks[slot];
+ if (!peak) return;
+ this.hintsByDevice[deviceId] = peak.index - (detected.anchor + slot * slotSamples);
+ });
+
+ return this.hintsByDevice;
+ },
+
+ hintsFor(schedule) {
+ return schedule.slotOrder.map(deviceId => this.hintsByDevice[deviceId] ?? 0);
+ },
+
+ handleRoundComplete(result) {
+ const reports = result.reports.map(report => ({ ...report, peaks: report.peaks ?? [] }));
+ const solved = EchoDsp.solveRound(reports, {
+ speedOfSound: EchoDsp.speedOfSound(this.TEMPERATURE_CELSIUS),
+ previousPoints: this.previousPoints
+ });
+
+ this.previousPoints = this.pointsByReportIndex(reports, solved);
+ this.onUpdate({ kind: "solved", result, reports, solved, deviceId: this.deviceId });
+ return solved;
+ },
+
+ pointsByReportIndex(reports, solved) {
+ const points = new Array(reports.length).fill(null);
+ solved.keep.forEach((reportIndex, i) => {
+ points[reportIndex] = solved.points[i];
+ });
+ return points;
+ },
+
+ setEpsilon(metres) {
+ this.epsilon = metres;
+ localStorage.setItem("echo.epsilon", String(metres));
+ return this.epsilon;
+ },
+
+ loadEpsilon() {
+ const stored = Number(localStorage.getItem("echo.epsilon"));
+ this.epsilon = Number.isFinite(stored) && stored > 0 ? stored : 0.08;
+ return this.epsilon;
+ },
+
+ async leave() {
+ await this.connection?.invoke("LeaveRoom").catch(() => {});
+ await this.connection?.stop();
+ await EchoAudio.stop();
+ this.connection = null;
+ this.pending = null;
+ this.previousPoints = null;
+ this.hintsByDevice = {};
+ return true;
+ }
+};
+
+if (typeof window !== "undefined") window.EchoSession = EchoSession;
diff --git a/JoshHeaps.Net/wwwroot/js/EchoScripts/echoMain.js b/JoshHeaps.Net/wwwroot/js/EchoScripts/echoMain.js
new file mode 100644
index 0000000..3ded793
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/EchoScripts/echoMain.js
@@ -0,0 +1,282 @@
+/*
+ * Page wiring for /echo. Renders what the session measures: the matched-filter trace with the
+ * arrivals it picked, the pairwise ranges, and — while only two devices are present — one large
+ * number, because that number is the whole measurement.
+ */
+const EchoPage = {
+ HISTORY_LENGTH: 12,
+
+ elements: {},
+ lastSolved: null,
+ lastDiagnostics: null,
+ history: [],
+ roundsSeen: 0,
+
+ start() {
+ this.elements = {
+ room: document.getElementById("echoRoom"),
+ name: document.getElementById("echoName"),
+ join: document.getElementById("echoJoin"),
+ leave: document.getElementById("echoLeave"),
+ status: document.getElementById("echoStatus"),
+ warnings: document.getElementById("echoWarnings"),
+ readout: document.getElementById("echoReadout"),
+ pairs: document.getElementById("echoPairs"),
+ roster: document.getElementById("echoRoster"),
+ trace: document.getElementById("echoTrace"),
+ epsilon: document.getElementById("echoEpsilon"),
+ shareLink: document.getElementById("echoShareLink"),
+ diagnostics: document.getElementById("echoDiagnostics")
+ };
+
+ this.elements.room.value = new URLSearchParams(location.search).get("room") ?? this.randomCode();
+ this.elements.epsilon.value = EchoSession.loadEpsilon();
+ this.elements.epsilon.addEventListener("change", () => this.applyEpsilon());
+ this.elements.join.addEventListener("click", () => this.join());
+ this.elements.leave.addEventListener("click", () => this.leave());
+
+ return this;
+ },
+
+ randomCode() {
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ return Array.from({ length: 4 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("");
+ },
+
+ applyEpsilon() {
+ const metres = Number(this.elements.epsilon.value);
+ if (!Number.isFinite(metres) || metres < 0) return null;
+
+ EchoSession.setEpsilon(metres);
+ return metres;
+ },
+
+ async join() {
+ const roomCode = this.elements.room.value.trim().toUpperCase();
+ if (!roomCode) return null;
+
+ this.setStatus("Requesting the microphone…");
+ this.elements.join.disabled = true;
+
+ try {
+ const joined = await EchoSession.join(roomCode, this.deviceName(), { onUpdate: update => this.handle(update) });
+ this.showJoined(joined);
+ return joined;
+ } catch (error) {
+ this.setStatus(`Could not start: ${error.message}`);
+ this.elements.join.disabled = false;
+ return null;
+ }
+ },
+
+ deviceName() {
+ const typed = this.elements.name.value.trim();
+ if (typed) return typed;
+
+ return /android|iphone|ipad|mobile/i.test(navigator.userAgent) ? "phone" : "laptop";
+ },
+
+ showJoined(joined) {
+ this.elements.leave.hidden = false;
+ this.elements.room.disabled = true;
+ this.elements.name.disabled = true;
+ this.elements.shareLink.textContent = `${location.origin}/echo?room=${joined.roomCode}`;
+ this.elements.shareLink.href = `/echo?room=${joined.roomCode}`;
+ this.renderWarnings(joined.audio);
+ this.renderRoster(joined.room.devices);
+ this.setStatus(`Listening at ${joined.audio.sampleRate}Hz. Open the same room on another device.`);
+ return joined;
+ },
+
+ async leave() {
+ await EchoSession.leave();
+ this.elements.leave.hidden = true;
+ this.elements.join.disabled = false;
+ this.elements.room.disabled = false;
+ this.elements.name.disabled = false;
+ this.setStatus("Left the room.");
+ return true;
+ },
+
+ handle(update) {
+ if (update.kind === "room") return this.renderRoster(update.room.devices);
+ if (update.kind === "envelope") {
+ this.renderDiagnostics(update.diagnostics);
+ return this.renderTrace(update);
+ }
+ if (update.kind === "solved") return this.renderSolved(update);
+ return null;
+ },
+
+ /**
+ * Per-slot residual is the diagnostic that matters: it is how far each chirp landed from where
+ * its slot said it would. Steady residuals mean the arrivals are being attributed correctly;
+ * residuals jumping by more than a slot mean they are not, and every range is then meaningless.
+ */
+ renderDiagnostics(diagnostics) {
+ if (!diagnostics) return null;
+ this.lastDiagnostics = diagnostics;
+
+ const slots = diagnostics.slots
+ .map(slot => {
+ const label = slot.own ? "self" : slot.deviceId;
+ const residual = slot.residualMs === null ? "missed" : `${slot.residualMs > 0 ? "+" : ""}${slot.residualMs}ms`;
+ return `