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); +}