Echo acoustic ranging #5

Open
jheaps wants to merge 5 commits from echo-acoustic-ranging into master
6 changed files with 486 additions and 0 deletions
Showing only changes of commit 94054c0f1b - Show all commits
+52
View File
@@ -0,0 +1,52 @@
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;
}
}
+84
View File
@@ -0,0 +1,84 @@
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; }
}
+9
View File
@@ -1,6 +1,7 @@
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);
@@ -30,6 +31,12 @@ 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>();
@@ -72,4 +79,6 @@ app.MapControllers();
app.MapHub<ChessHub>("/chessHub"); app.MapHub<ChessHub>("/chessHub");
app.MapHub<EchoHub>("/echoHub");
app.Run(); app.Run();
@@ -0,0 +1,189 @@
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);
}
}
@@ -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";
/// <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;
}
}
@@ -0,0 +1,45 @@
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);
}