diff --git a/CMakeLists.txt b/CMakeLists.txt
index d0d23cbdd..274c6438e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -655,6 +655,7 @@ set(CORE_SOURCES
src/core/AetherDspModePolicy.cpp
src/core/AudioEngine.cpp
src/core/TxMicChannelNormalizer.cpp
+ src/core/TxVoiceProcessor.cpp
src/core/ChannelStripPresets.cpp
src/core/Biquad.cpp
src/core/StereoBiquad.cpp
@@ -3655,6 +3656,13 @@ target_include_directories(tx_mic_channel_normalizer_test PRIVATE
target_link_libraries(tx_mic_channel_normalizer_test PRIVATE Qt6::Core)
add_test(NAME tx_mic_channel_normalizer_test COMMAND tx_mic_channel_normalizer_test)
+add_executable(tx_voice_processor_test
+ tests/tx_voice_processor_test.cpp
+)
+target_include_directories(tx_voice_processor_test PRIVATE src)
+target_link_libraries(tx_voice_processor_test PRIVATE aethercore Qt6::Core)
+add_test(NAME tx_voice_processor_test COMMAND tx_voice_processor_test)
+
# Pins the SkyRoof-parity WFM DSP chain: NCO offset correction removes the
# discriminator DC term (fixed pan + Doppler-stepped slice), twin linear-phase
# resamplers deliver exactly 48 kHz from any native DAX IQ rate, and streaming
@@ -5665,6 +5673,7 @@ add_executable(device_diagnostics_test
)
target_include_directories(device_diagnostics_test PRIVATE src)
target_link_libraries(device_diagnostics_test PRIVATE Qt6::Core)
+add_test(NAME device_diagnostics_test COMMAND device_diagnostics_test)
add_executable(midi_settings_test
tests/midi_settings_test.cpp
diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md
index b0fce5d9f..ead6d2f1b 100644
--- a/docs/architecture/audio-pipeline.md
+++ b/docs/architecture/audio-pipeline.md
@@ -20,9 +20,14 @@ all run through the same DSP chain:
monitor tones use local output sinks that are independent of the RX speaker
buffer. They are logically tone generators, represented as stereo output
frames.
-- **PC mic voice TX path**: `QAudioSource` captures Int16 mic audio, converts it
- to the internal 24 kHz stereo frame shape, then runs the voice TX strip before
- Opus remote audio TX or an uncompressed VITA fallback.
+- **PC mic voice TX path**: `QAudioSource` captures Int16 mic audio at the
+ negotiated device rate. After channel canonicalization, `TxVoiceProcessor`
+ converts to float once, normalizes to a fixed 48 kHz DSP domain, runs optional
+ TX RN2 and the complete voice strip, then performs one 48-to-24 kHz conversion
+ and one TPDF-dithered Int16 quantization at the existing 24 kHz voice-output
+ boundary. That boundary is final for Flex `remote_audio_tx`; HL2 and Icom
+ currently consume the same 24 kHz Int16 seam and convert onward inside their
+ backends.
- **Opus `remote_audio_tx` path**: the normal PC mic voice path sends 24 kHz
stereo Int16 frames as 10 ms Opus packets over VITA-49 PCC `0x8005`.
- **RADE TX/RX path**: RADE branches early from PC mic capture and bypasses the
@@ -38,11 +43,13 @@ The internal voice and modem paths are mostly **logically mono** but are often
represented as **stereo frames** because the radio/audio interfaces expect
interleaved L/R samples. Important cases:
-- PC mic voice TX is carried as stereo Int16 frames after capture normalization.
- Stereo capture is first collapsed to a canonical mono voice signal using Auto
- Left/Right/Average selection, then duplicated to L/R. If resampling is needed,
- that canonical mono signal is sent through `Resampler::processMonoToStereo()`.
-- Opus `remote_audio_tx` packets are always 24 kHz stereo Int16 frames.
+- PC mic voice TX is canonicalized as duplicated-stereo Int16 at the negotiated
+ device rate. Stereo capture is first collapsed to a canonical mono voice
+ signal using Auto Left/Right/Average selection. `TxVoiceProcessor` converts
+ that signal to float, resamples mono to 48 kHz when needed, and duplicates it
+ to L/R for continuous float32 processing.
+- Opus `remote_audio_tx` encoder input is always 24 kHz stereo Int16 in 10 ms
+ frames.
- RADE modem/speech processing is logically mono, but AudioEngine handoff and
VITA packetization use 24 kHz stereo float32 frames.
- DAX radio-native TX packets are mono Int16, but the DAX bridge and TCI hand
@@ -287,28 +294,27 @@ radio audio streams.
```mermaid
flowchart TD
A["QAudioSource mic capture
Int16, preferred stereo"] --> B["AudioEngine::onTxAudioReady()"]
- B --> C["Canonicalize mic channels
Auto Left/Right/Average/Mono
duplicate mono to L/R"]
- C --> D{"Needs resample?"}
- D -->|yes| E["Use canonical mono
processMonoToStereo()
24 kHz stereo Int16"]
- D -->|no| H{"RADE mode?"}
- E --> H
- H -->|yes| I["PC mic gain -> canonical meter
Int16 stereo -> float32 stereo
txRawPcmReady()"]
- I --> J["RADEEngine"]
- H -->|no| K{"DAX TX mode?"}
- K -->|yes| L["return
DAX/TCI feed feedDaxTxAudio()"]
- K -->|no| M["ClientTxTestTone
optional replacement tone"]
- M --> N["applyClientTxDspInt16()
Gate -> EQ -> DeEss -> Comp -> Tube -> PUDU -> Reverb"]
- N --> O["Post-DSP monitor tap"]
- O --> P["PC mic gain
0..100 -> 0.0..1.0 attenuation"]
- P --> Q["Quindar tone insertion"]
- Q --> R["ClientFinalLimiter"]
- R --> S["Final monitor tap"]
- S --> T["txPostChainScopeReady
average L/R
voice post-limiter"]
- T --> U["PC mic meter
canonical stereo frame level"]
- U --> V["scopeSamplesReady
average L/R"]
- V --> W{"Opus enabled?"}
- W -->|yes| X["10 ms Opus remote_audio_tx
PCC 0x8005"]
- W -->|no| Y["Uncompressed VITA fallback
PCC 0x03E3"]
+ B --> C["Canonicalize mic channels at device rate
Auto Left/Right/Average/Mono
duplicate mono to L/R Int16"]
+ C --> D{"RADE mode?"}
+ D -->|yes| E["Legacy RADE device-rate -> 24 kHz SRC if needed
PC mic gain, meter, Int16 -> float32"]
+ E --> F["RADEEngine
separate fixed-rate modem path"]
+ D -->|no| G{"DAX TX mode?"}
+ G -->|yes| H["return
DAX/TCI feed feedDaxTxAudio()"]
+ G -->|no| I["TxVoiceProcessor
Int16 -> mono float32 once"]
+ I --> J["Input SRC when needed
device rate -> fixed 48 kHz DSP rate
duplicate mono to L/R"]
+ J --> K["Optional TX RN2 at native 48 kHz"]
+ K --> L["Optional 48 kHz test tone"]
+ L --> M["User-ordered 48 kHz float strip
Gate / EQ / DeEss / Comp / Tube / PUDU / Reverb"]
+ M --> N["PC mic gain -> Quindar -> final limiter
48 kHz float32 stereo"]
+ N --> O["Independent L/R egress SRC
48 kHz -> 24 kHz float32"]
+ O --> P["Linked TPDF dither + round-to-nearest
quantize once to 24 kHz stereo Int16"]
+ P --> Q["Final monitor, scopes, PC mic meter"]
+ Q --> R{"Backend consumes TX audio seam?"}
+ R -->|yes| U["IRadioBackend::submitTxAudio()
24 kHz stereo Int16"]
+ U --> V["HL2 / Icom backend
convert for backend-native processing"]
+ R -->|no| S{"Opus enabled?"}
+ S -->|yes| T["10 ms Opus remote_audio_tx
PCC 0x8005"]
+ S -->|no| W["Uncompressed VITA fallback
PCC 0x03E3"]
```
### QAudioSource format negotiation
@@ -319,7 +325,9 @@ flowchart TD
- macOS prefers sample rates in this order for general devices: 48 kHz,
44.1 kHz, then 24 kHz. For Bluetooth headset inputs that CoreAudio reports
as native 8, 16, or 24 kHz-only, AetherSDR opens the mic at that native rate
- first and then uses its normal radio-native conversion when needed.
+ first. Normal voice then normalizes the captured signal to its fixed 48 kHz
+ DSP domain; the separate RADE route instead converts it to RADE's 24 kHz
+ handoff rate. DAX/TCI TX does not consume the PC-mic capture buffer.
- Linux and other non-Windows platforms prefer 24 kHz, then 48 kHz, then
44.1 kHz.
- Stereo is tried before mono for each sample rate.
@@ -330,13 +338,15 @@ flowchart TD
- Linux and Windows use pull mode: the device's `readyRead` signal calls
`onTxAudioReady()` directly.
-When the capture sample rate is not 24 kHz, `m_txResampler` converts to the
-internal 24 kHz voice rate.
+The negotiated rate is not the voice DSP rate. For the normal voice path,
+`TxVoiceProcessor` normalizes it to a fixed 48 kHz float domain. The older
+`m_txResampler` remains only for the separate RADE branch, which still expects
+24 kHz stereo at its `RADEEngine` handoff.
### Capture normalization and resampling behavior
-`onTxAudioReady()` normalizes the mic data to interleaved 24 kHz stereo Int16
-before the voice TX chain or early RADE/DAX branches:
+`onTxAudioReady()` first canonicalizes mic channels as interleaved stereo Int16
+at the negotiated device rate. Rate conversion then depends on the route:
- The actual negotiated channel count is stored as `m_txInputChannels`.
- Mono input is duplicated to stereo with no level change.
@@ -345,15 +355,62 @@ before the voice TX chain or early RADE/DAX branches:
channel when the weaker side is at least 12 dB down above a -65 dBFS floor,
otherwise averages balanced L/R. A short hold keeps the previous one-sided
selection through quiet pauses.
-- The canonical mono sample is duplicated back to L/R for the rest of the voice
- path.
-- Input that needs resampling is converted from the canonical duplicated stereo
- to float32 mono and resampled with `Resampler::processMonoToStereo()`.
- `processStereoToStereo()` is not used on raw mic stereo.
+- The canonical mono sample is duplicated back to L/R without changing the
+ capture rate.
+- The normal voice route passes that canonical Int16 frame to
+ `TxVoiceProcessor::processCapturedInt16()`. It converts the duplicated signal
+ to mono float32 once, uses the stateful r8brain `Resampler::process()` path to
+ reach 48 kHz when needed, and then duplicates the 48 kHz mono result to L/R.
+ After consuming the capture samples, it replaces that caller-owned block
+ in-place with the final 24 kHz stereo Int16 transport output. Queued monitor
+ consumers therefore retain an immutable completed block rather than sharing
+ a reusable processor-owned output buffer.
+- `prepare(..., maxInputFrames)` sizes the normal realtime working set; it is
+ not an input ceiling. A capture callback larger than that reservation is
+ still processed, with scratch buffers allowed to grow for the exceptional
+ block, rather than dropping the delayed microphone audio.
+- Native 48 kHz input skips the ingress SRC but still enters the same float32
+ processing domain.
+- The voice SRCs use a 12% transition-band profile selected to retain the
+ supported modulation bandwidth through 10 kHz while avoiding the much larger
+ group delay of the general-purpose 2% profile. Their deterministic serial SRC
+ delay, expressed in the 48 kHz DSP domain, is 394 frames (about 8.2 ms) for
+ 48 kHz capture, 615 frames (about 12.8 ms) for 44.1 kHz capture, and 788
+ frames (about 16.4 ms) for 24 kHz capture. Supported low-rate capture has a
+ longer interpolation filter: 1,240 frames (about 25.8 ms) at 16 kHz and
+ 2,104 frames (about 43.8 ms) at 8 kHz. These figures include the serial
+ ingress and egress SRCs but not optional DSP latency. Enabled gate lookahead
+ and the nominal 480-frame RNNoise contribution are added by
+ `TxVoiceProcessor::latencyFrames()`, which reports the configured-path delay
+ in 48 kHz frames. The SRC and gate terms are deterministic. RNNoise's current
+ callback-sized streaming adapter can emit additional startup silence for
+ irregular callback partitions while waiting for complete 480-frame output,
+ so the RNNoise term is nominal rather than a universal wall-clock guarantee.
+- RADE retains its separate conversion to 24 kHz before its early branch. DAX
+ TX does not consume this mic buffer; DAX/TCI audio arrives separately through
+ `feedDaxTxAudio()`.
+
+### TX rate diagnostics
+
+Support snapshots retain `resampling_active` as the route-level statement that
+the selected TX path currently performs any SRC. The explicit fields identify
+which conversion is responsible:
+
+- `voice_input_normalizing_to_48k`: the normal voice path converts the
+ negotiated capture rate to the fixed 48 kHz DSP rate.
+- `voice_egress_resampling_to_24k`: the normal voice path performs its required
+ 48-to-24 kHz output conversion. This is true even for native 48 kHz capture.
+- `rade_resampling_to_24k`: the separate RADE path converts the capture rate to
+ its fixed 24 kHz input rate.
+
+Route priority mirrors `onTxAudioReady()`: RADE reports only its own SRC, DAX
+mic bypass reports no PC-mic SRC, and normal voice reports its unconditional
+egress SRC plus ingress normalization when needed. This prevents the historical
+generic field from changing meaning when the capture rate changes.
### Voice TX ordering after capture/resampling
-After capture normalization, `onTxAudioReady()` uses this ordering:
+After capture channel canonicalization, `onTxAudioReady()` uses this ordering:
1. **RADE early path**: if RADE mode is active, PC mic gain is applied, the
client mic meter is computed from the canonical stereo frame level, Int16
@@ -362,32 +419,67 @@ After capture normalization, `onTxAudioReady()` uses this ordering:
2. **DAX TX bypass**: if DAX TX mode is active, the PC mic voice handler returns.
DAX/TCI audio enters through `feedDaxTxAudio()` and intentionally bypasses the
voice DSP chain.
-3. **Test tone**: `ClientTxTestTone` can replace the mic data with a generated
- 24 kHz stereo Int16 tone before user voice DSP.
-4. **Voice DSP strip**: `applyClientTxDspInt16()` runs the ordered chain. The
- default order is Gate, EQ, DeEss, Comp, Tube, PUDU, Reverb, though the order
- is user-configurable.
-5. **Post-DSP monitor tap**: `m_txPostDspMonitor->feedTxPostDsp()` receives the
- post-strip Int16 stereo signal before PC mic gain.
-6. **PC mic gain**: `setPcMicGain(0..100)` maps to `0.0..1.0` and attenuates all
- samples. It is not a boost stage.
-7. **Quindar tone**: `ClientQuindarTone::process()` inserts Quindar tones after
- PC mic gain and before the final limiter.
-8. **Final limiter**: `ClientFinalLimiter` runs after all voice-strip work and
- Quindar insertion.
-9. **Final monitor tap**: `m_txFinalMonitor->feedTxFinal()` receives the
- post-limiter signal.
-10. **TX post-chain scope**: `txPostChainScopeReady` receives a mono scope signal
- made by averaging L/R from the post-limiter Int16 stereo signal.
-11. **PC mic meter**: `pcMicLevelChanged` uses the post-limiter canonical stereo
- frame level, so right-only input devices meter correctly.
-12. **Main scope**: `scopeSamplesReady(..., true)` receives a mono scope signal
+3. **Float conversion and ingress SRC**: `TxVoiceProcessor` converts canonical
+ Int16 to mono float32 once. If the device is not already at 48 kHz, a
+ stateful r8brain SRC converts it to the fixed DSP rate; the result is then
+ duplicated to float32 stereo.
+4. **Optional TX RN2**: the mic-preamp RNNoise instance runs directly in the
+ native 48 kHz domain. `TxVoiceProcessor` supplies a reusable caller-owned
+ output buffer to `RNNoiseFilter::process48kStereo(input, output)`; RX RN2
+ retains its existing legacy rate wrapper.
+5. **Test tone**: `ClientTxTestTone` can replace the mic signal with a generated
+ 48 kHz float32 stereo tone before the user voice strip.
+6. **Voice DSP strip**: `TxVoiceProcessor::processChannelStrip()` runs float32
+ processors directly in the user-selected order. The default order is Gate,
+ EQ, DeEss, Comp, Tube, PUDU, Reverb.
+7. **PC mic gain**: `setPcMicGain(0..100)` maps to `0.0..1.0` and attenuates the
+ 48 kHz float samples. It is not a boost stage.
+8. **Quindar tone**: `ClientQuindarTone::process()` inserts Quindar tones at
+ 48 kHz after PC mic gain and before the final limiter.
+9. **Final limiter**: `ClientFinalLimiter::process()` runs at 48 kHz after all
+ voice-strip work and Quindar insertion. Non-finite samples are replaced with
+ silence before they can enter the stateful egress SRC.
+10. **Egress SRC**: independent left and right `Resampler` instances convert
+ 48 kHz float32 to 24 kHz float32, preserving the stereo representation. The
+ matched instances must return equal frame counts. If they ever diverge, the
+ common aligned prefix is retained, the mismatch is logged, and both
+ resamplers reset before the next block so the channels cannot remain offset.
+11. **Transport quantization**: one unshaped TPDF value with a 2-LSB
+ peak-to-peak range is added per frame, identically to L/R so the duplicated
+ mono voice representation remains exact. Samples are then rounded to the
+ nearest Int16 code and saturated at the Int16 rails. The allocation-free
+ deterministic PRNG has persistent streaming state and is returned to its
+ fixed seed by `TxVoiceProcessor::reset()` for reproducible offline tests.
+ When both float samples in a frame are exactly zero, the PRNG still advances
+ but the frame remains the exact Int16 digital-silence code. Nonzero samples,
+ including sub-LSB values, retain normal TPDF treatment.
+12. **Measurement seams**: capture is disabled by default and production
+ `AudioEngine` does not toggle it per block. Offline/tests enable it
+ explicitly when they need the named intermediate buffers.
+ `normalizedFloat48Stereo()` exposes the normalized 48 kHz input and
+ `postChannelStripFloat48Stereo()` exposes the 48 kHz post-strip/pre-gain
+ signal. `transportFloat32Stereo()` exposes the final 24 kHz float
+ representation; the caller-owned in/out block carries the corresponding
+ dithered Int16 transport representation.
+13. **Monitor taps**: both legacy monitor pointers currently receive the stable
+ post-limiter, post-SRC 24 kHz Int16 representation. The named 48 kHz
+ measurement seam above is the accurate post-strip tap.
+14. **TX post-chain scope**: `txPostChainScopeReady` receives a mono scope signal
+ made by averaging L/R from the final 24 kHz Int16 stereo signal.
+15. **PC mic meter**: `pcMicLevelChanged` uses the final transport-rate canonical
+ stereo frame level, so right-only input devices meter correctly.
+16. **Main scope**: `scopeSamplesReady(..., true)` receives a mono scope signal
made by averaging L/R.
-13. **Packetization**:
- - If Opus TX is enabled, the path encodes 10 ms Opus packets for
+17. **Packetization**:
+ - Backends with `takesTxAudioOverSeam` receive the completed 24 kHz stereo
+ Int16 block through `IRadioBackend::submitTxAudio()`. HL2 converts it to
+ its 48 kHz host-modulator domain, while Icom converts to its configured
+ 48 kHz radio-audio stream. These conversions mean the voice processor's
+ quantization is final only for the Flex path today.
+ - For Flex, if Opus TX is enabled, the path encodes 10 ms Opus packets for
`remote_audio_tx`.
- - Otherwise, the fallback path packetizes 128 stereo frames as float32 VITA
- PCC `0x03E3`.
+ - Otherwise, the Flex fallback path packetizes 128 stereo frames as
+ float32 VITA PCC `0x03E3`.
### PC mic gain and final limiter
@@ -402,9 +494,30 @@ The final limiter defaults are:
- output trim: `0.0 dB`
- DC block: true
-The limiter is channel-linked. It optionally DC-blocks each channel, applies
-output trim as a pre-limiter drive stage, and then limits peaks against the
-ceiling with attack/release smoothing.
+The limiter is channel-linked and prepared at 48 kHz. It optionally DC-blocks
+each channel, applies output trim as a pre-limiter drive stage, and then limits
+peaks against the ceiling with attack/release smoothing.
+
+The limiter precedes the 48-to-24 kHz egress SRC. Although it bounds samples in
+the 48 kHz domain, the SRC's band-limited reconstruction can overshoot that
+sample ceiling. The final Int16 conversion saturates any resulting over-range
+samples, so the limiter ceiling is not currently a guaranteed post-SRC or true-
+peak ceiling. Changing that policy requires measured headroom or a separately
+specified post-SRC safeguard; it must not silently add lookahead latency.
+
+### Passband authority, SRC filtering, and oversampling
+
+The client does not add a selectable TX passband filter in this path. The radio
+remains authoritative for the configured transmit mode and passband. The
+low-pass filtering inherent in the 48-to-24 kHz r8brain conversion is the SRC's
+anti-alias filter; it must not be treated as the artistic or operator-selected
+TX passband filter.
+
+All voice stages currently run at the fixed 48 kHz DSP rate. This refactor does
+not add local oversampling around tube saturation, PUDU excitation, compressor
+drive/limiting, or the final limiter. Any future oversampling belongs around the
+specific harmonic-generating stage and must return to the 48 kHz domain before
+the single egress SRC.
## Opus TX/RX
@@ -415,6 +528,9 @@ The normal remote voice path uses `OpusCodec`:
- PCM format: interleaved Int16
- Frame duration: 10 ms
- Frame size: 240 sample frames, or 480 Int16 samples
+- Encoder bitrate: 70 kbps by default
+- Encoder complexity: 10
+- Encoder signal hint: `OPUS_SIGNAL_VOICE`
- Encoded packet cadence: one Opus frame per VITA packet
For TX, `AudioEngine::onTxAudioReady()` accumulates exactly 10 ms of 24 kHz
@@ -422,10 +538,10 @@ stereo Int16 audio before calling `OpusCodec::encode()`. The encoded Opus frame
is wrapped in a VITA-49 packet using PCC `0x8005` for `remote_audio_tx` and the
current remote TX stream id.
-Encoded packets are not written immediately. They are appended to
-`m_opusTxQueue`, capped to roughly 20 packets, and a 10 ms pacing timer drains
-one packet per tick. If the queue grows beyond the cap, the oldest packet is
-dropped.
+Encoded packets are not written immediately. `OpusTxPacer` holds at most 20
+packets and follows 10 ms elapsed-time deadlines. A late timer event can drain a
+bounded catch-up batch; an empty queue re-anchors the schedule, and an overflow
+drops the oldest queued packet.
For RX Opus audio, `PanadapterStream::decodeOpusAudio()` decodes PCC `0x8005`
payloads to 24 kHz stereo Int16 and converts them to float32 stereo before
@@ -480,6 +596,13 @@ When `m_radeMode` is active, `AudioEngine::onTxAudioReady()` branches before the
normal Opus voice TX path. RADE receives float32 PCM and bypasses the Opus
`remote_audio_tx` encoder entirely.
+On the transition into RADE, `AudioEngine::setRadeMode(true)` resets the
+RADE-only device-to-24 kHz resampler on the audio thread before publishing the
+new mode. The reset therefore discards history from a previous RADE session
+without running resampler reset/prewarm work inside the realtime capture
+callback. This changes neither RADE's rate domain nor its steady-state signal
+latency.
+
`RADEEngine::feedTxAudio()` expects 24 kHz stereo float32 PCM. It averages L/R
and downsamples to 16 kHz mono for LPCNet feature extraction, encodes the RADE
modem data, converts the 8 kHz modem waveform back to 24 kHz stereo float32, and
@@ -639,8 +762,8 @@ equivalent taps.
Local/client taps:
-- `pcMicLevelChanged` on the PC mic voice path is a post-final-limiter Int16
- meter over the canonical stereo frame level.
+- `pcMicLevelChanged` on the PC mic voice path is measured from the final
+ post-limiter, post-egress-SRC, quantized 24 kHz Int16 representation.
- `pcMicLevelChanged` on the RADE early branch is after PC mic gain but before
Int16-to-float conversion and uses the canonical stereo frame level.
- `pcMicLevelChanged` on the DAX/TCI path is computed in `feedDaxTxAudio()` from
@@ -651,10 +774,11 @@ Local/client taps:
output trim or optional 48 kHz resampling.
- `scopeSamplesReady` is a shared local scope signal. Stereo sources are
converted to mono by averaging L/R.
-- `txPostChainScopeReady` is taken after the PC mic voice final limiter and
- converts stereo to mono by averaging L/R. DAX/TCI and RADE also emit this
- high-rate TX scope from their pre-packetization bypass audio so the WAVE
- display continues to show digital-mode transmit waveforms.
+- `txPostChainScopeReady` on the PC mic voice path is taken from the final
+ post-limiter, post-egress-SRC 24 kHz Int16 representation and converts stereo
+ to mono by averaging L/R. DAX/TCI and RADE also emit this high-rate TX scope
+ from their pre-packetization bypass audio so the WAVE display continues to
+ show digital-mode transmit waveforms.
- `rxPostChainScopeReady` is taken after the RX client strip, optional RX
upsampling, RX boost, and RX output trim on the non-BNR path. For BNR it is
taken after BNR output resampling and trim. In both cases it is before speaker
@@ -685,12 +809,17 @@ Radio-provided taps:
| Speaker write | RX drain timer in `AudioEngine::startRxStream()` | float32 stereo buffers | `QAudioSink` writes | 24 or 48 kHz | 2 | Caps buffers and mixes RADE decoded speech |
| CW sidetone | `CwSidetoneGenerator` | key state | float32 stereo | normally 48 kHz | 2 | Local-only sidetone sink |
| Quindar local monitor | `QuindarLocalSink` | tone state | float32 stereo | 48 kHz | 2 | Local-only Quindar monitor sink |
-| PC mic capture | `AudioEngine::startTxStream()` | device Int16 | Int16 from `QAudioSource` | 24, 44.1, or 48 kHz | 1 or 2 | macOS push-buffer polling; Linux/Windows pull mode |
-| PC mic normalization | `AudioEngine::onTxAudioReady()` | Int16 mono/stereo | Int16 stereo | 24 kHz | 1 or 2 -> 1 -> 2 | Auto selects stronger one-sided stereo channel or averages balanced stereo before resampling/duplication |
-| PC mic voice strip | `AudioEngine::applyClientTxDspInt16()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | Ordered Gate/EQ/DeEss/Comp/Tube/PUDU/Reverb |
-| PC mic gain | `AudioEngine::onTxAudioReady()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | 0..100 maps to 0.0..1.0 attenuation |
-| Quindar TX insertion | `ClientQuindarTone::process()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | Inserts tones before final limiter |
-| Final voice limiter | `ClientFinalLimiter::processInt16Stereo()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | DC block, output trim, linked peak limiting |
+| PC mic capture | `AudioEngine::startTxStream()` | device Int16 | Int16 from `QAudioSource` | negotiated device rate | 1 or 2 | macOS push-buffer polling; Linux/Windows pull mode |
+| PC mic channel canonicalization | `TxMicChannelNormalizer::canonicalizeInt16ToMonoStereo()` | Int16 mono/stereo | duplicated-stereo Int16 | negotiated device rate | 1 or 2 -> 1 -> 2 | Auto selects stronger one-sided stereo channel or averages balanced stereo; no SRC here |
+| Voice float conversion / ingress SRC | `TxVoiceProcessor::processCapturedInt16()` | duplicated-stereo Int16 | float32 stereo | device rate -> 48 kHz when needed | 2 -> 1 -> 2 | Converts to float once; stateful mono r8brain SRC; native 48 kHz skips SRC |
+| TX RN2 | `RNNoiseFilter::process48kStereo(input, output)` | float32 stereo | float32 stereo | 48 kHz | 2 -> 1 -> 2 | Optional mic denoiser in `Native48k` rate domain; reuses caller-owned output storage |
+| PC mic voice strip | `TxVoiceProcessor::processChannelStrip()` | float32 stereo | float32 stereo | 48 kHz | 2 | Ordered Gate/EQ/DeEss/Comp/Tube/PUDU/Reverb |
+| PC mic gain | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz | 2 | 0..100 maps to 0.0..1.0 attenuation |
+| Quindar TX insertion | `ClientQuindarTone::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | Inserts tones before final limiter |
+| Final voice limiter | `ClientFinalLimiter::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | DC block, output trim, linked peak limiting |
+| Voice egress SRC | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz -> 24 kHz | 2 | Independent matched L/R r8brain instances preserve stereo; 12% transition profile; state persists across blocks; group delay is included by `latencyFrames()`. An impossible frame-count mismatch preserves the aligned common prefix and queues both SRC histories for reset on the audio event loop between callbacks. |
+| Voice transport quantization | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | Int16 stereo | 24 kHz | 2 | Linked unshaped TPDF (2 LSB peak-to-peak), round-to-nearest, and Int16 saturation; exact-zero frames remain digital zero while the PRNG advances |
+| Backend TX audio seam | `RadioModel::submitTxAudio()` / `IRadioBackend::submitTxAudio()` | Int16 stereo | backend-dependent | 24 kHz at seam | 2 | Used by host-modulating backends; HL2 and Icom currently convert the 24 kHz seam output to 48 kHz backend processing |
| Opus TX packetization | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x8005` Opus | 24 kHz | 2 | 10 ms packets, paced queue |
| Uncompressed voice fallback | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x03E3` float32 stereo | 24 kHz | 2 | 128 stereo frames per packet |
| RADE TX branch | `AudioEngine::onTxAudioReady()` | Int16 stereo | float32 stereo | 24 kHz | 2 | Applies PC mic gain, canonical meter, emits `txRawPcmReady()` |
@@ -723,9 +852,12 @@ warning before it is discarded and regenerated.
| `AudioEngine::resampleStereo()` | Resample without downmix | float32 stereo | float32 stereo | Uses independent L/R resamplers; preserves pan |
| `AudioEngine::processNr2()` | Downmix and duplicate | float32 stereo | float32 stereo | Averages L/R, NR2 mono processing, duplicates, reapplies pan |
| `AudioEngine::processBnr()` | Downmix, 24->48, 48->24, duplicate | float32 stereo | float32 stereo | BNR path is mono internally |
-| `AudioEngine::onTxAudioReady()`, mono input | Duplicate canonical mono | Int16 mono | Int16 stereo | Direct Int16 mono duplication before optional resample |
-| `AudioEngine::onTxAudioReady()`, stereo input | Canonicalize and duplicate | Int16 stereo | Int16 stereo | Auto Left/Right/Average avoids one-sided stereo 6.02 dB loss |
-| `AudioEngine::onTxAudioReady()`, resample | Resample canonical mono and duplicate | Int16 stereo | Int16 stereo | Canonical duplicated stereo -> float32 mono -> `processMonoToStereo()` -> Int16 |
+| `TxMicChannelNormalizer`, mono input | Duplicate canonical mono | Int16 mono | Int16 stereo | Direct Int16 mono duplication at the negotiated device rate |
+| `TxMicChannelNormalizer`, stereo input | Canonicalize and duplicate | Int16 stereo | Int16 stereo | Auto Left/Right/Average avoids one-sided stereo 6.02 dB loss; retains device rate |
+| `TxVoiceProcessor`, ingress | Format conversion, resample, duplicate | canonical Int16 stereo at device rate | float32 stereo 48 kHz | Takes one canonical channel, converts to float once, uses mono `Resampler::process()` when needed, then duplicates |
+| `TxVoiceProcessor`, egress | Preserve stereo and downsample | float32 stereo 48 kHz | float32 stereo 24 kHz | Separate L/R `Resampler` instances; no downmix |
+| `TxVoiceProcessor`, transport boundary | Dither and quantize | float32 stereo 24 kHz | Int16 stereo 24 kHz | Single linked-channel TPDF-dithered conversion using round-to-nearest and Int16 saturation; exact-zero frames preserve digital silence |
+| `IRadioBackend::submitTxAudio()` | Backend TX handoff | Int16 stereo 24 kHz | backend-dependent | Flex does not use this seam for normal voice; HL2 and Icom currently convert the shared 24 kHz output for 48 kHz backend processing |
| `AudioEngine::onTxAudioReady()`, RADE branch | Format conversion | Int16 stereo | float32 stereo | After PC mic gain and canonical meter |
| `AudioEngine::onTxAudioReady()`, Opus TX | Encoding | Int16 stereo | Opus payload | 10 ms / 240 frame packets |
| `AudioEngine::onTxAudioReady()`, VITA fallback | Format conversion | Int16 stereo | float32 stereo VITA | 128 stereo frames per packet |
@@ -747,18 +879,18 @@ warning before it is discarded and regenerated.
| Signal/name | File/function | Pre/post stage | Channel policy | Units |
| --- | --- | --- | --- | --- |
-| `AudioEngine::pcMicLevelChanged`, PC voice | `AudioEngine::onTxAudioReady()` | Post final limiter, before Opus/fallback packetization | Canonical stereo frame level | dBFS peak and RMS |
+| `AudioEngine::pcMicLevelChanged`, PC voice | `AudioEngine::onTxAudioReady()` | Post final limiter, 48-to-24 kHz SRC, and Int16 quantization; before Opus/fallback packetization | Canonical stereo frame level | dBFS peak and RMS |
| `AudioEngine::pcMicLevelChanged`, RADE | `AudioEngine::onTxAudioReady()` RADE branch | After PC mic gain, before Int16->float32 and RADE engine | Canonical stereo frame level | dBFS peak and RMS |
| `AudioEngine::pcMicLevelChanged`, DAX/TCI | `AudioEngine::feedDaxTxAudio()` | Before DAX route packetization | All float samples | dBFS peak and RMS |
| `AudioEngine::levelChanged` | `AudioEngine::feedAudioData()` | After selected RX NR, before RX client strip/boost/trim/resample | RMS over all float samples in the buffer | Linear RMS |
| `AudioEngine::scopeSamplesReady`, TX voice | `AudioEngine::emitScopeFromInt16Stereo()` | Post PC mic meter, before packetization | Average L/R | float PCM scope samples, sample rate |
| `AudioEngine::scopeSamplesReady`, TX DAX | `AudioEngine::emitScopeFromFloat32Stereo()` | In `feedDaxTxAudio()` before route packetization | Average L/R | float PCM scope samples, sample rate |
| `AudioEngine::scopeSamplesReady`, RX | `AudioEngine::emitScopeFromFloat32Stereo()` | In `writeAudio()` or BNR output path near RX buffering | Average L/R | float PCM scope samples, sample rate |
-| `AudioEngine::txPostChainScopeReady`, TX voice | `AudioEngine::emitTxPostChainScopeFromInt16Stereo()` | Post final limiter and final monitor tap | Average L/R | float PCM scope samples, sample rate |
+| `AudioEngine::txPostChainScopeReady`, TX voice | `AudioEngine::emitTxPostChainScopeFromInt16Stereo()` | Final 24 kHz Int16 representation, post limiter/SRC/quantization and final monitor tap | Average L/R | float PCM scope samples, sample rate |
| `AudioEngine::txPostChainScopeReady`, TX DAX/TCI/RADE | `AudioEngine::emitTxPostChainScopeFromFloat32Stereo()` | Pre-packetization digital bypass audio | Average L/R | float PCM scope samples, sample rate |
| `AudioEngine::rxPostChainScopeReady` | `AudioEngine::emitRxPostChainScopeFromFloat32Stereo()` | Non-BNR: after RX strip, upsample, boost, and trim; BNR: after BNR resample/trim; before buffer append | Average L/R | float PCM scope samples, sample rate |
| RX EQ analyzer | `AudioEngine::tapClientEqRxStereo()` | After RX EQ, before RX Gate/Comp/DeEss/Tube/PUDU | Average L/R | float mono analyzer samples |
-| TX EQ analyzer | `AudioEngine::tapClientEqTxInt16()` / `tapClientEqTxFloat32()` | After TX EQ or EQ bypass inside TX strip | Average L/R for stereo | float mono analyzer samples |
+| TX EQ analyzer | `AudioEngine::tapClientEqTxFloat32()` | After TX EQ or EQ bypass inside the 48 kHz TX strip | Average L/R for stereo | float mono analyzer samples |
| macOS DAX RX level | `VirtualAudioBridge::feedDaxAudio()` | After DAX RX gain, before shared-memory write | Left channel only | Linear RMS |
| macOS DAX TX level | `VirtualAudioBridge::readTxAudio()` | After DAX TX gain, before `txAudioReady()` | Left channel only | Linear RMS |
| PipeWire DAX RX level | `PipeWireAudioBridge::feedDaxAudio()` | After downmix/upconvert for PipeWire source | All output mono samples | Linear RMS |
@@ -775,6 +907,13 @@ warning before it is discarded and regenerated.
preserving stereo image or radio pan matters.
- PC mic capture is canonicalized before resampling and metering, so one-sided
stereo microphones keep full level and right-only microphones meter correctly.
+- Keep the normal voice strip in the fixed 48 kHz float domain. The 24 kHz rate
+ is the existing voice-output boundary, not the voice DSP engine rate. For
+ Flex `remote_audio_tx`, apply dither and quantize only once after the final
+ 48-to-24 kHz SRC. The shared HL2/Icom seam currently consumes that same
+ Int16 block and performs additional backend-specific conversion; removing
+ that legacy round trip requires a separately reviewed backend-capability and
+ transport-contract change.
- DAX/TCI and RADE intentionally bypass the client voice strip. Do not move them
through voice EQ/compression/limiting unless the digital-mode behavior is
deliberately being redesigned.
diff --git a/docs/architecture/pipelines.md b/docs/architecture/pipelines.md
index 32b19ea09..3c04af239 100644
--- a/docs/architecture/pipelines.md
+++ b/docs/architecture/pipelines.md
@@ -89,17 +89,18 @@ TX AUDIO ROUTING SUMMARY: ◄── AUDIO THREAD
│ └─→ radio-native VITA PCC 0x0123
│
▼
- test tone → client TX DSP chain → post-DSP monitor
- → PC mic gain → Quindar → final limiter → meters/scopes
+ device-rate Int16 → canonical mono → 48 kHz float normalization
+ → RN2 → test tone → client TX DSP chain → PC mic gain
+ → Quindar → final limiter → 48-to-24 kHz SRC
+ → linked TPDF + Int16 quantization → monitors/meters/scopes
│
- ├─→ Opus remote_audio_tx VITA PCC 0x8005
- └─→ uncompressed VITA fallback PCC 0x03E3
- │
- ▼ [queued to NETWORK]
- PanadapterStream.sendToRadio()
- │
- ▼
- Radio UDP 4991
+ ├─→ backend TX seam (HL2/Icom, 24 kHz Int16)
+ │ └─→ backend-native conversion/modulation
+ │
+ └─→ Flex Opus PCC 0x8005 / uncompressed PCC 0x03E3
+ └─→ [queued to NETWORK]
+ PanadapterStream.sendToRadio()
+ └─→ Radio UDP 4991
The PSK Reporter WSPR beacon is a one-shot, operator-armed digital source. A
precise timer on the AudioEngine worker thread generates sample-accurate 4-FSK
diff --git a/docs/architecture/tx-audio-signal-path.md b/docs/architecture/tx-audio-signal-path.md
index 2ed9e5776..431e744d9 100644
--- a/docs/architecture/tx-audio-signal-path.md
+++ b/docs/architecture/tx-audio-signal-path.md
@@ -18,6 +18,14 @@ PC mic voice TX stream before VITA/Opus packetization. The radio receives
the already-shaped voice signal and treats it identically to any other
PC-mic input (enters at SC_MIC, meter 26).
+Mic hardware remains Int16 at its negotiated device rate. The normal voice
+path canonicalizes the selected channel, converts to float once, normalizes to
+a fixed 48 kHz processing domain, and remains float through RN2 and the complete
+channel strip. A 12%-transition-band SRC then converts 48 kHz to the existing
+24 kHz Flex transport rate before linked TPDF dither and the sole Int16
+quantization on the Flex path. See `audio-pipeline.md` for measured SRC delay
+and the separate HL2/Icom backend seam behavior.
+
DAX/TCI TX and RADE are intentionally not part of this voice strip.
DAX/TCI bypasses client voice DSP in `AudioEngine::feedDaxTxAudio()`.
RADE branches early from `AudioEngine::onTxAudioReady()` and bypasses
@@ -29,10 +37,16 @@ to bypass, double-click to open the floating editor.
```
PC mic capture (QAudioSource)
+ │
+ ▼ Int16 at negotiated device rate
+Canonical channel selection → float conversion → 48 kHz normalization
+ │
+ ▼
+Optional native-48 kHz RN2 → 48 kHz test tone
│
▼
┌───────────────────────────────────────────────────────────────────┐
-│ CHAIN widget — drag-drop ordered TX DSP pipeline │
+│ CHAIN widget — drag-drop ordered 48 kHz float TX DSP pipeline │
│ │
│ [GATE] → [EQ] → [DESS] → [COMP] → [TUBE] → [PUDU] → [VERB] │
│ │
@@ -45,7 +59,7 @@ PC mic capture (QAudioSource)
│ ClientReverb — Freeverb (disabled by default) │
│ │
│ Audio thread loads the packed chain order once per block and │
-│ dispatches each stage to its per-stage apply helper. │
+│ TxVoiceProcessor dispatches each enabled processor directly. │
└─────────┬──────────────────────────────────────────────────────────┘
│
▼ (meters: per-stage inputPeak/outputPeak/GR, ClientEq FFT
@@ -55,6 +69,9 @@ PC mic capture (QAudioSource)
PC mic gain → Quindar → final limiter → meters/scopes
│
▼
+ 48-to-24 kHz SRC → linked TPDF dither → Int16 quantization
+ │
+ ▼
Opus remote_audio_tx / VITA encode → UDP → radio
```
diff --git a/docs/audio-sink-factory.md b/docs/audio-sink-factory.md
index d3c81b7da..6ecf54799 100644
--- a/docs/audio-sink-factory.md
+++ b/docs/audio-sink-factory.md
@@ -9,8 +9,9 @@
Every audio sink and source in AetherSDR currently answers two questions on its
own:
-1. **"What rate / sample format does this device want, and how do I get my
- 24 kHz canonical audio onto it?"** — reimplemented ~9 times with ~6 divergent
+1. **"What rate / sample format does this device want, and how do I bridge it
+ to the selected route's canonical device-boundary rate?"** — reimplemented
+ ~9 times with ~6 divergent
fallback ladders and per-OS `#ifdef` branches that have drifted apart. This
is the root of a recurring class of platform bugs (44.1k-only devices
silently failing on some sinks, WASAPI Float32-only devices rejecting Int16,
@@ -84,7 +85,7 @@ The ladder, in one place, owns:
| Int16 ↔ Float32 | both tried per rate; Float-first output, Int16-first input | [#2669] / [#1090] |
| `preferredFormat()` catch-all | final rung for Float32-only virtual drivers / WASAPI | [#3231] |
| macOS mic preferred-rate-first | avoids silent 48 k open on 16 k-native mics | PR [#2930] |
-| macOS Bluetooth-HFP mic | native 8/16/24 k first, never forced to 48 k | [#2615] |
+| macOS Bluetooth-HFP mic | open native 8/16/24 k first; normal voice then normalizes to 48 k, while RADE converts separately to 24 k | [#2615] |
| Windows probe-at-open | skip unreliable `isFormatSupported`, try at open | [#2120] / [#2929] |
### Layer 2 — `AudioDeviceNegotiator` (live wrapper, Qt Multimedia) — next
diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp
index ea9023405..bb40fda07 100644
--- a/src/core/AudioEngine.cpp
+++ b/src/core/AudioEngine.cpp
@@ -43,6 +43,7 @@
#include "MacNRFilter.h"
#endif
#include "Resampler.h"
+#include "TxVoiceProcessor.h"
#ifdef Q_OS_MAC
#include
@@ -76,6 +77,23 @@
namespace AetherSDR {
+static_assert(static_cast(AudioEngine::TxChainStage::None)
+ == static_cast(TxVoiceProcessor::Stage::None));
+static_assert(static_cast(AudioEngine::TxChainStage::Eq)
+ == static_cast(TxVoiceProcessor::Stage::Eq));
+static_assert(static_cast(AudioEngine::TxChainStage::Comp)
+ == static_cast(TxVoiceProcessor::Stage::Comp));
+static_assert(static_cast(AudioEngine::TxChainStage::Gate)
+ == static_cast(TxVoiceProcessor::Stage::Gate));
+static_assert(static_cast(AudioEngine::TxChainStage::DeEss)
+ == static_cast(TxVoiceProcessor::Stage::DeEss));
+static_assert(static_cast(AudioEngine::TxChainStage::Tube)
+ == static_cast(TxVoiceProcessor::Stage::Tube));
+static_assert(static_cast(AudioEngine::TxChainStage::Enh)
+ == static_cast(TxVoiceProcessor::Stage::Enh));
+static_assert(static_cast(AudioEngine::TxChainStage::Reverb)
+ == static_cast(TxVoiceProcessor::Stage::Reverb));
+
static QString wisdomDir();
static void logNr2WisdomSummary(const QString& context);
static void logNr2WisdomGenerationSummary(SpectralNR::WisdomResult result);
@@ -1722,6 +1740,7 @@ AudioEngine::AudioEngine(QObject* parent)
, m_clientTxTestTone(std::make_unique())
, m_wsprBeacon(std::make_unique())
, m_clientQuindarTone(std::make_unique())
+ , m_txVoiceProcessor(std::make_unique())
{
// Recorder-sidetone generator: always enabled at a fixed, audible level and
// centre pan so a Client-Side QSO recording captures the operator's sent
@@ -1778,25 +1797,34 @@ AudioEngine::AudioEngine(QObject* parent)
emit txDecodeAudioReady(buf);
});
- // Prepare client DSP at the native 24 kHz rate. Sink resampling is
- // handled separately after EQ — EQ always runs at radio-native rate.
+ // RX remains radio-native at 24 kHz. TX voice is prepared below through
+ // TxVoiceProcessor in its fixed 48 kHz float processing domain.
m_clientEqRx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientEqTx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientCompTx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientGateTx->prepare(DEFAULT_SAMPLE_RATE);
m_clientGateRx->prepare(DEFAULT_SAMPLE_RATE);
m_clientCompRx->prepare(DEFAULT_SAMPLE_RATE);
m_clientTubeRx->prepare(DEFAULT_SAMPLE_RATE);
m_clientPuduRx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientDeEssTx->prepare(DEFAULT_SAMPLE_RATE);
m_clientDeEssRx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientTubeTx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientPuduTx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientReverbTx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientFinalLimiterTx->prepare(DEFAULT_SAMPLE_RATE);
- m_clientTxTestTone->prepare(DEFAULT_SAMPLE_RATE);
m_wsprBeacon->prepare(DEFAULT_SAMPLE_RATE);
- m_clientQuindarTone->prepare(DEFAULT_SAMPLE_RATE);
+
+ TxVoiceProcessor::Processors txProcessors;
+ txProcessors.eq = m_clientEqTx.get();
+ txProcessors.comp = m_clientCompTx.get();
+ txProcessors.gate = m_clientGateTx.get();
+ txProcessors.deEss = m_clientDeEssTx.get();
+ txProcessors.tube = m_clientTubeTx.get();
+ txProcessors.pudu = m_clientPuduTx.get();
+ txProcessors.reverb = m_clientReverbTx.get();
+ txProcessors.finalLimiter = m_clientFinalLimiterTx.get();
+ txProcessors.testTone = m_clientTxTestTone.get();
+ txProcessors.quindar = m_clientQuindarTone.get();
+ txProcessors.eqTapContext = this;
+ txProcessors.eqTap = [](void* context, const float* stereo, int frames) {
+ auto* engine = static_cast(context);
+ engine->tapClientEqTxFloat32(stereo, frames * 2, 2);
+ };
+ m_txVoiceProcessor->setProcessors(txProcessors);
+ m_txVoiceProcessor->prepare(DEFAULT_SAMPLE_RATE, 16384);
m_wsprPumpTimer = new QTimer(this);
m_wsprPumpTimer->setTimerType(Qt::PreciseTimer);
m_wsprPumpTimer->setInterval(5);
@@ -2619,6 +2647,11 @@ QAudioFormat AudioEngine::makeFormat() const
return fmt;
}
+bool AudioEngine::txInputNormalizationTo48k() const
+{
+ return m_txInputRate != TxVoiceProcessor::kDspRate;
+}
+
QJsonArray AudioEngine::audioEndpointDiagnostics() const
{
QThread* const ownerThread = thread();
@@ -2721,7 +2754,24 @@ QJsonArray AudioEngine::audioEndpointDiagnostics() const
tx["sample_rate_hz"] = txRunning ? QJsonValue(m_txInputRate) : QJsonValue();
tx["channel_count"] = txRunning ? QJsonValue(m_txInputChannels) : QJsonValue();
tx["sample_format"] = txRunning ? QStringLiteral("Int16") : QString();
- tx["resampling_active"] = txRunning ? QJsonValue(m_txNeedsResample) : QJsonValue();
+ const DeviceDiagnostics::TxAudioResamplingRoute txResampling =
+ DeviceDiagnostics::txAudioResamplingRoute(
+ m_radeMode.load(std::memory_order_acquire),
+ m_daxTxMode.load(std::memory_order_acquire),
+ m_txInputRate != TxVoiceProcessor::kDspRate,
+ m_radeTxNeedsResample);
+ tx["resampling_active"] = txRunning
+ ? QJsonValue(txResampling.active)
+ : QJsonValue();
+ tx["voice_input_normalizing_to_48k"] = txRunning
+ ? QJsonValue(txResampling.voiceInputNormalizingTo48k)
+ : QJsonValue();
+ tx["voice_egress_resampling_to_24k"] = txRunning
+ ? QJsonValue(txResampling.voiceEgressResamplingTo24k)
+ : QJsonValue();
+ tx["rade_resampling_to_24k"] = txRunning
+ ? QJsonValue(txResampling.radeResamplingTo24k)
+ : QJsonValue();
tx["note"] = m_txInputMono ? QStringLiteral("mono input promoted to stereo for radio TX") : QString();
const TxCaptureHealthTracker::Snapshot txHealth =
m_txCaptureHealth.snapshot(txCaptureNowMs());
@@ -4708,21 +4758,6 @@ void AudioEngine::tapClientEqRxStereo(const float* stereoInterleaved, int frames
m_clientEqTapRxWrite = w;
}
-void AudioEngine::tapClientEqTxInt16(const int16_t* int16stereo, int frames)
-{
- if (frames <= 0) return;
- std::unique_lock lk(m_clientEqTapMutex, std::try_to_lock);
- if (!lk.owns_lock()) return;
- int w = m_clientEqTapTxWrite;
- for (int i = 0; i < frames; ++i) {
- const float l = int16stereo[i * 2] / 32768.0f;
- const float r = int16stereo[i * 2 + 1] / 32768.0f;
- m_clientEqTapTx[w] = 0.5f * (l + r);
- w = (w + 1) & (kClientEqTapSize - 1);
- }
- m_clientEqTapTxWrite = w;
-}
-
void AudioEngine::tapClientEqTxFloat32(const float* f32, int samples, int channels)
{
if (samples <= 0 || channels < 1 || channels > 2) return;
@@ -4768,105 +4803,6 @@ bool AudioEngine::copyRecentClientEqTxSamples(float* out, int count) const
return true;
}
-void AudioEngine::applyClientEqTxInt16(QByteArray& int16stereo)
-{
- if (int16stereo.isEmpty()) return;
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return; // must be stereo
- const int frames = samples / 2;
-
- // EQ processing only when enabled. The tap below runs regardless
- // so the editor's TX FFT analyzer always reflects live mic input,
- // even when the EQ stage is bypassed in the CHAIN widget.
- if (m_clientEqTx && m_clientEqTx->isEnabled()) {
- m_clientEqTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientEqTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) {
- f32[i] = i16[i] / 32768.0f;
- }
-
- m_clientEqTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(std::clamp(f32[i] * 32768.0f,
- -32768.0f, 32767.0f));
- }
- }
- // Always tap — bypassed-EQ case means tap captures pre-EQ samples
- // (which equal post-EQ samples since no processing happened).
- tapClientEqTxInt16(reinterpret_cast(int16stereo.constData()),
- frames);
-}
-
-void AudioEngine::applyClientEqTxFloat32(QByteArray& float32)
-{
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- // feedDaxTxAudio can deliver mono OR stereo float32 (depends on packet
- // class). Treat even sample counts as stereo, odd counts as mono.
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
-
- if (m_clientEqTx && m_clientEqTx->isEnabled()) {
- m_clientEqTx->process(reinterpret_cast(float32.data()),
- frames, channels);
- }
- // Always tap so the editor's TX FFT analyzer reflects live audio
- // even when the EQ stage is bypassed in the CHAIN widget.
- tapClientEqTxFloat32(reinterpret_cast(float32.constData()),
- samples, channels);
-}
-
-void AudioEngine::applyClientCompTxInt16(QByteArray& int16stereo)
-{
- if (!m_clientCompTx) return;
- const bool compOn = m_clientCompTx->isEnabled();
- const bool driveOn = m_clientCompTx->driveDb() > 0.0f;
- const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0;
- const bool limOn = m_clientCompTx->limiterEnabled();
- if (!compOn && !driveOn && !phaseOn && !limOn) return;
- if (int16stereo.isEmpty()) return;
-
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return;
- const int frames = samples / 2;
-
- m_clientCompTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientCompTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f;
-
- m_clientCompTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(
- std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
- }
-}
-
-void AudioEngine::applyClientCompTxFloat32(QByteArray& float32)
-{
- if (!m_clientCompTx) return;
- // Drive and Phase (#2887) and the brickwall limiter inside the comp
- // are useful even when the comp curve itself is bypassed, so the
- // dispatch only short-circuits when none of the four sub-stages
- // need to run.
- const bool compOn = m_clientCompTx->isEnabled();
- const bool driveOn = m_clientCompTx->driveDb() > 0.0f;
- const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0;
- const bool limOn = m_clientCompTx->limiterEnabled();
- if (!compOn && !driveOn && !phaseOn && !limOn) return;
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
- m_clientCompTx->process(reinterpret_cast(float32.data()),
- frames, channels);
-}
-
void AudioEngine::applyClientCompRxFloat32(QByteArray& float32)
{
if (!m_clientCompRx || !m_clientCompRx->isEnabled()) return;
@@ -4878,40 +4814,6 @@ void AudioEngine::applyClientCompRxFloat32(QByteArray& float32)
frames, 2);
}
-void AudioEngine::applyClientGateTxInt16(QByteArray& int16stereo)
-{
- if (!m_clientGateTx || !m_clientGateTx->isEnabled()) return;
- if (int16stereo.isEmpty()) return;
-
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return;
- const int frames = samples / 2;
-
- m_clientGateTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientGateTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f;
-
- m_clientGateTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(
- std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
- }
-}
-
-void AudioEngine::applyClientGateTxFloat32(QByteArray& float32)
-{
- if (!m_clientGateTx || !m_clientGateTx->isEnabled()) return;
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
- m_clientGateTx->process(reinterpret_cast(float32.data()),
- frames, channels);
-}
-
void AudioEngine::applyClientGateRxFloat32(QByteArray& float32)
{
if (!m_clientGateRx || !m_clientGateRx->isEnabled()) return;
@@ -4923,29 +4825,6 @@ void AudioEngine::applyClientGateRxFloat32(QByteArray& float32)
frames, 2);
}
-void AudioEngine::applyClientDeEssTxInt16(QByteArray& int16stereo)
-{
- if (!m_clientDeEssTx || !m_clientDeEssTx->isEnabled()) return;
- if (int16stereo.isEmpty()) return;
-
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return;
- const int frames = samples / 2;
-
- m_clientDeEssTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientDeEssTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f;
-
- m_clientDeEssTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(
- std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
- }
-}
-
void AudioEngine::applyClientDeEssRxFloat32(QByteArray& float32)
{
if (!m_clientDeEssRx || !m_clientDeEssRx->isEnabled()) return;
@@ -4956,51 +4835,6 @@ void AudioEngine::applyClientDeEssRxFloat32(QByteArray& float32)
frames, 2);
}
-void AudioEngine::applyClientDeEssTxFloat32(QByteArray& float32)
-{
- if (!m_clientDeEssTx || !m_clientDeEssTx->isEnabled()) return;
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
- m_clientDeEssTx->process(reinterpret_cast(float32.data()),
- frames, channels);
-}
-
-void AudioEngine::applyClientTubeTxInt16(QByteArray& int16stereo)
-{
- if (!m_clientTubeTx || !m_clientTubeTx->isEnabled()) return;
- if (int16stereo.isEmpty()) return;
-
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return;
- const int frames = samples / 2;
-
- m_clientTubeTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientTubeTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f;
-
- m_clientTubeTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(
- std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
- }
-}
-
-void AudioEngine::applyClientTubeTxFloat32(QByteArray& float32)
-{
- if (!m_clientTubeTx || !m_clientTubeTx->isEnabled()) return;
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
- m_clientTubeTx->process(reinterpret_cast(float32.data()),
- frames, channels);
-}
-
void AudioEngine::applyClientTubeRxFloat32(QByteArray& float32)
{
if (!m_clientTubeRx || !m_clientTubeRx->isEnabled()) return;
@@ -5012,40 +4846,6 @@ void AudioEngine::applyClientTubeRxFloat32(QByteArray& float32)
frames, 2);
}
-void AudioEngine::applyClientPuduTxInt16(QByteArray& int16stereo)
-{
- if (!m_clientPuduTx || !m_clientPuduTx->isEnabled()) return;
- if (int16stereo.isEmpty()) return;
-
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return;
- const int frames = samples / 2;
-
- m_clientPuduTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientPuduTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f;
-
- m_clientPuduTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(
- std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
- }
-}
-
-void AudioEngine::applyClientPuduTxFloat32(QByteArray& float32)
-{
- if (!m_clientPuduTx || !m_clientPuduTx->isEnabled()) return;
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
- m_clientPuduTx->process(reinterpret_cast(float32.data()),
- frames, channels);
-}
-
void AudioEngine::applyClientPuduRxFloat32(QByteArray& float32)
{
if (!m_clientPuduRx || !m_clientPuduRx->isEnabled()) return;
@@ -5057,120 +4857,6 @@ void AudioEngine::applyClientPuduRxFloat32(QByteArray& float32)
frames, 2);
}
-void AudioEngine::applyClientReverbTxInt16(QByteArray& int16stereo)
-{
- if (!m_clientReverbTx || !m_clientReverbTx->isEnabled()) return;
- if (int16stereo.isEmpty()) return;
-
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return;
- const int frames = samples / 2;
-
- m_clientReverbTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientReverbTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f;
-
- m_clientReverbTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(
- std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
- }
-}
-
-void AudioEngine::applyClientFinalLimiterTxInt16(QByteArray& int16stereo)
-{
- if (!m_clientFinalLimiterTx) return;
- if (int16stereo.isEmpty()) return;
-
- const int samples = int16stereo.size() / static_cast(sizeof(int16_t));
- if ((samples & 1) != 0) return;
- const int frames = samples / 2;
-
- m_clientFinalLimiterTxScratch.resize(samples * static_cast(sizeof(float)));
- auto* f32 = reinterpret_cast(m_clientFinalLimiterTxScratch.data());
- const auto* i16 = reinterpret_cast(int16stereo.constData());
- for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f;
-
- m_clientFinalLimiterTx->process(f32, frames, 2);
-
- auto* out = reinterpret_cast(int16stereo.data());
- for (int i = 0; i < samples; ++i) {
- out[i] = static_cast(
- std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
- }
-}
-
-void AudioEngine::applyClientFinalLimiterTxFloat32(QByteArray& float32)
-{
- if (!m_clientFinalLimiterTx) return;
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
- m_clientFinalLimiterTx->process(reinterpret_cast(float32.data()),
- frames, channels);
-}
-
-void AudioEngine::applyClientReverbTxFloat32(QByteArray& float32)
-{
- if (!m_clientReverbTx || !m_clientReverbTx->isEnabled()) return;
- if (float32.isEmpty()) return;
- const int samples = float32.size() / static_cast(sizeof(float));
- const int channels = (samples % 2 == 0) ? 2 : 1;
- const int frames = samples / channels;
- m_clientReverbTx->process(reinterpret_cast(float32.data()),
- frames, channels);
-}
-
-void AudioEngine::applyClientTxDspInt16(QByteArray& int16stereo)
-{
- // Order determines whether the compressor colours the raw mic signal
- // before the EQ shapes it (default, Pro-XL "tone shaping after
- // dynamics"), or the EQ shapes first and the compressor tames the
- // resulting peaks. EQ's tap is always fed post-EQ so the analyzer
- // shows the final signal leaving the TX DSP chain.
- // Walk the packed chain-stage list and dispatch each entry to its
- // matching per-stage apply helper. The audio thread loads the
- // full chain in one atomic read — each byte is a TxChainStage.
- const uint64_t packed = m_txChainPacked.load(std::memory_order_acquire);
- for (int i = 0; i < kMaxTxChainStages; ++i) {
- const auto stage = static_cast((packed >> (i * 8)) & 0xFF);
- switch (stage) {
- case TxChainStage::None: return; // end-of-list marker
- case TxChainStage::Eq: applyClientEqTxInt16(int16stereo); break;
- case TxChainStage::Comp: applyClientCompTxInt16(int16stereo); break;
- case TxChainStage::Gate: applyClientGateTxInt16(int16stereo); break;
- case TxChainStage::DeEss: applyClientDeEssTxInt16(int16stereo); break;
- case TxChainStage::Tube: applyClientTubeTxInt16(int16stereo); break;
- // "Enh" is the legacy enum name; the user-facing label is
- // PUDU (Phase 5 exciter, Aphex/Behringer-modelled).
- case TxChainStage::Enh: applyClientPuduTxInt16(int16stereo); break;
- case TxChainStage::Reverb: applyClientReverbTxInt16(int16stereo); break;
- }
- }
-}
-
-void AudioEngine::applyClientTxDspFloat32(QByteArray& float32)
-{
- const uint64_t packed = m_txChainPacked.load(std::memory_order_acquire);
- for (int i = 0; i < kMaxTxChainStages; ++i) {
- const auto stage = static_cast((packed >> (i * 8)) & 0xFF);
- switch (stage) {
- case TxChainStage::None: return;
- case TxChainStage::Eq: applyClientEqTxFloat32(float32); break;
- case TxChainStage::Comp: applyClientCompTxFloat32(float32); break;
- case TxChainStage::Gate: applyClientGateTxFloat32(float32); break;
- case TxChainStage::DeEss: applyClientDeEssTxFloat32(float32); break;
- case TxChainStage::Tube: applyClientTubeTxFloat32(float32); break;
- case TxChainStage::Enh: applyClientPuduTxFloat32(float32); break;
- case TxChainStage::Reverb: applyClientReverbTxFloat32(float32); break;
- }
- }
-}
-
void AudioEngine::applyClientRxDspFloat32(QByteArray& float32)
{
// Walk the packed RX chain-stage list and dispatch each entry to
@@ -5252,8 +4938,7 @@ AudioEngine::TxChainStage stageFromName(const QString& name)
}
// Canonical default order for a fresh install — stages appear in the
-// order they'll typically be wanted in the signal chain. Only Eq and
-// Comp do anything today; the others are no-ops until their DSP ships.
+// order they'll typically be wanted in the signal chain.
QVector defaultChain()
{
return {
@@ -7107,16 +6792,23 @@ void AudioEngine::setRn2TxEnabled(bool on)
std::lock_guard lock(m_dspMutex);
if (on) {
m_rn2Tx = std::make_unique(
- RNNoiseFilter::OutputMode::ProcessedMono);
+ RNNoiseFilter::OutputMode::ProcessedMono,
+ RNNoiseFilter::RateDomain::Native48k);
if (!m_rn2Tx->isValid()) {
qCWarning(lcAudio) << "AudioEngine: RN2 TX rnnoise_create() failed — disabling";
+ m_txVoiceProcessor->setRnnoise(nullptr);
m_rn2Tx.reset();
emit rn2TxEnabledChanged(false);
return;
}
+ m_txVoiceProcessor->setRnnoise(m_rn2Tx.get());
m_rn2TxEnabled.store(true);
} else {
m_rn2TxEnabled.store(false);
+ // TxVoiceProcessor does not own this raw association. Clear it while
+ // the filter is still alive so a later prepare()/reset() cannot
+ // dereference an RNNoiseFilter destroyed here.
+ m_txVoiceProcessor->setRnnoise(nullptr);
m_rn2Tx.reset();
}
saveAetherialTubePreampTxSettings();
@@ -7674,22 +7366,32 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP
qCInfo(lcAudio) << "AudioEngine: selected TX input format:"
<< fmt.sampleRate() << "Hz" << fmt.channelCount() << "ch";
- // Record actual negotiated input format for resampling in onTxAudioReady
+ // Record the negotiated device format. Voice normalizes directly to the
+ // 48 kHz DSP island; the legacy 24 kHz resampler below is retained only
+ // for the separate RADE branch.
m_txInputRate = fmt.sampleRate();
m_txInputChannels = fmt.channelCount();
m_txInputMono = (m_txInputChannels == 1);
- m_txNeedsResample = (m_txInputRate != 24000);
+ m_radeTxNeedsResample = (m_txInputRate != DEFAULT_SAMPLE_RATE);
- // Create polyphase resampler for high-quality rate conversion
- if (m_txNeedsResample)
+ // Create the RADE path's polyphase resampler for high-quality conversion.
+ if (m_radeTxNeedsResample) {
m_txResampler = std::make_unique(m_txInputRate, DEFAULT_SAMPLE_RATE, 16384);
- else
+ } else {
m_txResampler.reset();
+ }
+ if (!m_txVoiceProcessor->prepare(m_txInputRate, 16384)) {
+ qCWarning(lcAudio) << "AudioEngine: failed to prepare 48 kHz TX voice processor"
+ << "for input rate" << m_txInputRate;
+ return false;
+ }
qCDebug(lcAudio) << "AudioEngine: TX input device:" << dev.description()
<< "id:" << dev.id()
<< "rate:" << fmt.sampleRate() << "ch:" << fmt.channelCount()
- << "resample:" << m_txNeedsResample;
+ << "voice normalize to 48k:"
+ << (m_txInputRate != TxVoiceProcessor::kDspRate)
+ << "RADE resample to 24k:" << m_radeTxNeedsResample;
#ifdef Q_OS_MAC
// macOS: QAudioSource pull mode broken — use push mode with QBuffer
@@ -7804,12 +7506,21 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP
m_txInputRate = rate;
m_txInputChannels = ch;
m_txInputMono = (m_txInputChannels == 1);
- m_txNeedsResample = (rate != 24000);
- if (m_txNeedsResample) {
+ m_radeTxNeedsResample = (rate != DEFAULT_SAMPLE_RATE);
+ if (m_radeTxNeedsResample) {
m_txResampler = std::make_unique(rate, 24000, 16384);
} else {
m_txResampler.reset();
}
+ if (!m_txVoiceProcessor->prepare(rate, 16384)) {
+ qCWarning(lcAudio)
+ << "AudioEngine: failed to prepare 48 kHz TX voice processor"
+ << "for fallback input rate" << rate;
+ delete m_audioSource;
+ m_audioSource = nullptr;
+ m_micDevice = nullptr;
+ continue;
+ }
txOpened = true;
break;
}
@@ -7886,13 +7597,17 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP
<< ":" << radioPort << "streamId:" << Qt::hex << m_txStreamId
<< Qt::dec << "device:" << dev.description() << "id:" << dev.id()
<< "rate:" << m_txInputRate << "ch:" << m_txInputChannels
- << "resample:" << m_txNeedsResample;
+ << "voice normalize to 48k:"
+ << (m_txInputRate != TxVoiceProcessor::kDspRate)
+ << "RADE resample to 24k:" << m_radeTxNeedsResample;
AudioSummaryLogger::TxSourceSummary summary;
summary.deviceDescription = dev.description();
summary.sampleRate = m_txInputRate;
summary.channelCount = m_txInputChannels;
summary.sampleFormat = fmt.sampleFormat();
- summary.resamplingTo24k = m_txNeedsResample;
+ summary.normalizingTo48k =
+ (m_txInputRate != TxVoiceProcessor::kDspRate);
+ summary.radeResamplingTo24k = m_radeTxNeedsResample;
summary.fallbackOccurred = txFallbackOccurred;
summary.fallbackReason = txFallbackReasons.join(QStringLiteral("; "));
AudioSummaryLogger::logTxSource(summary);
@@ -7942,7 +7657,7 @@ void AudioEngine::stopTxStream()
m_txInputChannels = 2;
m_txInputMono = false;
m_txInputRate = DEFAULT_SAMPLE_RATE;
- m_txNeedsResample = false;
+ m_radeTxNeedsResample = false;
m_txMicChannelState.reset();
m_lastTxMicChannelLog.invalidate();
m_txSourceStartTime.invalidate();
@@ -8090,11 +7805,12 @@ void AudioEngine::onTxAudioReady()
if (data.isEmpty()) return;
logTxInputChannelDiagnostics(channelDiagnostics, "TX mic");
- // Resample canonical mono int16 to 24kHz duplicated stereo if needed, then
- // convert to float32 for RADE. Normal TX path stays int16 (Opus requires
- // int16). Do not call processStereoToStereo() here: that helper would
- // average raw mic L/R and reintroduce the one-sided-channel 6.02 dB loss.
- if (m_txNeedsResample && m_txResampler) {
+ // RADE remains a fixed 24 kHz island. Voice skips this block and enters
+ // TxVoiceProcessor at the negotiated device rate. Do not call
+ // processStereoToStereo() here: that helper would average raw mic L/R and
+ // reintroduce the one-sided-channel 6.02 dB loss.
+ const bool radeMode = m_radeMode.load(std::memory_order_acquire);
+ if (radeMode && m_radeTxNeedsResample && m_txResampler) {
// Convert canonical duplicated int16 stereo → float32 mono for the
// mono-to-stereo resampler.
const auto* i16 = reinterpret_cast(data.constData());
@@ -8119,7 +7835,7 @@ void AudioEngine::onTxAudioReady()
}
// RADE mode: apply client-side gain + meter, then convert int16 → float32
- if (m_radeMode) {
+ if (radeMode) {
// Apply client-side mic gain (same int16 gain path as SSB below)
const float gain = m_pcMicGain.load();
if (gain < 0.999f) {
@@ -8149,96 +7865,47 @@ void AudioEngine::onTxAudioReady()
// Don't send mic audio — it would conflict with the DAX stream.
if (m_daxTxMode) return;
- // ── RN2 mic pre-amp (TX neural denoiser) ─────────────────────
- // Runs strictly on the voice path — both digital-mode early-returns
- // above (m_radeMode, m_daxTxMode) skip this hook so RN2 is guaranteed
- // never to touch RADE / DAX / TCI / RTTY / FT8 / FDV audio. Placed
- // BEFORE the test tone + user DSP chain so any downstream gate /
- // comp / EQ / saturator processes denoised audio rather than
- // amplifying the noise floor.
- //
- // RNNoiseFilter::process() takes / returns 24 kHz duplicated-stereo
- // FLOAT32 (despite its header comment claiming int16). At this
- // point in the flow `data` is 24 kHz duplicated-stereo int16, so we
- // convert in → process → convert out. Conversion is in-place over
- // pre-sized scratch buffers — no per-block heap traffic after the
- // first call. (#2813)
- if (m_rn2TxEnabled.load() && m_rn2Tx && m_rn2Tx->isValid()) {
- const auto* i16 = reinterpret_cast(data.constData());
- const int samples = data.size() / static_cast(sizeof(int16_t));
- m_rn2TxF32In.resize(samples * static_cast(sizeof(float)));
- auto* fin = reinterpret_cast(m_rn2TxF32In.data());
- for (int i = 0; i < samples; ++i) fin[i] = i16[i] / 32768.0f;
-
- m_rn2TxF32In = m_rn2Tx->process(m_rn2TxF32In);
-
- const int outSamples = m_rn2TxF32In.size() / static_cast(sizeof(float));
- const auto* fout = reinterpret_cast(m_rn2TxF32In.constData());
- data.resize(outSamples * static_cast(sizeof(int16_t)));
- auto* i16Out = reinterpret_cast(data.data());
- for (int i = 0; i < outSamples; ++i) {
- const float clamped = std::clamp(fout[i] * 32768.0f, -32768.0f, 32767.0f);
- i16Out[i] = static_cast(clamped);
- }
- }
-
- // ── Client-side TX DSP: compressor + parametric EQ ──────────────────
- // Runs after mic capture and resample, before PC mic gain / metering /
- // Opus / VITA-49, so the user hears the shaped signal exactly as the
- // radio will receive it. Chain order (CMP→EQ vs EQ→CMP) is user-
- // selectable via setTxChainOrder().
- // ── Test tone (head of chain) ───────────────────────────────
- // When enabled, replaces mic input with a sine so the user can
- // run the chain on a known signal. Runs BEFORE the user's DSP
- // chain so the tone exits the strip with all stages applied.
- if (m_clientTxTestTone && m_clientTxTestTone->isEnabled()) {
- const int samples = data.size() / static_cast(sizeof(int16_t));
- const int frames = samples / 2;
- m_clientTxTestTone->process(
- reinterpret_cast(data.data()), frames, 2);
- }
-
- applyClientTxDspInt16(data);
-
- // ── PUDU monitor tap ─────────────────────────────────────────
- // Feeds the post-DSP int16 bytes into the TX monitor if one is
- // registered. Lock-free atomic pointer load; the monitor's
- // feedTxPostDsp() itself handles the not-recording fast-path.
+ // ── Fixed-rate TX voice processor ───────────────────────────────────
+ // Canonical mic input enters this seam at the negotiated device rate,
+ // becomes float once, and stays float through RN2, the user-orderable
+ // channel strip, mic gain, Quindar, and the final limiter. A matched
+ // stereo r8brain pair performs the sole 48 -> 24 kHz conversion inside
+ // this voice processor; quantization happens once at its 24 kHz output
+ // boundary. That is final for Flex Opus/VITA. Host-modulating backends
+ // currently consume the same Int16 seam and may convert onward.
+ // Radio-authoritative mode/passband filtering remains downstream.
+ m_txVoiceProcessor->setStageOrder(
+ m_txChainPacked.load(std::memory_order_acquire));
+ m_txVoiceProcessor->setMicGain(m_pcMicGain.load());
+ m_txVoiceProcessor->setRnnoiseEnabled(m_rn2TxEnabled.load());
+ const bool voiceProcessed = m_txVoiceProcessor->processCapturedInt16(data);
+ if (m_txVoiceProcessor->egressRecoveryPending()
+ && !m_txVoiceEgressRecoveryQueued) {
+ m_txVoiceEgressRecoveryQueued = true;
+ const bool queued = QMetaObject::invokeMethod(
+ this,
+ [this]() {
+ m_txVoiceProcessor->recoverEgressAfterMismatch();
+ m_txVoiceEgressRecoveryQueued = false;
+ },
+ Qt::QueuedConnection);
+ if (!queued) {
+ m_txVoiceEgressRecoveryQueued = false;
+ qCWarning(lcAudio)
+ << "AudioEngine: failed to queue TX egress SRC recovery";
+ }
+ }
+ if (!voiceProcessed) {
+ return;
+ }
+
+ // The legacy pre-tail monitor currently has no active GUI owner. Keep its
+ // feed alive at the stable 24 kHz representation until it is replaced by
+ // the explicit 48 kHz postChannelStripFloat48Stereo() measurement seam.
if (auto* mon = m_txPostDspMonitor.load(std::memory_order_acquire)) {
mon->feedTxPostDsp(data);
}
- // ── Apply client-side PC mic gain (int16) ───────────────────────────
- const float gain = m_pcMicGain.load();
- if (gain < 0.999f) {
- auto* pcm = reinterpret_cast(data.data());
- int sampleCount = data.size() / static_cast(sizeof(int16_t));
- for (int i = 0; i < sampleCount; ++i)
- pcm[i] = static_cast(std::clamp(
- static_cast(pcm[i] * gain), -32768, 32767));
- }
-
- // ── Quindar tones (#2262) ───────────────────────────────────────────
- // Sits AFTER the user DSP chain and PC mic gain but BEFORE the final
- // brickwall limiter, so the generated tone is unprocessed by Comp/EQ
- // (no comp pumping, no EQ tilt) but is still bounded by the configured
- // ceiling. Driven by TransmitModel's PTT coordinator on phone modes;
- // the stage replaces samples wholesale during Engaging/Disengaging
- // phases and is a no-op the rest of the time.
- if (m_clientQuindarTone) {
- const int frames = data.size() / static_cast(sizeof(int16_t) * 2);
- m_clientQuindarTone->process(
- reinterpret_cast(data.data()), frames, 2);
- }
-
- // ── Final brickwall limiter (TX tail) ───────────────────────────────
- // Sits at the very end of the chain — after every user-configurable
- // stage AND after PC mic gain — so no sample escapes louder than the
- // configured ceiling regardless of upstream behaviour. Its meters
- // (input / output peak, GR, active) are what the strip's "Final
- // Output Stage" panel reads.
- applyClientFinalLimiterTxInt16(data);
-
// ── Final-output monitor tap (+ local CW/CWX sidetone for recording) ──
// Mirror the post-PUDU monitor at the chain's tail (post-limiter) for the
// PUDU TX monitor and the Client-Side QSO recorder's VOICE tap (#3556).
@@ -8489,8 +8156,39 @@ void AudioEngine::setAllowBluetoothTelephonyOutput(bool on)
void AudioEngine::setRadeMode(bool on)
{
- if (m_radeMode == on) return;
- m_radeMode = on;
+ if (m_radeMode.load(std::memory_order_acquire) == on) {
+ return;
+ }
+ if (on) {
+ // Voice no longer advances RADE's 24 kHz SRC, so discard any history
+ // retained from the previous RADE session before publishing the new
+ // mode. The resampler belongs to the audio thread; run the reset there,
+ // between callbacks, and preserve setRadeMode()'s synchronous contract.
+ QThread* const ownerThread = thread();
+ if (ownerThread && ownerThread != QThread::currentThread()) {
+ if (!ownerThread->isRunning()) {
+ qCWarning(lcAudio)
+ << "AudioEngine: cannot enable RADE while audio thread is stopped";
+ return;
+ }
+ const bool invoked = QMetaObject::invokeMethod(
+ this,
+ [this]() {
+ if (m_txResampler) {
+ m_txResampler->reset();
+ }
+ },
+ Qt::BlockingQueuedConnection);
+ if (!invoked) {
+ qCWarning(lcAudio)
+ << "AudioEngine: failed to reset RADE TX resampler";
+ return;
+ }
+ } else if (m_txResampler) {
+ m_txResampler->reset();
+ }
+ }
+ m_radeMode.store(on, std::memory_order_release);
// RADE TX: onTxAudioReady() emits txRawPcmReady (float32) then returns
// early — the Opus voice TX path never runs. RADEEngine receives the
// raw PCM, encodes it to a modem waveform, and emits it via
@@ -8502,8 +8200,9 @@ void AudioEngine::setRadeMode(bool on)
// use the physical mic and discard every dax_tx packet, producing no
// TX waveform. feedDaxTxAudio/m_daxTxUseRadioRoute are irrelevant:
// RADE bypasses feedDaxTxAudio entirely.
- if (!on)
+ if (!on) {
m_radeRxBuffer.clear();
+ }
clearTxAccumulators();
}
diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h
index aa03353b8..ccc594969 100644
--- a/src/core/AudioEngine.h
+++ b/src/core/AudioEngine.h
@@ -44,6 +44,7 @@ class RNNoiseFilter;
class DeepFilterFilter;
class NvidiaAfxFilter;
class Resampler;
+class TxVoiceProcessor;
class ClientEq;
class ClientComp;
class ClientGate;
@@ -178,7 +179,8 @@ class AudioEngine : public QObject {
bool hasKiwiSdrAudioSource(const QString& sourceId) const;
int txInputSampleRate() const { return m_txInputRate; }
int txInputChannelCount() const { return m_txInputChannels; }
- bool txInputResamplingTo24k() const { return m_txNeedsResample; }
+ bool txInputNormalizationTo48k() const;
+ bool txRadeResamplingTo24k() const { return m_radeTxNeedsResample; }
bool rxOutputResamplingActive() const { return m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE; }
QJsonArray audioEndpointDiagnostics() const;
QJsonObject startAutomationAudioCapture(int durationMs,
@@ -363,11 +365,9 @@ class AudioEngine : public QObject {
// registration — clear to nullptr before destroying the monitor.
void setTxPostDspMonitor(ClientPuduMonitor* m) noexcept;
- // Generalised TX DSP chain — each stage is a separate processing
- // block run in order on the TX audio path. Only Eq and Comp are
- // implemented today; the remaining stages are placeholders for
- // Phase 2+ work (Gate, DeEss, Tube, Enh from #1661) and are no-ops
- // until their DSP classes ship.
+ // Generalised TX DSP chain — each stage is a separate float processing
+ // block run in order by TxVoiceProcessor at 48 kHz. Numeric values are
+ // persisted in the packed atomic and therefore form a stable contract.
enum class TxChainStage : uint8_t {
None = 0, // sentinel / end-of-list marker
Gate = 1,
@@ -836,42 +836,16 @@ private slots:
RxDspSource source,
ExternalRxAudioSourceState* externalSource) const;
#endif
- // Apply client-side TX EQ in-place. No-op if disabled. Caller owns data.
- void applyClientEqTxInt16(QByteArray& int16stereo);
- void applyClientEqTxFloat32(QByteArray& float32);
- // Apply client-side TX compressor in-place. No-op if disabled.
- void applyClientCompTxInt16(QByteArray& int16stereo);
- void applyClientCompTxFloat32(QByteArray& float32);
// RX comp operates on the post-Gate float32 stereo buffer.
void applyClientCompRxFloat32(QByteArray& float32);
- // Apply client-side TX gate in-place. No-op if disabled.
- void applyClientGateTxInt16(QByteArray& int16stereo);
- void applyClientGateTxFloat32(QByteArray& float32);
// RX gate operates on the post-EQ float32 stereo buffer.
void applyClientGateRxFloat32(QByteArray& float32);
- // Apply client-side TX de-esser in-place. No-op if disabled.
- void applyClientDeEssTxInt16(QByteArray& int16stereo);
- void applyClientDeEssTxFloat32(QByteArray& float32);
// RX de-esser operates on the post-Comp float32 stereo buffer (#2425).
void applyClientDeEssRxFloat32(QByteArray& float32);
- // Apply client-side TX tube saturator in-place. No-op if disabled.
- void applyClientTubeTxInt16(QByteArray& int16stereo);
- void applyClientTubeTxFloat32(QByteArray& float32);
// RX tube operates on the post-Comp float32 stereo buffer.
void applyClientTubeRxFloat32(QByteArray& float32);
- // Apply client-side TX PUDU exciter in-place. No-op if disabled.
- void applyClientPuduTxInt16(QByteArray& int16stereo);
- void applyClientPuduTxFloat32(QByteArray& float32);
// RX pudu operates on the post-Tube float32 stereo buffer.
void applyClientPuduRxFloat32(QByteArray& float32);
- // Apply client-side TX reverb in-place. No-op if disabled.
- void applyClientReverbTxInt16(QByteArray& int16stereo);
- void applyClientReverbTxFloat32(QByteArray& float32);
- void applyClientFinalLimiterTxInt16(QByteArray& int16stereo);
- void applyClientFinalLimiterTxFloat32(QByteArray& float32);
- // Apply the whole TX DSP chain (CMP + EQ) in the configured order.
- void applyClientTxDspInt16(QByteArray& int16stereo);
- void applyClientTxDspFloat32(QByteArray& float32);
void accumulatePcMicMeterInt16Stereo(const QByteArray& int16stereo);
void logTxInputChannelDiagnostics(const TxMicChannelNormalizer::Diagnostics& diagnostics,
@@ -1014,7 +988,7 @@ private slots:
// when TX-decode is off.
std::atomic m_cwDecodeTxTapEnabled{false};
std::atomic m_tncRxTapEnabled{false};
- bool m_txNeedsResample{false}; // TX: input rate != 24kHz, needs resampling
+ bool m_radeTxNeedsResample{false}; // RADE: input rate != 24 kHz
bool m_txInputMono{false}; // TX: legacy convenience mirror of m_txInputChannels == 1
int m_txInputChannels{2}; // TX: actual negotiated input channel count
int m_txInputRate{24000}; // TX: actual input sample rate
@@ -1026,7 +1000,7 @@ private slots:
TxMicChannelNormalizer::AutoState m_daxRadioTxChannelState;
QElapsedTimer m_lastTxMicChannelLog;
QElapsedTimer m_lastDaxRadioChannelLog;
- std::unique_ptr m_txResampler; // e.g. 48k→24k (lazy init)
+ std::unique_ptr m_txResampler; // RADE e.g. 48k -> 24k (lazy init)
// DSP lifecycle mutex: held during feedAudioData() DSP section AND
// during enable/disable to prevent use-after-free (#502)
@@ -1068,9 +1042,6 @@ private slots:
// RN2 ownership pattern above. (#2813)
std::unique_ptr m_rn2Tx;
std::atomic m_rn2TxEnabled{false};
- // Scratch buffer for int16 ↔ float32 conversion around RN2 TX.
- // Audio-thread only — grows once to steady state, then alloc-free.
- QByteArray m_rn2TxF32In;
// Client-side DFNR (DeepFilterNet3)
#ifdef HAVE_DFNR
@@ -1111,6 +1082,10 @@ private slots:
std::unique_ptr m_clientTxTestTone;
std::unique_ptr m_wsprBeacon;
std::unique_ptr m_clientQuindarTone;
+ std::unique_ptr m_txVoiceProcessor;
+ // Audio-thread only: prevents duplicate queued recovery work after the
+ // impossible matched-egress frame-count mismatch.
+ bool m_txVoiceEgressRecoveryQueued{false};
// Audio-thread-loaded pointer for the post-final-limiter monitor
// (final-output recording). Same lock-free atomic pointer pattern
// as m_txPostDspMonitor.
@@ -1122,8 +1097,7 @@ private slots:
// a uint64_t so the audio thread can load the full order in a
// single atomic read per block. TxChainStage::None terminates the
// list; unused slots are zero. Default canonical order:
- // [Gate, Eq, DeEss, Comp, Tube, Enh] — but only Eq and Comp have
- // implementations today, so the others are no-op pass-throughs.
+ // [Gate, Eq, DeEss, Comp, Tube, Enh, Reverb].
std::atomic m_txChainPacked{0};
// Master-bypass snapshot — the stages that were enabled at the
// moment setTxBypassed(true) was called. Used solely to restore
@@ -1154,22 +1128,14 @@ private slots:
bool m_rxBypassActive{false};
// Scratch buffer for in-place EQ on the RX path (avoids per-call alloc).
QByteArray m_clientEqRxScratch;
- QByteArray m_clientEqTxScratch;
- QByteArray m_clientCompTxScratch;
QByteArray m_clientCompRxScratch;
- QByteArray m_clientGateTxScratch;
QByteArray m_clientGateRxScratch;
- QByteArray m_clientDeEssTxScratch;
QByteArray m_clientDeEssRxScratch;
- QByteArray m_clientTubeTxScratch;
QByteArray m_clientTubeRxScratch;
- QByteArray m_clientPuduTxScratch;
QByteArray m_clientPuduRxScratch;
- QByteArray m_clientReverbTxScratch;
- QByteArray m_clientFinalLimiterTxScratch;
// Post-EQ analyzer tap. One ring per path, mono (L+R averaged).
- // Audio thread writes via tapClientEqRxStereo() / tapClientEqTxInt16()
- // / tapClientEqTxFloat32(); UI thread snapshots via the public
+ // Audio thread writes via tapClientEqRxStereo() / tapClientEqTxFloat32();
+ // UI thread snapshots via the public
// copyRecent*() accessors. Mutex is held for microseconds only.
mutable std::mutex m_clientEqTapMutex;
float m_clientEqTapRx[kClientEqTapSize]{};
@@ -1177,7 +1143,6 @@ private slots:
int m_clientEqTapRxWrite{0};
int m_clientEqTapTxWrite{0};
void tapClientEqRxStereo(const float* stereoInterleaved, int frames);
- void tapClientEqTxInt16(const int16_t* int16stereo, int frames);
void tapClientEqTxFloat32(const float* f32, int samples, int channels);
// Pre-allocated NR2 output buffers (avoid per-call heap allocation)
diff --git a/src/core/AudioFormatNegotiator.h b/src/core/AudioFormatNegotiator.h
index 081ec5780..5dd4cd242 100644
--- a/src/core/AudioFormatNegotiator.h
+++ b/src/core/AudioFormatNegotiator.h
@@ -3,8 +3,8 @@
// ─── Audio format / sample-rate negotiation policy ───────────────────────────
//
// One ladder, one set of per-OS rules — the single home for "what rate and
-// sample format does this device want, and how do I get my 24 kHz canonical
-// audio onto it" (issue #3306).
+// sample format does this device want, and how do I bridge between that rate
+// and the caller's canonical device-boundary rate" (issue #3306).
//
// Historically each audio sink/source re-implemented this with its own
// divergent fallback ladder and per-OS `#ifdef` branches, which is the root of
@@ -30,8 +30,11 @@
namespace AetherSDR {
namespace AudioFormatNegotiator {
-// Canonical internal rate: the radio VITA-49 narrowband audio rate. Everything
-// resamples to/from this single value (AudioEngine::DEFAULT_SAMPLE_RATE).
+// Default device-boundary rate: the radio VITA-49 narrowband audio rate and
+// the canonical rate for RX and several digital/legacy routes
+// (AudioEngine::DEFAULT_SAMPLE_RATE). This is not a universal DSP rate: normal
+// PC-mic voice is normalized to TxVoiceProcessor's fixed 48 kHz float domain,
+// then returns to 24 kHz at its current transport/backend seam.
constexpr int kInternalRate = 24000;
// Target OS is data, not an #ifdef, so every runner tests every ladder.
@@ -87,7 +90,8 @@ struct DeviceCaps {
int channels = 2;
// macOS Bluetooth hands-free/SCO capture route: caps out at 8/16/24k and
- // must be opened at its native low rate, NOT forced to 48k (#2615).
+ // must be opened at its native low rate (#2615). After capture, normal
+ // voice normalizes to 48k; the separate RADE route converts to 24k.
bool isBluetoothHfp = false;
// False => isFormatSupported() is not trustworthy for this backend, so the
diff --git a/src/core/AudioSummaryLogger.cpp b/src/core/AudioSummaryLogger.cpp
index e0772d1dc..9b25cba3b 100644
--- a/src/core/AudioSummaryLogger.cpp
+++ b/src/core/AudioSummaryLogger.cpp
@@ -168,8 +168,9 @@ QString formatTxSource(const TxSourceSummary& summary)
.arg(summary.channelCount)
.arg(modeText(summary.channelCount),
sampleFormatName(summary.sampleFormat))
- << QStringLiteral(" resampleTo24k=%1 %2")
- .arg(yesNo(summary.resamplingTo24k),
+ << QStringLiteral(" voiceNormalizeTo48k=%1 radeResampleTo24k=%2 %3")
+ .arg(yesNo(summary.normalizingTo48k),
+ yesNo(summary.radeResamplingTo24k),
fallbackText(summary.fallbackOccurred, summary.fallbackReason));
return lines.join(QLatin1Char('\n'));
}
diff --git a/src/core/AudioSummaryLogger.h b/src/core/AudioSummaryLogger.h
index c07d99759..a947b6ea6 100644
--- a/src/core/AudioSummaryLogger.h
+++ b/src/core/AudioSummaryLogger.h
@@ -21,7 +21,8 @@ struct TxSourceSummary {
int sampleRate{0};
int channelCount{0};
QAudioFormat::SampleFormat sampleFormat{QAudioFormat::Unknown};
- bool resamplingTo24k{false};
+ bool normalizingTo48k{false};
+ bool radeResamplingTo24k{false};
bool fallbackOccurred{false};
QString fallbackReason;
};
diff --git a/src/core/ClientFinalLimiter.h b/src/core/ClientFinalLimiter.h
index e672bf247..2b772f660 100644
--- a/src/core/ClientFinalLimiter.h
+++ b/src/core/ClientFinalLimiter.h
@@ -7,12 +7,11 @@
namespace AetherSDR {
-// Final-stage brickwall limiter for the TX audio chain — sits at the
-// very tail of the chain, after every user-configurable stage (Gate,
-// EQ, Comp, DeEss, Tube, PUDU, Reverb) AND after the PC mic gain
-// scaling. Its job is to ensure no sample escapes louder than the
-// configured ceiling, regardless of what the upstream chain does (a
-// reverb tail spike, an over-driven PUDU, or a mic-gain user error).
+// Final nonlinear processor in the 48 kHz TX audio chain. It runs after every
+// user-configurable stage (Gate, EQ, Comp, DeEss, Tube, PUDU, Reverb) and after
+// PC mic gain, but before the 48-to-24 kHz egress SRC. It bounds its own 48 kHz
+// output samples against the configured ceiling; downstream SRC reconstruction
+// can overshoot that sample ceiling before final Int16 saturation.
//
// Topology: feed-forward peak limiter with a per-block smoothed
// envelope (fast attack, moderately fast release) applied as a single
@@ -43,9 +42,9 @@ class ClientFinalLimiter {
void setCeilingDb(float db) noexcept;
float ceilingDb() const noexcept;
- // Master output trim applied AFTER the limiter. Useful for
- // setting average level independently of the brickwall ceiling
- // (ceiling caps peaks; trim sets RMS). Range [-12, +12] dB.
+ // Drive trim applied before the limiter. Positive values increase limiter
+ // activity; negative values lower the signal below the ceiling. Range
+ // [-12, +12] dB.
void setOutputTrimDb(float db) noexcept;
float outputTrimDb() const noexcept;
@@ -63,9 +62,8 @@ class ClientFinalLimiter {
// UI-thread meter snapshots.
float inputPeakDb() const noexcept; // pre-limiter peak
- float outputPeakDb() const noexcept; // post-limiter peak (the
- // value the radio actually
- // sees)
+ float outputPeakDb() const noexcept; // post-limiter 48 kHz peak;
+ // egress SRC may overshoot it
float outputRmsDb() const noexcept; // ~300 ms post-limiter RMS
float gainReductionDb() const noexcept; // ≤ 0 dB
bool active() const noexcept; // true while limiter is
diff --git a/src/core/ClientQuindarTone.h b/src/core/ClientQuindarTone.h
index 78586b3a4..2e8399811 100644
--- a/src/core/ClientQuindarTone.h
+++ b/src/core/ClientQuindarTone.h
@@ -128,7 +128,7 @@ class ClientQuindarTone {
// is hit — Quindar must always be locally audible whenever it's
// overlaying the TX stream. Writes to the stereo float32 output
// buffer using independent local-rate phase state (separate from
- // process(), which runs at the radio's 24 kHz TX rate). When the
+ // process(), which runs in the fixed 48 kHz TX DSP domain). When the
// atomic phase is Idle or Live, leaves the buffer as zeros. Never
// mutates the atomic phase — the TX-path process() is the source
// of truth for transitions; this path just mirrors them.
diff --git a/src/core/ClientReverb.cpp b/src/core/ClientReverb.cpp
index c55ba5b1d..6e76f95e5 100644
--- a/src/core/ClientReverb.cpp
+++ b/src/core/ClientReverb.cpp
@@ -38,13 +38,10 @@ void ClientReverb::prepare(double sampleRate)
{
m_sampleRate = sampleRate;
- // Allocate comb + allpass delay buffers at max (Size=1) lengths,
- // per channel. Stereo-spread adds kStereoSpread samples on the
- // right channel.
- // Allocate both channels at the SAME max length — L's stereo-spread
- // variant is the upper bound for both so a single cached active-
- // length works without overrunning either buffer. Trades ~23
- // unused samples per comb per channel for a simpler index loop.
+ // Allocate comb + allpass delay buffers at max (Size=1) lengths. The
+ // historical Freeverb spread constant remains only as common headroom:
+ // both channels use the same maximum and active lengths. This trades about
+ // 23 unused samples per delay per channel for a simpler index loop.
for (int i = 0; i < kNumCombs; ++i) {
const int baseLen = scaleLenForRate(kCombTuningsL44k[i], sampleRate);
const int maxLen = baseLen + kStereoSpread;
diff --git a/src/core/ClientReverb.h b/src/core/ClientReverb.h
index 7d9bd7e5c..5b80de228 100644
--- a/src/core/ClientReverb.h
+++ b/src/core/ClientReverb.h
@@ -8,9 +8,11 @@ namespace AetherSDR {
// Client-side reverb — TX DSP chain Phase 6 (Freeverb). Eight parallel
// lowpass-feedback comb filters in parallel summed through four series
-// allpass filters, stereo-spread by 23 samples between L and R. A
-// pre-delay ring buffer sits in front of the reverb core. Voice-
-// oriented knob set; no "studio" parameters.
+// allpass filters per channel. The current implementation uses matched
+// active delay lengths for L and R; the historical 23-sample spread constant
+// is allocation headroom, not active stereo decorrelation. A pre-delay ring
+// buffer sits in front of the reverb core. Voice-oriented knob set; no
+// "studio" parameters.
//
// Thread model mirrors ClientTube / ClientGate / ClientDeEss: UI
// thread writes atomics + bumps a version counter; the audio thread
@@ -19,8 +21,9 @@ namespace AetherSDR {
//
// Buffer sizes are fixed in prepare() based on sample rate — max comb
// length + stereo-spread headroom for Size=1, plus max pre-delay of
-// 100 ms. Typical TX path runs at 24 kHz; total buffer budget per
-// ClientReverb instance is ~12 kB of float samples.
+// 100 ms. The TX voice strip prepares this processor at 48 kHz; its delay
+// buffers contain about 147 KiB of float storage at that rate, excluding
+// vector bookkeeping.
class ClientReverb {
public:
ClientReverb();
diff --git a/src/core/ClientTxTestTone.cpp b/src/core/ClientTxTestTone.cpp
index 519081ffa..bc8dded16 100644
--- a/src/core/ClientTxTestTone.cpp
+++ b/src/core/ClientTxTestTone.cpp
@@ -89,4 +89,29 @@ void ClientTxTestTone::process(int16_t* interleaved, int frames, int channels) n
}
}
+void ClientTxTestTone::process(float* interleaved, int frames, int channels) noexcept
+{
+ if (!interleaved || frames <= 0 || channels < 1 || channels > 2) {
+ return;
+ }
+ recacheIfDirty();
+ if (!m_cached.enabled) {
+ return;
+ }
+
+ const float inc = m_cached.phaseInc;
+ const float amp = m_cached.ampLin;
+ for (int f = 0; f < frames; ++f) {
+ const float sample = std::sin(m_phase) * amp;
+ interleaved[f * channels] = sample;
+ if (channels == 2) {
+ interleaved[f * channels + 1] = sample;
+ }
+ m_phase += inc;
+ if (m_phase > kTwoPi) {
+ m_phase -= kTwoPi;
+ }
+ }
+}
+
} // namespace AetherSDR
diff --git a/src/core/ClientTxTestTone.h b/src/core/ClientTxTestTone.h
index 2a745be0e..697ebc560 100644
--- a/src/core/ClientTxTestTone.h
+++ b/src/core/ClientTxTestTone.h
@@ -37,6 +37,7 @@ class ClientTxTestTone {
// Audio thread — overwrite int16 stereo samples with the
// generated tone. No-op if disabled.
void process(int16_t* interleaved, int frames, int channels) noexcept;
+ void process(float* interleaved, int frames, int channels) noexcept;
void reset() noexcept;
diff --git a/src/core/DeviceDiagnostics.cpp b/src/core/DeviceDiagnostics.cpp
index 9ba24a3f0..566bf1ee6 100644
--- a/src/core/DeviceDiagnostics.cpp
+++ b/src/core/DeviceDiagnostics.cpp
@@ -276,8 +276,23 @@ QJsonObject buildAudioDevicesSnapshot(const AudioEngine* audio, const QJsonObjec
txRoute["actual_sample_format"] = (audio && audio->isTxStreaming())
? QJsonValue(QStringLiteral("Int16"))
: QJsonValue();
- txRoute["resampling_to_24k"] = (audio && audio->isTxStreaming())
- ? QJsonValue(audio->txInputResamplingTo24k())
+ const TxAudioResamplingRoute txResampling = audio
+ ? txAudioResamplingRoute(
+ audio->isRadeMode(),
+ audio->isDaxTxMode(),
+ audio->txInputNormalizationTo48k(),
+ audio->txRadeResamplingTo24k())
+ : TxAudioResamplingRoute{};
+ txRoute["voice_input_normalizing_to_48k"] =
+ (audio && audio->isTxStreaming())
+ ? QJsonValue(txResampling.voiceInputNormalizingTo48k)
+ : QJsonValue();
+ txRoute["voice_egress_resampling_to_24k"] =
+ (audio && audio->isTxStreaming())
+ ? QJsonValue(txResampling.voiceEgressResamplingTo24k)
+ : QJsonValue();
+ txRoute["rade_resampling_to_24k"] = (audio && audio->isTxStreaming())
+ ? QJsonValue(txResampling.radeResamplingTo24k)
: QJsonValue();
// Surface the active TX slice's id, mode, and per-slice DAX channel here
// so the bundle's TX route summary has the same context a triager would
diff --git a/src/core/DeviceDiagnostics.h b/src/core/DeviceDiagnostics.h
index 8eda4b509..844ba319f 100644
--- a/src/core/DeviceDiagnostics.h
+++ b/src/core/DeviceDiagnostics.h
@@ -15,6 +15,38 @@ struct AudioBusType {
QString source;
};
+struct TxAudioResamplingRoute {
+ bool active{false};
+ bool voiceInputNormalizingTo48k{false};
+ bool voiceEgressResamplingTo24k{false};
+ bool radeResamplingTo24k{false};
+};
+
+// Mirrors AudioEngine::onTxAudioReady() route priority without requiring an
+// audio device in diagnostics tests. RADE consumes the mic buffer first, DAX
+// bypasses it, and normal voice always performs the 48 -> 24 kHz egress SRC.
+inline constexpr TxAudioResamplingRoute txAudioResamplingRoute(
+ bool radeMode,
+ bool daxTxMode,
+ bool voiceInputNeedsNormalization,
+ bool radeInputNeedsResampling) noexcept
+{
+ if (radeMode) {
+ return {
+ .active = radeInputNeedsResampling,
+ .radeResamplingTo24k = radeInputNeedsResampling,
+ };
+ }
+ if (daxTxMode) {
+ return {};
+ }
+ return {
+ .active = true,
+ .voiceInputNormalizingTo48k = voiceInputNeedsNormalization,
+ .voiceEgressResamplingTo24k = true,
+ };
+}
+
inline AudioBusType inferAudioBusType(const QString& description, const QByteArray& id)
{
const QString text = (description + QLatin1Char(' ') + QString::fromLatin1(id)).toLower();
diff --git a/src/core/RNNoiseFilter.cpp b/src/core/RNNoiseFilter.cpp
index 949f0084e..99e6c10d5 100644
--- a/src/core/RNNoiseFilter.cpp
+++ b/src/core/RNNoiseFilter.cpp
@@ -7,6 +7,7 @@
#include
#include
#include
+#include
#include
namespace AetherSDR {
@@ -18,13 +19,16 @@ Q_LOGGING_CATEGORY(lcRn2, "aether.rn2")
// RNNoise frame size: 480 samples at 48kHz = 10ms
static constexpr int FRAME_SIZE = 480;
-RNNoiseFilter::RNNoiseFilter(OutputMode outputMode)
+RNNoiseFilter::RNNoiseFilter(OutputMode outputMode, RateDomain rateDomain)
: m_outputMode(outputMode)
+ , m_rateDomain(rateDomain)
{
for (int channel = 0; channel < processingChannels(); ++channel) {
m_states[channel] = rnnoise_create(nullptr);
- m_up[channel] = std::make_unique(24000, 48000);
- m_down[channel] = std::make_unique(48000, 24000);
+ if (m_rateDomain == RateDomain::Legacy24k) {
+ m_up[channel] = std::make_unique(24000, 48000);
+ m_down[channel] = std::make_unique(48000, 24000);
+ }
}
}
@@ -60,18 +64,29 @@ void RNNoiseFilter::reset()
rnnoise_destroy(m_states[channel]);
const bool inUse = channel < channels;
m_states[channel] = inUse ? rnnoise_create(nullptr) : nullptr;
- m_up[channel] = inUse ? std::make_unique(24000, 48000) : nullptr;
- m_down[channel] = inUse ? std::make_unique(48000, 24000) : nullptr;
- m_inAccum[channel].clear();
+ const bool needsResamplers = inUse
+ && m_rateDomain == RateDomain::Legacy24k;
+ m_up[channel] = needsResamplers
+ ? std::make_unique(24000, 48000)
+ : nullptr;
+ m_down[channel] = needsResamplers
+ ? std::make_unique(48000, 24000)
+ : nullptr;
+ m_inAccum[channel].resize(0);
m_input24k[channel].clear();
m_processed48k[channel].clear();
m_processed48kFloat[channel].clear();
}
- m_outAccum.clear();
+ m_outAccum.resize(0);
}
QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo)
{
+ if (m_rateDomain != RateDomain::Legacy24k) {
+ qCWarning(lcRn2)
+ << "RNNoiseFilter: 24 kHz process() called on native-48 kHz instance";
+ return pcm24kStereo;
+ }
if (!isValid() || pcm24kStereo.isEmpty())
return pcm24kStereo;
@@ -147,7 +162,7 @@ QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo)
reinterpret_cast(&accumData[consumedSamples]),
leftoverSamples * static_cast(sizeof(float)));
} else {
- m_inAccum[channel].clear();
+ m_inAccum[channel].resize(0);
}
// 3. Scale RNNoise output back to [-1, 1], then downsample each
@@ -211,4 +226,110 @@ QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo)
return QByteArray(needed, '\0');
}
+int RNNoiseFilter::process48kStereo(
+ const QByteArray& pcm48kStereo, QByteArray& output)
+{
+ output.resize(0);
+ if (m_rateDomain != RateDomain::Native48k) {
+ qCWarning(lcRn2)
+ << "RNNoiseFilter: process48kStereo() requires native-48 kHz rate domain";
+ output.resize(pcm48kStereo.size());
+ if (!pcm48kStereo.isEmpty()) {
+ std::memcpy(output.data(), pcm48kStereo.constData(),
+ pcm48kStereo.size());
+ }
+ return output.size() / (2 * static_cast(sizeof(float)));
+ }
+ if (!isValid() || pcm48kStereo.isEmpty()) {
+ output.resize(pcm48kStereo.size());
+ if (!pcm48kStereo.isEmpty()) {
+ std::memcpy(output.data(), pcm48kStereo.constData(),
+ pcm48kStereo.size());
+ }
+ return output.size() / (2 * static_cast(sizeof(float)));
+ }
+
+ const int stereoFrames =
+ pcm48kStereo.size() / (2 * static_cast(sizeof(float)));
+ if (stereoFrames <= 0) {
+ return 0;
+ }
+
+ const auto* src = reinterpret_cast(pcm48kStereo.constData());
+ const int channels = processingChannels();
+ int completeFrames = std::numeric_limits::max();
+ std::array totalAccumSamples{0, 0};
+
+ for (int channel = 0; channel < channels; ++channel) {
+ const int start = m_inAccum[channel].size() / static_cast(sizeof(float));
+ m_inAccum[channel].resize(
+ (start + stereoFrames) * static_cast(sizeof(float)));
+ auto* accum = reinterpret_cast(m_inAccum[channel].data());
+ for (int frame = 0; frame < stereoFrames; ++frame) {
+ const float sample = channels == 2
+ ? src[frame * 2 + channel]
+ : 0.5f * (src[frame * 2] + src[frame * 2 + 1]);
+ accum[start + frame] = sample * 32768.0f;
+ }
+ totalAccumSamples[channel] = start + stereoFrames;
+ completeFrames = std::min(
+ completeFrames, totalAccumSamples[channel] / FRAME_SIZE);
+ }
+
+ if (completeFrames > 0) {
+ const int consumedSamples = completeFrames * FRAME_SIZE;
+ for (int channel = 0; channel < channels; ++channel) {
+ m_processed48k[channel].resize(consumedSamples);
+ auto* accum = reinterpret_cast(m_inAccum[channel].data());
+ for (int frame = 0; frame < completeFrames; ++frame) {
+ float* frameOutput =
+ &m_processed48k[channel][frame * FRAME_SIZE];
+ float* input = &accum[frame * FRAME_SIZE];
+ if (m_dryMix > 0.0f) {
+ rnnoise_process_frame_with_dry_mix(
+ m_states[channel], frameOutput, input, m_dryMix);
+ } else {
+ rnnoise_process_frame(m_states[channel], frameOutput, input);
+ }
+ }
+
+ const int leftover = totalAccumSamples[channel] - consumedSamples;
+ if (leftover > 0) {
+ std::memmove(accum, &accum[consumedSamples],
+ leftover * sizeof(float));
+ m_inAccum[channel].resize(
+ leftover * static_cast(sizeof(float)));
+ } else {
+ m_inAccum[channel].resize(0);
+ }
+ }
+
+ const int oldOutputSamples =
+ m_outAccum.size() / static_cast(sizeof(float));
+ m_outAccum.resize(
+ (oldOutputSamples + consumedSamples * 2)
+ * static_cast(sizeof(float)));
+ auto* dst = reinterpret_cast(m_outAccum.data())
+ + oldOutputSamples;
+ for (int frame = 0; frame < consumedSamples; ++frame) {
+ const float left = m_processed48k[0][frame] / 32768.0f;
+ const float right = channels == 2
+ ? m_processed48k[1][frame] / 32768.0f
+ : left;
+ dst[frame * 2] = left;
+ dst[frame * 2 + 1] = right;
+ }
+ }
+
+ const int needed = pcm48kStereo.size();
+ if (m_outAccum.size() >= needed) {
+ output.resize(needed);
+ std::memcpy(output.data(), m_outAccum.constData(), needed);
+ m_outAccum.remove(0, needed);
+ return stereoFrames;
+ }
+ output.fill('\0', needed);
+ return stereoFrames;
+}
+
} // namespace AetherSDR
diff --git a/src/core/RNNoiseFilter.h b/src/core/RNNoiseFilter.h
index b170094d3..268e4f469 100644
--- a/src/core/RNNoiseFilter.h
+++ b/src/core/RNNoiseFilter.h
@@ -32,7 +32,14 @@ class RNNoiseFilter {
ProcessedMono,
};
- explicit RNNoiseFilter(OutputMode outputMode = OutputMode::PreserveRxStereo);
+ enum class RateDomain {
+ Legacy24k,
+ Native48k,
+ };
+
+ explicit RNNoiseFilter(
+ OutputMode outputMode = OutputMode::PreserveRxStereo,
+ RateDomain rateDomain = RateDomain::Legacy24k);
~RNNoiseFilter();
// Process a block of 24kHz stereo FLOAT32 PCM (NOT int16
@@ -41,6 +48,12 @@ class RNNoiseFilter {
// Returns the processed block in the same format and frame count.
QByteArray process(const QByteArray& pcm24kStereo);
+ // Process native RNNoise-rate audio without the wrapper's 24 <-> 48 kHz
+ // resamplers. Input and output are interleaved 48 kHz stereo float32 with
+ // an identical frame count. This is the TX voice path's fixed-rate seam;
+ // the existing process() entry point remains the 24 kHz RX-compatible API.
+ int process48kStereo(const QByteArray& pcm48kStereo, QByteArray& output);
+
// Fraction of the original spectrum retained in each RX frame, clamped to
// [0, 1]. 0 (the default) is full suppression — RN2's behavior since it
// shipped. Owned by Rn2SettingsModel; AudioEngine calls this under its
@@ -61,11 +74,13 @@ class RNNoiseFilter {
std::array, 2> m_up; // 24kHz → 48kHz per channel
std::array, 2> m_down; // 48kHz → 24kHz per channel
std::array m_inAccum; // 48kHz mono float input
- QByteArray m_outAccum; // 24kHz stereo float output
+ // Native 48 kHz or legacy 24 kHz stereo float output.
+ QByteArray m_outAccum;
std::array, 2> m_input24k;
std::array, 2> m_processed48k;
std::array, 2> m_processed48kFloat;
OutputMode m_outputMode{OutputMode::PreserveRxStereo};
+ RateDomain m_rateDomain{RateDomain::Legacy24k};
float m_dryMix{0.0f};
bool m_warnedChannelLengthMismatch{false};
};
diff --git a/src/core/Resampler.cpp b/src/core/Resampler.cpp
index b5a8d9344..b38073b48 100644
--- a/src/core/Resampler.cpp
+++ b/src/core/Resampler.cpp
@@ -2,6 +2,8 @@
#include "CDSPResampler.h"
+#include
+
namespace AetherSDR {
Resampler::Resampler(double srcRate, double dstRate, int maxBlockSamples, double reqTransBand)
@@ -9,6 +11,7 @@ Resampler::Resampler(double srcRate, double dstRate, int maxBlockSamples, double
, m_dstRate(dstRate)
, m_maxBlockSamples(maxBlockSamples)
, m_resampler(std::make_unique(srcRate, dstRate, maxBlockSamples, reqTransBand))
+ , m_groupDelayInputFrames(m_resampler->getInLenBeforeOutPos(0))
{
m_inBuf.reserve(maxBlockSamples);
prewarm();
@@ -18,34 +21,47 @@ Resampler::~Resampler() = default;
QByteArray Resampler::process(const float* in, int numSamples)
{
- if (numSamples <= 0) return {};
+ QByteArray result;
+ process(in, numSamples, result);
+ return result;
+}
- // r8b does not bounds-check against aMaxInLen; exceeding it silently
- // overflows internal filter buffers. Chunk so each call stays within limit.
- if (numSamples > m_maxBlockSamples) {
- QByteArray result;
- for (int offset = 0; offset < numSamples; offset += m_maxBlockSamples)
- result.append(process(in + offset, std::min(numSamples - offset, m_maxBlockSamples)));
- return result;
+int Resampler::process(const float* in, int numSamples, QByteArray& output)
+{
+ output.resize(0);
+ if (!in || numSamples <= 0) {
+ return 0;
}
- // Convert float32 → double
- m_inBuf.resize(numSamples);
- for (int i = 0; i < numSamples; ++i)
- m_inBuf[i] = static_cast(in[i]);
-
- // Resample
- double* outPtr = nullptr;
- int outLen = m_resampler->process(m_inBuf.data(), numSamples, outPtr);
-
- if (outLen <= 0 || !outPtr) return {};
-
- // Convert double → float32
- QByteArray result(outLen * static_cast(sizeof(float)), Qt::Uninitialized);
- auto* dst = reinterpret_cast(result.data());
- for (int i = 0; i < outLen; ++i)
- dst[i] = static_cast(outPtr[i]);
- return result;
+ // r8b does not bounds-check against aMaxInLen; exceeding it silently
+ // overflows internal filter buffers. Feed bounded chunks while appending
+ // directly into the caller's reusable output storage.
+ int totalOutputSamples = 0;
+ for (int offset = 0; offset < numSamples; offset += m_maxBlockSamples) {
+ const int chunkSamples = std::min(
+ numSamples - offset, m_maxBlockSamples);
+ m_inBuf.resize(chunkSamples);
+ for (int index = 0; index < chunkSamples; ++index) {
+ m_inBuf[static_cast(index)] =
+ static_cast(in[offset + index]);
+ }
+
+ double* outputPointer = nullptr;
+ const int outputSamples = m_resampler->process(
+ m_inBuf.data(), chunkSamples, outputPointer);
+ if (outputSamples <= 0 || !outputPointer) {
+ continue;
+ }
+
+ const int oldBytes = output.size();
+ output.resize(oldBytes + outputSamples * static_cast(sizeof(float)));
+ auto* destination = reinterpret_cast(output.data() + oldBytes);
+ for (int index = 0; index < outputSamples; ++index) {
+ destination[index] = static_cast(outputPointer[index]);
+ }
+ totalOutputSamples += outputSamples;
+ }
+ return totalOutputSamples;
}
QByteArray Resampler::processStereoToMono(const float* stereoIn, int numStereoFrames)
@@ -142,6 +158,12 @@ QByteArray Resampler::processStereoToStereo(const float* stereoIn, int numStereo
return result;
}
+void Resampler::reset()
+{
+ m_resampler->clear();
+ prewarm();
+}
+
void Resampler::prewarm()
{
if (std::abs(m_srcRate - m_dstRate) < 0.001) return;
@@ -152,11 +174,12 @@ void Resampler::prewarm()
// first real audio sample produces output immediately, removing the transient that would
// otherwise appear at the start of every audio session.
double* outPtr = nullptr;
- int lenRequired = m_resampler->getInLenBeforeOutPos(0);
+ int lenRequired = m_groupDelayInputFrames;
while (lenRequired > 0) {
- int len = std::min(lenRequired, m_maxBlockSamples);
- std::vector zeros(len, 0.0);
- m_resampler->process(zeros.data(), len, outPtr);
+ const int len = std::min(lenRequired, m_maxBlockSamples);
+ m_inBuf.resize(static_cast(len));
+ std::fill(m_inBuf.begin(), m_inBuf.end(), 0.0);
+ m_resampler->process(m_inBuf.data(), len, outPtr);
lenRequired -= len;
}
}
diff --git a/src/core/Resampler.h b/src/core/Resampler.h
index 4d7672602..2a3374894 100644
--- a/src/core/Resampler.h
+++ b/src/core/Resampler.h
@@ -27,6 +27,11 @@ class Resampler {
// Resample mono float32 PCM. Returns resampled mono float32.
QByteArray process(const float* in, int numSamples);
+ // Reuse caller-owned storage. The returned count is mono float samples.
+ // Reserving output before the first call keeps steady-state conversion
+ // allocation-free for blocks no larger than maxBlockSamples.
+ int process(const float* in, int numSamples, QByteArray& output);
+
// Convenience: stereo float32 → mono downsample → resampled mono float32
QByteArray processStereoToMono(const float* stereoIn, int numStereoFrames);
@@ -36,9 +41,18 @@ class Resampler {
// Convenience: stereo float32 → downmix to mono → resample → duplicate to stereo float32
QByteArray processStereoToStereo(const float* stereoIn, int numStereoFrames);
+ // Discard streaming state and consume startup latency again. Call between
+ // independent streams, never from the realtime process callback.
+ void reset();
+
double srcRate() const { return m_srcRate; }
double dstRate() const { return m_dstRate; }
+ // Linear-phase signal delay expressed in source-rate samples. prewarm()
+ // consumes r8brain's no-output startup interval, but it does not remove
+ // this acoustic delay from the converted waveform.
+ int groupDelayInputFrames() const noexcept { return m_groupDelayInputFrames; }
+
private:
void prewarm();
@@ -46,6 +60,7 @@ class Resampler {
double m_dstRate;
int m_maxBlockSamples;
std::unique_ptr m_resampler;
+ int m_groupDelayInputFrames{0};
std::vector m_inBuf; // float32 → double conversion buffer
};
diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp
new file mode 100644
index 000000000..80dd61a2c
--- /dev/null
+++ b/src/core/TxVoiceProcessor.cpp
@@ -0,0 +1,536 @@
+#include "TxVoiceProcessor.h"
+
+#include "ClientComp.h"
+#include "ClientDeEss.h"
+#include "ClientEq.h"
+#include "ClientFinalLimiter.h"
+#include "ClientGate.h"
+#include "ClientPudu.h"
+#include "ClientQuindarTone.h"
+#include "ClientReverb.h"
+#include "ClientTube.h"
+#include "ClientTxTestTone.h"
+#include "RNNoiseFilter.h"
+#include "Resampler.h"
+
+#include
+
+#include
+#include
+
+namespace AetherSDR {
+
+namespace {
+Q_LOGGING_CATEGORY(lcTxVoiceProcessor, "aether.audio.tx.voice")
+}
+
+TxVoiceProcessor::TxVoiceProcessor() = default;
+TxVoiceProcessor::~TxVoiceProcessor() = default;
+
+bool TxVoiceProcessor::prepare(int inputRate, int maxInputFrames)
+{
+ if (inputRate <= 0 || maxInputFrames <= 0) {
+ return false;
+ }
+
+ m_inputRate = inputRate;
+ m_maxInputFrames = maxInputFrames;
+ if (inputRate != kDspRate) {
+ m_inputResampler = std::make_unique(
+ inputRate,
+ kDspRate,
+ maxInputFrames,
+ kVoiceSrcTransitionBandPercent);
+ } else {
+ m_inputResampler.reset();
+ }
+
+ m_maxDspFrames = static_cast(
+ std::ceil(static_cast(maxInputFrames) * kDspRate / inputRate)) + 32;
+ m_outputLeftResampler = std::make_unique(
+ kDspRate,
+ kTransportRate,
+ m_maxDspFrames,
+ kVoiceSrcTransitionBandPercent);
+ m_outputRightResampler = std::make_unique(
+ kDspRate,
+ kTransportRate,
+ m_maxDspFrames,
+ kVoiceSrcTransitionBandPercent);
+
+ m_inputMono.reserve(static_cast(maxInputFrames));
+ m_outputLeft.reserve(static_cast(m_maxDspFrames));
+ m_outputRight.reserve(static_cast(m_maxDspFrames));
+ m_resampledMono48.reserve(m_maxDspFrames * static_cast(sizeof(float)));
+ m_resampledLeft24.reserve(
+ (m_maxDspFrames / 2 + 32) * static_cast(sizeof(float)));
+ m_resampledRight24.reserve(
+ (m_maxDspFrames / 2 + 32) * static_cast(sizeof(float)));
+ m_rnnoiseOutput48.reserve(
+ m_maxDspFrames * kChannels * static_cast(sizeof(float)));
+ m_work48.reserve(m_maxDspFrames * kChannels * static_cast(sizeof(float)));
+ m_normalized48.reserve(m_work48.capacity());
+ m_postStrip48.reserve(m_work48.capacity());
+ m_transportFloat.reserve(
+ (m_maxDspFrames / 2 + 32) * kChannels * static_cast(sizeof(float)));
+ prepareProcessors();
+ m_prepared = true;
+ reset();
+ return true;
+}
+
+void TxVoiceProcessor::prepareProcessors()
+{
+ if (m_processors.eq) {
+ m_processors.eq->prepare(kDspRate);
+ }
+ if (m_processors.comp) {
+ m_processors.comp->prepare(kDspRate);
+ }
+ if (m_processors.gate) {
+ m_processors.gate->prepare(kDspRate);
+ }
+ if (m_processors.deEss) {
+ m_processors.deEss->prepare(kDspRate);
+ }
+ if (m_processors.tube) {
+ m_processors.tube->prepare(kDspRate);
+ }
+ if (m_processors.pudu) {
+ m_processors.pudu->prepare(kDspRate);
+ }
+ if (m_processors.reverb) {
+ m_processors.reverb->prepare(kDspRate);
+ }
+ if (m_processors.finalLimiter) {
+ m_processors.finalLimiter->prepare(kDspRate);
+ }
+ if (m_processors.testTone) {
+ m_processors.testTone->prepare(kDspRate);
+ }
+ if (m_processors.quindar) {
+ m_processors.quindar->prepare(kDspRate);
+ }
+}
+
+void TxVoiceProcessor::reset()
+{
+ m_ditherState = kDitherSeed;
+ m_warnedEgressFrameMismatch = false;
+ m_egressRecoveryPending = false;
+ if (m_inputResampler) {
+ m_inputResampler->reset();
+ }
+ if (m_outputLeftResampler) {
+ m_outputLeftResampler->reset();
+ }
+ if (m_outputRightResampler) {
+ m_outputRightResampler->reset();
+ }
+ if (m_processors.eq) {
+ m_processors.eq->reset();
+ }
+ if (m_processors.comp) {
+ m_processors.comp->reset();
+ }
+ if (m_processors.gate) {
+ m_processors.gate->reset();
+ }
+ if (m_processors.deEss) {
+ m_processors.deEss->reset();
+ }
+ if (m_processors.tube) {
+ m_processors.tube->reset();
+ }
+ if (m_processors.pudu) {
+ m_processors.pudu->reset();
+ }
+ if (m_processors.reverb) {
+ m_processors.reverb->reset();
+ }
+ if (m_processors.finalLimiter) {
+ m_processors.finalLimiter->reset();
+ }
+ if (m_processors.testTone) {
+ m_processors.testTone->reset();
+ }
+ if (m_processors.quindar) {
+ m_processors.quindar->reset();
+ }
+ if (m_rnnoiseEnabled && m_processors.rnnoise) {
+ m_processors.rnnoise->reset();
+ }
+ m_work48.resize(0);
+ m_resampledMono48.resize(0);
+ m_resampledLeft24.resize(0);
+ m_resampledRight24.resize(0);
+ m_rnnoiseOutput48.resize(0);
+ m_normalized48.resize(0);
+ m_postStrip48.resize(0);
+ m_transportFloat.resize(0);
+}
+
+void TxVoiceProcessor::setProcessors(const Processors& processors) noexcept
+{
+ m_processors = processors;
+ if (m_prepared) {
+ prepareProcessors();
+ }
+}
+
+void TxVoiceProcessor::setStageOrder(uint64_t packedStages) noexcept
+{
+ m_packedStages = packedStages;
+}
+
+void TxVoiceProcessor::setRnnoiseEnabled(bool enabled) noexcept
+{
+ m_rnnoiseEnabled = enabled;
+}
+
+void TxVoiceProcessor::setRnnoise(RNNoiseFilter* rnnoise) noexcept
+{
+ m_processors.rnnoise = rnnoise;
+}
+
+void TxVoiceProcessor::setMicGain(float gain) noexcept
+{
+ m_micGain = std::isfinite(gain) ? std::clamp(gain, 0.0f, 1.0f) : 1.0f;
+}
+
+void TxVoiceProcessor::setMeasurementCaptureEnabled(bool enabled) noexcept
+{
+ m_captureMeasurements = enabled;
+ if (!enabled) {
+ m_normalized48.resize(0);
+ m_postStrip48.resize(0);
+ }
+}
+
+bool TxVoiceProcessor::processCapturedInt16(QByteArray& canonicalInputOutput)
+{
+ if (!m_prepared || canonicalInputOutput.isEmpty()) {
+ return false;
+ }
+ const int inputFrames = canonicalInputOutput.size()
+ / (kChannels * static_cast(sizeof(int16_t)));
+ if (inputFrames <= 0) {
+ return false;
+ }
+
+ // m_maxInputFrames is the expected callback size used for preparation and
+ // steady-state allocation. Device callbacks can exceed it after a
+ // scheduling stall; the stateful resamplers already process such input in
+ // bounded chunks, and the scratch buffers may grow for this exceptional
+ // case. Process the delayed audio now instead of creating a silent hole.
+
+ const auto* input = reinterpret_cast(
+ canonicalInputOutput.constData());
+ m_inputMono.resize(static_cast(inputFrames));
+ for (int frame = 0; frame < inputFrames; ++frame) {
+ m_inputMono[static_cast(frame)] = input[frame * 2] / 32768.0f;
+ }
+
+ const float* mono48Samples = m_inputMono.data();
+ int frames48 = inputFrames;
+ if (m_inputResampler) {
+ frames48 = m_inputResampler->process(
+ m_inputMono.data(), inputFrames, m_resampledMono48);
+ mono48Samples = reinterpret_cast(
+ m_resampledMono48.constData());
+ }
+ if (frames48 <= 0) {
+ return false;
+ }
+
+ m_work48.resize(frames48 * kChannels * static_cast(sizeof(float)));
+ auto* work = reinterpret_cast(m_work48.data());
+ for (int frame = 0; frame < frames48; ++frame) {
+ work[frame * 2] = mono48Samples[frame];
+ work[frame * 2 + 1] = mono48Samples[frame];
+ }
+ return processWorkBuffer(frames48, canonicalInputOutput);
+}
+
+bool TxVoiceProcessor::processFloat48(const float* interleavedStereo,
+ int frames,
+ QByteArray& transportInt16Output)
+{
+ if (!m_prepared || !interleavedStereo || frames <= 0
+ || frames > m_maxDspFrames) {
+ return false;
+ }
+ m_work48.resize(frames * kChannels * static_cast(sizeof(float)));
+ std::copy_n(interleavedStereo, frames * kChannels,
+ reinterpret_cast(m_work48.data()));
+ return processWorkBuffer(frames, transportInt16Output);
+}
+
+bool TxVoiceProcessor::processWorkBuffer(int frames48,
+ QByteArray& transportInt16Output)
+{
+ if (m_captureMeasurements) {
+ m_normalized48 = m_work48;
+ }
+
+ if (m_rnnoiseEnabled && m_processors.rnnoise
+ && m_processors.rnnoise->isValid()) {
+ m_processors.rnnoise->process48kStereo(m_work48, m_rnnoiseOutput48);
+ m_work48.swap(m_rnnoiseOutput48);
+ }
+
+ auto* work = reinterpret_cast(m_work48.data());
+ if (m_processors.testTone && m_processors.testTone->isEnabled()) {
+ m_processors.testTone->process(work, frames48, kChannels);
+ }
+ processChannelStrip(m_work48);
+ if (m_captureMeasurements) {
+ m_postStrip48 = m_work48;
+ }
+
+ work = reinterpret_cast(m_work48.data());
+ if (m_micGain < 0.999f) {
+ for (int sample = 0; sample < frames48 * kChannels; ++sample) {
+ work[sample] *= m_micGain;
+ }
+ }
+ if (m_processors.quindar) {
+ m_processors.quindar->process(work, frames48, kChannels);
+ }
+ if (m_processors.finalLimiter) {
+ m_processors.finalLimiter->process(work, frames48, kChannels);
+ }
+
+ // A malformed parameter transition or third-party DSP failure must not
+ // poison the stateful egress SRC with NaN/Inf values. Replace only
+ // non-finite samples; finite over-range samples are still measured and
+ // clipped once at the integer transport boundary below.
+ for (int sample = 0; sample < frames48 * kChannels; ++sample) {
+ if (!std::isfinite(work[sample])) {
+ work[sample] = 0.0f;
+ }
+ }
+
+ m_outputLeft.resize(static_cast(frames48));
+ m_outputRight.resize(static_cast(frames48));
+ for (int frame = 0; frame < frames48; ++frame) {
+ m_outputLeft[static_cast(frame)] = work[frame * 2];
+ m_outputRight[static_cast(frame)] = work[frame * 2 + 1];
+ }
+ const int leftOutputFrames = m_outputLeftResampler->process(
+ m_outputLeft.data(), frames48, m_resampledLeft24);
+ const int rightOutputFrames = m_outputRightResampler->process(
+ m_outputRight.data(), frames48, m_resampledRight24);
+ const int outputFrames = reconcileEgressFrameCounts(
+ leftOutputFrames, rightOutputFrames);
+ if (outputFrames <= 0) {
+ m_transportFloat.resize(0);
+ transportInt16Output.resize(0);
+ return false;
+ }
+
+ m_transportFloat.resize(
+ outputFrames * kChannels * static_cast(sizeof(float)));
+ transportInt16Output.resize(
+ outputFrames * kChannels * static_cast(sizeof(int16_t)));
+ auto* outputFloat = reinterpret_cast(m_transportFloat.data());
+ auto* outputInt16 = reinterpret_cast(
+ transportInt16Output.data());
+ const auto* left = reinterpret_cast(m_resampledLeft24.constData());
+ const auto* right = reinterpret_cast(m_resampledRight24.constData());
+ for (int frame = 0; frame < outputFrames; ++frame) {
+ outputFloat[frame * 2] = left[frame];
+ outputFloat[frame * 2 + 1] = right[frame];
+ const bool exactDigitalSilence = left[frame] == 0.0f
+ && right[frame] == 0.0f;
+ // Advance the streaming PRNG for every frame so dither remains
+ // block-partition invariant. Exact zero has no quantization error to
+ // decorrelate, so preserve the transport's digital-silence code.
+ const float ditherLsb = nextTpdfDitherLsb();
+ if (exactDigitalSilence) {
+ outputInt16[frame * 2] = 0;
+ outputInt16[frame * 2 + 1] = 0;
+ continue;
+ }
+ for (int channel = 0; channel < kChannels; ++channel) {
+ outputInt16[frame * 2 + channel] = quantizeTransportSample(
+ outputFloat[frame * 2 + channel], ditherLsb);
+ }
+ }
+ return true;
+}
+
+int TxVoiceProcessor::reconcileEgressFrameCounts(
+ int leftFrames, int rightFrames)
+{
+ if (leftFrames == rightFrames) {
+ m_warnedEgressFrameMismatch = false;
+ return leftFrames;
+ }
+
+ if (!m_warnedEgressFrameMismatch) {
+ qCWarning(lcTxVoiceProcessor)
+ << "TX egress SRC channel frame mismatch: left=" << leftFrames
+ << "right=" << rightFrames
+ << "-- transmitting the common prefix and requesting recovery"
+ " between callbacks";
+ m_warnedEgressFrameMismatch = true;
+ }
+
+ // Both resamplers consumed the same input interval, so their common
+ // prefix belongs to the current aligned timeline. Preserve that prefix,
+ // discard only the unmatched tail, and ask the owner to reset both
+ // histories between callbacks so the dropped tail cannot become a
+ // permanent L/R offset. Resampler::reset() performs prewarming and must not
+ // run from this realtime processing call.
+ m_egressRecoveryPending = true;
+ return std::min(leftFrames, rightFrames);
+}
+
+void TxVoiceProcessor::recoverEgressAfterMismatch()
+{
+ if (!m_egressRecoveryPending) {
+ return;
+ }
+ m_outputLeftResampler->reset();
+ m_outputRightResampler->reset();
+ m_egressRecoveryPending = false;
+ m_warnedEgressFrameMismatch = false;
+}
+
+uint32_t TxVoiceProcessor::nextDitherRandom24() noexcept
+{
+ // SplitMix64 is deterministic, allocation-free, and has no zero-state
+ // trap. The upper 24 bits provide more than enough resolution for dither
+ // applied at an int16 boundary.
+ m_ditherState += 0x9E3779B97F4A7C15ULL;
+ uint64_t mixed = m_ditherState;
+ mixed = (mixed ^ (mixed >> 30U)) * 0xBF58476D1CE4E5B9ULL;
+ mixed = (mixed ^ (mixed >> 27U)) * 0x94D049BB133111EBULL;
+ mixed ^= mixed >> 31U;
+ return static_cast(mixed >> 40U);
+}
+
+float TxVoiceProcessor::nextTpdfDitherLsb() noexcept
+{
+ constexpr float kRandom24Scale = 1.0f / 16777216.0f;
+ const int32_t first = static_cast(nextDitherRandom24());
+ const int32_t second = static_cast(nextDitherRandom24());
+ return static_cast(first - second) * kRandom24Scale;
+}
+
+int16_t TxVoiceProcessor::quantizeTransportSample(
+ float sample, float ditherLsb) noexcept
+{
+ const double dithered = static_cast(sample) * 32768.0
+ + static_cast(ditherLsb);
+ const double saturated = std::clamp(dithered, -32768.0, 32767.0);
+ return static_cast(std::lround(saturated));
+}
+
+void TxVoiceProcessor::processChannelStrip(QByteArray& float48Stereo) noexcept
+{
+ auto* samples = reinterpret_cast(float48Stereo.data());
+ const int frames = float48Stereo.size()
+ / (kChannels * static_cast(sizeof(float)));
+ for (int index = 0; index < kMaxStages; ++index) {
+ const Stage stage = static_cast(
+ (m_packedStages >> (index * 8)) & 0xFF);
+ switch (stage) {
+ case Stage::None:
+ return;
+ case Stage::Eq:
+ if (m_processors.eq && m_processors.eq->isEnabled()) {
+ m_processors.eq->process(samples, frames, kChannels);
+ }
+ if (m_processors.eqTap) {
+ m_processors.eqTap(m_processors.eqTapContext, samples, frames);
+ }
+ break;
+ case Stage::Comp:
+ if (m_processors.comp
+ && (m_processors.comp->isEnabled()
+ || m_processors.comp->driveDb() > 0.0f
+ || m_processors.comp->phaseRotatorStages() > 0
+ || m_processors.comp->limiterEnabled())) {
+ m_processors.comp->process(samples, frames, kChannels);
+ }
+ break;
+ case Stage::Gate:
+ if (m_processors.gate && m_processors.gate->isEnabled()) {
+ m_processors.gate->process(samples, frames, kChannels);
+ }
+ break;
+ case Stage::DeEss:
+ if (m_processors.deEss && m_processors.deEss->isEnabled()) {
+ m_processors.deEss->process(samples, frames, kChannels);
+ }
+ break;
+ case Stage::Tube:
+ if (m_processors.tube && m_processors.tube->isEnabled()) {
+ m_processors.tube->process(samples, frames, kChannels);
+ }
+ break;
+ case Stage::Enh:
+ if (m_processors.pudu && m_processors.pudu->isEnabled()) {
+ m_processors.pudu->process(samples, frames, kChannels);
+ }
+ break;
+ case Stage::Reverb:
+ if (m_processors.reverb && m_processors.reverb->isEnabled()) {
+ m_processors.reverb->process(samples, frames, kChannels);
+ }
+ break;
+ }
+ }
+}
+
+const QByteArray& TxVoiceProcessor::transportFloat32Stereo() const noexcept
+{
+ return m_transportFloat;
+}
+
+const QByteArray& TxVoiceProcessor::normalizedFloat48Stereo() const noexcept
+{
+ return m_normalized48;
+}
+
+const QByteArray& TxVoiceProcessor::postChannelStripFloat48Stereo() const noexcept
+{
+ return m_postStrip48;
+}
+
+int TxVoiceProcessor::latencyFrames() const noexcept
+{
+ int frames = 0;
+ if (m_inputResampler) {
+ frames += static_cast(std::lround(
+ static_cast(m_inputResampler->groupDelayInputFrames())
+ * kDspRate / m_inputRate));
+ }
+ if (m_outputLeftResampler) {
+ frames += static_cast(std::lround(
+ static_cast(m_outputLeftResampler->groupDelayInputFrames())
+ * kDspRate / m_outputLeftResampler->srcRate()));
+ }
+
+ frames += m_rnnoiseEnabled && m_processors.rnnoise
+ && m_processors.rnnoise->isValid()
+ ? 480
+ : 0;
+ for (int index = 0; index < kMaxStages; ++index) {
+ const Stage stage = static_cast(
+ (m_packedStages >> (index * 8)) & 0xFF);
+ if (stage == Stage::None) {
+ break;
+ }
+ if (stage == Stage::Gate && m_processors.gate
+ && m_processors.gate->isEnabled()) {
+ frames += static_cast(std::lround(
+ m_processors.gate->lookaheadMs() * kDspRate / 1000.0f));
+ }
+ }
+ return frames;
+}
+
+} // namespace AetherSDR
diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h
new file mode 100644
index 000000000..a86009653
--- /dev/null
+++ b/src/core/TxVoiceProcessor.h
@@ -0,0 +1,175 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+
+namespace AetherSDR {
+
+class ClientComp;
+class ClientDeEss;
+class ClientEq;
+class ClientFinalLimiter;
+class ClientGate;
+class ClientPudu;
+class ClientQuindarTone;
+class ClientReverb;
+class ClientTube;
+class ClientTxTestTone;
+class RNNoiseFilter;
+class Resampler;
+class TxVoiceProcessorTestAccess;
+
+// Headless, backend-independent TX voice rate-domain processor. AudioEngine
+// remains responsible for capture normalization, mode routing, metering, and
+// transport. This class makes the canonical 48 kHz float DSP island explicit
+// and returns the existing 24 kHz stereo int16 voice-output representation.
+// It is the final PCM boundary for Flex remote_audio_tx; host-modulating
+// backends currently consume the same seam and may convert onward.
+class TxVoiceProcessor {
+public:
+ static constexpr int kDspRate = 48000;
+ static constexpr int kTransportRate = 24000;
+ static constexpr int kChannels = 2;
+ static constexpr int kMaxStages = 8;
+ // Voice only: preserve the supported 10 kHz modulation passband while
+ // avoiding the delay of r8brain's general-purpose 2% transition profile.
+ static constexpr double kVoiceSrcTransitionBandPercent = 12.0;
+
+ enum class Stage : uint8_t {
+ None = 0,
+ Gate = 1,
+ Eq = 2,
+ DeEss = 3,
+ Comp = 4,
+ Tube = 5,
+ Enh = 6,
+ Reverb = 7,
+ };
+
+ struct Processors {
+ using EqTap = void (*)(void* context, const float* stereo, int frames);
+
+ ClientEq* eq{nullptr};
+ ClientComp* comp{nullptr};
+ ClientGate* gate{nullptr};
+ ClientDeEss* deEss{nullptr};
+ ClientTube* tube{nullptr};
+ ClientPudu* pudu{nullptr};
+ ClientReverb* reverb{nullptr};
+ ClientFinalLimiter* finalLimiter{nullptr};
+ ClientTxTestTone* testTone{nullptr};
+ ClientQuindarTone* quindar{nullptr};
+ RNNoiseFilter* rnnoise{nullptr};
+ EqTap eqTap{nullptr};
+ void* eqTapContext{nullptr};
+ };
+
+ TxVoiceProcessor();
+ ~TxVoiceProcessor();
+
+ TxVoiceProcessor(const TxVoiceProcessor&) = delete;
+ TxVoiceProcessor& operator=(const TxVoiceProcessor&) = delete;
+
+ // Call outside the realtime callback when the capture format changes.
+ // maxInputFrames sizes the normal realtime working set; an unusually
+ // large capture callback is still processed rather than discarded.
+ bool prepare(int inputRate, int maxInputFrames);
+ void reset();
+
+ void setProcessors(const Processors& processors) noexcept;
+ void setStageOrder(uint64_t packedStages) noexcept;
+ void setRnnoiseEnabled(bool enabled) noexcept;
+ // Non-owning association. The owner must pass nullptr before destroying
+ // the RNNoiseFilter; AudioEngine establishes and clears it at that seam.
+ void setRnnoise(RNNoiseFilter* rnnoise) noexcept;
+ void setMicGain(float gain) noexcept;
+ void setMeasurementCaptureEnabled(bool enabled) noexcept;
+
+ // Input must already be canonical duplicated-stereo int16. The channel
+ // selection/averaging policy remains in TxMicChannelNormalizer. On
+ // success, the consumed capture block is replaced in-place with the final
+ // 24 kHz stereo int16 transport block. The caller retains ownership, so a
+ // queued consumer can keep that immutable block without sharing storage
+ // that this processor will write on the next callback.
+ bool processCapturedInt16(QByteArray& canonicalInputOutput);
+
+ // Offline/test entry point for audio already in the canonical DSP domain.
+ // Input is interleaved stereo float32 at exactly 48 kHz. The caller owns
+ // the returned 24 kHz stereo int16 transport block.
+ bool processFloat48(const float* interleavedStereo,
+ int frames,
+ QByteArray& transportInt16Output);
+
+ // A matched egress pair should always return equal frame counts. If it
+ // does not, processing preserves the aligned common prefix and raises this
+ // request. The owner must service it between realtime callbacks; resetting
+ // r8brain from processWorkBuffer() would violate Resampler's contract.
+ bool egressRecoveryPending() const noexcept
+ {
+ return m_egressRecoveryPending;
+ }
+ void recoverEgressAfterMismatch();
+
+ const QByteArray& transportFloat32Stereo() const noexcept;
+ const QByteArray& normalizedFloat48Stereo() const noexcept;
+ const QByteArray& postChannelStripFloat48Stereo() const noexcept;
+
+ int inputRate() const noexcept { return m_inputRate; }
+ bool isPrepared() const noexcept { return m_prepared; }
+
+ // Configured-path delay expressed in 48 kHz DSP frames. Includes exact
+ // serial ingress/egress SRC group delay, RNNoise's nominal one-frame delay,
+ // and enabled gate lookahead. The matched L/R egress SRCs run in parallel
+ // and therefore contribute one delay. RNNoise's callback-sized streaming
+ // adapter can add startup silence for irregular block partitions, so its
+ // term is not an exact wall-clock guarantee. Reverb pre-delay is an
+ // artistic wet-path parameter rather than whole-signal latency.
+ int latencyFrames() const noexcept;
+
+private:
+ friend class TxVoiceProcessorTestAccess;
+
+ static constexpr uint64_t kDitherSeed = 0x6A09E667F3BCC909ULL;
+
+ void processChannelStrip(QByteArray& float48Stereo) noexcept;
+ bool processWorkBuffer(int frames48, QByteArray& transportInt16Output);
+ int reconcileEgressFrameCounts(int leftFrames, int rightFrames);
+ void prepareProcessors();
+ uint32_t nextDitherRandom24() noexcept;
+ float nextTpdfDitherLsb() noexcept;
+ static int16_t quantizeTransportSample(
+ float sample, float ditherLsb) noexcept;
+
+ int m_inputRate{kDspRate};
+ int m_maxInputFrames{0};
+ int m_maxDspFrames{0};
+ bool m_prepared{false};
+ bool m_rnnoiseEnabled{false};
+ bool m_captureMeasurements{false};
+ bool m_warnedEgressFrameMismatch{false};
+ bool m_egressRecoveryPending{false};
+ float m_micGain{1.0f};
+ uint64_t m_packedStages{0};
+ uint64_t m_ditherState{kDitherSeed};
+ Processors m_processors;
+
+ std::unique_ptr m_inputResampler;
+ std::unique_ptr m_outputLeftResampler;
+ std::unique_ptr m_outputRightResampler;
+ std::vector m_inputMono;
+ std::vector m_outputLeft;
+ std::vector m_outputRight;
+ QByteArray m_resampledMono48;
+ QByteArray m_resampledLeft24;
+ QByteArray m_resampledRight24;
+ QByteArray m_rnnoiseOutput48;
+ QByteArray m_work48;
+ QByteArray m_normalized48;
+ QByteArray m_postStrip48;
+ QByteArray m_transportFloat;
+};
+
+} // namespace AetherSDR
diff --git a/src/gui/MainWindow_DspApplets.cpp b/src/gui/MainWindow_DspApplets.cpp
index bd756ce25..91a2ecdbb 100644
--- a/src/gui/MainWindow_DspApplets.cpp
+++ b/src/gui/MainWindow_DspApplets.cpp
@@ -738,7 +738,7 @@ void MainWindow::applySpeechProcessorToClientComp(bool operatorIntent)
// EqualizerModel emits `eq TXsc 63Hz=…` / `eq RXsc …`, which a radio with no
// Flex command plane never receives. The equalizer those sliders are asking for
// is ClientEq, already in both audio paths — TX through
-// AudioEngine::applyClientTxDspInt16, RX through processMixedRxAudioData.
+// TxVoiceProcessor, RX through AudioEngine::processMixedRxAudioData.
//
// THE OCTAVE BANDS OCCUPY ClientEq SLOTS 0..7, which are the same slots the
// Aetherial strip's editor uses, because these are the same ClientEq objects the
diff --git a/src/gui/SliceTroubleshootingDialog.cpp b/src/gui/SliceTroubleshootingDialog.cpp
index cc2f43408..d7bb0caa6 100644
--- a/src/gui/SliceTroubleshootingDialog.cpp
+++ b/src/gui/SliceTroubleshootingDialog.cpp
@@ -246,13 +246,19 @@ QString formatAudioEndpointBullet(const QJsonObject& endpoint)
const QString backend = orPlaceholder(endpoint["backend"].toString());
const QString state = orPlaceholder(endpoint["state"].toString(), "n/a");
const QString error = orPlaceholder(endpoint["error"].toString(), "n/a");
- const QString details = QString("rate `%1`, channels `%2`, format `%3`, resampling `%4`")
+ QString details = QString("rate `%1`, channels `%2`, format `%3`, resampling `%4`")
.arg(formatHzValue(endpoint["sample_rate_hz"]))
.arg(endpoint["channel_count"].isDouble()
? QString::number(endpoint["channel_count"].toInt())
: QStringLiteral("n/a"))
.arg(orPlaceholder(endpoint["sample_format"].toString()))
.arg(formatBoolValue(endpoint["resampling_active"]));
+ if (endpoint.contains("voice_input_normalizing_to_48k")) {
+ details += QString(", voice input normalization to 48 kHz `%1`, voice egress resampling to 24 kHz `%2`, RADE resampling to 24 kHz `%3`")
+ .arg(formatBoolValue(endpoint["voice_input_normalizing_to_48k"]))
+ .arg(formatBoolValue(endpoint["voice_egress_resampling_to_24k"]))
+ .arg(formatBoolValue(endpoint["rade_resampling_to_24k"]));
+ }
QString line = QString("- `%1` [%2 %3]: operational `%4`, running `%5`, state `%6`, error `%7`, backend `%8`, device `%9`, %10")
.arg(orPlaceholder(endpoint["name"].toString()))
diff --git a/tests/audio_format_negotiation_test.cpp b/tests/audio_format_negotiation_test.cpp
index 59c9cb584..59630253f 100644
--- a/tests/audio_format_negotiation_test.cpp
+++ b/tests/audio_format_negotiation_test.cpp
@@ -157,8 +157,9 @@ int main()
Mac, Out, Pan, dev({48000}, {F}),
true, 48000, F, ResamplerKind::PreservePan, 2, true, FormatPreference::Int16First});
- // ── macOS Bluetooth-HFP mic: native low rate first, NOT forced to 48k
- // (#2615). preferred-first puts 16k ahead of the 48k ladder. ───────────
+ // ── macOS Bluetooth-HFP mic: open the native low rate first (#2615).
+ // preferred-first puts 16k ahead of the 48k device ladder; downstream
+ // voice DSP still normalizes the captured signal to its own 48k domain. ─
{
DeviceCaps c = dev({8000, 16000, 24000}, {I});
c.isBluetoothHfp = true;
diff --git a/tests/device_diagnostics_test.cpp b/tests/device_diagnostics_test.cpp
index 6a423a219..b9ff11d43 100644
--- a/tests/device_diagnostics_test.cpp
+++ b/tests/device_diagnostics_test.cpp
@@ -7,6 +7,8 @@
#include
using AetherSDR::DeviceDiagnostics::inferAudioBusType;
+using AetherSDR::DeviceDiagnostics::TxAudioResamplingRoute;
+using AetherSDR::DeviceDiagnostics::txAudioResamplingRoute;
namespace {
@@ -62,6 +64,46 @@ int main()
QByteArray("coreaudio-default-output"),
QStringLiteral("Unknown"));
+ const TxAudioResamplingRoute nativeVoice = txAudioResamplingRoute(
+ false, false, false, false);
+ report("48 kHz voice reports its egress SRC",
+ nativeVoice.active
+ && !nativeVoice.voiceInputNormalizingTo48k
+ && nativeVoice.voiceEgressResamplingTo24k
+ && !nativeVoice.radeResamplingTo24k);
+
+ const TxAudioResamplingRoute normalizedVoice = txAudioResamplingRoute(
+ false, false, true, true);
+ report("non-48 kHz voice reports both serial SRCs",
+ normalizedVoice.active
+ && normalizedVoice.voiceInputNormalizingTo48k
+ && normalizedVoice.voiceEgressResamplingTo24k
+ && !normalizedVoice.radeResamplingTo24k);
+
+ const TxAudioResamplingRoute nativeRade = txAudioResamplingRoute(
+ true, false, true, false);
+ report("native-rate RADE reports no active SRC",
+ !nativeRade.active
+ && !nativeRade.voiceInputNormalizingTo48k
+ && !nativeRade.voiceEgressResamplingTo24k
+ && !nativeRade.radeResamplingTo24k);
+
+ const TxAudioResamplingRoute resampledRade = txAudioResamplingRoute(
+ true, true, true, true);
+ report("RADE route priority reports only its active SRC",
+ resampledRade.active
+ && !resampledRade.voiceInputNormalizingTo48k
+ && !resampledRade.voiceEgressResamplingTo24k
+ && resampledRade.radeResamplingTo24k);
+
+ const TxAudioResamplingRoute daxOnly = txAudioResamplingRoute(
+ false, true, true, true);
+ report("DAX mic bypass reports no SRC",
+ !daxOnly.active
+ && !daxOnly.voiceInputNormalizingTo48k
+ && !daxOnly.voiceEgressResamplingTo24k
+ && !daxOnly.radeResamplingTo24k);
+
std::printf("\n%s\n", g_failed == 0 ? "All tests passed." : "Some tests failed.");
return g_failed == 0 ? 0 : 1;
}
diff --git a/tests/rnnoise_filter_test.cpp b/tests/rnnoise_filter_test.cpp
index 5a5ecc6e9..b3a87717e 100644
--- a/tests/rnnoise_filter_test.cpp
+++ b/tests/rnnoise_filter_test.cpp
@@ -665,6 +665,55 @@ bool testBinauralPhaseDifferenceSurvivesDenoising()
return true;
}
+bool testNative48ReusableOutputPreservesCapacity()
+{
+ RNNoiseFilter filter(
+ RNNoiseFilter::OutputMode::ProcessedMono,
+ RNNoiseFilter::RateDomain::Native48k);
+ QByteArray input(
+ kBlockFrames * 2 * static_cast(sizeof(float)), '\0');
+ QByteArray output;
+ output.reserve(input.size() * 4);
+ const qsizetype reservedCapacity = output.capacity();
+ const int outputFrames = filter.process48kStereo(input, output);
+
+ if (outputFrames != kBlockFrames || output.size() != input.size()
+ || output.capacity() < reservedCapacity) {
+ std::printf("native-48 reusable output lost capacity: "
+ "frames=%d bytes=%lld reserved=%lld capacity=%lld\n",
+ outputFrames,
+ static_cast(output.size()),
+ static_cast(reservedCapacity),
+ static_cast(output.capacity()));
+ return false;
+ }
+ return true;
+}
+
+bool testNative48EntryPointRejectsLegacyRateDomain()
+{
+ RNNoiseFilter filter(
+ RNNoiseFilter::OutputMode::ProcessedMono,
+ RNNoiseFilter::RateDomain::Legacy24k);
+ QByteArray input(
+ kBlockFrames * 2 * static_cast(sizeof(float)), Qt::Uninitialized);
+ auto* samples = reinterpret_cast(input.data());
+ for (int frame = 0; frame < kBlockFrames; ++frame) {
+ samples[frame * 2] = 0.25f;
+ samples[frame * 2 + 1] = -0.125f;
+ }
+
+ QByteArray output;
+ const int outputFrames = filter.process48kStereo(input, output);
+ if (outputFrames != kBlockFrames || output != input) {
+ std::printf("legacy rate domain did not pass through native-48 input: "
+ "frames=%d bytes=%lld\n",
+ outputFrames, static_cast(output.size()));
+ return false;
+ }
+ return true;
+}
+
} // namespace
int main()
@@ -674,7 +723,9 @@ int main()
|| !testProcessedMonoOutputIsDuplicated()
|| !testNoiseFloorDoesNotBreatheWithSpeech()
|| !testStereoChannelsStaySynchronizedWithConstantDryFloor()
- || !testBinauralPhaseDifferenceSurvivesDenoising()) {
+ || !testBinauralPhaseDifferenceSurvivesDenoising()
+ || !testNative48ReusableOutputPreservesCapacity()
+ || !testNative48EntryPointRejectsLegacyRateDomain()) {
return 1;
}
std::printf("rnnoise_filter_test passed\n");
diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp
new file mode 100644
index 000000000..0331f07ad
--- /dev/null
+++ b/tests/tx_voice_processor_test.cpp
@@ -0,0 +1,961 @@
+#include "core/ClientTube.h"
+#include "core/ClientGate.h"
+#include "core/RNNoiseFilter.h"
+#include "core/Resampler.h"
+#include "core/TxVoiceProcessor.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include