From 635700ce5e8d2868c9c085084b2f2b92ea0f3062 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 24 Jun 2026 19:48:15 -0500 Subject: [PATCH] RR1-T102 More work to get ready for windows app --- Resgrid.Audio.Core/ComService.cs | 5 +- Resgrid.Audio.Core/Radio/RadioBridge.cs | 31 +++- Resgrid.Audio.Relay.Console/Program.cs | 149 ++++-------------- Resgrid.Relay.Engine/RelayStatus.cs | 84 ++++++++++ .../ServiceCollectionExtensions.cs | 31 ++++ .../Services/AudioImportService.cs | 27 ++++ .../Services/DispatchRelayService.cs | 27 ++++ .../Services/NotSupportedRelayService.cs | 26 +++ .../Services/RadioRelayService.cs | 28 ++++ .../Services/RecordRelayService.cs | 26 +++ .../Services/RelayServiceBase.cs | 99 ++++++++++++ .../Services/RelayServiceFactory.cs | 49 ++++++ .../Services/SmtpRelayService.cs | 55 +++++++ Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs | 2 +- .../Smtp/StatusReportingSmtpTelemetry.cs | 84 ++++++++++ Resgrid.Relay.Engine/TuneSample.cs | 10 ++ Resgrid.Relay.Engine/Voice/AudioImportMode.cs | 128 +++++++++++++++ .../Voice/DispatchVoiceMode.cs | 12 +- Resgrid.Relay.Engine/Voice/RadioMode.cs | 47 ++++-- Resgrid.Relay.Engine/Voice/RecordMode.cs | 9 +- 20 files changed, 785 insertions(+), 144 deletions(-) create mode 100644 Resgrid.Relay.Engine/RelayStatus.cs create mode 100644 Resgrid.Relay.Engine/ServiceCollectionExtensions.cs create mode 100644 Resgrid.Relay.Engine/Services/AudioImportService.cs create mode 100644 Resgrid.Relay.Engine/Services/DispatchRelayService.cs create mode 100644 Resgrid.Relay.Engine/Services/NotSupportedRelayService.cs create mode 100644 Resgrid.Relay.Engine/Services/RadioRelayService.cs create mode 100644 Resgrid.Relay.Engine/Services/RecordRelayService.cs create mode 100644 Resgrid.Relay.Engine/Services/RelayServiceBase.cs create mode 100644 Resgrid.Relay.Engine/Services/RelayServiceFactory.cs create mode 100644 Resgrid.Relay.Engine/Services/SmtpRelayService.cs create mode 100644 Resgrid.Relay.Engine/Smtp/StatusReportingSmtpTelemetry.cs create mode 100644 Resgrid.Relay.Engine/TuneSample.cs create mode 100644 Resgrid.Relay.Engine/Voice/AudioImportMode.cs diff --git a/Resgrid.Audio.Core/ComService.cs b/Resgrid.Audio.Core/ComService.cs index 2e0712c..54c8e4b 100644 --- a/Resgrid.Audio.Core/ComService.cs +++ b/Resgrid.Audio.Core/ComService.cs @@ -8,13 +8,14 @@ using Resgrid.Audio.Core.Model; using Resgrid.Providers.ApiClient.V4; using Resgrid.Providers.ApiClient.V4.Models; +using Serilog; using Serilog.Core; namespace Resgrid.Audio.Core { public class ComService { - private readonly Logger _logger; + private readonly ILogger _logger; private readonly AudioProcessor _audioProcessor; private readonly IResgridApiClient _apiClient; private readonly IResgridHealthApi _healthApi; @@ -24,7 +25,7 @@ public class ComService public event EventHandler CallCreatedEvent; - public ComService(Logger logger, AudioProcessor audioProcessor, IResgridApiClient apiClient, IResgridHealthApi healthApi, IResgridCallsApi callsApi) + public ComService(ILogger logger, AudioProcessor audioProcessor, IResgridApiClient apiClient, IResgridHealthApi healthApi, IResgridCallsApi callsApi) { _logger = logger; _audioProcessor = audioProcessor; diff --git a/Resgrid.Audio.Core/Radio/RadioBridge.cs b/Resgrid.Audio.Core/Radio/RadioBridge.cs index edabf59..1fd373b 100644 --- a/Resgrid.Audio.Core/Radio/RadioBridge.cs +++ b/Resgrid.Audio.Core/Radio/RadioBridge.cs @@ -99,6 +99,31 @@ public async Task StartAsync(IVoiceRoomSession session, CancellationToken cancel _session.ChannelName, _settings.Squelch.Mode, _settings.Ptt); } + /// True while channel audio is being keyed to the radio (PTT active). + public bool Transmitting => _transmitting; + + /// True while radio audio is open and passing through to the channel. + public bool Receiving => _receiving; + + /// Raised whenever or changes. + public event EventHandler TxRxChanged; + + private void SetReceiving(bool value) + { + if (_receiving == value) + return; + _receiving = value; + TxRxChanged?.Invoke(this, EventArgs.Empty); + } + + private void SetTransmitting(bool value) + { + if (_transmitting == value) + return; + _transmitting = value; + TxRxChanged?.Invoke(this, EventArgs.Empty); + } + // ---- Radio receive → channel --------------------------------------------- private void OnRadioAudio(object sender, short[] frame) @@ -111,7 +136,7 @@ private void OnRadioAudio(object sender, short[] frame) _emergency?.Process(frame); bool open = IsSquelchOpen(frame); - _receiving = open; + SetReceiving(open); if (!open || (_settings.AntiLoop && _transmitting)) return; @@ -166,7 +191,7 @@ private void OnChannelAudio(object sender, VoiceAudioFrame frame) if (!_ptt.IsKeyed) { _ptt.Key(); - _transmitting = true; + SetTransmitting(true); _courtesyPlayed = false; _logger?.Debug("PTT keyed (channel audio from {Who})", frame.Participant?.ToString() ?? "remote"); } @@ -197,7 +222,7 @@ private void ManageTransmitTail() if (idleMs >= _settings.TxHangMs + _settings.TxTailMs) { _ptt.Unkey(); - _transmitting = false; + SetTransmitting(false); _logger?.Debug("PTT unkeyed after {Idle:0} ms idle", idleMs); } } diff --git a/Resgrid.Audio.Relay.Console/Program.cs b/Resgrid.Audio.Relay.Console/Program.cs index 0714763..d183cc0 100644 --- a/Resgrid.Audio.Relay.Console/Program.cs +++ b/Resgrid.Audio.Relay.Console/Program.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Configuration; using Resgrid.Relay.Engine; using Resgrid.Relay.Engine.Configuration; -using Resgrid.Relay.Engine.Smtp; +using Resgrid.Relay.Engine.Services; using Resgrid.Providers.ApiClient.V4; using Serilog; using Serilog.Core; @@ -68,7 +68,6 @@ public static async Task Main(string[] args) private static async Task RunAsync() { var hostOptions = LoadHostOptions(); - var mode = (hostOptions.Mode ?? "smtp").Trim().ToLowerInvariant(); // Validate required settings before starting (fail fast with clear messages). if (!ValidateOptions(hostOptions)) @@ -88,28 +87,24 @@ private static async Task RunAsync() try { - switch (mode) + // The engine owns all mode wiring; the console just builds the service and runs it. + IRelayService service; + try { - case "smtp": - { - var smtpLogger = CreateLogger(debug: false); - await using var telemetry = SmtpTelemetry.Create(hostOptions, smtpLogger); - using var apiClient = new ResgridV4ApiClient(hostOptions.Resgrid); - await SmtpRelayRunner.RunAsync(hostOptions.Smtp, telemetry, apiClient, cancellationTokenSource.Token).ConfigureAwait(false); - return 0; - } - case "audio": - return await RunAudioModeAsync(hostOptions, cancellationTokenSource.Token).ConfigureAwait(false); - case "radio": - return await RunRadioModeAsync(hostOptions, cancellationTokenSource.Token).ConfigureAwait(false); - case "record": - return await EngineVoice.RecordMode.RunAsync(hostOptions, CreateLogger(debug: false), cancellationTokenSource.Token).ConfigureAwait(false); - case "dispatch": - return await EngineVoice.DispatchVoiceMode.RunAsync(hostOptions, CreateLogger(debug: false), cancellationTokenSource.Token).ConfigureAwait(false); - default: - Cli.Error.WriteLine($"Unsupported relay mode '{hostOptions.Mode}'. Supported modes are 'smtp', 'audio', 'radio', 'record' and 'dispatch'."); - return 1; + service = RelayServiceFactory.Create(hostOptions, CreateLogger(debug: false)); } + catch (ArgumentException ex) + { + Cli.Error.WriteLine(ex.Message); + return 1; + } + + await using (service) + { + await service.StartAsync(cancellationTokenSource.Token).ConfigureAwait(false); + } + + return service.State == RelayServiceState.Faulted ? 1 : 0; } finally { @@ -261,68 +256,6 @@ private static void ShowHelp() } #if NET10_0_WINDOWS - private static async Task RunAudioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) - { - var config = LoadAudioConfig(hostOptions.AudioConfigPath); - var apiOptions = ResolveResgridOptions(hostOptions, config); - using var apiClient = new ResgridV4ApiClient(apiOptions); - var healthApi = new HealthApi(apiClient); - var callsApi = new CallsApi(apiClient); - - Logger logger = CreateLogger(config.Debug); - var audioStorage = new WatcherAudioStorage(logger); - var evaluator = new AudioEvaluator(logger); - var recorder = new AudioRecorder(evaluator, audioStorage); - var processor = new AudioProcessor(recorder, evaluator, audioStorage); - var comService = new ComService(logger, processor, apiClient, healthApi, callsApi); - - evaluator.WatcherTriggered += (_, eventArgs) => - Cli.WriteLine($"{DateTime.Now:G}: WATCHER TRIGGERED: {eventArgs.Watcher.Name}"); - - comService.CallCreatedEvent += (_, eventArgs) => - Cli.WriteLine($"{eventArgs.Timestamp:G}: CALL CREATED: {eventArgs.CallId} ({eventArgs.CallNumber})"); - - comService.Init(config); - if (!comService.IsConnectionValid()) - { - Cli.Error.WriteLine("Unable to reach the Resgrid v4 API with the configured OpenID Connect settings."); - return 1; - } - - Cli.WriteLine($"Listening for dispatches on device {config.InputDevice}"); - processor.Init(config); - processor.Start(); - - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - ConsoleCancelEventHandler stopHandler = (_, eventArgs) => - { - eventArgs.Cancel = true; - recorder.Stop(); - linkedCts.Cancel(); - }; - Cli.CancelKeyPress += stopHandler; - - try - { - while (!linkedCts.IsCancellationRequested && - (recorder.RecordingState == RecordingState.Monitoring || - recorder.RecordingState == RecordingState.Recording || - recorder.RecordingState == RecordingState.RequestedStop)) - { - await Task.Delay(250, linkedCts.Token).ConfigureAwait(false); - } - } - catch (TaskCanceledException) - { - } - finally - { - Cli.CancelKeyPress -= stopHandler; - } - - return 0; - } - private static async Task SetupAsync() { Cli.WriteLine("Resgrid Relay Audio Setup"); @@ -432,15 +365,23 @@ private static string SafeName(HidDevice device) catch { return "(unnamed)"; } } - private static Task RunRadioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) - => EngineVoice.RadioMode.RunAsync(hostOptions, CreateLogger(debug: false), cancellationToken); - private static async Task TuneAsync(string[] args) { var hostOptions = LoadHostOptions(); if (args.Length > 0 && Int32.TryParse(args[0], out var device)) hostOptions.Radio.InputDevice = device; + Cli.WriteLine($"Tuning input device {hostOptions.Radio.InputDevice}. Open={hostOptions.Radio.Squelch.OpenDbfs} dBFS, Close={hostOptions.Radio.Squelch.CloseDbfs} dBFS."); + Cli.WriteLine("Key up the radio (or feed static) and adjust OpenDbfs just above the static floor. Ctrl+C to stop."); + + // Render the live meter in-place from the engine's tune samples. + var progress = new Progress(sample => + { + int bars = Math.Clamp((int)((sample.Dbfs + 80) / 80 * 30), 0, 30); + var meter = new string('#', bars).PadRight(30, '·'); + Cli.Write($"\r[{meter}] {sample.Dbfs,6:0.0} dBFS {(sample.SquelchOpen ? "OPEN " : "closed")}"); + }); + using var cancellationTokenSource = new CancellationTokenSource(); ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => { @@ -450,11 +391,12 @@ private static async Task TuneAsync(string[] args) Cli.CancelKeyPress += cancelHandler; try { - return await EngineVoice.RadioMode.RunTuneAsync(hostOptions, cancellationTokenSource.Token).ConfigureAwait(false); + return await EngineVoice.RadioMode.RunTuneAsync(hostOptions, Logger.None, cancellationTokenSource.Token, progress).ConfigureAwait(false); } finally { Cli.CancelKeyPress -= cancelHandler; + Cli.WriteLine(); } } @@ -528,20 +470,6 @@ private static string DefaultAudioConfigPath() return Path.Combine(AppContext.BaseDirectory, "settings.json"); } - private static ResgridApiClientOptions ResolveResgridOptions(RelayHostOptions hostOptions, Config config) - { - return new ResgridApiClientOptions - { - BaseUrl = FirstNonEmpty(hostOptions.Resgrid.BaseUrl, config.Resgrid?.BaseUrl, config.ApiUrl, "https://api.resgrid.com"), - ApiVersion = FirstNonEmpty(hostOptions.Resgrid.ApiVersion, config.Resgrid?.ApiVersion, "4"), - ClientId = FirstNonEmpty(hostOptions.Resgrid.ClientId, config.Resgrid?.ClientId), - ClientSecret = FirstNonEmpty(hostOptions.Resgrid.ClientSecret, config.Resgrid?.ClientSecret), - RefreshToken = FirstNonEmpty(hostOptions.Resgrid.RefreshToken, config.Resgrid?.RefreshToken), - Scope = FirstNonEmpty(hostOptions.Resgrid.Scope, config.Resgrid?.Scope, "openid profile email offline_access mobile"), - TokenCachePath = FirstNonEmpty(hostOptions.Resgrid.TokenCachePath, config.Resgrid?.TokenCachePath, Path.Combine(AppContext.BaseDirectory, "data", "resgrid-token.json")) - }; - } - private static string Prompt(string text, string defaultValue) { Cli.Write($"{text}{(String.IsNullOrWhiteSpace(defaultValue) ? String.Empty : $" [{defaultValue}]")}: "); @@ -549,12 +477,6 @@ private static string Prompt(string text, string defaultValue) return String.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim(); } #else - private static Task RunAudioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) - { - Cli.Error.WriteLine("Audio mode is only available in the net10.0-windows build."); - return Task.FromResult(1); - } - private static Task SetupAsync() { Cli.Error.WriteLine("Audio setup is only available in the net10.0-windows build."); @@ -573,12 +495,6 @@ private static Task MonitorAsync(string[] args) return Task.FromResult(1); } - private static Task RunRadioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) - { - Cli.Error.WriteLine("Radio bridge mode is only available in the net10.0-windows build (it needs physical audio devices)."); - return Task.FromResult(1); - } - private static Task TuneAsync(string[] args) { Cli.Error.WriteLine("Tune is only available in the net10.0-windows build."); @@ -593,10 +509,5 @@ private static Logger CreateLogger(bool debug) .WriteTo.Console() .CreateLogger(); } - - private static string FirstNonEmpty(params string[] values) - { - return values.FirstOrDefault(x => !String.IsNullOrWhiteSpace(x)); - } } } diff --git a/Resgrid.Relay.Engine/RelayStatus.cs b/Resgrid.Relay.Engine/RelayStatus.cs new file mode 100644 index 0000000..62c64f9 --- /dev/null +++ b/Resgrid.Relay.Engine/RelayStatus.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Resgrid.Relay.Engine +{ + /// + /// Mutable that a running relay service updates as it + /// connects and passes traffic. Connection/level/flag setters raise + /// ; the counters are incremented atomically. + /// A service sets only the connections it actually uses — the rest stay + /// so the UI can grey them out. + /// + public sealed class RelayStatus : IRelayStatus + { + public event PropertyChangedEventHandler PropertyChanged; + + private ConnectionState _resgridApi = ConnectionState.NotApplicable; + public ConnectionState ResgridApi { get => _resgridApi; set => SetField(ref _resgridApi, value); } + + private ConnectionState _liveKit = ConnectionState.NotApplicable; + public ConnectionState LiveKit { get => _liveKit; set => SetField(ref _liveKit, value); } + + private ConnectionState _redis = ConnectionState.NotApplicable; + public ConnectionState Redis { get => _redis; set => SetField(ref _redis, value); } + + private ConnectionState _smtp = ConnectionState.NotApplicable; + public ConnectionState Smtp { get => _smtp; set => SetField(ref _smtp, value); } + + private ConnectionState _tts = ConnectionState.NotApplicable; + public ConnectionState Tts { get => _tts; set => SetField(ref _tts, value); } + + private double _inputDbfs; + public double InputDbfs { get => _inputDbfs; set => SetField(ref _inputDbfs, value); } + + private bool _squelchOpen; + public bool SquelchOpen { get => _squelchOpen; set => SetField(ref _squelchOpen, value); } + + private bool _transmitting; + public bool Transmitting { get => _transmitting; set => SetField(ref _transmitting, value); } + + private bool _receiving; + public bool Receiving { get => _receiving; set => SetField(ref _receiving, value); } + + private long _callsCreated; + public long CallsCreated => Interlocked.Read(ref _callsCreated); + + private long _messagesProcessed; + public long MessagesProcessed => Interlocked.Read(ref _messagesProcessed); + + private long _transmissionsRecorded; + public long TransmissionsRecorded => Interlocked.Read(ref _transmissionsRecorded); + + public void IncrementCallsCreated() + { + Interlocked.Increment(ref _callsCreated); + OnPropertyChanged(nameof(CallsCreated)); + } + + public void IncrementMessagesProcessed() + { + Interlocked.Increment(ref _messagesProcessed); + OnPropertyChanged(nameof(MessagesProcessed)); + } + + public void IncrementTransmissionsRecorded() + { + Interlocked.Increment(ref _transmissionsRecorded); + OnPropertyChanged(nameof(TransmissionsRecorded)); + } + + private void SetField(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + return; + field = value; + OnPropertyChanged(propertyName); + } + + private void OnPropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/Resgrid.Relay.Engine/ServiceCollectionExtensions.cs b/Resgrid.Relay.Engine/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a723a1f --- /dev/null +++ b/Resgrid.Relay.Engine/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Services; +using Serilog; + +namespace Resgrid.Relay.Engine +{ + /// + /// DI registration for the relay engine. Keeps it minimal for Phase B3 — only the + /// per-mode service factory delegate is registered here; the log bus and config + /// services arrive in later phases. + /// + public static class ServiceCollectionExtensions + { + /// + /// Registers a singleton factory delegate that builds the right + /// for a given and logger. + /// + public static IServiceCollection AddRelayEngine(this IServiceCollection services) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + + services.AddSingleton>( + _ => RelayServiceFactory.Create); + + return services; + } + } +} diff --git a/Resgrid.Relay.Engine/Services/AudioImportService.cs b/Resgrid.Relay.Engine/Services/AudioImportService.cs new file mode 100644 index 0000000..14c5042 --- /dev/null +++ b/Resgrid.Relay.Engine/Services/AudioImportService.cs @@ -0,0 +1,27 @@ +#if NET10_0_WINDOWS +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Voice; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// 'audio' relay service (Windows): tone-detect dispatch importer that watches a + /// Windows audio device and creates Resgrid calls. + /// + public sealed class AudioImportService : RelayServiceBase + { + public AudioImportService(RelayHostOptions options, ILogger logger) + : base("audio", options, logger) + { + } + + protected override async Task ExecuteAsync(CancellationToken token) + { + await AudioImportMode.RunAsync(Options, Logger, token, MutableStatus).ConfigureAwait(false); + } + } +} +#endif diff --git a/Resgrid.Relay.Engine/Services/DispatchRelayService.cs b/Resgrid.Relay.Engine/Services/DispatchRelayService.cs new file mode 100644 index 0000000..7deacb1 --- /dev/null +++ b/Resgrid.Relay.Engine/Services/DispatchRelayService.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Voice; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// 'dispatch' relay service: tones out new Resgrid calls (alert tones + TTS) onto a + /// PTT channel. Cross-platform. + /// + public sealed class DispatchRelayService : RelayServiceBase + { + public DispatchRelayService(RelayHostOptions options, ILogger logger) + : base("dispatch", options, logger) + { + } + + protected override async Task ExecuteAsync(CancellationToken token) + { + MutableStatus.LiveKit = ConnectionState.Connecting; + MutableStatus.Tts = ConnectionState.Connecting; + await DispatchVoiceMode.RunAsync(Options, Logger, token, MutableStatus).ConfigureAwait(false); + } + } +} diff --git a/Resgrid.Relay.Engine/Services/NotSupportedRelayService.cs b/Resgrid.Relay.Engine/Services/NotSupportedRelayService.cs new file mode 100644 index 0000000..0b087df --- /dev/null +++ b/Resgrid.Relay.Engine/Services/NotSupportedRelayService.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// Placeholder relay service returned for Windows-only modes (radio/audio) when the + /// engine is running on a non-Windows build. Faults immediately with a clear message; + /// catches the exception and transitions to Faulted. + /// + public sealed class NotSupportedRelayService : RelayServiceBase + { + public NotSupportedRelayService(string mode, RelayHostOptions options, ILogger logger) + : base(mode, options, logger) + { + } + + protected override Task ExecuteAsync(CancellationToken token) + { + throw new PlatformNotSupportedException($"The '{Mode}' relay mode requires Windows."); + } + } +} diff --git a/Resgrid.Relay.Engine/Services/RadioRelayService.cs b/Resgrid.Relay.Engine/Services/RadioRelayService.cs new file mode 100644 index 0000000..94a72b5 --- /dev/null +++ b/Resgrid.Relay.Engine/Services/RadioRelayService.cs @@ -0,0 +1,28 @@ +#if NET10_0_WINDOWS +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Voice; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// 'radio' relay service (Windows): bridges a physically-attached radio with a + /// Resgrid PTT channel. + /// + public sealed class RadioRelayService : RelayServiceBase + { + public RadioRelayService(RelayHostOptions options, ILogger logger) + : base("radio", options, logger) + { + } + + protected override async Task ExecuteAsync(CancellationToken token) + { + MutableStatus.LiveKit = ConnectionState.Connecting; + await RadioMode.RunAsync(Options, Logger, token, MutableStatus).ConfigureAwait(false); + } + } +} +#endif diff --git a/Resgrid.Relay.Engine/Services/RecordRelayService.cs b/Resgrid.Relay.Engine/Services/RecordRelayService.cs new file mode 100644 index 0000000..fa611c0 --- /dev/null +++ b/Resgrid.Relay.Engine/Services/RecordRelayService.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Voice; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// 'record' relay service: records every PTT transmission on one or all channels. + /// Cross-platform. + /// + public sealed class RecordRelayService : RelayServiceBase + { + public RecordRelayService(RelayHostOptions options, ILogger logger) + : base("record", options, logger) + { + } + + protected override async Task ExecuteAsync(CancellationToken token) + { + MutableStatus.LiveKit = ConnectionState.Connecting; + await RecordMode.RunAsync(Options, Logger, token, MutableStatus).ConfigureAwait(false); + } + } +} diff --git a/Resgrid.Relay.Engine/Services/RelayServiceBase.cs b/Resgrid.Relay.Engine/Services/RelayServiceBase.cs new file mode 100644 index 0000000..8c73521 --- /dev/null +++ b/Resgrid.Relay.Engine/Services/RelayServiceBase.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// Shared lifecycle / state-machine plumbing for + /// implementations. Subclasses implement — the long-lived + /// run that returns when the linked token is cancelled or the mode faults — and update + /// as they connect and pass traffic. + /// + public abstract class RelayServiceBase : IRelayService + { + private readonly object _sync = new object(); + private CancellationTokenSource _cts; + private RelayServiceState _state = RelayServiceState.Stopped; + + protected RelayServiceBase(string mode, RelayHostOptions options, ILogger logger) + { + Mode = mode ?? throw new ArgumentNullException(nameof(mode)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + Logger = logger; + } + + public string Mode { get; } + public RelayServiceState State => _state; + public event EventHandler StateChanged; + + public IRelayStatus Status => MutableStatus; + + /// The concrete, writable status a subclass mutates while running. + protected RelayStatus MutableStatus { get; } = new RelayStatus(); + + protected RelayHostOptions Options { get; } + protected ILogger Logger { get; } + + /// + /// The long-lived mode run. Returns normally when is + /// cancelled; throw to fault the service. + /// + protected abstract Task ExecuteAsync(CancellationToken token); + + public async Task StartAsync(CancellationToken token) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); + TransitionTo(RelayServiceState.Starting); + try + { + TransitionTo(RelayServiceState.Running); + await ExecuteAsync(_cts.Token).ConfigureAwait(false); + TransitionTo(RelayServiceState.Stopped); + } + catch (OperationCanceledException) + { + TransitionTo(RelayServiceState.Stopped); + } + catch (Exception ex) + { + // IRelayService contract: StartAsync returns on fault — surface it via + // State/StateChanged rather than throwing back to the caller. + Logger?.Error(ex, "Relay mode '{Mode}' faulted", Mode); + TransitionTo(RelayServiceState.Faulted, ex.Message); + } + } + + public Task StopAsync() + { + if (_state == RelayServiceState.Starting || _state == RelayServiceState.Running) + TransitionTo(RelayServiceState.Stopping); + try { _cts?.Cancel(); } + catch (ObjectDisposedException) { } + return Task.CompletedTask; + } + + public virtual async ValueTask DisposeAsync() + { + try { _cts?.Cancel(); } + catch (ObjectDisposedException) { } + _cts?.Dispose(); + await Task.CompletedTask.ConfigureAwait(false); + } + + private void TransitionTo(RelayServiceState next, string error = null) + { + RelayServiceState prev; + lock (_sync) + { + prev = _state; + if (prev == next) + return; + _state = next; + } + StateChanged?.Invoke(this, new RelayStateChangedEventArgs(prev, next, error)); + } + } +} diff --git a/Resgrid.Relay.Engine/Services/RelayServiceFactory.cs b/Resgrid.Relay.Engine/Services/RelayServiceFactory.cs new file mode 100644 index 0000000..d7e0179 --- /dev/null +++ b/Resgrid.Relay.Engine/Services/RelayServiceFactory.cs @@ -0,0 +1,49 @@ +using System; +using Resgrid.Relay.Engine.Configuration; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// Creates the right for a configured mode. Windows-only + /// modes (radio/audio) resolve to their real service on a Windows build and to a + /// elsewhere so the host can still surface a + /// clear Faulted state instead of crashing. + /// + public static class RelayServiceFactory + { + public static IRelayService Create(RelayHostOptions options, ILogger logger) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + var mode = (options.Mode ?? "smtp").Trim().ToLowerInvariant(); + + switch (mode) + { + case "smtp": + return new SmtpRelayService(options, logger); + case "record": + return new RecordRelayService(options, logger); + case "dispatch": + return new DispatchRelayService(options, logger); + case "audio": +#if NET10_0_WINDOWS + return new AudioImportService(options, logger); +#else + return new NotSupportedRelayService(mode, options, logger); +#endif + case "radio": +#if NET10_0_WINDOWS + return new RadioRelayService(options, logger); +#else + return new NotSupportedRelayService(mode, options, logger); +#endif + default: + throw new ArgumentException( + $"Unsupported relay mode '{options.Mode}'. Supported modes are 'smtp', 'audio', 'radio', 'record' and 'dispatch'.", + nameof(options)); + } + } + } +} diff --git a/Resgrid.Relay.Engine/Services/SmtpRelayService.cs b/Resgrid.Relay.Engine/Services/SmtpRelayService.cs new file mode 100644 index 0000000..f6044b0 --- /dev/null +++ b/Resgrid.Relay.Engine/Services/SmtpRelayService.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Relay.Engine.Smtp; +using Resgrid.Providers.ApiClient.V4; +using Serilog; + +namespace Resgrid.Relay.Engine.Services +{ + /// + /// 'smtp' relay service: runs the SMTP dispatch relay and projects API/Redis/SMTP + /// connection state and the processed-message counter onto the status surface. + /// Cross-platform. + /// + public sealed class SmtpRelayService : RelayServiceBase + { + public SmtpRelayService(RelayHostOptions options, ILogger logger) + : base("smtp", options, logger) + { + } + + protected override async Task ExecuteAsync(CancellationToken token) + { + using var apiClient = new ResgridV4ApiClient(Options.Resgrid); + + MutableStatus.ResgridApi = ConnectionState.Connecting; + try + { + var healthy = await apiClient.IsHealthyAsync(token).ConfigureAwait(false); + MutableStatus.ResgridApi = healthy ? ConnectionState.Connected : ConnectionState.Disconnected; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // A failed health probe must not prevent the relay from listening — the + // SMTP server can still accept mail and call the API per-message. + Logger?.Warning(ex, "Resgrid API health probe failed at SMTP relay startup"); + MutableStatus.ResgridApi = ConnectionState.Disconnected; + } + + MutableStatus.Redis = Options.Smtp.RedisCache?.Enabled == true + ? ConnectionState.Connecting + : ConnectionState.NotApplicable; + + var inner = SmtpTelemetry.Create(Options, Logger); + await using var telemetry = new StatusReportingSmtpTelemetry(inner, MutableStatus); + + await SmtpRelayRunner.RunAsync(Options.Smtp, telemetry, apiClient, token).ConfigureAwait(false); + } + } +} diff --git a/Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs b/Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs index 7989be8..15fe478 100644 --- a/Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs +++ b/Resgrid.Relay.Engine/Smtp/SmtpTelemetry.cs @@ -81,7 +81,7 @@ private SmtpTelemetry(ILogger logger, RelayTelemetryOptions options, string serv _release); } - public static ISmtpTelemetry Create(RelayHostOptions hostOptions, Logger logger) + public static ISmtpTelemetry Create(RelayHostOptions hostOptions, ILogger logger) { if (hostOptions == null) throw new ArgumentNullException(nameof(hostOptions)); diff --git a/Resgrid.Relay.Engine/Smtp/StatusReportingSmtpTelemetry.cs b/Resgrid.Relay.Engine/Smtp/StatusReportingSmtpTelemetry.cs new file mode 100644 index 0000000..458e75c --- /dev/null +++ b/Resgrid.Relay.Engine/Smtp/StatusReportingSmtpTelemetry.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using Resgrid.Relay.Engine.Configuration; +using SmtpServer; +using SmtpServer.Mail; + +namespace Resgrid.Relay.Engine.Smtp +{ + /// + /// Decorates an inner , forwarding every call to it while + /// also projecting the relay's live state onto a : SMTP + /// connection state on start/stop/fault and the processed-message counter. + /// + public sealed class StatusReportingSmtpTelemetry : ISmtpTelemetry + { + private readonly ISmtpTelemetry _inner; + private readonly RelayStatus _status; + + public StatusReportingSmtpTelemetry(ISmtpTelemetry inner, RelayStatus status) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _status = status ?? throw new ArgumentNullException(nameof(status)); + } + + public void RelayStarting(SmtpRelayOptions options) + { + _status.Smtp = ConnectionState.Connected; + _inner.RelayStarting(options); + } + + public void RelayStopped(SmtpRelayOptions options) + { + _status.Smtp = ConnectionState.Disconnected; + _inner.RelayStopped(options); + } + + public void RelayFaulted(SmtpRelayOptions options, Exception exception) + { + _status.Smtp = ConnectionState.Disconnected; + _inner.RelayFaulted(options, exception); + } + + public void SessionCreated(ISessionContext context) => _inner.SessionCreated(context); + + public void SessionCompleted(ISessionContext context) => _inner.SessionCompleted(context); + + public void SessionCancelled(ISessionContext context) => _inner.SessionCancelled(context); + + public void SessionFaulted(ISessionContext context, Exception exception) => + _inner.SessionFaulted(context, exception); + + public void SenderAccepted(ISessionContext context, IMailbox from, int size) => + _inner.SenderAccepted(context, from, size); + + public void RecipientEvaluated(ISessionContext context, IMailbox to, IMailbox from, bool accepted, string reason) => + _inner.RecipientEvaluated(context, to, from, accepted, reason); + + public void MessageReceived(ISessionContext context, SmtpMessageSummary message) => + _inner.MessageReceived(context, message); + + public void DuplicateMessage(ISessionContext context, SmtpMessageSummary message) => + _inner.DuplicateMessage(context, message); + + public void UnroutableMessage(ISessionContext context, SmtpMessageSummary message) => + _inner.UnroutableMessage(context, message); + + public void UnsupportedTarget(ISessionContext context, SmtpMessageSummary message) => + _inner.UnsupportedTarget(context, message); + + public void MessageProcessingStarted(ISessionContext context, SmtpMessageSummary message) => + _inner.MessageProcessingStarted(context, message); + + public void MessageProcessed(ISessionContext context, SmtpMessageSummary message, TimeSpan duration) + { + _status.IncrementMessagesProcessed(); + _inner.MessageProcessed(context, message, duration); + } + + public void MessageFailed(ISessionContext context, SmtpMessageSummary message, Exception exception, TimeSpan duration) => + _inner.MessageFailed(context, message, exception, duration); + + public ValueTask DisposeAsync() => _inner.DisposeAsync(); + } +} diff --git a/Resgrid.Relay.Engine/TuneSample.cs b/Resgrid.Relay.Engine/TuneSample.cs new file mode 100644 index 0000000..2417c84 --- /dev/null +++ b/Resgrid.Relay.Engine/TuneSample.cs @@ -0,0 +1,10 @@ +namespace Resgrid.Relay.Engine +{ + /// + /// A single live receive-level sample emitted by the radio tuner: the measured input + /// level in dBFS and whether the squelch gate is currently open. Surfaced via + /// so a console meter or the WPF Radio tuner can render + /// it without the engine taking a UI dependency. + /// + public readonly record struct TuneSample(double Dbfs, bool SquelchOpen); +} diff --git a/Resgrid.Relay.Engine/Voice/AudioImportMode.cs b/Resgrid.Relay.Engine/Voice/AudioImportMode.cs new file mode 100644 index 0000000..a3b7da0 --- /dev/null +++ b/Resgrid.Relay.Engine/Voice/AudioImportMode.cs @@ -0,0 +1,128 @@ +#if NET10_0_WINDOWS +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Audio.Core; +using Resgrid.Audio.Core.Model; +using Resgrid.Relay.Engine.Configuration; +using Resgrid.Providers.ApiClient.V4; +using Serilog; + +namespace Resgrid.Relay.Engine.Voice +{ + /// + /// 'audio' mode (Windows): watches a Windows audio device for dispatch tones and + /// creates Resgrid calls when a watcher triggers. Extracted from the console host so + /// it can be driven by the relay service host. Windows-only because it depends on + /// NAudio capture (via , referenced only under + /// net10.0-windows). + /// + public static class AudioImportMode + { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken, RelayStatus status = null) + { + var config = LoadAudioConfig(options.AudioConfigPath); + var apiOptions = ResolveResgridOptions(options, config); + using var apiClient = new ResgridV4ApiClient(apiOptions); + var healthApi = new HealthApi(apiClient); + var callsApi = new CallsApi(apiClient); + + // The Resgrid API client is built and ready to be used. + if (status != null) + status.ResgridApi = ConnectionState.Connected; + + var audioStorage = new WatcherAudioStorage(logger); + var evaluator = new AudioEvaluator(logger); + var recorder = new AudioRecorder(evaluator, audioStorage); + var processor = new AudioProcessor(recorder, evaluator, audioStorage); + var comService = new ComService(logger, processor, apiClient, healthApi, callsApi); + + evaluator.WatcherTriggered += (_, eventArgs) => + logger.Information("WATCHER TRIGGERED: {Watcher}", eventArgs.Watcher.Name); + + comService.CallCreatedEvent += (_, eventArgs) => + { + logger.Information("CALL CREATED: {CallId} ({CallNumber})", eventArgs.CallId, eventArgs.CallNumber); + if (status != null) + status.IncrementCallsCreated(); + }; + + comService.Init(config); + if (!comService.IsConnectionValid()) + { + logger.Error("Unable to reach the Resgrid v4 API with the configured OpenID Connect settings."); + return 1; + } + + logger.Information("Listening for dispatches on device {InputDevice}", config.InputDevice); + processor.Init(config); + processor.Start(); + + // The recorder's sample aggregator publishes the peak dBFS of each capture + // block — surface it as the live input level so the UI meter can move. + if (status != null && recorder.SampleAggregator != null) + recorder.SampleAggregator.MaximumCalculated += (_, eventArgs) => status.InputDbfs = eventArgs.Db; + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + try + { + while (!linkedCts.IsCancellationRequested && + (recorder.RecordingState == RecordingState.Monitoring || + recorder.RecordingState == RecordingState.Recording || + recorder.RecordingState == RecordingState.RequestedStop)) + { + await Task.Delay(250, linkedCts.Token).ConfigureAwait(false); + } + } + catch (TaskCanceledException) + { + } + finally + { + recorder.Stop(); + } + + return 0; + } + + private static Config LoadAudioConfig(string path) + { + var payload = File.ReadAllText(path); + var config = JsonSerializer.Deserialize(payload, JsonOptions) ?? new Config(); + if (config.Resgrid == null) + config.Resgrid = new ResgridConnectionSettings(); + if (config.DispatchMapping == null) + config.DispatchMapping = new DispatchMappingSettings(); + + return config; + } + + private static ResgridApiClientOptions ResolveResgridOptions(RelayHostOptions hostOptions, Config config) + { + return new ResgridApiClientOptions + { + BaseUrl = FirstNonEmpty(hostOptions.Resgrid.BaseUrl, config.Resgrid?.BaseUrl, config.ApiUrl, "https://api.resgrid.com"), + ApiVersion = FirstNonEmpty(hostOptions.Resgrid.ApiVersion, config.Resgrid?.ApiVersion, "4"), + ClientId = FirstNonEmpty(hostOptions.Resgrid.ClientId, config.Resgrid?.ClientId), + ClientSecret = FirstNonEmpty(hostOptions.Resgrid.ClientSecret, config.Resgrid?.ClientSecret), + RefreshToken = FirstNonEmpty(hostOptions.Resgrid.RefreshToken, config.Resgrid?.RefreshToken), + Scope = FirstNonEmpty(hostOptions.Resgrid.Scope, config.Resgrid?.Scope, "openid profile email offline_access mobile"), + TokenCachePath = FirstNonEmpty(hostOptions.Resgrid.TokenCachePath, config.Resgrid?.TokenCachePath, Path.Combine(AppContext.BaseDirectory, "data", "resgrid-token.json")) + }; + } + + private static string FirstNonEmpty(params string[] values) => + values.FirstOrDefault(x => !String.IsNullOrWhiteSpace(x)); + } +} +#endif diff --git a/Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs b/Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs index dba5abf..ddeef09 100644 --- a/Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs +++ b/Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Resgrid.Relay.Engine; using Resgrid.Relay.Engine.Configuration; using Resgrid.Audio.Voice; using Resgrid.Audio.Voice.Connection; @@ -20,7 +21,7 @@ namespace Resgrid.Relay.Engine.Voice /// public static class DispatchVoiceMode { - public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken) + public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken, RelayStatus status = null) { if (string.IsNullOrWhiteSpace(options.Tts.ServiceBaseUrl)) { @@ -47,7 +48,16 @@ public static async Task RunAsync(RelayHostOptions options, ILogger logger, var session = await manager.JoinAsync(channel, cancellationToken).ConfigureAwait(false); var publisher = await session.CreatePublisherAsync("dispatch", cancellationToken).ConfigureAwait(false); + // Channel joined and session established — LiveKit is up. + if (status != null) + status.LiveKit = ConnectionState.Connected; + using var tts = new ResgridTtsClient(options.Tts, logger); + + // TTS client created — the Resgrid TTS service is reachable. + if (status != null) + status.Tts = ConnectionState.Connected; + var service = new DispatchToneOutService(tts, new ToneGenerator(), options.DispatchVoice.Tone, logger); // Prime "seen" with the current backlog so startup doesn't re-announce open calls. diff --git a/Resgrid.Relay.Engine/Voice/RadioMode.cs b/Resgrid.Relay.Engine/Voice/RadioMode.cs index 1b6904e..3f3005d 100644 --- a/Resgrid.Relay.Engine/Voice/RadioMode.cs +++ b/Resgrid.Relay.Engine/Voice/RadioMode.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.IO.Ports; using Resgrid.Audio.Core.Radio; +using Resgrid.Relay.Engine; using Resgrid.Relay.Engine.Configuration; using Resgrid.Audio.Voice; using Resgrid.Audio.Voice.Abstractions; @@ -25,7 +26,7 @@ namespace Resgrid.Relay.Engine.Voice /// public static class RadioMode { - public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken) + public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken, RelayStatus status = null) { using var apiClient = new ResgridV4ApiClient(options.Resgrid); var voiceApi = new VoiceApi(apiClient); @@ -49,6 +50,10 @@ public static async Task RunAsync(RelayHostOptions options, ILogger logger, var channel = await provider.GetChannelAsync(options.Voice.Channel, deptId, cancellationToken).ConfigureAwait(false); var session = await manager.JoinAsync(channel, cancellationToken).ConfigureAwait(false); + // Session joined — LiveKit is up. + if (status != null) + status.LiveKit = ConnectionState.Connected; + var radioSettings = MapSettings(options.Radio, logger); // Serial PTT and serial carrier-detect on the same COM port must share one // SerialPort instance — opening the same port twice fails on Windows. RunAsync @@ -63,6 +68,16 @@ public static async Task RunAsync(RelayHostOptions options, ILogger logger, await using var bridge = new RadioBridge(device, ptt, carrier, radioSettings, logger, mdc, emergency, alertSink); await bridge.StartAsync(session, cancellationToken).ConfigureAwait(false); + // Mirror the bridge's live TX/RX state onto the status surface. + // note: squelch open-state and input dBFS live inside RadioBridge in radio mode + // and are not exposed, so InputDbfs/SquelchOpen are intentionally left unwired here. + if (status != null) + bridge.TxRxChanged += (_, __) => + { + status.Transmitting = bridge.Transmitting; + status.Receiving = bridge.Receiving; + }; + TransmissionRecorder recorder = null; List recorderDisposables = null; ITransmissionLog recorderLog = null; @@ -85,39 +100,37 @@ public static async Task RunAsync(RelayHostOptions options, ILogger logger, if (recorderDisposables != null) foreach (var d in recorderDisposables) d.Dispose(); - await bridge.DisposeAsync().ConfigureAwait(false); + // bridge is disposed by its `await using` scope on method exit — no explicit call. return 0; } - /// Live receive-level meter + squelch state to help tune the anti-static threshold. - public static async Task RunTuneAsync(RelayHostOptions options, CancellationToken cancellationToken) + /// + /// Live receive-level metering + squelch state to help tune the anti-static threshold. + /// Each ~150 ms sample is reported via (the engine renders + /// nothing itself); is used only for device diagnostics. + /// + public static async Task RunTuneAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken, IProgress progress = null) { - var logger = Serilog.Core.Logger.None; var radioSettings = MapSettings(options.Radio, logger); var gate = new SquelchGate(radioSettings.Squelch, AudioFormat.SampleRate); - var device = new NAudioRadioDevice(radioSettings.InputDevice, radioSettings.OutputDevice, logger); + using var device = new NAudioRadioDevice(radioSettings.InputDevice, radioSettings.OutputDevice, logger); - var lastPrintTicks = 0L; + var lastSampleTicks = 0L; device.SamplesReceived += (_, frame) => { bool open = gate.Process(frame); var now = DateTime.UtcNow.Ticks; - if (now - lastPrintTicks < TimeSpan.TicksPerMillisecond * 150) + if (now - lastSampleTicks < TimeSpan.TicksPerMillisecond * 150) return; - lastPrintTicks = now; + lastSampleTicks = now; - double db = gate.LastDbfs; - int bars = Math.Clamp((int)((db + 80) / 80 * 30), 0, 30); - var meter = new string('#', bars).PadRight(30, '·'); - logger.Information($"[{meter}] {db,6:0.0} dBFS {(open ? "OPEN " : "closed")}"); + // Surface the live level/squelch so the console meter (and the WPF Radio + // tuner) can render it; the engine itself stays UI-free. + progress?.Report(new TuneSample(gate.LastDbfs, open)); }; device.StartReceive(); - logger.Information($"Tuning input device {radioSettings.InputDevice}. Open={radioSettings.Squelch.OpenDbfs} dBFS, Close={radioSettings.Squelch.CloseDbfs} dBFS."); - logger.Information("Key up the radio (or feed static) and adjust OpenDbfs just above the static floor. Ctrl+C to stop."); - await VoiceModeRuntime.WaitForCancellationAsync(cancellationToken).ConfigureAwait(false); - device.Dispose(); return 0; } diff --git a/Resgrid.Relay.Engine/Voice/RecordMode.cs b/Resgrid.Relay.Engine/Voice/RecordMode.cs index 0ce861a..abd413f 100644 --- a/Resgrid.Relay.Engine/Voice/RecordMode.cs +++ b/Resgrid.Relay.Engine/Voice/RecordMode.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Resgrid.Relay.Engine; using Resgrid.Relay.Engine.Configuration; using Resgrid.Audio.Voice; using Resgrid.Audio.Voice.Abstractions; @@ -21,7 +22,7 @@ namespace Resgrid.Relay.Engine.Voice /// public static class RecordMode { - public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken) + public static async Task RunAsync(RelayHostOptions options, ILogger logger, CancellationToken cancellationToken, RelayStatus status = null) { using var apiClient = new ResgridV4ApiClient(options.Resgrid); var voiceApi = new VoiceApi(apiClient); @@ -47,10 +48,16 @@ public static async Task RunAsync(RelayHostOptions options, ILogger logger, { var session = await manager.JoinAsync(channel, cancellationToken).ConfigureAwait(false); var recorder = new TransmissionRecorder(session, options.Recorder.Segmentation, stores, log, logger); + if (status != null) + recorder.TransmissionRecorded += (_, __) => status.IncrementTransmissionsRecorded(); recorder.Start(); recorders.Add(recorder); } + // All requested channels joined — LiveKit is up. + if (status != null) + status.LiveKit = ConnectionState.Connected; + logger.Information($"Recording {channels.Count} channel(s) to {options.Recorder.Store}. Press Ctrl+C to stop."); await VoiceModeRuntime.WaitForCancellationAsync(cancellationToken).ConfigureAwait(false); }