diff --git a/JoshHeaps.Net/Pages/Echo.cshtml b/JoshHeaps.Net/Pages/Echo.cshtml new file mode 100644 index 0000000..a29da33 --- /dev/null +++ b/JoshHeaps.Net/Pages/Echo.cshtml @@ -0,0 +1,70 @@ +@page +@model JoshHeaps.Net.Pages.EchoModel +@{ + Layout = "_Layout"; + ViewData["Title"] = "Echo"; +} + +
+

Echo

+

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

+

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

+
+ +
+ + + + + +
+ +

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

+

Join link:

+
+ +
not measuring
+ +
+
+

Devices

+ +
+
+

Ranges

+
+
+
+

Timing

+

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

+
+
+
+ +
+

Matched filter

+

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

+ +
+ +← Back + +@section Scripts { + + + + + +} + +@section Styles { + + +} diff --git a/JoshHeaps.Net/Pages/Echo.cshtml.cs b/JoshHeaps.Net/Pages/Echo.cshtml.cs new file mode 100644 index 0000000..4949471 --- /dev/null +++ b/JoshHeaps.Net/Pages/Echo.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JoshHeaps.Net.Pages +{ + public class EchoModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/JoshHeaps.Net/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 `
  • ${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 + ? `${rows.join("")}
    pairrangeraw
    ` + : ""; + + return rows.length; + }, + + /** The matched-filter trace, with a marker on each arrival the detector accepted. */ + renderTrace({ envelope, detected, round }) { + const canvas = this.elements.trace; + const context = canvas.getContext("2d"); + const width = (canvas.width = canvas.clientWidth); + const height = (canvas.height = 160); + const peak = EchoDsp.maxInRange(envelope, 0, envelope.length).value || 1; + + context.clearRect(0, 0, width, height); + context.strokeStyle = "rgba(9, 255, 0, 0.75)"; + context.beginPath(); + + const bucket = envelope.length / width; + for (let x = 0; x < width; x++) { + const start = Math.floor(x * bucket); + const highest = EchoDsp.maxInRange(envelope, start, Math.min(envelope.length, Math.floor(start + bucket))).value; + const y = height - (highest / peak) * (height - 8) - 4; + x === 0 ? context.moveTo(x, y) : context.lineTo(x, y); + } + + context.stroke(); + this.drawMarkers(context, detected, round, envelope.length, width, height); + return canvas; + }, + + drawMarkers(context, detected, round, envelopeLength, width, height) { + if (!detected) return null; + + context.font = "11px 'Cascadia Code', monospace"; + + detected.peaks.forEach((peak, slot) => { + if (!peak) return; + + const x = (peak.index / envelopeLength) * width; + const own = slot === round.ownSlot; + context.strokeStyle = own ? "#5fff5f" : "rgba(255, 255, 255, 0.55)"; + context.fillStyle = context.strokeStyle; + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, height); + context.stroke(); + context.fillText(own ? `self (${slot})` : `slot ${slot}`, x + 4, 14 + slot * 13); + }); + + return detected.peaks.length; + } +}; + +document.addEventListener("DOMContentLoaded", () => EchoPage.start());