Echo acoustic ranging #5
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<IGameStore, GameStore>();
|
||||
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||
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())
|
||||
{
|
||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||
@@ -72,4 +79,6 @@ app.MapControllers();
|
||||
|
||||
app.MapHub<ChessHub>("/chessHub");
|
||||
|
||||
app.MapHub<EchoHub>("/echoHub");
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user