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