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