From 3b92965cd013da6cbb0d8da639dd8dca2fbdd3d1 Mon Sep 17 00:00:00 2001 From: Brantlab Date: Tue, 19 May 2026 12:31:39 -0400 Subject: [PATCH 1/3] feat(daemon): add VOX control mode Implement audio-level based VOX radio control with RX/TX gate detection, startup/reconnect suppression, learned noise-floor calibration, and gated RX forwarding. Wire VOX mode into daemon startup, local audio callbacks, and example configuration. --- daemon/Configuration.cs | 4 + daemon/LocalAudio.cs | 37 ++- daemon/Program.cs | 54 +++- daemon/Radio.VOX.cs | 510 ++++++++++++++++++++++++++++++++++++++ daemon/config.example.yml | 25 +- 5 files changed, 622 insertions(+), 8 deletions(-) create mode 100644 daemon/Radio.VOX.cs diff --git a/daemon/Configuration.cs b/daemon/Configuration.cs index 9921e56..ca76d14 100644 --- a/daemon/Configuration.cs +++ b/daemon/Configuration.cs @@ -64,6 +64,10 @@ public class ControlConfig /// Config for motorola SB9600 /// public MotoSb9600Config Sb9600 = new MotoSb9600Config(); + /// + /// Config for VOX audio-level based control + /// + public VoxConfig Vox = new VoxConfig(); } /// diff --git a/daemon/LocalAudio.cs b/daemon/LocalAudio.cs index deba686..205d1a8 100644 --- a/daemon/LocalAudio.cs +++ b/daemon/LocalAudio.cs @@ -47,6 +47,10 @@ internal class LocalAudio // RX Audio Objects private SDL2AudioSource rxSource; private AudioEncoder rxEncoder; + private AudioEncoder rxMonitorEncoder; + private AudioFormat rxAudioFormat = AudioFormat.Empty; + private long lastRawRxSampleMs = long.MinValue; + private bool started = false; // TX Audio Objects private SDL2AudioEndPoint txEndpoint; @@ -60,6 +64,8 @@ internal class LocalAudio // RX audio callback action public Action RxEncodedSampleCallback; + public Action RxRawSampleCallback; + public Action TxPcmSampleCallback; public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool rxOnly = false) { @@ -76,6 +82,7 @@ public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool r // Setup RX audio devices rxEncoder = new AudioEncoder(); + rxMonitorEncoder = new AudioEncoder(); rxSource = new SDL2AudioSource(rxDevice, rxEncoder); rxSource.OnAudioSourceError += (e) => { Log.Logger.Error("Got RX audio error: {error}", e); @@ -83,7 +90,17 @@ public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool r // Setup RX sample callback rxSource.OnAudioSourceEncodedSample += (uint durationRtpUnits, byte[] samples) => { //Log.Logger.Verbose("Got {count} encoded RX samples", samples.Length); - RxEncodedSampleCallback(durationRtpUnits, samples); + RxEncodedSampleCallback?.Invoke(durationRtpUnits, samples); + if (RxRawSampleCallback != null && Environment.TickCount64 - lastRawRxSampleMs > 1000 && rxAudioFormat.ClockRate > 0) + { + short[] pcmSamples = rxMonitorEncoder.DecodeAudio(samples, rxAudioFormat); + uint durationMilliseconds = (uint)(pcmSamples.Length * 1000 / rxAudioFormat.ClockRate); + RxRawSampleCallback(AudioSamplingRatesEnum.Rate16KHz, durationMilliseconds, pcmSamples); + } + }; + rxSource.OnAudioSourceRawSample += (AudioSamplingRatesEnum samplingRate, uint durationMilliseconds, short[] samples) => { + lastRawRxSampleMs = Environment.TickCount64; + RxRawSampleCallback?.Invoke(samplingRate, durationMilliseconds, samples); }; Log.Logger.Information(" RX: {rxDevice}", rxDevice); // Setup TX audio devices if we aren't rx-only @@ -101,6 +118,13 @@ public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool r public void Start(AudioFormat audioFormat) { + if (started) + { + Log.Logger.Debug("Audio device(s) already started, keeping existing format {format}/{rate}/{chans}", rxAudioFormat.FormatName, rxAudioFormat.ClockRate, rxAudioFormat.ChannelCount); + return; + } + + rxAudioFormat = audioFormat; // Set audio formats rxSource.SetAudioSourceFormat(audioFormat); if (!rxOnly) @@ -114,14 +138,18 @@ public void Start(AudioFormat audioFormat) txEndpoint.StartAudioSink(); } Log.Logger.Debug("Audio device(s) started using format {format}/{rate}/{chans}", audioFormat.FormatName, audioFormat.ClockRate, audioFormat.ChannelCount); + started = true; } public async Task Stop() { - await rxSource.CloseAudio(); - if (!rxOnly) + if (started) { - await txEndpoint.CloseAudioSink(); + await rxSource.CloseAudio(); + if (!rxOnly) + { + await txEndpoint.CloseAudioSink(); + } } // De-init SDL2 SDL2Helper.QuitSDL(); @@ -132,6 +160,7 @@ public void TxAudioCallback(short[] pcm16Samples) { // Do nothing if we're RX only if (rxOnly) { return; } + TxPcmSampleCallback?.Invoke(pcm16Samples); // Convert the short[] samples into byte[] samples byte[] pcm16Bytes = new byte[pcm16Samples.Length * 2]; Buffer.BlockCopy(pcm16Samples, 0, pcm16Bytes, 0, pcm16Samples.Length * 2); diff --git a/daemon/Program.cs b/daemon/Program.cs index 497804a..e4d9748 100644 --- a/daemon/Program.cs +++ b/daemon/Program.cs @@ -202,6 +202,34 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no // Switch based on control mode switch(Config.Control.ControlMode) { + case RadioControlMode.VOX: + { + VoxRadio voxRadio = null; + Action rtcFormatCallback = (audioFormat) => + { + localAudio.Start(audioFormat); + voxRadio?.ReArmStartupDelay("WebRTC audio negotiation"); + }; + + voxRadio = new VoxRadio( + Config.Daemon.Name, + Config.Daemon.Desc, + Config.Control.RxOnly, + Config.Daemon.ListenAddress, + Config.Daemon.ListenPort, + Config.Control.Vox, + localAudio.TxAudioCallback, + 16000, + rtcFormatCallback, + Config.Softkeys, + Config.TextLookups.Zone, + Config.TextLookups.Channel + ); + radio = voxRadio; + localAudio.RxRawSampleCallback += voxRadio.HandleRxAudioSamples; + localAudio.TxPcmSampleCallback += voxRadio.HandleTxAudioSamples; + } + break; case RadioControlMode.SB9600: { radio = new MotoSb9600Radio( @@ -230,7 +258,15 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no } // Setup RX audio callback - localAudio.RxEncodedSampleCallback += radio.RxSendEncodedSamples; + if (Config.Control.ControlMode == RadioControlMode.VOX) + { + localAudio.RxEncodedSampleCallback += ((VoxRadio)radio).HandleRxEncodedSamples; + localAudio.Start(GetDefaultAudioFormat()); + } + else + { + localAudio.RxEncodedSampleCallback += radio.RxSendEncodedSamples; + } // Start radio radio.Start(noreset); @@ -332,5 +368,19 @@ static void GetAudioDeviceInfo(string devName) } SDL2Helper.QuitSDL(); } + + static AudioFormat GetDefaultAudioFormat() + { + AudioEncoder audioEncoder = new AudioEncoder(); + AudioFormat g722 = audioEncoder.SupportedFormats.Find(f => f.FormatName == "G722"); + + if (g722.ClockRate == 0) + { + var audioFormatManager = new MediaFormatManager(audioEncoder.SupportedFormats); + return audioFormatManager.SelectedFormat; + } + + return g722; + } } -} \ No newline at end of file +} diff --git a/daemon/Radio.VOX.cs b/daemon/Radio.VOX.cs new file mode 100644 index 0000000..d31ba2c --- /dev/null +++ b/daemon/Radio.VOX.cs @@ -0,0 +1,510 @@ +using rc2_core; +using Serilog; +using SIPSorceryMedia.Abstractions; +using System.Net; + +namespace daemon +{ + /// + /// Config object used to parse YML config for VOX control. + /// + public class VoxConfig + { + /// + /// Static zone name shown in the console for VOX radios. + /// + public string ZoneName = "VOX"; + /// + /// Static channel name shown in the console for VOX radios. + /// + public string ChannelName = "Audio"; + /// + /// RX audio level, in dBFS, required to mark the radio receiving. + /// + public double RxThresholdDb = -45.0; + /// + /// TX audio level, in dBFS, required to mark the radio transmitting. + /// + public double TxThresholdDb = -45.0; + /// + /// Audio must remain above threshold for this many milliseconds before the gate opens. + /// + public int AttackMs = 80; + /// + /// The gate remains open this many milliseconds after audio drops below threshold. + /// + public int HangMs = 800; + /// + /// Audio samples are ignored for this many milliseconds after startup to let devices settle. + /// + public int StartupDelayMs = 1000; + /// + /// VOX opens only when audio is this many dB above the learned noise floor. + /// + public double NoiseMarginDb = 8.0; + /// + /// After startup or reconnect, require one quiet sample before the gate can open. + /// + public bool RequireQuietAfterReset = true; + } + + /// + /// Radio implementation for audio-only installations where TX/RX state is inferred from audio levels. + /// + internal sealed class VoxRadio : rc2_core.Radio + { + private readonly object stateLock = new object(); + private readonly VoxConfig voxConfig; + private readonly VoxGate rxGate; + private readonly VoxGate txGate; + private readonly bool txDisabled; + private readonly System.Timers.Timer hangTimer; + private readonly int startupDelayMs; + private long ignoreAudioUntilMs; + private bool calibrationPending; + private bool started; + + public VoxRadio( + string name, string desc, bool rxOnly, + IPAddress listenAddress, int listenPort, + VoxConfig voxConfig, + Action txAudioCallback, int txAudioSampleRate, Action rtcFormatCallback, + List softkeys, + List zoneLookups = null, List chanLookups = null + ) : base(name, desc, rxOnly, listenAddress, listenPort, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate, rtcFormatCallback) + { + this.voxConfig = voxConfig ?? new VoxConfig(); + txDisabled = rxOnly; + RxOnly = rxOnly; + startupDelayMs = Math.Max(0, this.voxConfig.StartupDelayMs); + + rxGate = new VoxGate( + this.voxConfig.RxThresholdDb, + this.voxConfig.NoiseMarginDb, + this.voxConfig.RequireQuietAfterReset, + Math.Max(0, this.voxConfig.AttackMs), + Math.Max(1, this.voxConfig.HangMs)); + txGate = new VoxGate( + this.voxConfig.TxThresholdDb, + this.voxConfig.NoiseMarginDb, + this.voxConfig.RequireQuietAfterReset, + Math.Max(0, this.voxConfig.AttackMs), + Math.Max(1, this.voxConfig.HangMs)); + + Status.ZoneName = this.voxConfig.ZoneName; + Status.ChannelName = this.voxConfig.ChannelName; + + hangTimer = new System.Timers.Timer(Math.Clamp(this.voxConfig.HangMs / 4, 50, 250)); + hangTimer.AutoReset = true; + hangTimer.Elapsed += (sender, args) => ExpireGates(); + } + + public override void Start(bool reset = false) + { + Log.Information("Starting new VOX radio instance"); + base.Start(reset); + + lock (stateLock) + { + started = true; + rxGate.Reset(); + txGate.Reset(); + Status.ZoneName = voxConfig.ZoneName; + Status.ChannelName = voxConfig.ChannelName; + Status.State = RadioState.Idle; + ArmWarmup(Environment.TickCount64); + } + + if (startupDelayMs > 0) + { + Log.Debug("VOX startup delay active for {delay} ms", startupDelayMs); + } + + hangTimer.Start(); + RadioStatusCallback(); + } + + public void ReArmStartupDelay(string reason) + { + bool statusChanged = false; + + lock (stateLock) + { + ArmWarmup(Environment.TickCount64); + + if (started && Status.State != RadioState.Idle) + { + Status.State = RadioState.Idle; + statusChanged = true; + } + } + + if (startupDelayMs > 0) + { + Log.Debug("VOX startup delay re-armed for {delay} ms ({reason})", startupDelayMs, reason); + } + else + { + Log.Debug("VOX gates reset ({reason})", reason); + } + + if (statusChanged) + { + RadioStatusCallback(); + } + } + + public override void Stop() + { + hangTimer.Stop(); + + lock (stateLock) + { + started = false; + Status.State = RadioState.Disconnected; + } + + RadioStatusCallback(); + base.Stop(); + } + + public override bool SetTransmit(bool tx) + { + if (tx && txDisabled) + { + Log.Warning("Ignoring VOX transmit request because this radio is RX-only"); + return false; + } + + Log.Debug("Acknowledging VOX transmit {state}; radio state will follow TX audio level", tx ? "start" : "stop"); + return true; + } + + public override bool ChangeChannel(bool down) + { + Log.Debug("Ignoring channel change request because VOX control has no channel interface"); + return false; + } + + public override bool PressButton(SoftkeyName name) + { + Log.Debug("Ignoring button press {name} because VOX control has no button interface", name); + return false; + } + + public override bool ReleaseButton(SoftkeyName name) + { + Log.Debug("Ignoring button release {name} because VOX control has no button interface", name); + return false; + } + + public void HandleRxAudioSamples(AudioSamplingRatesEnum samplingRate, uint durationMilliseconds, short[] samples) + { + ProcessSamples(rxGate, samples, "RX"); + } + + public void HandleTxAudioSamples(short[] samples) + { + if (txDisabled) + { + return; + } + + ProcessSamples(txGate, samples, "TX"); + } + + public void HandleRxEncodedSamples(uint durationRtpUnits, byte[] encodedSamples) + { + bool shouldForward; + + lock (stateLock) + { + shouldForward = started && Status.State == RadioState.Receiving; + } + + if (!shouldForward) + { + return; + } + + RxSendEncodedSamples(durationRtpUnits, encodedSamples); + } + + private void ProcessSamples(VoxGate gate, short[] samples, string direction) + { + if (samples == null || samples.Length == 0) + { + return; + } + + long nowMs = Environment.TickCount64; + double levelDb = CalculateRmsDb(samples); + RadioState nextState = RadioState.Idle; + bool statusChanged = false; + + lock (stateLock) + { + if (!started) + { + return; + } + + if (nowMs < ignoreAudioUntilMs) + { + gate.Calibrate(levelDb); + if (Status.State != RadioState.Idle) + { + Status.State = RadioState.Idle; + statusChanged = true; + } + } + } + + if (nowMs < ignoreAudioUntilMs) + { + if (statusChanged) + { + RadioStatusCallback(); + } + + return; + } + + lock (stateLock) + { + if (!started) + { + return; + } + + CompleteWarmup(); + gate.Process(levelDb, nowMs); + nextState = GetVoxState(); + if (Status.State != nextState) + { + Status.State = nextState; + statusChanged = true; + } + } + + if (statusChanged) + { + Log.Debug("VOX {direction} level {level:0.0} dBFS changed radio state to {state}", direction, levelDb, nextState); + RadioStatusCallback(); + } + } + + private void ExpireGates() + { + RadioState nextState = RadioState.Idle; + bool statusChanged = false; + + lock (stateLock) + { + if (!started) + { + return; + } + + long nowMs = Environment.TickCount64; + + if (nowMs < ignoreAudioUntilMs) + { + if (Status.State != RadioState.Idle) + { + Status.State = RadioState.Idle; + statusChanged = true; + } + + nextState = RadioState.Idle; + } + else + { + CompleteWarmup(); + rxGate.Expire(nowMs); + txGate.Expire(nowMs); + + nextState = GetVoxState(); + if (Status.State != nextState) + { + Status.State = nextState; + statusChanged = true; + } + } + } + + if (statusChanged) + { + Log.Debug("VOX hang timer changed radio state to {state}", nextState); + RadioStatusCallback(); + } + } + + private RadioState GetVoxState() + { + if (!txDisabled && txGate.Active) + { + return RadioState.Transmitting; + } + + if (rxGate.Active) + { + return RadioState.Receiving; + } + + return RadioState.Idle; + } + + private void ArmWarmup(long nowMs) + { + rxGate.Reset(); + txGate.Reset(); + ignoreAudioUntilMs = nowMs + startupDelayMs; + calibrationPending = true; + } + + private void CompleteWarmup() + { + if (!calibrationPending) + { + return; + } + + calibrationPending = false; + rxGate.CompleteCalibration(); + txGate.CompleteCalibration(); + Log.Debug( + "VOX calibration complete: RX floor {rxFloor:0.0} dBFS, RX effective threshold {rxThreshold:0.0} dBFS; TX floor {txFloor:0.0} dBFS, TX effective threshold {txThreshold:0.0} dBFS", + rxGate.NoiseFloorDb, + rxGate.EffectiveThresholdDb, + txGate.NoiseFloorDb, + txGate.EffectiveThresholdDb); + } + + private static double CalculateRmsDb(short[] samples) + { + double sumSquares = 0.0; + + foreach (short sample in samples) + { + double normalized = sample / 32768.0; + sumSquares += normalized * normalized; + } + + double rms = Math.Sqrt(sumSquares / samples.Length); + if (rms <= 0.0) + { + return double.NegativeInfinity; + } + + return 20.0 * Math.Log10(rms); + } + + private sealed class VoxGate + { + private readonly double thresholdDb; + private readonly double noiseMarginDb; + private readonly bool requireQuietAfterReset; + private readonly int attackMs; + private readonly int hangMs; + private long? aboveThresholdSinceMs; + private long lastAboveThresholdMs = long.MinValue; + private double calibrationSumDb; + private int calibrationCount; + private bool waitingForQuiet; + + public bool Active { get; private set; } + public double NoiseFloorDb { get; private set; } = double.NegativeInfinity; + public double EffectiveThresholdDb + { + get + { + if (double.IsNegativeInfinity(NoiseFloorDb)) + { + return thresholdDb; + } + + return Math.Max(thresholdDb, NoiseFloorDb + noiseMarginDb); + } + } + + public VoxGate(double thresholdDb, double noiseMarginDb, bool requireQuietAfterReset, int attackMs, int hangMs) + { + this.thresholdDb = thresholdDb; + this.noiseMarginDb = noiseMarginDb; + this.requireQuietAfterReset = requireQuietAfterReset; + this.attackMs = attackMs; + this.hangMs = hangMs; + } + + public void Calibrate(double levelDb) + { + if (double.IsNegativeInfinity(levelDb) || double.IsNaN(levelDb)) + { + return; + } + + calibrationSumDb += levelDb; + calibrationCount++; + } + + public void CompleteCalibration() + { + if (calibrationCount > 0) + { + NoiseFloorDb = calibrationSumDb / calibrationCount; + } + } + + public void Process(double levelDb, long nowMs) + { + double effectiveThresholdDb = EffectiveThresholdDb; + + if (waitingForQuiet) + { + if (levelDb < effectiveThresholdDb) + { + waitingForQuiet = false; + } + + return; + } + + if (levelDb >= effectiveThresholdDb) + { + lastAboveThresholdMs = nowMs; + aboveThresholdSinceMs ??= nowMs; + + if (!Active && nowMs - aboveThresholdSinceMs.Value >= attackMs) + { + Active = true; + } + + return; + } + + aboveThresholdSinceMs = null; + Expire(nowMs); + } + + public void Expire(long nowMs) + { + if (Active && lastAboveThresholdMs != long.MinValue && nowMs - lastAboveThresholdMs >= hangMs) + { + Active = false; + aboveThresholdSinceMs = null; + } + } + + public void Reset() + { + Active = false; + aboveThresholdSinceMs = null; + lastAboveThresholdMs = long.MinValue; + calibrationSumDb = 0.0; + calibrationCount = 0; + waitingForQuiet = requireQuietAfterReset; + } + } + } +} diff --git a/daemon/config.example.yml b/daemon/config.example.yml index 91f005a..3ffd320 100644 --- a/daemon/config.example.yml +++ b/daemon/config.example.yml @@ -17,7 +17,7 @@ daemon: control: # Control Mode # - # 0 - VOX (control of TX/RX states is based on audio levels only) [Not Yet Implemented] + # 0 - VOX (control of TX/RX states is based on audio levels only) # 1 - TRC (Tone remote control based on EIA tone signalling) [Not Yet Implemented] # 2 - SB9600 (emulation of Motorola Astro W-series and MCS2000 model-3 control heads over SB9600) # 3 - XCMP Serial (control of Motorola XTL radios via serial) @@ -27,6 +27,27 @@ control: # RX Only (TX disabled) rxOnly: false + # VOX configuration + vox: + # Static zone/channel labels shown by the console + zoneName: "VOX" + channelName: "Audio" + # Audio level in dBFS required to mark RX/TX active. + # Less negative is less sensitive; more negative is more sensitive. + rxThresholdDb: -45 + txThresholdDb: -45 + # Audio must stay above threshold for attackMs before opening, + # and stays open for hangMs after dropping below threshold. + attackMs: 80 + hangMs: 800 + # Ignore audio briefly after startup so USB device-open noise does not trip VOX. + startupDelayMs: 1000 + # During startup/reconnect, learn the idle noise floor and require audio + # to exceed it by this margin before opening VOX. + noiseMarginDb: 8 + # After startup/reconnect, require quiet before the next VOX open. + requireQuietAfterReset: true + # SB9600 configuration sb9600: # Serial port name (COMx on Windows, /dev/ttyX on linux) @@ -103,4 +124,4 @@ softkeys: - TGRP - SEC - RCL - - SEL \ No newline at end of file + - SEL From 92e3810de7f69f4e043b2a8affec57a5a729ce46 Mon Sep 17 00:00:00 2001 From: Brantlab Date: Tue, 19 May 2026 13:25:14 -0400 Subject: [PATCH 2/3] Fix VOX TX gating for alerts Route WebRTC TX audio through the VOX radio before sending it to the local TX device. Track explicit VOX transmit requests so alert tones can key and play correctly while preventing audio from other selected radios from opening the VOX radio. --- daemon/LocalAudio.cs | 2 -- daemon/Program.cs | 6 ++++- daemon/Radio.VOX.cs | 59 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/daemon/LocalAudio.cs b/daemon/LocalAudio.cs index 205d1a8..cafe013 100644 --- a/daemon/LocalAudio.cs +++ b/daemon/LocalAudio.cs @@ -65,7 +65,6 @@ internal class LocalAudio // RX audio callback action public Action RxEncodedSampleCallback; public Action RxRawSampleCallback; - public Action TxPcmSampleCallback; public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool rxOnly = false) { @@ -160,7 +159,6 @@ public void TxAudioCallback(short[] pcm16Samples) { // Do nothing if we're RX only if (rxOnly) { return; } - TxPcmSampleCallback?.Invoke(pcm16Samples); // Convert the short[] samples into byte[] samples byte[] pcm16Bytes = new byte[pcm16Samples.Length * 2]; Buffer.BlockCopy(pcm16Samples, 0, pcm16Bytes, 0, pcm16Samples.Length * 2); diff --git a/daemon/Program.cs b/daemon/Program.cs index e4d9748..b8b413b 100644 --- a/daemon/Program.cs +++ b/daemon/Program.cs @@ -205,6 +205,10 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no case RadioControlMode.VOX: { VoxRadio voxRadio = null; + Action txAudioCallback = (samples) => + { + voxRadio?.HandleTxAudioSamples(samples); + }; Action rtcFormatCallback = (audioFormat) => { localAudio.Start(audioFormat); @@ -218,6 +222,7 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no Config.Daemon.ListenAddress, Config.Daemon.ListenPort, Config.Control.Vox, + txAudioCallback, localAudio.TxAudioCallback, 16000, rtcFormatCallback, @@ -227,7 +232,6 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no ); radio = voxRadio; localAudio.RxRawSampleCallback += voxRadio.HandleRxAudioSamples; - localAudio.TxPcmSampleCallback += voxRadio.HandleTxAudioSamples; } break; case RadioControlMode.SB9600: diff --git a/daemon/Radio.VOX.cs b/daemon/Radio.VOX.cs index d31ba2c..8568c43 100644 --- a/daemon/Radio.VOX.cs +++ b/daemon/Radio.VOX.cs @@ -60,15 +60,17 @@ internal sealed class VoxRadio : rc2_core.Radio private readonly bool txDisabled; private readonly System.Timers.Timer hangTimer; private readonly int startupDelayMs; + private readonly Action txAudioOutputCallback; private long ignoreAudioUntilMs; private bool calibrationPending; + private bool txRequested; private bool started; public VoxRadio( string name, string desc, bool rxOnly, IPAddress listenAddress, int listenPort, VoxConfig voxConfig, - Action txAudioCallback, int txAudioSampleRate, Action rtcFormatCallback, + Action txAudioCallback, Action txAudioOutputCallback, int txAudioSampleRate, Action rtcFormatCallback, List softkeys, List zoneLookups = null, List chanLookups = null ) : base(name, desc, rxOnly, listenAddress, listenPort, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate, rtcFormatCallback) @@ -77,6 +79,7 @@ public VoxRadio( txDisabled = rxOnly; RxOnly = rxOnly; startupDelayMs = Math.Max(0, this.voxConfig.StartupDelayMs); + this.txAudioOutputCallback = txAudioOutputCallback; rxGate = new VoxGate( this.voxConfig.RxThresholdDb, @@ -107,6 +110,7 @@ public override void Start(bool reset = false) lock (stateLock) { started = true; + txRequested = false; rxGate.Reset(); txGate.Reset(); Status.ZoneName = voxConfig.ZoneName; @@ -161,6 +165,7 @@ public override void Stop() lock (stateLock) { started = false; + txRequested = false; Status.State = RadioState.Disconnected; } @@ -176,6 +181,31 @@ public override bool SetTransmit(bool tx) return false; } + RadioState nextState = RadioState.Idle; + bool statusChanged = false; + + lock (stateLock) + { + txRequested = tx; + + if (!tx) + { + txGate.Reset(); + } + + nextState = GetVoxState(); + if (Status.State != nextState) + { + Status.State = nextState; + statusChanged = true; + } + } + + if (statusChanged) + { + RadioStatusCallback(); + } + Log.Debug("Acknowledging VOX transmit {state}; radio state will follow TX audio level", tx ? "start" : "stop"); return true; } @@ -205,12 +235,20 @@ public void HandleRxAudioSamples(AudioSamplingRatesEnum samplingRate, uint durat public void HandleTxAudioSamples(short[] samples) { - if (txDisabled) + bool shouldForward; + + lock (stateLock) + { + shouldForward = started && txRequested && !txDisabled; + } + + if (!shouldForward) { return; } ProcessSamples(txGate, samples, "TX"); + txAudioOutputCallback?.Invoke(samples); } public void HandleRxEncodedSamples(uint durationRtpUnits, byte[] encodedSamples) @@ -252,9 +290,10 @@ private void ProcessSamples(VoxGate gate, short[] samples, string direction) if (nowMs < ignoreAudioUntilMs) { gate.Calibrate(levelDb); - if (Status.State != RadioState.Idle) + nextState = GetVoxState(); + if (Status.State != nextState) { - Status.State = RadioState.Idle; + Status.State = nextState; statusChanged = true; } } @@ -310,13 +349,12 @@ private void ExpireGates() if (nowMs < ignoreAudioUntilMs) { - if (Status.State != RadioState.Idle) + nextState = GetVoxState(); + if (Status.State != nextState) { - Status.State = RadioState.Idle; + Status.State = nextState; statusChanged = true; } - - nextState = RadioState.Idle; } else { @@ -342,6 +380,11 @@ private void ExpireGates() private RadioState GetVoxState() { + if (!txDisabled && txRequested) + { + return RadioState.Transmitting; + } + if (!txDisabled && txGate.Active) { return RadioState.Transmitting; From a96637dfcd57465540283a3cf7750a9fe18be522 Mon Sep 17 00:00:00 2001 From: Brantlab Date: Sun, 24 May 2026 12:05:07 -0400 Subject: [PATCH 3/3] docs(daemon): comment VOX audio gate flow --- daemon/LocalAudio.cs | 8 ++++++++ daemon/Program.cs | 9 +++++++++ daemon/Radio.VOX.cs | 29 +++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/daemon/LocalAudio.cs b/daemon/LocalAudio.cs index cafe013..25a4f45 100644 --- a/daemon/LocalAudio.cs +++ b/daemon/LocalAudio.cs @@ -64,6 +64,8 @@ internal class LocalAudio // RX audio callback action public Action RxEncodedSampleCallback; + // Optional raw PCM monitor used by VOX mode to detect RX activity without + // changing the encoded audio path used by normal radios. public Action RxRawSampleCallback; public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool rxOnly = false) @@ -92,6 +94,9 @@ public LocalAudio(string rxDevice, string txDevice, rc2_core.Radio radio, bool r RxEncodedSampleCallback?.Invoke(durationRtpUnits, samples); if (RxRawSampleCallback != null && Environment.TickCount64 - lastRawRxSampleMs > 1000 && rxAudioFormat.ClockRate > 0) { + // Some SDL backends only provide encoded callbacks. Decode a monitor + // copy so VOX still receives audio levels, but rate-limit it behind + // direct raw callbacks to avoid duplicate gate updates. short[] pcmSamples = rxMonitorEncoder.DecodeAudio(samples, rxAudioFormat); uint durationMilliseconds = (uint)(pcmSamples.Length * 1000 / rxAudioFormat.ClockRate); RxRawSampleCallback(AudioSamplingRatesEnum.Rate16KHz, durationMilliseconds, pcmSamples); @@ -119,6 +124,9 @@ public void Start(AudioFormat audioFormat) { if (started) { + // VOX can request Start from both initial setup and later WebRTC format + // negotiation. Keep the first active device session instead of reopening + // SDL devices mid-call. Log.Logger.Debug("Audio device(s) already started, keeping existing format {format}/{rate}/{chans}", rxAudioFormat.FormatName, rxAudioFormat.ClockRate, rxAudioFormat.ChannelCount); return; } diff --git a/daemon/Program.cs b/daemon/Program.cs index b8b413b..0a28c59 100644 --- a/daemon/Program.cs +++ b/daemon/Program.cs @@ -205,12 +205,16 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no case RadioControlMode.VOX: { VoxRadio voxRadio = null; + // WebRTC TX audio enters through the base RC2 radio callback. Capture + // the VoxRadio after construction so the callback can feed the TX gate. Action txAudioCallback = (samples) => { voxRadio?.HandleTxAudioSamples(samples); }; Action rtcFormatCallback = (audioFormat) => { + // Start local SDL audio using the negotiated WebRTC format, then + // restart VOX warmup so device-open noise is ignored. localAudio.Start(audioFormat); voxRadio?.ReArmStartupDelay("WebRTC audio negotiation"); }; @@ -221,6 +225,7 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no Config.Control.RxOnly, Config.Daemon.ListenAddress, Config.Daemon.ListenPort, + Config.Daemon.AllowedNetworks, Config.Control.Vox, txAudioCallback, localAudio.TxAudioCallback, @@ -231,6 +236,8 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no Config.TextLookups.Channel ); radio = voxRadio; + // Raw RX samples drive VOX state; encoded RX samples are forwarded + // separately only while VoxRadio reports Receiving. localAudio.RxRawSampleCallback += voxRadio.HandleRxAudioSamples; } break; @@ -264,6 +271,8 @@ static async Task Startup(FileInfo configFile, bool debug, bool verbose, bool no // Setup RX audio callback if (Config.Control.ControlMode == RadioControlMode.VOX) { + // VOX needs a default format before a peer negotiates so local RX can + // start and begin detecting audio. Re-negotiation may update this later. localAudio.RxEncodedSampleCallback += ((VoxRadio)radio).HandleRxEncodedSamples; localAudio.Start(GetDefaultAudioFormat()); } diff --git a/daemon/Radio.VOX.cs b/daemon/Radio.VOX.cs index 8568c43..0b22576 100644 --- a/daemon/Radio.VOX.cs +++ b/daemon/Radio.VOX.cs @@ -50,6 +50,9 @@ public class VoxConfig /// /// Radio implementation for audio-only installations where TX/RX state is inferred from audio levels. + /// VOX mode uses the normal RC2 WebRTC server for console connections, but it replaces + /// hardware control-line state with two audio gates: one for local RX audio and one for + /// console TX audio. /// internal sealed class VoxRadio : rc2_core.Radio { @@ -61,6 +64,8 @@ internal sealed class VoxRadio : rc2_core.Radio private readonly System.Timers.Timer hangTimer; private readonly int startupDelayMs; private readonly Action txAudioOutputCallback; + // Startup/reconnect noise can briefly spike when SDL or WebRTC audio devices open. + // During this window the gates learn the idle floor instead of changing radio state. private long ignoreAudioUntilMs; private bool calibrationPending; private bool txRequested; @@ -68,12 +73,12 @@ internal sealed class VoxRadio : rc2_core.Radio public VoxRadio( string name, string desc, bool rxOnly, - IPAddress listenAddress, int listenPort, + IPAddress listenAddress, int listenPort, List allowedNetworks, VoxConfig voxConfig, Action txAudioCallback, Action txAudioOutputCallback, int txAudioSampleRate, Action rtcFormatCallback, List softkeys, List zoneLookups = null, List chanLookups = null - ) : base(name, desc, rxOnly, listenAddress, listenPort, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate, rtcFormatCallback) + ) : base(name, desc, rxOnly, listenAddress, listenPort, allowedNetworks, softkeys, zoneLookups, chanLookups, txAudioCallback, txAudioSampleRate, rtcFormatCallback) { this.voxConfig = voxConfig ?? new VoxConfig(); txDisabled = rxOnly; @@ -97,6 +102,8 @@ public VoxRadio( Status.ZoneName = this.voxConfig.ZoneName; Status.ChannelName = this.voxConfig.ChannelName; + // The timer enforces hang time even when no more audio buffers arrive after + // the signal falls below threshold. hangTimer = new System.Timers.Timer(Math.Clamp(this.voxConfig.HangMs / 4, 50, 250)); hangTimer.AutoReset = true; hangTimer.Elapsed += (sender, args) => ExpireGates(); @@ -130,6 +137,8 @@ public override void Start(bool reset = false) public void ReArmStartupDelay(string reason) { + // WebRTC format negotiation can restart local audio devices. Re-arm warmup so + // device-open noise does not immediately trip RX or TX. bool statusChanged = false; lock (stateLock) @@ -186,6 +195,8 @@ public override bool SetTransmit(bool tx) lock (stateLock) { + // PTT is acknowledged immediately, but actual TX audio is still gated so + // silence from the console does not hold the radio in Transmitting forever. txRequested = tx; if (!tx) @@ -230,11 +241,14 @@ public override bool ReleaseButton(SoftkeyName name) public void HandleRxAudioSamples(AudioSamplingRatesEnum samplingRate, uint durationMilliseconds, short[] samples) { + // Local microphone/radio RX samples drive the receive gate. ProcessSamples(rxGate, samples, "RX"); } public void HandleTxAudioSamples(short[] samples) { + // Console TX samples drive the transmit gate and are then forwarded to the + // local audio output only while a TX request is active. bool shouldForward; lock (stateLock) @@ -253,6 +267,8 @@ public void HandleTxAudioSamples(short[] samples) public void HandleRxEncodedSamples(uint durationRtpUnits, byte[] encodedSamples) { + // Encoded RX audio is sent to WebRTC peers only while the RX gate says the + // radio is actually receiving. bool shouldForward; lock (stateLock) @@ -280,6 +296,8 @@ private void ProcessSamples(VoxGate gate, short[] samples, string direction) RadioState nextState = RadioState.Idle; bool statusChanged = false; + // During warmup the sample level is used only for noise-floor calibration. + // Once warmup completes, the same gate applies threshold, attack, and hang. lock (stateLock) { if (!started) @@ -380,6 +398,8 @@ private void ExpireGates() private RadioState GetVoxState() { + // Prefer TX over RX so a console transmit request wins if both gates are + // active at the same time. if (!txDisabled && txRequested) { return RadioState.Transmitting; @@ -426,6 +446,7 @@ private void CompleteWarmup() private static double CalculateRmsDb(short[] samples) { + // Convert PCM16 audio to dBFS so thresholds are independent of buffer length. double sumSquares = 0.0; foreach (short sample in samples) @@ -445,6 +466,8 @@ private static double CalculateRmsDb(short[] samples) private sealed class VoxGate { + // VoxGate tracks one direction of audio. It combines a static dBFS threshold + // with an optional learned noise floor, attack time, and hang time. private readonly double thresholdDb; private readonly double noiseMarginDb; private readonly bool requireQuietAfterReset; @@ -503,6 +526,8 @@ public void Process(double levelDb, long nowMs) { double effectiveThresholdDb = EffectiveThresholdDb; + // After a reset, require a below-threshold sample before allowing the gate + // to open. This prevents already-hot audio from immediately retriggering. if (waitingForQuiet) { if (levelDb < effectiveThresholdDb)