Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Resgrid.Audio.Core/ComService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,7 +25,7 @@ public class ComService

public event EventHandler<CallCreatedEventArgs> 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;
Expand Down
31 changes: 28 additions & 3 deletions Resgrid.Audio.Core/Radio/RadioBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ public async Task StartAsync(IVoiceRoomSession session, CancellationToken cancel
_session.ChannelName, _settings.Squelch.Mode, _settings.Ptt);
}

/// <summary>True while channel audio is being keyed to the radio (PTT active).</summary>
public bool Transmitting => _transmitting;

/// <summary>True while radio audio is open and passing through to the channel.</summary>
public bool Receiving => _receiving;

/// <summary>Raised whenever <see cref="Transmitting"/> or <see cref="Receiving"/> changes.</summary>
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)
Expand All @@ -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;
Comment on lines +139 to 142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Only mark Receiving when RX audio is actually forwarded.

Line 139 uses raw squelch-open state, but Line 141 can still drop the frame because anti-loop is suppressing RX while TX is active. The new status surface then reports Receiving = true even though nothing is being published to the channel.

Suggested fix
 			bool open = IsSquelchOpen(frame);
-			SetReceiving(open);
+			bool receiving = open && !(_settings.AntiLoop && _transmitting);
+			SetReceiving(receiving);
 
-			if (!open || (_settings.AntiLoop && _transmitting))
+			if (!receiving)
 				return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SetReceiving(open);
if (!open || (_settings.AntiLoop && _transmitting))
return;
bool open = IsSquelchOpen(frame);
bool receiving = open && !(_settings.AntiLoop && _transmitting);
SetReceiving(receiving);
if (!receiving)
return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Core/Radio/RadioBridge.cs` around lines 139 - 142, Update
RadioBridge so Receiving is only set when RX audio is actually forwarded, not
just when squelch is open. In the receive path around SetReceiving/open and the
AntiLoop check, move or gate the Receiving state update so it happens only after
the early-return conditions pass, or clear it when _settings.AntiLoop suppresses
forwarding while _transmitting is true. Use the RadioBridge receive flow and
SetReceiving as the key symbols to adjust.

Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
149 changes: 30 additions & 119 deletions Resgrid.Audio.Relay.Console/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -68,7 +68,6 @@ public static async Task<int> Main(string[] args)
private static async Task<int> RunAsync()
{
var hostOptions = LoadHostOptions();
var mode = (hostOptions.Mode ?? "smtp").Trim().ToLowerInvariant();

// Validate required settings before starting (fail fast with clear messages).
if (!ValidateOptions(hostOptions))
Expand All @@ -88,28 +87,24 @@ private static async Task<int> 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;
Comment on lines +102 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect relay service wrappers for non-zero RunAsync result handling.
# Expected: Each ExecuteAsync that awaits a mode RunAsync(...) stores/checks the int result
# and throws (or otherwise causes RelayServiceBase to transition to Faulted) when result != 0.

set -euo pipefail

fd -i '.*(RelayService|ImportService)\.cs$' . \
  | xargs rg -n -C 6 'ExecuteAsync|RunAsync\(|returnCode|exitCode|!=\s*0|throw new'

Repository: Resgrid/Relay

Length of output: 7645


🏁 Script executed:

find . -name "*.cs" -type f \( -name "AudioImportMode.cs" -o -name "SmtpRelayRunner.cs" -o -name "RecordMode.cs" -o -name "RadioMode.cs" -o -name "DispatchVoiceMode.cs" \) | xargs ast-grep outline

Repository: Resgrid/Relay

Length of output: 393


🏁 Script executed:

find . -name "*.cs" -type f | xargs rg -l "class.*RunAsync"

Repository: Resgrid/Relay

Length of output: 155


🏁 Script executed:

rg -n "public static.*Task.*RunAsync" --type cs --files-with-matches

Repository: Resgrid/Relay

Length of output: 151


🏁 Script executed:

rg -l "class.*Mode" --type cs --max-count 10

Repository: Resgrid/Relay

Length of output: 151


🏁 Script executed:

find . -name "*.cs" -type f -exec grep -l "RunAsync" {} \; | head -20

Repository: Resgrid/Relay

Length of output: 699


🏁 Script executed:

cat -n Resgrid.Relay.Engine/Voice/AudioImportMode.cs | head -60

Repository: Resgrid/Relay

Length of output: 2812


🏁 Script executed:

cat -n Resgrid.Relay.Engine/Voice/AudioImportMode.cs | tail -50

Repository: Resgrid/Relay

Length of output: 2429


🏁 Script executed:

cat -n Resgrid.Relay.Engine/Voice/RecordMode.cs

Repository: Resgrid/Relay

Length of output: 5671


🏁 Script executed:

cat -n Resgrid.Relay.Engine/Voice/RadioMode.cs

Repository: Resgrid/Relay

Length of output: 12185


🏁 Script executed:

cat -n Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs

Repository: Resgrid/Relay

Length of output: 5744


🏁 Script executed:

cat -n Resgrid.Relay.Engine/Smtp/SmtpRelayRunner.cs

Repository: Resgrid/Relay

Length of output: 228


🏁 Script executed:

find . -name "SmtpRelayRunner.cs" -type f

Repository: Resgrid/Relay

Length of output: 151


🏁 Script executed:

find . -name "*Smtp*.cs" -type f

Repository: Resgrid/Relay

Length of output: 525


🏁 Script executed:

cat -n Resgrid.Relay.Engine/Services/SmtpRelayService.cs

Repository: Resgrid/Relay

Length of output: 2331


🏁 Script executed:

rg -l "class.*SmtpRelayRunner" --type cs

Repository: Resgrid/Relay

Length of output: 151


🏁 Script executed:

rg "SmtpRelayRunner" -t cs --type-limit 1 --files-with-matches

Repository: Resgrid/Relay

Length of output: 593


Return values from mode runners are ignored, masking failures

The relay service wrappers (AudioImportService, RecordRelayService, RadioRelayService, DispatchRelayService) await their respective mode RunAsync methods without checking the returned int.

While AudioImportMode, RecordMode, RadioMode, and DispatchVoiceMode all return Task<int> (returning non-zero codes for specific failures like seat limits or missing configuration), the services discard this value. Since RelayServiceBase only marks the state as Faulted if an exception is thrown, a non-zero return code is currently treated as a successful shutdown (exit code 0).

Ensure wrappers explicitly check the returned status and throw if it is non-zero.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay.Console/Program.cs` around lines 102 - 107, The relay
service wrappers are ignoring the non-zero status returned by the mode runners,
so failures from methods like RunAsync on AudioImportService,
RecordRelayService, RadioRelayService, and DispatchRelayService are being
treated as success. Update the service wrapper flow around
RelayServiceBase.StartAsync / the mode RunAsync calls to capture the returned
int, and if it is non-zero, throw an exception so the service is marked faulted
and the process exits with a failure code instead of returning 0.

}
finally
{
Expand Down Expand Up @@ -261,68 +256,6 @@ private static void ShowHelp()
}

#if NET10_0_WINDOWS
private static async Task<int> 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<int> SetupAsync()
{
Cli.WriteLine("Resgrid Relay Audio Setup");
Expand Down Expand Up @@ -432,15 +365,23 @@ private static string SafeName(HidDevice device)
catch { return "(unnamed)"; }
}

private static Task<int> RunRadioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken)
=> EngineVoice.RadioMode.RunAsync(hostOptions, CreateLogger(debug: false), cancellationToken);

private static async Task<int> 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<TuneSample>(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) =>
{
Expand All @@ -450,11 +391,12 @@ private static async Task<int> 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();
}
}

Expand Down Expand Up @@ -528,33 +470,13 @@ 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}]")}: ");
var input = Cli.ReadLine();
return String.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim();
}
#else
private static Task<int> 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<int> SetupAsync()
{
Cli.Error.WriteLine("Audio setup is only available in the net10.0-windows build.");
Expand All @@ -573,12 +495,6 @@ private static Task<int> MonitorAsync(string[] args)
return Task.FromResult(1);
}

private static Task<int> 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<int> TuneAsync(string[] args)
{
Cli.Error.WriteLine("Tune is only available in the net10.0-windows build.");
Expand All @@ -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));
}
}
}
84 changes: 84 additions & 0 deletions Resgrid.Relay.Engine/RelayStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;

namespace Resgrid.Relay.Engine
{
/// <summary>
/// Mutable <see cref="IRelayStatus"/> that a running relay service updates as it
/// connects and passes traffic. Connection/level/flag setters raise
/// <see cref="INotifyPropertyChanged"/>; the counters are incremented atomically.
/// A service sets only the connections it actually uses — the rest stay
/// <see cref="ConnectionState.NotApplicable"/> so the UI can grey them out.
/// </summary>
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<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return;
field = value;
OnPropertyChanged(propertyName);
}

private void OnPropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
Loading
Loading