diff --git a/JoshHeaps.Net/Hubs/EchoHub.cs b/JoshHeaps.Net/Hubs/EchoHub.cs
new file mode 100644
index 0000000..c67c4e3
--- /dev/null
+++ b/JoshHeaps.Net/Hubs/EchoHub.cs
@@ -0,0 +1,52 @@
+using JoshHeaps.Net.Models;
+using JoshHeaps.Net.Services.Interfaces;
+using Microsoft.AspNetCore.SignalR;
+
+namespace JoshHeaps.Net.Hubs;
+
+///
+/// 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.
+///
+public class EchoHub(IEchoRoomStore rooms) : Hub
+{
+ /// Join (or create) a room and receive an id plus the current roster.
+ public async Task 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;
+ }
+
+ /// File this device's arrival indices for the room's open round.
+ public bool ReportRound(EchoPeakReport report) => rooms.Report(Context.ConnectionId, report);
+
+ ///
+ /// 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.
+ ///
+ public long ServerTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ public async Task 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 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;
+ }
+}
diff --git a/JoshHeaps.Net/Models/EchoModels.cs b/JoshHeaps.Net/Models/EchoModels.cs
new file mode 100644
index 0000000..d18a751
--- /dev/null
+++ b/JoshHeaps.Net/Models/EchoModels.cs
@@ -0,0 +1,84 @@
+namespace JoshHeaps.Net.Models;
+
+/// A device taking part in a ranging room.
+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; }
+}
+
+///
+/// One chirp cycle: every device plays in turn, and every device listens to the whole thing.
+///
+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 Reports { get; } = [];
+}
+
+///
+/// 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.
+///
+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; }
+}
+
+/// Tells every device when to chirp: its own slot index and how long a slot lasts.
+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; }
+
+ ///
+ /// 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.
+ ///
+ public long StartsAtUnixMs { get; init; }
+}
+
+///
+/// Every device's peaks for one round, broadcast unchanged. Each client solves the geometry itself
+/// so the server never needs the ranging maths.
+///
+public sealed class EchoRoundResult
+{
+ public required string RoundId { get; init; }
+ public required string[] SlotOrder { get; init; }
+ public required EchoPeakReport[] Reports { get; init; }
+}
diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs
index cd006d7..b665273 100644
--- a/JoshHeaps.Net/Program.cs
+++ b/JoshHeaps.Net/Program.cs
@@ -1,6 +1,7 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
+using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
@@ -30,6 +31,12 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.Configure(configuration.GetSection(EchoRoundSettings.SectionName));
+builder.Services.AddSingleton(provider =>
+ provider.GetRequiredService>().Value);
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHostedService();
@@ -72,4 +79,6 @@ app.MapControllers();
app.MapHub("/chessHub");
+app.MapHub("/echoHub");
+
app.Run();
\ No newline at end of file
diff --git a/JoshHeaps.Net/Services/Implementations/EchoRoomStore.cs b/JoshHeaps.Net/Services/Implementations/EchoRoomStore.cs
new file mode 100644
index 0000000..9ed5f48
--- /dev/null
+++ b/JoshHeaps.Net/Services/Implementations/EchoRoomStore.cs
@@ -0,0 +1,189 @@
+using System.Collections.Concurrent;
+using JoshHeaps.Net.Models;
+using JoshHeaps.Net.Services.Interfaces;
+
+namespace JoshHeaps.Net.Services.Implementations;
+
+///
+/// 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.
+///
+public sealed class EchoRoomStore : IEchoRoomStore
+{
+ private sealed class Room
+ {
+ public required string Code { get; init; }
+ public List Devices { get; } = [];
+ public EchoRound? Round { get; set; }
+ public int RoundCounter { get; set; }
+ public DateTimeOffset LastActivity { get; set; } = DateTimeOffset.UtcNow;
+ }
+
+ private readonly ConcurrentDictionary _rooms = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary _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 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);
+ }
+}
diff --git a/JoshHeaps.Net/Services/Implementations/EchoRoundService.cs b/JoshHeaps.Net/Services/Implementations/EchoRoundService.cs
new file mode 100644
index 0000000..bef55c0
--- /dev/null
+++ b/JoshHeaps.Net/Services/Implementations/EchoRoundService.cs
@@ -0,0 +1,107 @@
+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";
+
+ ///
+ /// 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.
+ ///
+ public int SlotMilliseconds { get; set; } = 500;
+
+ /// Time after the last chirp for propagation, detection and reporting.
+ public int TailMilliseconds { get; set; } = 700;
+
+ public int GraceMilliseconds { get; set; } = 1500;
+ public int GapMilliseconds { get; set; } = 250;
+ public int IdleRoomMinutes { get; set; } = 10;
+
+ ///
+ /// 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.
+ ///
+ public int LeadMilliseconds { get; set; } = 600;
+}
+
+///
+/// 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.
+///
+public sealed class EchoRoundService(
+ IEchoRoomStore rooms,
+ IHubContext hub,
+ EchoRoundSettings settings,
+ ILogger logger) : BackgroundService
+{
+ private readonly Dictionary _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;
+ }
+}
diff --git a/JoshHeaps.Net/Services/Interfaces/IEchoRoomStore.cs b/JoshHeaps.Net/Services/Interfaces/IEchoRoomStore.cs
new file mode 100644
index 0000000..8b90548
--- /dev/null
+++ b/JoshHeaps.Net/Services/Interfaces/IEchoRoomStore.cs
@@ -0,0 +1,45 @@
+using JoshHeaps.Net.Models;
+
+namespace JoshHeaps.Net.Services.Interfaces;
+
+///
+/// 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.
+///
+public interface IEchoRoomStore
+{
+ /// Add a device to a room, creating the room if this is the first arrival.
+ EchoJoinResult Join(string roomCode, string connectionId, string displayName, int sampleRate);
+
+ /// Remove whichever device owns this connection, returning the room it left.
+ (string? roomCode, EchoRoomSnapshot? room) Leave(string connectionId);
+
+ /// Snapshot of a room's roster, or null if the room is gone.
+ EchoRoomSnapshot? Snapshot(string roomCode);
+
+ /// Room codes with at least two devices, which is the minimum for a measurement.
+ IReadOnlyCollection MeasurableRooms { get; }
+
+ ///
+ /// Open a new round for a room, rotating which device chirps first so no single device is
+ /// permanently the slot-order anchor.
+ ///
+ EchoRoundSchedule? StartRound(
+ string roomCode,
+ int slotMilliseconds,
+ int tailMilliseconds,
+ TimeSpan lead,
+ TimeSpan grace);
+
+ /// File a device's peaks against the room's open round.
+ bool Report(string connectionId, EchoPeakReport report);
+
+ ///
+ /// Close the open round if every device has reported or its deadline has passed, returning the
+ /// reports to broadcast.
+ ///
+ EchoRoundResult? TryCloseRound(string roomCode);
+
+ /// Drop rooms that have had no activity for longer than .
+ int PruneIdle(TimeSpan idleFor);
+}