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.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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user