Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7562ded92a | ||
|
|
392b9ec212 | ||
|
|
769c7819e0 | ||
|
|
c048529654 | ||
|
|
e8eb90a085 | ||
|
|
fc167ae2b0 | ||
|
|
088f05e0d2 | ||
|
|
04dcae7993 |
@@ -1,4 +1,4 @@
|
|||||||
# .gitea/workflows/deploy.yml
|
# .gitea/workflows/deploy.yml
|
||||||
name: Build and Deploy
|
name: Build and Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -12,13 +12,17 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-deploy:
|
build-deploy:
|
||||||
|
# Gitea's runner images are lean, so the toolchain is installed below rather
|
||||||
|
# than assumed. The glibc that libchess_engine.so links against comes from
|
||||||
|
# this image, not from the runner host — keep it matched to the prod server.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install deploy tools
|
- name: Install toolchain
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends rsync openssh-client
|
apt-get install -y --no-install-recommends \
|
||||||
|
cmake build-essential rsync openssh-client
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,10 +33,26 @@ jobs:
|
|||||||
dotnet-version: '8.0.x'
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore
|
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||||
|
|
||||||
|
- name: Build native chess engine (libchess_engine.so)
|
||||||
|
run: |
|
||||||
|
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build native/chess_engine/build
|
||||||
|
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||||
|
|
||||||
|
# Publishing the project rather than the solution keeps chess_engine.vcxproj
|
||||||
|
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: dotnet publish -c Release -o ./publish
|
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||||
|
|
||||||
|
- name: Verify payload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -f publish/Resources/libchess_engine.so \
|
||||||
|
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
|
||||||
|
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|
||||||
|
|| { echo "stockfish is not executable"; exit 1; }
|
||||||
|
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
env:
|
env:
|
||||||
@@ -47,6 +67,8 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||||
|
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||||
- name: Rsync to server
|
- name: Rsync to server
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
@@ -56,11 +78,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PORT="${SSH_PORT:-22}"
|
PORT="${SSH_PORT:-22}"
|
||||||
rsync -az --delete \
|
rsync -az --delete --exclude 'chess-data' \
|
||||||
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
||||||
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
|
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
|
||||||
|
|
||||||
- name: Restart service
|
- name: Reload and restart service
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
using Microsoft.Playwright;
|
|
||||||
using Microsoft.Playwright.NUnit;
|
|
||||||
using NUnit.Framework;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.UiTests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exercises the acoustic-ranging pipeline against a simulated room. The DSP runs in the browser
|
|
||||||
/// because that is where it runs in production; these tests drive the same files the page loads,
|
|
||||||
/// so there is no second implementation to drift.
|
|
||||||
/// </summary>
|
|
||||||
[TestFixture]
|
|
||||||
public class EchoDspTests : PageTest
|
|
||||||
{
|
|
||||||
private TestConfiguration Config => TestConfiguration.Instance;
|
|
||||||
|
|
||||||
public override BrowserNewContextOptions ContextOptions() => Config.GetBrowserContextOptions();
|
|
||||||
|
|
||||||
[SetUp]
|
|
||||||
public async Task LoadPipeline()
|
|
||||||
{
|
|
||||||
await Page.GotoAsync(Config.Test.BaseUrl);
|
|
||||||
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoDsp.js" });
|
|
||||||
await Page.AddScriptTagAsync(new() { Url = "/js/EchoScripts/EchoSim.js" });
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Matched_Filter_Finds_The_Chirp_Within_One_Sample()
|
|
||||||
{
|
|
||||||
var error = await Page.EvaluateAsync<double>("""
|
|
||||||
() => {
|
|
||||||
const sampleRate = 48000;
|
|
||||||
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
|
|
||||||
const recording = new Float32Array(sampleRate);
|
|
||||||
for (let i = 0; i < recording.length; i++) recording[i] = (Math.random() * 2 - 1) * 0.02;
|
|
||||||
|
|
||||||
const offset = 12345;
|
|
||||||
for (let i = 0; i < chirp.length; i++) recording[offset + i] += chirp[i] * 0.3;
|
|
||||||
|
|
||||||
const peak = EchoDsp.findFirstPeak(EchoDsp.matchedFilterEnvelope(recording, chirp));
|
|
||||||
return Math.abs(peak.index - offset);
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(error, Is.LessThan(1.0), "arrival should be located to within a sample");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task A_Tone_Cannot_Be_Located_But_A_Chirp_Can()
|
|
||||||
{
|
|
||||||
var ratios = await Page.EvaluateAsync<double[]>("""
|
|
||||||
() => {
|
|
||||||
const sampleRate = 48000;
|
|
||||||
const sidelobeRatio = template => {
|
|
||||||
const recording = new Float32Array(sampleRate / 2);
|
|
||||||
const offset = 8000;
|
|
||||||
for (let i = 0; i < template.length; i++) recording[offset + i] += template[i];
|
|
||||||
|
|
||||||
const envelope = EchoDsp.matchedFilterEnvelope(recording, template);
|
|
||||||
const peak = EchoDsp.maxInRange(envelope, 0, envelope.length);
|
|
||||||
let highest = 0;
|
|
||||||
for (let i = 0; i < envelope.length; i++) {
|
|
||||||
if (Math.abs(i - peak.index) < 200) continue;
|
|
||||||
highest = Math.max(highest, envelope[i]);
|
|
||||||
}
|
|
||||||
return highest / peak.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const chirp = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 2000, endHz: 8000 });
|
|
||||||
const tone = EchoDsp.makeChirp({ sampleRate, durationSeconds: 0.05, startHz: 5000, endHz: 5000 });
|
|
||||||
return [sidelobeRatio(chirp), sidelobeRatio(tone)];
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(ratios[1], Is.GreaterThan(0.5), "a tone should correlate almost as well far from the true arrival");
|
|
||||||
Assert.That(ratios[0], Is.LessThan(0.25), "a chirp should give one unambiguous arrival");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Clock_Offset_And_Pipeline_Latency_Cancel()
|
|
||||||
{
|
|
||||||
var distance = await Page.EvaluateAsync<double>("""
|
|
||||||
() => {
|
|
||||||
const sampleRate = 48000;
|
|
||||||
const speedOfSound = 343;
|
|
||||||
const flight = (4.2 / speedOfSound) * sampleRate;
|
|
||||||
const slot = 0.4 * sampleRate;
|
|
||||||
const offsetB = 987654;
|
|
||||||
const latencyA = 0.031 * sampleRate;
|
|
||||||
const latencyB = 0.128 * sampleRate;
|
|
||||||
|
|
||||||
return EchoDsp.pairDistance({
|
|
||||||
a1: latencyA,
|
|
||||||
a2: slot + latencyB + flight,
|
|
||||||
b1: offsetB + latencyA + flight,
|
|
||||||
b2: offsetB + slot + latencyB,
|
|
||||||
sampleRate,
|
|
||||||
speedOfSound
|
|
||||||
});
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(distance, Is.EqualTo(4.2).Within(0.001));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Collocated_Devices_Read_Zero_Before_Calibration()
|
|
||||||
{
|
|
||||||
var distance = await Page.EvaluateAsync<double>("""
|
|
||||||
() => {
|
|
||||||
const sampleRate = 48000;
|
|
||||||
const speedOfSound = 343;
|
|
||||||
const spacing = (0.19 / speedOfSound) * sampleRate;
|
|
||||||
const slot = 0.4 * sampleRate;
|
|
||||||
const latencyA = 0.04 * sampleRate;
|
|
||||||
const latencyB = 0.11 * sampleRate;
|
|
||||||
|
|
||||||
// Two tabs on one machine: one speaker, one microphone, so the self path and the
|
|
||||||
// cross path are the same physical distance.
|
|
||||||
return EchoDsp.pairDistance({
|
|
||||||
a1: latencyA + spacing,
|
|
||||||
a2: slot + latencyB + spacing,
|
|
||||||
b1: latencyA + spacing,
|
|
||||||
b2: slot + latencyB + spacing,
|
|
||||||
sampleRate,
|
|
||||||
speedOfSound
|
|
||||||
});
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(distance, Is.EqualTo(0).Within(0.001));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Speaker_To_Microphone_Spacing_Is_Added_Back()
|
|
||||||
{
|
|
||||||
var distances = await Page.EvaluateAsync<double[]>("""
|
|
||||||
() => {
|
|
||||||
const sampleRate = 48000;
|
|
||||||
const speedOfSound = 343;
|
|
||||||
const truth = 3.0;
|
|
||||||
const epsilonA = 0.18;
|
|
||||||
const epsilonB = 0.04;
|
|
||||||
const samples = metres => (metres / speedOfSound) * sampleRate;
|
|
||||||
const slot = 0.4 * sampleRate;
|
|
||||||
|
|
||||||
const peaks = {
|
|
||||||
a1: samples(epsilonA),
|
|
||||||
a2: slot + samples(truth),
|
|
||||||
b1: samples(truth),
|
|
||||||
b2: slot + samples(epsilonB),
|
|
||||||
sampleRate,
|
|
||||||
speedOfSound
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
EchoDsp.pairDistance(peaks),
|
|
||||||
EchoDsp.pairDistance({ ...peaks, epsilonA, epsilonB })
|
|
||||||
];
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(distances[0], Is.EqualTo(3.0 - 0.11).Within(0.005), "uncorrected range reads short");
|
|
||||||
Assert.That(distances[1], Is.EqualTo(3.0).Within(0.005), "correcting for spacing recovers the true range");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Simulated_Room_Recovers_Distances_And_Layout()
|
|
||||||
{
|
|
||||||
var errors = await Page.EvaluateAsync<double[]>("""
|
|
||||||
() => {
|
|
||||||
const result = EchoSim.runRound({
|
|
||||||
positions: [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]]
|
|
||||||
});
|
|
||||||
return [EchoSim.worstDistanceError(result), EchoSim.worstPositionError(result), result.keep.length];
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(errors[2], Is.EqualTo(5), "every device should survive a clean round");
|
|
||||||
Assert.That(errors[0], Is.LessThan(0.05), "worst pairwise range error");
|
|
||||||
Assert.That(errors[1], Is.LessThan(0.15), "worst recovered position error");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task A_Reflection_Louder_Than_The_Direct_Path_Does_Not_Win()
|
|
||||||
{
|
|
||||||
var errors = await Page.EvaluateAsync<double[]>("""
|
|
||||||
() => {
|
|
||||||
const measure = relativeThreshold => EchoSim.worstDistanceError(EchoSim.runRound({
|
|
||||||
positions: [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]],
|
|
||||||
reflections: [{ extraMetres: 1.8, gain: 5 }],
|
|
||||||
peakOptions: { relativeThreshold }
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [measure(undefined), measure(0.5)];
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(errors[0], Is.LessThan(0.05), "the first arrival is the distance, not the loudest one");
|
|
||||||
Assert.That(errors[1], Is.GreaterThan(1.5),
|
|
||||||
"a threshold high enough to miss the direct path must measure the reflection instead — this is what the default guards against");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Echoes_Inside_The_Correlation_Lobe_Bound_The_Accuracy()
|
|
||||||
{
|
|
||||||
var errors = await Page.EvaluateAsync<double[]>("""
|
|
||||||
() => {
|
|
||||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.7, 1.4]];
|
|
||||||
const worst = reflectionExtraRange =>
|
|
||||||
EchoSim.worstDistanceError(EchoSim.runRound({ positions, reflectionExtraRange }));
|
|
||||||
|
|
||||||
return [worst([0.4, 4.0]), worst([0.08, 0.4])];
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(errors[0], Is.LessThan(0.01), "echoes well clear of the direct arrival are rejected outright");
|
|
||||||
Assert.That(errors[1], Is.LessThan(0.15),
|
|
||||||
"echoes arriving inside the correlation lobe cannot be separated and bias the range — this bounds what a device resting on a hard surface can achieve");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task A_Bad_Measurement_Is_Rejected_And_The_Layout_Survives()
|
|
||||||
{
|
|
||||||
var outcome = await Page.EvaluateAsync<double[]>("""
|
|
||||||
() => {
|
|
||||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9], [1.6, 1.3]];
|
|
||||||
const config = EchoSim.buildConfiguration({ positions });
|
|
||||||
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
|
|
||||||
|
|
||||||
reports[3].peaks[0] += 9000;
|
|
||||||
|
|
||||||
const solved = EchoDsp.solveRound(reports, { speedOfSound: config.speedOfSound });
|
|
||||||
const truth = solved.keep.map(index => positions[index]);
|
|
||||||
const aligned = EchoDsp.alignToReference(solved.points, truth);
|
|
||||||
const worst = Math.max(...aligned.map((point, i) => EchoSim.separation(point, truth[i])));
|
|
||||||
const brokenPairSurvived = solved.keep.includes(0) && solved.keep.includes(3);
|
|
||||||
|
|
||||||
return [solved.keep.length, brokenPairSurvived ? 1 : 0, worst];
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(outcome[0], Is.EqualTo(4), "exactly one endpoint of the bad pair should be dropped");
|
|
||||||
Assert.That(outcome[1], Is.EqualTo(0), "the impossible pair must not survive");
|
|
||||||
Assert.That(outcome[2], Is.LessThan(0.2), "the remaining layout should be unpoisoned");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Alignment_Undoes_An_Arbitrary_Rotation_And_Mirror()
|
|
||||||
{
|
|
||||||
var errors = await Page.EvaluateAsync<double[]>("""
|
|
||||||
() => {
|
|
||||||
const reference = [[0, 0], [3.4, 0], [2.9, 2.7], [0.2, 2.5]];
|
|
||||||
const scramble = (points, angle, mirror) => points.map(([x, y]) => {
|
|
||||||
const mx = x * mirror;
|
|
||||||
return [mx * Math.cos(angle) - y * Math.sin(angle) + 11, mx * Math.sin(angle) + y * Math.cos(angle) - 4];
|
|
||||||
});
|
|
||||||
|
|
||||||
const worst = mirror => {
|
|
||||||
const aligned = EchoDsp.alignToReference(scramble(reference, 0.9, mirror), reference);
|
|
||||||
return Math.max(...aligned.map((point, i) => EchoSim.separation(point, reference[i])));
|
|
||||||
};
|
|
||||||
|
|
||||||
return [worst(1), worst(-1)];
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(errors[0], Is.LessThan(1e-9), "rotation and translation should be recovered exactly");
|
|
||||||
Assert.That(errors[1], Is.LessThan(1e-9), "a mirrored solve should be un-mirrored onto the reference");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Consecutive_Frames_Do_Not_Rotate_Or_Flip()
|
|
||||||
{
|
|
||||||
var drift = await Page.EvaluateAsync<double>("""
|
|
||||||
() => {
|
|
||||||
const positions = [[0, 0], [3.2, 0], [3.0, 2.6], [0.4, 2.9]];
|
|
||||||
const first = EchoSim.runRound({ positions, seed: 11 });
|
|
||||||
|
|
||||||
const previous = new Array(positions.length).fill(null);
|
|
||||||
first.keep.forEach((device, i) => { previous[device] = first.points[i]; });
|
|
||||||
|
|
||||||
const config = EchoSim.buildConfiguration({ positions, seed: 22 });
|
|
||||||
const reports = EchoSim.detectAll(EchoSim.synthesizeRound(config), config);
|
|
||||||
const second = EchoDsp.solveRound(reports, {
|
|
||||||
speedOfSound: config.speedOfSound,
|
|
||||||
previousPoints: previous
|
|
||||||
});
|
|
||||||
|
|
||||||
return Math.max(...second.keep.map((device, i) => EchoSim.separation(second.points[i], previous[device])));
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(drift, Is.LessThan(0.3), "a stationary room should not move between frames");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
using Microsoft.Playwright;
|
|
||||||
using Microsoft.Playwright.NUnit;
|
|
||||||
using NUnit.Framework;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.UiTests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drives two real browsers through a real room: the hub, the round scheduler, slot rotation,
|
|
||||||
/// detection and the solve all run unchanged. Only the microphone is synthetic, so the answer is
|
|
||||||
/// known in advance — this is everything except the acoustics.
|
|
||||||
/// </summary>
|
|
||||||
[TestFixture]
|
|
||||||
public class EchoRoomTests : PlaywrightTest
|
|
||||||
{
|
|
||||||
private const double TargetMetres = 2.5;
|
|
||||||
private IPlaywright? _playwright;
|
|
||||||
private IBrowser? _browser;
|
|
||||||
private readonly List<IBrowserContext> _contexts = [];
|
|
||||||
private TestConfiguration Config => TestConfiguration.Instance;
|
|
||||||
|
|
||||||
[OneTimeSetUp]
|
|
||||||
public async Task LaunchBrowser()
|
|
||||||
{
|
|
||||||
// Its own Playwright instance and browser: the fake-media launch flags have to be set at
|
|
||||||
// launch time, and the fixture-managed browser is already running by the time tests start.
|
|
||||||
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
|
||||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
|
||||||
{
|
|
||||||
Headless = true,
|
|
||||||
Args =
|
|
||||||
[
|
|
||||||
"--use-fake-ui-for-media-stream",
|
|
||||||
"--use-fake-device-for-media-stream",
|
|
||||||
"--autoplay-policy=no-user-gesture-required"
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[OneTimeTearDown]
|
|
||||||
public async Task CloseBrowser()
|
|
||||||
{
|
|
||||||
if (_browser is not null) await _browser.CloseAsync();
|
|
||||||
_playwright?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public async Task CloseContexts()
|
|
||||||
{
|
|
||||||
foreach (var context in _contexts) await context.CloseAsync();
|
|
||||||
_contexts.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Two_Devices_Measure_The_Distance_Between_Them()
|
|
||||||
{
|
|
||||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
|
||||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
|
||||||
var second = await NewDeviceAsync(roomCode, "phone");
|
|
||||||
|
|
||||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
|
||||||
|
|
||||||
var measured = await WaitForMeasurementAsync(first);
|
|
||||||
var alsoMeasured = await WaitForMeasurementAsync(second);
|
|
||||||
|
|
||||||
Assert.That(measured, Is.EqualTo(TargetMetres).Within(0.05), "the first device's range");
|
|
||||||
Assert.That(alsoMeasured, Is.EqualTo(TargetMetres).Within(0.05), "both devices should agree");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task A_Device_Leaving_Stops_The_Rounds_And_Rejoining_Resumes_Them()
|
|
||||||
{
|
|
||||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
|
||||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
|
||||||
var second = await NewDeviceAsync(roomCode, "phone");
|
|
||||||
|
|
||||||
await WaitForMeasurementAsync(first);
|
|
||||||
await second.ClickAsync("#echoLeave");
|
|
||||||
|
|
||||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(1);
|
|
||||||
await Expect(first.Locator("#echoStatus")).ToContainTextAsync("Waiting for a second device");
|
|
||||||
|
|
||||||
await second.ClickAsync("#echoJoin");
|
|
||||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
|
||||||
Assert.That(await WaitForMeasurementAsync(first), Is.EqualTo(TargetMetres).Within(0.05));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A stalled main thread is the two-tabs-on-one-machine failure: only one tab is visible, so the
|
|
||||||
/// other gets throttled and its round handling runs late. A late chirp attributed to the wrong
|
|
||||||
/// slot yields a plausible-looking but completely wrong range, so the requirement is not "always
|
|
||||||
/// measures" — it is "never reports a wrong answer". A round it cannot hit must be sat out.
|
|
||||||
/// </summary>
|
|
||||||
[Test]
|
|
||||||
public async Task A_Stalled_Device_Sits_Rounds_Out_Instead_Of_Reporting_Nonsense()
|
|
||||||
{
|
|
||||||
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
|
|
||||||
var first = await NewDeviceAsync(roomCode, "laptop");
|
|
||||||
var second = await NewDeviceAsync(roomCode, "phone", stallMilliseconds: 900);
|
|
||||||
|
|
||||||
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
|
|
||||||
await first.EvaluateAsync("() => { window.__seen = []; }");
|
|
||||||
await first.EvaluateAsync("""
|
|
||||||
() => {
|
|
||||||
const original = EchoPage.renderSolved.bind(EchoPage);
|
|
||||||
EchoPage.renderSolved = update => {
|
|
||||||
const range = update.solved?.matrix?.[0]?.[1];
|
|
||||||
if (range != null) window.__seen.push(range);
|
|
||||||
return original(update);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
""");
|
|
||||||
|
|
||||||
await first.WaitForTimeoutAsync(20000);
|
|
||||||
var seen = await first.EvaluateAsync<double[]>("() => window.__seen");
|
|
||||||
var satOut = await second.EvaluateAsync<int>("() => EchoSession.skippedRounds");
|
|
||||||
|
|
||||||
TestContext.Out.WriteLine($"reported ranges: {string.Join(", ", seen.Select(r => r.ToString("0.000")))}, sat out: {satOut}");
|
|
||||||
Assert.That(satOut, Is.GreaterThan(0),
|
|
||||||
"the stall must actually have cost the device some slots, or this test proves nothing");
|
|
||||||
Assert.That(seen, Is.Not.Empty, "a stalled peer should still let some rounds through");
|
|
||||||
Assert.That(seen, Is.All.EqualTo(TargetMetres).Within(0.05),
|
|
||||||
"every range that gets reported must be right — a stalled device must sit the round out, not chirp late");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task The_Capture_Worklet_Keeps_A_Continuous_Readable_Stream()
|
|
||||||
{
|
|
||||||
var page = await NewDeviceAsync($"T{Random.Shared.Next(1000, 9999)}", "laptop", fakeMicrophone: false);
|
|
||||||
|
|
||||||
await page.WaitForFunctionAsync(
|
|
||||||
"() => EchoAudio.highestFrame > 48000",
|
|
||||||
null,
|
|
||||||
new PageWaitForFunctionOptions { Timeout = 15000, PollingInterval = 100 });
|
|
||||||
|
|
||||||
var capture = await page.EvaluateAsync<double[]>("""
|
|
||||||
() => [
|
|
||||||
EchoAudio.context.sampleRate,
|
|
||||||
EchoAudio.warnings.length,
|
|
||||||
EchoAudio.read(EchoAudio.highestFrame - 24000, 24000)?.length ?? 0,
|
|
||||||
EchoAudio.read(EchoAudio.highestFrame + 1000, 100) === null ? 1 : 0,
|
|
||||||
Math.abs(EchoAudio.frameAt(EchoAudio.context.currentTime) - EchoAudio.highestFrame)
|
|
||||||
]
|
|
||||||
""");
|
|
||||||
|
|
||||||
Assert.That(capture[0], Is.EqualTo(48000), "the pipeline assumes it got the rate it asked for");
|
|
||||||
Assert.That(capture[1], Is.EqualTo(0), "a clean fake device should raise no capture warnings");
|
|
||||||
Assert.That(capture[2], Is.EqualTo(24000), "recent audio must be readable out of the ring");
|
|
||||||
Assert.That(capture[3], Is.EqualTo(1), "reads past the captured end must fail rather than return silence");
|
|
||||||
Assert.That(capture[4], Is.LessThan(48000),
|
|
||||||
"the frame index and the context clock must stay in the same domain — a scheduled playback time is converted straight into a recording position");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A page joined to the room with its microphone replaced by a synthesizer. Every slot's chirp
|
|
||||||
/// is placed where a room of this geometry would put it, including a different unknown output
|
|
||||||
/// latency per slot so the cancellation is actually exercised.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<IPage> NewDeviceAsync(
|
|
||||||
string roomCode,
|
|
||||||
string name,
|
|
||||||
bool fakeMicrophone = true,
|
|
||||||
int stallMilliseconds = 0)
|
|
||||||
{
|
|
||||||
var context = await _browser!.NewContextAsync(new BrowserNewContextOptions
|
|
||||||
{
|
|
||||||
IgnoreHTTPSErrors = true,
|
|
||||||
Permissions = ["microphone"]
|
|
||||||
});
|
|
||||||
|
|
||||||
_contexts.Add(context);
|
|
||||||
var page = await context.NewPageAsync();
|
|
||||||
page.Console += (_, message) =>
|
|
||||||
{
|
|
||||||
if (message.Type == "error") TestContext.Out.WriteLine($"[{name} console] {message.Text}");
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.GotoAsync($"{Config.Test.BaseUrl}/echo?room={roomCode}");
|
|
||||||
await page.FillAsync("#echoName", name);
|
|
||||||
if (fakeMicrophone) await page.EvaluateAsync(FakeMicrophoneScript, TargetMetres);
|
|
||||||
if (stallMilliseconds > 0) await page.EvaluateAsync(StallScript, stallMilliseconds);
|
|
||||||
await page.ClickAsync("#echoJoin");
|
|
||||||
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Blocks the main thread on every round announcement, the way a throttled tab does.</summary>
|
|
||||||
private const string StallScript = """
|
|
||||||
stallMs => {
|
|
||||||
const original = EchoSession.handleRoundStarting.bind(EchoSession);
|
|
||||||
EchoSession.handleRoundStarting = schedule => {
|
|
||||||
const until = performance.now() + stallMs;
|
|
||||||
while (performance.now() < until) { /* hold the thread */ }
|
|
||||||
return original(schedule);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
private static async Task<double> WaitForMeasurementAsync(IPage page)
|
|
||||||
{
|
|
||||||
await page.WaitForFunctionAsync(
|
|
||||||
"() => EchoPage.lastSolved?.solved?.matrix?.[0]?.[1] != null",
|
|
||||||
null,
|
|
||||||
new PageWaitForFunctionOptions { Timeout = 30000, PollingInterval = 250 });
|
|
||||||
|
|
||||||
return await page.EvaluateAsync<double>("() => EchoPage.lastSolved.solved.matrix[0][1]");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Synthesizes what the microphone would have heard for this round. The device's own chirp is
|
|
||||||
/// placed at the frame it was actually scheduled for rather than at its nominal slot position,
|
|
||||||
/// so any drift between "when the round said to play" and "when playback was really booked"
|
|
||||||
/// reaches the detector instead of being papered over by the harness.
|
|
||||||
/// </summary>
|
|
||||||
private const string FakeMicrophoneScript = """
|
|
||||||
targetMetres => {
|
|
||||||
const speedOfSound = 343;
|
|
||||||
const epsilonMetres = 0.08;
|
|
||||||
const latencyBySlot = [1400, 5200, 2600, 7100, 900, 4300, 3300, 6000];
|
|
||||||
|
|
||||||
EchoAudio.read = (startFrame, length) => {
|
|
||||||
const round = EchoSession.pending ?? EchoSession.lastRound;
|
|
||||||
if (!round) return null;
|
|
||||||
|
|
||||||
const chirp = EchoSession.chirp;
|
|
||||||
const toSamples = metres => Math.round((metres / speedOfSound) * round.sampleRate);
|
|
||||||
const recording = new Float32Array(length);
|
|
||||||
|
|
||||||
round.schedule.slotOrder.forEach((_, slot) => {
|
|
||||||
const own = slot === round.ownSlot;
|
|
||||||
const origin = own
|
|
||||||
? round.scheduledFrame - round.windowStart
|
|
||||||
: round.leadInSamples + slot * round.slotSamples;
|
|
||||||
const at = origin + latencyBySlot[slot] + toSamples(own ? epsilonMetres : targetMetres);
|
|
||||||
const amplitude = own ? 1.0 : 0.25;
|
|
||||||
|
|
||||||
for (let i = 0; i < chirp.length && at + i < length; i++) recording[at + i] += chirp[i] * amplitude;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) recording[i] += (Math.random() * 2 - 1) * 0.01;
|
|
||||||
return recording;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using JoshHeaps.Net.Models;
|
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Hubs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Membership and peak reporting for acoustic ranging rooms. Nothing that arrives here is audio:
|
|
||||||
/// a report is a handful of sample indices, and the geometry is solved on the clients.
|
|
||||||
/// </summary>
|
|
||||||
public class EchoHub(IEchoRoomStore rooms) : Hub
|
|
||||||
{
|
|
||||||
/// <summary>Join (or create) a room and receive an id plus the current roster.</summary>
|
|
||||||
public async Task<EchoJoinResult> JoinRoom(string roomCode, string displayName, int sampleRate)
|
|
||||||
{
|
|
||||||
var result = rooms.Join(roomCode, Context.ConnectionId, displayName, sampleRate);
|
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(result.RoomCode));
|
|
||||||
await Clients.Group(GroupFor(result.RoomCode)).SendAsync("RoomChanged", result.Room);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>File this device's arrival indices for the room's open round.</summary>
|
|
||||||
public bool ReportRound(EchoPeakReport report) => rooms.Report(Context.ConnectionId, report);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server clock, for estimating each device's offset from it. Only needs to be good to a few
|
|
||||||
/// tens of milliseconds: it decides which chirp is whose, never how far away anything is.
|
|
||||||
/// </summary>
|
|
||||||
public long ServerTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
||||||
|
|
||||||
public async Task<bool> LeaveRoom() => await RemoveFromRoom();
|
|
||||||
|
|
||||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
|
||||||
{
|
|
||||||
await RemoveFromRoom();
|
|
||||||
await base.OnDisconnectedAsync(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string GroupFor(string roomCode) => $"echo:{roomCode.ToUpperInvariant()}";
|
|
||||||
|
|
||||||
private async Task<bool> RemoveFromRoom()
|
|
||||||
{
|
|
||||||
var (roomCode, room) = rooms.Leave(Context.ConnectionId);
|
|
||||||
if (roomCode is null) return false;
|
|
||||||
|
|
||||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupFor(roomCode));
|
|
||||||
if (room is not null) await Clients.Group(GroupFor(roomCode)).SendAsync("RoomChanged", room);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
namespace JoshHeaps.Net.Models;
|
|
||||||
|
|
||||||
/// <summary>A device taking part in a ranging room.</summary>
|
|
||||||
public sealed class EchoDevice
|
|
||||||
{
|
|
||||||
public required string DeviceId { get; init; }
|
|
||||||
public required string ConnectionId { get; init; }
|
|
||||||
public required string DisplayName { get; set; }
|
|
||||||
public int SampleRate { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One chirp cycle: every device plays in turn, and every device listens to the whole thing.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EchoRound
|
|
||||||
{
|
|
||||||
public required string RoundId { get; init; }
|
|
||||||
public required string[] SlotOrder { get; init; }
|
|
||||||
public int SlotMilliseconds { get; init; }
|
|
||||||
public DateTimeOffset Deadline { get; init; }
|
|
||||||
public Dictionary<string, EchoPeakReport> Reports { get; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// What one device heard. Peaks are fractional sample indices into that device's own continuous
|
|
||||||
/// recording, one per slot, null where a chirp was not detected. Nothing else is ever uploaded.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EchoPeakReport
|
|
||||||
{
|
|
||||||
public required string DeviceId { get; init; }
|
|
||||||
public required string RoundId { get; init; }
|
|
||||||
public int Slot { get; init; }
|
|
||||||
public int SampleRate { get; init; }
|
|
||||||
public double Epsilon { get; init; }
|
|
||||||
public double?[] Peaks { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class EchoJoinResult
|
|
||||||
{
|
|
||||||
public required string DeviceId { get; init; }
|
|
||||||
public required string RoomCode { get; init; }
|
|
||||||
public required EchoRoomSnapshot Room { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class EchoRoomSnapshot
|
|
||||||
{
|
|
||||||
public required string RoomCode { get; init; }
|
|
||||||
public required EchoDeviceSnapshot[] Devices { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class EchoDeviceSnapshot
|
|
||||||
{
|
|
||||||
public required string DeviceId { get; init; }
|
|
||||||
public required string DisplayName { get; init; }
|
|
||||||
public int SampleRate { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Tells every device when to chirp: its own slot index and how long a slot lasts.</summary>
|
|
||||||
public sealed class EchoRoundSchedule
|
|
||||||
{
|
|
||||||
public required string RoundId { get; init; }
|
|
||||||
public required string[] SlotOrder { get; init; }
|
|
||||||
public int SlotMilliseconds { get; init; }
|
|
||||||
public int TailMilliseconds { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When slot zero should sound, in server time, set far enough ahead that every device can
|
|
||||||
/// receive the message and book the playback before it arrives. Devices schedule against this
|
|
||||||
/// rather than against message arrival, so one slow client cannot drag its chirp into another
|
|
||||||
/// device's slot and invalidate the round for everybody.
|
|
||||||
/// </summary>
|
|
||||||
public long StartsAtUnixMs { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Every device's peaks for one round, broadcast unchanged. Each client solves the geometry itself
|
|
||||||
/// so the server never needs the ranging maths.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EchoRoundResult
|
|
||||||
{
|
|
||||||
public required string RoundId { get; init; }
|
|
||||||
public required string[] SlotOrder { get; init; }
|
|
||||||
public required EchoPeakReport[] Reports { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
@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"]" />
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Pages
|
|
||||||
{
|
|
||||||
public class EchoModel : PageModel
|
|
||||||
{
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
using JoshHeaps.Net.Hubs;
|
||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -31,12 +30,6 @@ builder.Services.AddSingleton<IGameStore, GameStore>();
|
|||||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||||
builder.Services.AddSingleton<AutoTrainingSettings>();
|
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||||
|
|
||||||
builder.Services.Configure<EchoRoundSettings>(configuration.GetSection(EchoRoundSettings.SectionName));
|
|
||||||
builder.Services.AddSingleton(provider =>
|
|
||||||
provider.GetRequiredService<IOptions<EchoRoundSettings>>().Value);
|
|
||||||
builder.Services.AddSingleton<IEchoRoomStore, EchoRoomStore>();
|
|
||||||
builder.Services.AddHostedService<EchoRoundService>();
|
|
||||||
|
|
||||||
if (!builder.Environment.IsDevelopment())
|
if (!builder.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||||
@@ -79,6 +72,4 @@ app.MapControllers();
|
|||||||
|
|
||||||
app.MapHub<ChessHub>("/chessHub");
|
app.MapHub<ChessHub>("/chessHub");
|
||||||
|
|
||||||
app.MapHub<EchoHub>("/echoHub");
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ILogger<BlogService> _logger;
|
private readonly ILogger<BlogService> _logger;
|
||||||
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||||
|
|
||||||
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
|
|||||||
|
|
||||||
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
||||||
{
|
{
|
||||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
|
_cache.TryGetValue(key, out var entry);
|
||||||
|
|
||||||
|
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
|
||||||
return (T?)entry.Value;
|
return (T?)entry.Value;
|
||||||
|
|
||||||
var result = await factory();
|
var result = await factory();
|
||||||
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
|
|||||||
if (result is not null)
|
if (result is not null)
|
||||||
{
|
{
|
||||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
else if (entry is not null)
|
|
||||||
|
return entry is null ? null : ServeStale<T>(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale entries are dropped once the grace window closes so that a dead API fails the same
|
||||||
|
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
|
||||||
|
// the posts themselves, which reads as a site bug rather than an outage.
|
||||||
|
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
|
||||||
{
|
{
|
||||||
// API unreachable — serve stale cache
|
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
|
||||||
_logger.LogWarning("Serving stale cache for key {Key}", key);
|
{
|
||||||
|
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
|
||||||
return (T?)entry.Value;
|
return (T?)entry.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
_cache.TryRemove(key, out _);
|
||||||
|
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClearCache()
|
public void ClearCache()
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using JoshHeaps.Net.Models;
|
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Implementations;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// In-memory ranging rooms. Singleton: rooms are process-wide and short-lived, and the site runs
|
|
||||||
/// as a single instance, so there is nothing to persist and no backplane to coordinate.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EchoRoomStore : IEchoRoomStore
|
|
||||||
{
|
|
||||||
private sealed class Room
|
|
||||||
{
|
|
||||||
public required string Code { get; init; }
|
|
||||||
public List<EchoDevice> Devices { get; } = [];
|
|
||||||
public EchoRound? Round { get; set; }
|
|
||||||
public int RoundCounter { get; set; }
|
|
||||||
public DateTimeOffset LastActivity { get; set; } = DateTimeOffset.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private readonly ConcurrentDictionary<string, string> _roomsByConnection = [];
|
|
||||||
|
|
||||||
public EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate)
|
|
||||||
{
|
|
||||||
var room = _rooms.GetOrAdd(roomCode, code => new Room { Code = code });
|
|
||||||
var device = new EchoDevice
|
|
||||||
{
|
|
||||||
DeviceId = Guid.NewGuid().ToString("N")[..8],
|
|
||||||
ConnectionId = connectionId,
|
|
||||||
DisplayName = displayName,
|
|
||||||
SampleRate = sampleRate
|
|
||||||
};
|
|
||||||
|
|
||||||
lock (room)
|
|
||||||
{
|
|
||||||
room.Devices.RemoveAll(existing => existing.ConnectionId == connectionId);
|
|
||||||
room.Devices.Add(device);
|
|
||||||
room.LastActivity = DateTimeOffset.UtcNow;
|
|
||||||
_roomsByConnection[connectionId] = room.Code;
|
|
||||||
|
|
||||||
return new EchoJoinResult { DeviceId = device.DeviceId, RoomCode = room.Code, Room = SnapshotLocked(room) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public (string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId)
|
|
||||||
{
|
|
||||||
if (!_roomsByConnection.TryRemove(connectionId, out var roomCode)) return (null, null);
|
|
||||||
if (!_rooms.TryGetValue(roomCode, out var room)) return (roomCode, null);
|
|
||||||
|
|
||||||
lock (room)
|
|
||||||
{
|
|
||||||
room.Devices.RemoveAll(device => device.ConnectionId == connectionId);
|
|
||||||
room.Round = null;
|
|
||||||
room.LastActivity = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
if (room.Devices.Count == 0) _rooms.TryRemove(roomCode, out _);
|
|
||||||
|
|
||||||
return (roomCode, SnapshotLocked(room));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public EchoRoomSnapshot? Snapshot(string roomCode)
|
|
||||||
{
|
|
||||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
|
||||||
|
|
||||||
lock (room) return SnapshotLocked(room);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyCollection<string> MeasurableRooms =>
|
|
||||||
[.. _rooms.Values.Where(room => room.Devices.Count >= 2).Select(room => room.Code)];
|
|
||||||
|
|
||||||
public EchoRoundSchedule? StartRound(
|
|
||||||
string roomCode,
|
|
||||||
int slotMilliseconds,
|
|
||||||
int tailMilliseconds,
|
|
||||||
TimeSpan lead,
|
|
||||||
TimeSpan grace)
|
|
||||||
{
|
|
||||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
|
||||||
|
|
||||||
lock (room)
|
|
||||||
{
|
|
||||||
if (room.Round is not null || room.Devices.Count < 2) return null;
|
|
||||||
|
|
||||||
var slotOrder = RotatedSlotOrder(room);
|
|
||||||
var startsAt = DateTimeOffset.UtcNow + lead;
|
|
||||||
var duration = TimeSpan.FromMilliseconds(slotOrder.Length * slotMilliseconds + tailMilliseconds);
|
|
||||||
|
|
||||||
room.Round = new EchoRound
|
|
||||||
{
|
|
||||||
RoundId = Guid.NewGuid().ToString("N")[..12],
|
|
||||||
SlotOrder = slotOrder,
|
|
||||||
SlotMilliseconds = slotMilliseconds,
|
|
||||||
Deadline = startsAt + duration + grace
|
|
||||||
};
|
|
||||||
room.LastActivity = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
return new EchoRoundSchedule
|
|
||||||
{
|
|
||||||
RoundId = room.Round.RoundId,
|
|
||||||
SlotOrder = slotOrder,
|
|
||||||
SlotMilliseconds = slotMilliseconds,
|
|
||||||
TailMilliseconds = tailMilliseconds,
|
|
||||||
StartsAtUnixMs = startsAt.ToUnixTimeMilliseconds()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Report(string connectionId, EchoPeakReport report)
|
|
||||||
{
|
|
||||||
var device = FindDevice(connectionId, out var room);
|
|
||||||
if (device is null || room is null) return false;
|
|
||||||
|
|
||||||
lock (room)
|
|
||||||
{
|
|
||||||
if (room.Round?.RoundId != report.RoundId) return false;
|
|
||||||
if (!room.Round.SlotOrder.Contains(device.DeviceId)) return false;
|
|
||||||
|
|
||||||
room.Round.Reports[device.DeviceId] = report;
|
|
||||||
room.LastActivity = DateTimeOffset.UtcNow;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public EchoRoundResult? TryCloseRound(string roomCode)
|
|
||||||
{
|
|
||||||
if (!_rooms.TryGetValue(roomCode, out var room)) return null;
|
|
||||||
|
|
||||||
lock (room)
|
|
||||||
{
|
|
||||||
var round = room.Round;
|
|
||||||
if (round is null) return null;
|
|
||||||
|
|
||||||
var everyoneReported = round.SlotOrder.All(round.Reports.ContainsKey);
|
|
||||||
if (!everyoneReported && DateTimeOffset.UtcNow < round.Deadline) return null;
|
|
||||||
|
|
||||||
room.Round = null;
|
|
||||||
|
|
||||||
return new EchoRoundResult
|
|
||||||
{
|
|
||||||
RoundId = round.RoundId,
|
|
||||||
SlotOrder = round.SlotOrder,
|
|
||||||
Reports = [.. round.SlotOrder.Where(round.Reports.ContainsKey).Select(id => round.Reports[id])]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int PruneIdle(TimeSpan idleFor)
|
|
||||||
{
|
|
||||||
var cutoff = DateTimeOffset.UtcNow - idleFor;
|
|
||||||
var stale = _rooms.Values.Where(room => room.LastActivity < cutoff).Select(room => room.Code).ToList();
|
|
||||||
|
|
||||||
foreach (var code in stale) _rooms.TryRemove(code, out _);
|
|
||||||
|
|
||||||
return stale.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static EchoRoomSnapshot SnapshotLocked(Room room) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
RoomCode = room.Code,
|
|
||||||
Devices =
|
|
||||||
[
|
|
||||||
.. room.Devices.Select(device => new EchoDeviceSnapshot
|
|
||||||
{
|
|
||||||
DeviceId = device.DeviceId,
|
|
||||||
DisplayName = device.DisplayName,
|
|
||||||
SampleRate = device.SampleRate
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string[] RotatedSlotOrder(Room room)
|
|
||||||
{
|
|
||||||
var offset = room.RoundCounter++ % room.Devices.Count;
|
|
||||||
return [.. room.Devices.Skip(offset).Concat(room.Devices.Take(offset)).Select(device => device.DeviceId)];
|
|
||||||
}
|
|
||||||
|
|
||||||
private EchoDevice? FindDevice(string connectionId, out Room? room)
|
|
||||||
{
|
|
||||||
room = null;
|
|
||||||
if (!_roomsByConnection.TryGetValue(connectionId, out var roomCode)) return null;
|
|
||||||
if (!_rooms.TryGetValue(roomCode, out room)) return null;
|
|
||||||
|
|
||||||
lock (room) return room.Devices.FirstOrDefault(device => device.ConnectionId == connectionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Implementations;
|
|
||||||
|
|
||||||
public sealed class EchoRoundSettings
|
|
||||||
{
|
|
||||||
public const string SectionName = "Echo";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Slot length. Wide enough that no device's chirp can land in another's search window once
|
|
||||||
/// unknown output latency (tens of milliseconds, different per device) and message jitter are
|
|
||||||
/// accounted for.
|
|
||||||
/// </summary>
|
|
||||||
public int SlotMilliseconds { get; set; } = 500;
|
|
||||||
|
|
||||||
/// <summary>Time after the last chirp for propagation, detection and reporting.</summary>
|
|
||||||
public int TailMilliseconds { get; set; } = 700;
|
|
||||||
|
|
||||||
public int GraceMilliseconds { get; set; } = 1500;
|
|
||||||
public int GapMilliseconds { get; set; } = 250;
|
|
||||||
public int IdleRoomMinutes { get; set; } = 10;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How far ahead a round is announced. Must exceed the worst message delivery plus client
|
|
||||||
/// stall, or a device will find its slot already gone and sit the round out.
|
|
||||||
/// </summary>
|
|
||||||
public int LeadMilliseconds { get; set; } = 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drives continuous ranging: opens a round for every room that has two or more devices, closes it
|
|
||||||
/// once everyone has reported or the deadline passes, and broadcasts the raw peak table.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EchoRoundService(
|
|
||||||
IEchoRoomStore rooms,
|
|
||||||
IHubContext<EchoHub> hub,
|
|
||||||
EchoRoundSettings settings,
|
|
||||||
ILogger<EchoRoundService> logger) : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly Dictionary<string, DateTimeOffset> _nextRoundAllowedAt = [];
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
var lastPrune = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await TickAsync();
|
|
||||||
lastPrune = PruneIfDue(lastPrune);
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
logger.LogError(error, "Echo round tick failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(50, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TickAsync()
|
|
||||||
{
|
|
||||||
foreach (var roomCode in rooms.MeasurableRooms)
|
|
||||||
{
|
|
||||||
await CloseFinishedRoundAsync(roomCode);
|
|
||||||
await OpenRoundIfDueAsync(roomCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CloseFinishedRoundAsync(string roomCode)
|
|
||||||
{
|
|
||||||
var result = rooms.TryCloseRound(roomCode);
|
|
||||||
if (result is null) return;
|
|
||||||
|
|
||||||
_nextRoundAllowedAt[roomCode] = DateTimeOffset.UtcNow.AddMilliseconds(settings.GapMilliseconds);
|
|
||||||
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundComplete", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OpenRoundIfDueAsync(string roomCode)
|
|
||||||
{
|
|
||||||
if (_nextRoundAllowedAt.TryGetValue(roomCode, out var earliest) && DateTimeOffset.UtcNow < earliest) return;
|
|
||||||
|
|
||||||
var schedule = rooms.StartRound(
|
|
||||||
roomCode,
|
|
||||||
settings.SlotMilliseconds,
|
|
||||||
settings.TailMilliseconds,
|
|
||||||
TimeSpan.FromMilliseconds(settings.LeadMilliseconds),
|
|
||||||
TimeSpan.FromMilliseconds(settings.GraceMilliseconds));
|
|
||||||
|
|
||||||
if (schedule is null) return;
|
|
||||||
|
|
||||||
await hub.Clients.Group(EchoHub.GroupFor(roomCode)).SendAsync("RoundStarting", schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
private DateTimeOffset PruneIfDue(DateTimeOffset lastPrune)
|
|
||||||
{
|
|
||||||
if (DateTimeOffset.UtcNow - lastPrune < TimeSpan.FromMinutes(1)) return lastPrune;
|
|
||||||
|
|
||||||
var pruned = rooms.PruneIdle(TimeSpan.FromMinutes(settings.IdleRoomMinutes));
|
|
||||||
if (pruned > 0) logger.LogInformation("Pruned {Count} idle echo room(s)", pruned);
|
|
||||||
|
|
||||||
return DateTimeOffset.UtcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using JoshHeaps.Net.Models;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Process-wide registry of ranging rooms. Holds only device identity and the sample indices each
|
|
||||||
/// device reported, so the server is a scheduler and a relay — it never sees audio.
|
|
||||||
/// </summary>
|
|
||||||
public interface IEchoRoomStore
|
|
||||||
{
|
|
||||||
/// <summary>Add a device to a room, creating the room if this is the first arrival.</summary>
|
|
||||||
EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate);
|
|
||||||
|
|
||||||
/// <summary>Remove whichever device owns this connection, returning the room it left.</summary>
|
|
||||||
(string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId);
|
|
||||||
|
|
||||||
/// <summary>Snapshot of a room's roster, or null if the room is gone.</summary>
|
|
||||||
EchoRoomSnapshot? Snapshot(string roomCode);
|
|
||||||
|
|
||||||
/// <summary>Room codes with at least two devices, which is the minimum for a measurement.</summary>
|
|
||||||
IReadOnlyCollection<string> MeasurableRooms { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Open a new round for a room, rotating which device chirps first so no single device is
|
|
||||||
/// permanently the slot-order anchor.
|
|
||||||
/// </summary>
|
|
||||||
EchoRoundSchedule? StartRound(
|
|
||||||
string roomCode,
|
|
||||||
int slotMilliseconds,
|
|
||||||
int tailMilliseconds,
|
|
||||||
TimeSpan lead,
|
|
||||||
TimeSpan grace);
|
|
||||||
|
|
||||||
/// <summary>File a device's peaks against the room's open round.</summary>
|
|
||||||
bool Report(string connectionId, EchoPeakReport report);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Close the open round if every device has reported or its deadline has passed, returning the
|
|
||||||
/// reports to broadcast.
|
|
||||||
/// </summary>
|
|
||||||
EchoRoundResult? TryCloseRound(string roomCode);
|
|
||||||
|
|
||||||
/// <summary>Drop rooms that have had no activity for longer than <paramref name="idleFor"/>.</summary>
|
|
||||||
int PruneIdle(TimeSpan idleFor);
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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);
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pure signal-processing and geometry for acoustic ranging. No DOM, no Web Audio, no network:
|
|
||||||
* everything here is a function of its arguments so the simulator and the tests can drive the
|
|
||||||
* exact code the page runs.
|
|
||||||
*/
|
|
||||||
const EchoDsp = {
|
|
||||||
speedOfSound(temperatureCelsius = 20) {
|
|
||||||
return 331.3 + 0.606 * temperatureCelsius;
|
|
||||||
},
|
|
||||||
|
|
||||||
nextPowerOfTwo(value) {
|
|
||||||
let size = 1;
|
|
||||||
while (size < value) size <<= 1;
|
|
||||||
return size;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Linear frequency sweep, tapered at both ends. A sweep is used rather than a tone because a
|
|
||||||
* tone's autocorrelation peaks once per period, leaving no unambiguous arrival to measure.
|
|
||||||
*/
|
|
||||||
makeChirp({ sampleRate, durationSeconds, startHz, endHz, taperFraction = 0.15 }) {
|
|
||||||
const length = Math.round(sampleRate * durationSeconds);
|
|
||||||
const sweepRate = (endHz - startHz) / durationSeconds;
|
|
||||||
const chirp = new Float32Array(length);
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const t = i / sampleRate;
|
|
||||||
chirp[i] = Math.sin(2 * Math.PI * (startHz * t + 0.5 * sweepRate * t * t));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.applyTaper(chirp, taperFraction);
|
|
||||||
},
|
|
||||||
|
|
||||||
applyTaper(signal, fraction) {
|
|
||||||
const edge = Math.max(1, Math.floor(signal.length * fraction));
|
|
||||||
|
|
||||||
for (let i = 0; i < edge; i++) {
|
|
||||||
const window = 0.5 - 0.5 * Math.cos((Math.PI * i) / edge);
|
|
||||||
signal[i] *= window;
|
|
||||||
signal[signal.length - 1 - i] *= window;
|
|
||||||
}
|
|
||||||
|
|
||||||
return signal;
|
|
||||||
},
|
|
||||||
|
|
||||||
twiddles(size) {
|
|
||||||
this._twiddleCache ??= new Map();
|
|
||||||
const cached = this._twiddleCache.get(size);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const half = size >> 1;
|
|
||||||
const table = { cos: new Float64Array(half), sin: new Float64Array(half) };
|
|
||||||
|
|
||||||
for (let i = 0; i < half; i++) {
|
|
||||||
const angle = (-2 * Math.PI * i) / size;
|
|
||||||
table.cos[i] = Math.cos(angle);
|
|
||||||
table.sin[i] = Math.sin(angle);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._twiddleCache.set(size, table);
|
|
||||||
return table;
|
|
||||||
},
|
|
||||||
|
|
||||||
fft(real, imaginary, inverse = false) {
|
|
||||||
const size = real.length;
|
|
||||||
this.reverseBits(real, imaginary);
|
|
||||||
const { cos, sin } = this.twiddles(size);
|
|
||||||
|
|
||||||
for (let span = 2; span <= size; span <<= 1) {
|
|
||||||
const half = span >> 1;
|
|
||||||
const stride = size / span;
|
|
||||||
|
|
||||||
for (let base = 0; base < size; base += span) {
|
|
||||||
for (let k = 0; k < half; k++) {
|
|
||||||
const twiddle = k * stride;
|
|
||||||
const wReal = cos[twiddle];
|
|
||||||
const wImaginary = inverse ? -sin[twiddle] : sin[twiddle];
|
|
||||||
|
|
||||||
const lower = base + k;
|
|
||||||
const upper = lower + half;
|
|
||||||
const productReal = real[upper] * wReal - imaginary[upper] * wImaginary;
|
|
||||||
const productImaginary = real[upper] * wImaginary + imaginary[upper] * wReal;
|
|
||||||
|
|
||||||
real[upper] = real[lower] - productReal;
|
|
||||||
imaginary[upper] = imaginary[lower] - productImaginary;
|
|
||||||
real[lower] += productReal;
|
|
||||||
imaginary[lower] += productImaginary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inverse) return { real, imaginary };
|
|
||||||
|
|
||||||
for (let i = 0; i < size; i++) {
|
|
||||||
real[i] /= size;
|
|
||||||
imaginary[i] /= size;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { real, imaginary };
|
|
||||||
},
|
|
||||||
|
|
||||||
reverseBits(real, imaginary) {
|
|
||||||
const size = real.length;
|
|
||||||
|
|
||||||
for (let i = 1, j = 0; i < size; i++) {
|
|
||||||
let bit = size >> 1;
|
|
||||||
for (; j & bit; bit >>= 1) j ^= bit;
|
|
||||||
j ^= bit;
|
|
||||||
if (i >= j) continue;
|
|
||||||
|
|
||||||
[real[i], real[j]] = [real[j], real[i]];
|
|
||||||
[imaginary[i], imaginary[j]] = [imaginary[j], imaginary[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { real, imaginary };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matched-filter envelope of a recording against a template, via FFT cross-correlation.
|
|
||||||
* Negative frequencies are dropped so the result is the analytic envelope rather than a burst
|
|
||||||
* oscillating at the sweep frequency — an oscillating peak defeats sub-sample interpolation
|
|
||||||
* and makes first-arrival detection jitter by half a carrier period.
|
|
||||||
*/
|
|
||||||
matchedFilterEnvelope(recording, template) {
|
|
||||||
const size = this.nextPowerOfTwo(recording.length + template.length);
|
|
||||||
const recordingReal = new Float64Array(size);
|
|
||||||
const recordingImaginary = new Float64Array(size);
|
|
||||||
const templateReal = new Float64Array(size);
|
|
||||||
const templateImaginary = new Float64Array(size);
|
|
||||||
|
|
||||||
recordingReal.set(recording);
|
|
||||||
templateReal.set(template);
|
|
||||||
this.fft(recordingReal, recordingImaginary);
|
|
||||||
this.fft(templateReal, templateImaginary);
|
|
||||||
|
|
||||||
const analyticReal = new Float64Array(size);
|
|
||||||
const analyticImaginary = new Float64Array(size);
|
|
||||||
const half = size >> 1;
|
|
||||||
|
|
||||||
for (let i = 0; i <= half; i++) {
|
|
||||||
const gain = i === 0 || i === half ? 1 : 2;
|
|
||||||
analyticReal[i] = gain * (recordingReal[i] * templateReal[i] + recordingImaginary[i] * templateImaginary[i]);
|
|
||||||
analyticImaginary[i] = gain * (recordingImaginary[i] * templateReal[i] - recordingReal[i] * templateImaginary[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fft(analyticReal, analyticImaginary, true);
|
|
||||||
|
|
||||||
const envelope = new Float32Array(recording.length);
|
|
||||||
for (let i = 0; i < envelope.length; i++)
|
|
||||||
envelope[i] = Math.sqrt(analyticReal[i] * analyticReal[i] + analyticImaginary[i] * analyticImaginary[i]);
|
|
||||||
|
|
||||||
return envelope;
|
|
||||||
},
|
|
||||||
|
|
||||||
maxInRange(values, start, end) {
|
|
||||||
let index = start;
|
|
||||||
let value = -Infinity;
|
|
||||||
|
|
||||||
for (let i = start; i < end; i++) {
|
|
||||||
if (values[i] <= value) continue;
|
|
||||||
value = values[i];
|
|
||||||
index = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { index, value };
|
|
||||||
},
|
|
||||||
|
|
||||||
medianInRange(values, start, end, sampleLimit = 2048) {
|
|
||||||
const span = end - start;
|
|
||||||
if (span <= 0) return 0;
|
|
||||||
|
|
||||||
const stride = Math.max(1, Math.floor(span / sampleLimit));
|
|
||||||
const sampled = [];
|
|
||||||
for (let i = start; i < end; i += stride) sampled.push(values[i]);
|
|
||||||
|
|
||||||
sampled.sort((left, right) => left - right);
|
|
||||||
return sampled[sampled.length >> 1];
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sub-sample peak position by fitting a parabola through the peak and its neighbours. One
|
|
||||||
* tenth of a sample is 0.7mm of range at 48kHz, so this is most of the accuracy for free.
|
|
||||||
*/
|
|
||||||
refinePeakIndex(envelope, index) {
|
|
||||||
if (index <= 0 || index >= envelope.length - 1) return index;
|
|
||||||
|
|
||||||
const before = envelope[index - 1];
|
|
||||||
const peak = envelope[index];
|
|
||||||
const after = envelope[index + 1];
|
|
||||||
const curvature = before - 2 * peak + after;
|
|
||||||
if (curvature === 0) return index;
|
|
||||||
|
|
||||||
const offset = (0.5 * (before - after)) / curvature;
|
|
||||||
return index + Math.max(-1, Math.min(1, offset));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First arrival in a window, not the loudest one. A reflection off a wall or table often
|
|
||||||
* comes back louder than the direct path, and only the direct path is the distance.
|
|
||||||
*/
|
|
||||||
findFirstPeak(envelope, options = {}) {
|
|
||||||
const start = Math.max(0, Math.floor(options.start ?? 0));
|
|
||||||
const end = Math.min(envelope.length, Math.ceil(options.end ?? envelope.length));
|
|
||||||
if (end - start < 8) return null;
|
|
||||||
|
|
||||||
const loudest = this.maxInRange(envelope, start, end);
|
|
||||||
const noiseFloor = this.medianInRange(envelope, start, end);
|
|
||||||
const snr = noiseFloor > 0 ? loudest.value / noiseFloor : Infinity;
|
|
||||||
if (snr < (options.minSnr ?? 4)) return null;
|
|
||||||
|
|
||||||
// Held above the noise floor so sidelobes cannot trigger it, but well below the loudest
|
|
||||||
// arrival so that a reflection several times louder than the direct path cannot mask it.
|
|
||||||
const threshold = Math.max(
|
|
||||||
noiseFloor * (options.noiseMultiple ?? 6),
|
|
||||||
loudest.value * (options.relativeThreshold ?? 0.15)
|
|
||||||
);
|
|
||||||
|
|
||||||
let crossing = start;
|
|
||||||
while (crossing < end && envelope[crossing] < threshold) crossing++;
|
|
||||||
if (crossing >= end) return null;
|
|
||||||
|
|
||||||
const lobeEnd = Math.min(end, crossing + (options.lobeSamples ?? 64));
|
|
||||||
const arrival = this.maxInRange(envelope, crossing, lobeEnd);
|
|
||||||
|
|
||||||
return { index: this.refinePeakIndex(envelope, arrival.index), amplitude: arrival.value, snr };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locate every chirp of one round in a single device's recording.
|
|
||||||
*
|
|
||||||
* The device's own chirp is the anchor: it is always present and always the loudest thing in
|
|
||||||
* the recording, and its position absorbs this device's own output and input latency. Every
|
|
||||||
* other slot is then searched relative to that anchor, so no clock agreement between devices
|
|
||||||
* is required — only that the chirps stay in their slots.
|
|
||||||
*/
|
|
||||||
detectSlotPeaks({
|
|
||||||
envelope,
|
|
||||||
slotCount,
|
|
||||||
slotSamples,
|
|
||||||
ownSlot,
|
|
||||||
ownSearchStart,
|
|
||||||
ownSearchSamples,
|
|
||||||
slotHints = null,
|
|
||||||
peakOptions = {}
|
|
||||||
}) {
|
|
||||||
const own = this.findFirstPeak(envelope, {
|
|
||||||
...peakOptions,
|
|
||||||
start: ownSearchStart,
|
|
||||||
end: ownSearchStart + ownSearchSamples
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!own) return null;
|
|
||||||
|
|
||||||
const anchor = own.index - ownSlot * slotSamples;
|
|
||||||
const pad = Math.floor(slotSamples * 0.45);
|
|
||||||
const peaks = new Array(slotCount).fill(null);
|
|
||||||
peaks[ownSlot] = own;
|
|
||||||
|
|
||||||
for (let slot = 0; slot < slotCount; slot++) {
|
|
||||||
if (slot === ownSlot) continue;
|
|
||||||
|
|
||||||
const centre = anchor + slot * slotSamples + (slotHints?.[slot] ?? 0);
|
|
||||||
peaks[slot] = this.findFirstPeak(envelope, {
|
|
||||||
...peakOptions,
|
|
||||||
start: centre - pad,
|
|
||||||
end: centre + pad
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { anchor, peaks };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Distance between two devices from four arrival indices, each measured inside the recording
|
|
||||||
* of the device that made it. Clock offset and audio-pipeline latency appear once with each
|
|
||||||
* sign and cancel; the devices' own speaker-to-microphone spacing does not, and is added back.
|
|
||||||
*/
|
|
||||||
pairDistance({ a1, a2, b1, b2, sampleRate, sampleRateA, sampleRateB, speedOfSound, epsilonA = 0, epsilonB = 0 }) {
|
|
||||||
// Each interval is converted to seconds in its own device's sample rate before the two are
|
|
||||||
// subtracted: a device that hands back 44100 instead of 48000 would otherwise contribute
|
|
||||||
// its interval in the wrong unit.
|
|
||||||
const intervalA = (a2 - a1) / (sampleRateA ?? sampleRate);
|
|
||||||
const intervalB = (b2 - b1) / (sampleRateB ?? sampleRate);
|
|
||||||
return ((intervalA - intervalB) / 2) * speedOfSound + (epsilonA + epsilonB) / 2;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Symmetric distance matrix from one round of reports. Entries stay null where either device
|
|
||||||
* failed to hear one of the four chirps the pair needs.
|
|
||||||
*/
|
|
||||||
buildDistanceMatrix(reports, { speedOfSound = 343, maxDistance = 40 } = {}) {
|
|
||||||
const count = reports.length;
|
|
||||||
const matrix = Array.from({ length: count }, () => new Array(count).fill(null));
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
matrix[i][i] = 0;
|
|
||||||
|
|
||||||
for (let j = i + 1; j < count; j++) {
|
|
||||||
const distance = this.distanceBetween(reports[i], reports[j], speedOfSound);
|
|
||||||
if (distance === null || distance < -1 || distance > maxDistance) continue;
|
|
||||||
|
|
||||||
matrix[i][j] = Math.max(0, distance);
|
|
||||||
matrix[j][i] = matrix[i][j];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix;
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceBetween(deviceA, deviceB, speedOfSound) {
|
|
||||||
const a1 = deviceA.peaks[deviceA.slot];
|
|
||||||
const a2 = deviceA.peaks[deviceB.slot];
|
|
||||||
const b1 = deviceB.peaks[deviceA.slot];
|
|
||||||
const b2 = deviceB.peaks[deviceB.slot];
|
|
||||||
if (a1 === null || a2 === null || b1 === null || b2 === null) return null;
|
|
||||||
|
|
||||||
return this.pairDistance({
|
|
||||||
a1,
|
|
||||||
a2,
|
|
||||||
b1,
|
|
||||||
b2,
|
|
||||||
sampleRateA: deviceA.sampleRate,
|
|
||||||
sampleRateB: deviceB.sampleRate,
|
|
||||||
speedOfSound,
|
|
||||||
epsilonA: deviceA.epsilon ?? 0,
|
|
||||||
epsilonB: deviceB.epsilon ?? 0
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Largest set of devices linked by measured distances. Anything outside it cannot be placed
|
|
||||||
* relative to the others, and leaving it in makes the completed matrix infinite.
|
|
||||||
*/
|
|
||||||
largestConnectedComponent(matrix) {
|
|
||||||
const unvisited = new Set(matrix.map((_, index) => index));
|
|
||||||
let largest = [];
|
|
||||||
|
|
||||||
while (unvisited.size > 0) {
|
|
||||||
const component = [];
|
|
||||||
const queue = [unvisited.values().next().value];
|
|
||||||
unvisited.delete(queue[0]);
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.pop();
|
|
||||||
component.push(current);
|
|
||||||
|
|
||||||
for (const next of unvisited)
|
|
||||||
if (matrix[current][next] !== null) {
|
|
||||||
unvisited.delete(next);
|
|
||||||
queue.push(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component.length > largest.length) largest = component;
|
|
||||||
}
|
|
||||||
|
|
||||||
return largest.sort((left, right) => left - right);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop devices whose distances are geometrically impossible. One device reporting a bad peak
|
|
||||||
* distorts the whole layout, so the worst triangle-inequality offender is removed and the
|
|
||||||
* check repeated. Below four devices there is no redundancy left and nothing can be checked.
|
|
||||||
*/
|
|
||||||
rejectOutliers(matrix, candidates, tolerance = 0.5) {
|
|
||||||
const keep = [...candidates];
|
|
||||||
|
|
||||||
while (keep.length > 3) {
|
|
||||||
const violations = this.countTriangleViolations(matrix, keep, tolerance);
|
|
||||||
const worst = violations.reduce((best, count, index) => (count > violations[best] ? index : best), 0);
|
|
||||||
if (violations[worst] === 0) break;
|
|
||||||
|
|
||||||
keep.splice(worst, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return keep;
|
|
||||||
},
|
|
||||||
|
|
||||||
submatrix(matrix, indices) {
|
|
||||||
return indices.map(row => indices.map(column => matrix[row][column]));
|
|
||||||
},
|
|
||||||
|
|
||||||
countTriangleViolations(matrix, keep, tolerance) {
|
|
||||||
const violations = new Array(keep.length).fill(0);
|
|
||||||
|
|
||||||
for (let i = 0; i < keep.length; i++) {
|
|
||||||
for (let j = i + 1; j < keep.length; j++) {
|
|
||||||
for (let k = j + 1; k < keep.length; k++) {
|
|
||||||
const sides = [matrix[keep[i]][keep[j]], matrix[keep[j]][keep[k]], matrix[keep[i]][keep[k]]];
|
|
||||||
if (sides.some(side => side === null)) continue;
|
|
||||||
|
|
||||||
const longest = Math.max(...sides);
|
|
||||||
const perimeter = sides.reduce((sum, side) => sum + side, 0);
|
|
||||||
if (longest <= perimeter - longest + tolerance) continue;
|
|
||||||
|
|
||||||
violations[i]++;
|
|
||||||
violations[j]++;
|
|
||||||
violations[k]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return violations;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Fill gaps with the shortest known path between the two devices so MDS gets a full matrix. */
|
|
||||||
completeMatrix(matrix) {
|
|
||||||
const count = matrix.length;
|
|
||||||
const filled = matrix.map(row => row.map(value => (value === null ? Infinity : value)));
|
|
||||||
|
|
||||||
for (let via = 0; via < count; via++)
|
|
||||||
for (let i = 0; i < count; i++)
|
|
||||||
for (let j = 0; j < count; j++)
|
|
||||||
filled[i][j] = Math.min(filled[i][j], filled[i][via] + filled[via][j]);
|
|
||||||
|
|
||||||
return filled;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Jacobi eigendecomposition of a symmetric matrix. Returns eigenvalues and column vectors. */
|
|
||||||
symmetricEigen(matrix, maxSweeps = 100, tolerance = 1e-14) {
|
|
||||||
const count = matrix.length;
|
|
||||||
const working = matrix.map(row => Float64Array.from(row));
|
|
||||||
const vectors = Array.from({ length: count }, (_, i) => {
|
|
||||||
const column = new Float64Array(count);
|
|
||||||
column[i] = 1;
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let sweep = 0; sweep < maxSweeps; sweep++) {
|
|
||||||
if (this.offDiagonalMagnitude(working) < tolerance) break;
|
|
||||||
|
|
||||||
for (let p = 0; p < count - 1; p++)
|
|
||||||
for (let q = p + 1; q < count; q++)
|
|
||||||
this.rotateOut(working, vectors, p, q);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
values: working.map((row, i) => row[i]),
|
|
||||||
vectors
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
offDiagonalMagnitude(matrix) {
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < matrix.length; i++)
|
|
||||||
for (let j = i + 1; j < matrix.length; j++) total += matrix[i][j] * matrix[i][j];
|
|
||||||
|
|
||||||
return total;
|
|
||||||
},
|
|
||||||
|
|
||||||
rotateOut(matrix, vectors, p, q) {
|
|
||||||
if (Math.abs(matrix[p][q]) < 1e-300) return matrix;
|
|
||||||
|
|
||||||
const theta = (matrix[q][q] - matrix[p][p]) / (2 * matrix[p][q]);
|
|
||||||
const sign = theta >= 0 ? 1 : -1;
|
|
||||||
const tangent = sign / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
|
||||||
const cosine = 1 / Math.sqrt(tangent * tangent + 1);
|
|
||||||
const sine = tangent * cosine;
|
|
||||||
const count = matrix.length;
|
|
||||||
|
|
||||||
for (let k = 0; k < count; k++) {
|
|
||||||
const left = matrix[k][p];
|
|
||||||
const right = matrix[k][q];
|
|
||||||
matrix[k][p] = cosine * left - sine * right;
|
|
||||||
matrix[k][q] = sine * left + cosine * right;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let k = 0; k < count; k++) {
|
|
||||||
const left = matrix[p][k];
|
|
||||||
const right = matrix[q][k];
|
|
||||||
matrix[p][k] = cosine * left - sine * right;
|
|
||||||
matrix[q][k] = sine * left + cosine * right;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let k = 0; k < count; k++) {
|
|
||||||
const left = vectors[k][p];
|
|
||||||
const right = vectors[k][q];
|
|
||||||
vectors[k][p] = cosine * left - sine * right;
|
|
||||||
vectors[k][q] = sine * left + cosine * right;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classical multidimensional scaling: coordinates whose pairwise distances best reproduce the
|
|
||||||
* matrix. The result is only defined up to rotation, translation and mirroring.
|
|
||||||
*/
|
|
||||||
classicalMds(distances, dimensions = 2) {
|
|
||||||
const count = distances.length;
|
|
||||||
const squared = distances.map(row => row.map(value => value * value));
|
|
||||||
const rowMeans = squared.map(row => row.reduce((sum, value) => sum + value, 0) / count);
|
|
||||||
const grandMean = rowMeans.reduce((sum, value) => sum + value, 0) / count;
|
|
||||||
|
|
||||||
const centred = squared.map((row, i) => row.map((value, j) => -0.5 * (value - rowMeans[i] - rowMeans[j] + grandMean)));
|
|
||||||
const { values, vectors } = this.symmetricEigen(centred);
|
|
||||||
const order = values
|
|
||||||
.map((value, index) => ({ value, index }))
|
|
||||||
.sort((left, right) => right.value - left.value)
|
|
||||||
.slice(0, dimensions);
|
|
||||||
|
|
||||||
return Array.from({ length: count }, (_, i) =>
|
|
||||||
order.map(({ value, index }) => vectors[i][index] * Math.sqrt(Math.max(0, value)))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate, mirror and translate a constellation onto a reference layout. Without this, every
|
|
||||||
* solve returns an arbitrary orientation and the display spins and flips between updates.
|
|
||||||
*/
|
|
||||||
alignToReference(points, reference) {
|
|
||||||
if (!reference || reference.length !== points.length || points.length === 0) return points;
|
|
||||||
|
|
||||||
const pointCentre = this.centroid(points);
|
|
||||||
const referenceCentre = this.centroid(reference);
|
|
||||||
let best = null;
|
|
||||||
|
|
||||||
for (const mirror of [1, -1]) {
|
|
||||||
const candidate = this.rotateOnto(points, reference, pointCentre, referenceCentre, mirror);
|
|
||||||
if (!best || candidate.residual < best.residual) best = candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return best.points;
|
|
||||||
},
|
|
||||||
|
|
||||||
centroid(points) {
|
|
||||||
const sum = points.reduce((total, [x, y]) => [total[0] + x, total[1] + y], [0, 0]);
|
|
||||||
return [sum[0] / points.length, sum[1] / points.length];
|
|
||||||
},
|
|
||||||
|
|
||||||
rotateOnto(points, reference, pointCentre, referenceCentre, mirror) {
|
|
||||||
let sineTerm = 0;
|
|
||||||
let cosineTerm = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
const px = (points[i][0] - pointCentre[0]) * mirror;
|
|
||||||
const py = points[i][1] - pointCentre[1];
|
|
||||||
const qx = reference[i][0] - referenceCentre[0];
|
|
||||||
const qy = reference[i][1] - referenceCentre[1];
|
|
||||||
sineTerm += px * qy - py * qx;
|
|
||||||
cosineTerm += px * qx + py * qy;
|
|
||||||
}
|
|
||||||
|
|
||||||
const angle = Math.atan2(sineTerm, cosineTerm);
|
|
||||||
const cosine = Math.cos(angle);
|
|
||||||
const sine = Math.sin(angle);
|
|
||||||
let residual = 0;
|
|
||||||
|
|
||||||
const aligned = points.map((point, i) => {
|
|
||||||
const px = (point[0] - pointCentre[0]) * mirror;
|
|
||||||
const py = point[1] - pointCentre[1];
|
|
||||||
const x = px * cosine - py * sine + referenceCentre[0];
|
|
||||||
const y = px * sine + py * cosine + referenceCentre[1];
|
|
||||||
residual += (x - reference[i][0]) ** 2 + (y - reference[i][1]) ** 2;
|
|
||||||
return [x, y];
|
|
||||||
});
|
|
||||||
|
|
||||||
return { points: aligned, residual };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full solve for one round: distances, connectivity, outlier rejection, then a constellation
|
|
||||||
* aligned onto the previous frame.
|
|
||||||
*
|
|
||||||
* <c>previousPoints</c> is indexed by report position, with null for devices that were dropped
|
|
||||||
* last round, so alignment survives devices coming and going.
|
|
||||||
*/
|
|
||||||
solveRound(reports, { speedOfSound = 343, previousPoints = null, tolerance = 0.5 } = {}) {
|
|
||||||
const matrix = this.buildDistanceMatrix(reports, { speedOfSound });
|
|
||||||
const connected = this.largestConnectedComponent(matrix);
|
|
||||||
const keep = this.rejectOutliers(matrix, connected, tolerance);
|
|
||||||
const points = keep.length >= 2 ? this.classicalMds(this.completeMatrix(this.submatrix(matrix, keep))) : [];
|
|
||||||
const reference = previousPoints ? keep.map(index => previousPoints[index]) : null;
|
|
||||||
const alignable = reference?.length === points.length && reference.every(Boolean);
|
|
||||||
|
|
||||||
return {
|
|
||||||
matrix,
|
|
||||||
keep,
|
|
||||||
points: alignable ? this.alignToReference(points, reference) : points
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") window.EchoDsp = EchoDsp;
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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