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 +#include + +using AetherSDR::ClientTube; +using AetherSDR::ClientGate; +using AetherSDR::RNNoiseFilter; +using AetherSDR::Resampler; +using AetherSDR::TxVoiceProcessor; + +namespace AetherSDR { + +class TxVoiceProcessorTestAccess +{ +public: + static int reconcileEgressFrameCounts( + TxVoiceProcessor& processor, int leftFrames, int rightFrames) + { + return processor.reconcileEgressFrameCounts(leftFrames, rightFrames); + } + + static void dirtyEgressResamplerHistories(TxVoiceProcessor& processor) + { + std::vector left(480, 0.25f); + std::vector right(960, -0.25f); + QByteArray discarded; + processor.m_outputLeftResampler->process( + left.data(), static_cast(left.size()), discarded); + processor.m_outputRightResampler->process( + right.data(), static_cast(right.size()), discarded); + } +}; + +} // namespace AetherSDR + +namespace { + +int g_failed = 0; + +void report(const char* name, bool ok, const std::string& detail = {}) +{ + std::printf("%s %-68s %s\n", + ok ? "[ OK ]" : "[FAIL]", + name, + detail.c_str()); + if (!ok) { + ++g_failed; + } +} + +QByteArray makeCanonicalTone(int frames, int sampleRate, float frequencyHz) +{ + QByteArray result( + frames * 2 * static_cast(sizeof(int16_t)), Qt::Uninitialized); + auto* samples = reinterpret_cast(result.data()); + constexpr double kTwoPi = 6.28318530717958647692; + for (int frame = 0; frame < frames; ++frame) { + const float value = 0.25f * static_cast( + std::sin(kTwoPi * frequencyHz * frame / sampleRate)); + const int16_t quantized = static_cast(value * 32767.0f); + samples[frame * 2] = quantized; + samples[frame * 2 + 1] = quantized; + } + return result; +} + +std::vector makeFloatStereoTone( + int frames, int sampleRate, float frequencyHz) +{ + std::vector result(static_cast(frames * 2)); + constexpr double kTwoPi = 6.28318530717958647692; + for (int frame = 0; frame < frames; ++frame) { + const float sample = 0.25f * static_cast( + std::sin(kTwoPi * frequencyHz * frame / sampleRate)); + result[static_cast(frame * 2)] = sample; + result[static_cast(frame * 2 + 1)] = sample; + } + return result; +} + +double leftChannelRms(const QByteArray& floatStereo, int skipFrames) +{ + const auto* samples = reinterpret_cast( + floatStereo.constData()); + const int frames = floatStereo.size() + / (2 * static_cast(sizeof(float))); + if (!samples || frames <= skipFrames) { + return 0.0; + } + + double sumSquares = 0.0; + for (int frame = skipFrames; frame < frames; ++frame) { + const double sample = samples[frame * 2]; + sumSquares += sample * sample; + } + return std::sqrt(sumSquares / (frames - skipFrames)); +} + +double transportToneRms(float frequencyHz) +{ + constexpr int kInputFrames = 48000; + constexpr int kSettlingOutputFrames = 4000; + const std::vector input = makeFloatStereoTone( + kInputFrames, TxVoiceProcessor::kDspRate, frequencyHz); + + TxVoiceProcessor processor; + QByteArray transportOutput; + if (!processor.prepare(TxVoiceProcessor::kDspRate, kInputFrames) + || !processor.processFloat48( + input.data(), kInputFrames, transportOutput)) { + return 0.0; + } + return leftChannelRms( + processor.transportFloat32Stereo(), kSettlingOutputFrames); +} + +double capturedTransportToneRms(int inputRate, float frequencyHz) +{ + const int inputFrames = inputRate * 2; + TxVoiceProcessor processor; + QByteArray inputOutput = makeCanonicalTone( + inputFrames, inputRate, frequencyHz); + if (!processor.prepare(inputRate, inputFrames) + || !processor.processCapturedInt16(inputOutput)) { + return 0.0; + } + + // Measure the final second so both SRCs are in steady state and every + // integer-Hz test tone spans an exact number of cycles. + return leftChannelRms( + processor.transportFloat32Stereo(), + TxVoiceProcessor::kTransportRate); +} + +double capturedNormalizedToneRms(int inputRate, float frequencyHz) +{ + const int inputFrames = inputRate * 2; + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + QByteArray inputOutput = makeCanonicalTone( + inputFrames, inputRate, frequencyHz); + if (!processor.prepare(inputRate, inputFrames) + || !processor.processCapturedInt16(inputOutput)) { + return 0.0; + } + + return leftChannelRms( + processor.normalizedFloat48Stereo(), TxVoiceProcessor::kDspRate); +} + +double leftChannelToneMagnitude(const QByteArray& floatStereo, + int sampleRate, + float frequencyHz, + int startFrame, + int analysisFrames) +{ + const auto* samples = reinterpret_cast( + floatStereo.constData()); + const int availableFrames = floatStereo.size() + / (2 * static_cast(sizeof(float))); + if (!samples || startFrame < 0 || analysisFrames <= 0 + || startFrame + analysisFrames > availableFrames) { + return 0.0; + } + + constexpr double kTwoPi = 6.28318530717958647692; + double inPhase = 0.0; + double quadrature = 0.0; + for (int frame = 0; frame < analysisFrames; ++frame) { + const double phase = kTwoPi * frequencyHz * frame / sampleRate; + const double sample = samples[(startFrame + frame) * 2]; + inPhase += sample * std::cos(phase); + quadrature += sample * std::sin(phase); + } + return 2.0 * std::hypot(inPhase, quadrature) / analysisFrames; +} + +bool finiteFloatBuffer(const QByteArray& bytes) +{ + const auto* samples = reinterpret_cast(bytes.constData()); + const int count = bytes.size() / static_cast(sizeof(float)); + for (int index = 0; index < count; ++index) { + if (!std::isfinite(samples[index])) { + return false; + } + } + return true; +} + +bool duplicatedStereo(const QByteArray& bytes, bool floatSamples) +{ + const int sampleBytes = floatSamples + ? static_cast(sizeof(float)) + : static_cast(sizeof(int16_t)); + const int frames = bytes.size() / (2 * sampleBytes); + if (floatSamples) { + const auto* samples = reinterpret_cast(bytes.constData()); + for (int frame = 0; frame < frames; ++frame) { + if (samples[frame * 2] != samples[frame * 2 + 1]) { + return false; + } + } + } else { + const auto* samples = reinterpret_cast(bytes.constData()); + for (int frame = 0; frame < frames; ++frame) { + if (samples[frame * 2] != samples[frame * 2 + 1]) { + return false; + } + } + } + return true; +} + +uint64_t packedSingleStage(TxVoiceProcessor::Stage stage) +{ + return static_cast(stage); +} + +void testFixedRateContract() +{ + report("DSP rate is pinned to 48 kHz", TxVoiceProcessor::kDspRate == 48000); + report("transport rate remains 24 kHz", + TxVoiceProcessor::kTransportRate == 24000); + report("TX voice SRC transition profile is 12 percent", + TxVoiceProcessor::kVoiceSrcTransitionBandPercent == 12.0); +} + +void testVoiceSrcBandwidthAndAliasRejection() +{ + const double referenceRms = transportToneRms(1000.0f); + const double tenKhzRms = transportToneRms(10000.0f); + const double tenKhzGainDb = 20.0 * std::log10( + std::max(tenKhzRms / referenceRms, 1.0e-15)); + + report("voice SRC remains within 0.1 dB through 10 kHz", + referenceRms > 0.0 && std::abs(tenKhzGainDb) <= 0.1, + "gainDb=" + std::to_string(tenKhzGainDb)); + + double worstAliasDb = -300.0; + for (const float frequencyHz : { + 14000.0f, 16000.0f, 18000.0f, 20000.0f, 22000.0f}) { + const double aliasRms = transportToneRms(frequencyHz); + const double aliasDb = 20.0 * std::log10( + std::max(aliasRms / referenceRms, 1.0e-15)); + worstAliasDb = std::max(worstAliasDb, aliasDb); + } + report("aliases folding into 0-10 kHz remain below -100 dB", + referenceRms > 0.0 && worstAliasDb <= -100.0, + "worstAliasDb=" + std::to_string(worstAliasDb)); +} + +void testCapturedRateSrcBandwidthAndAliasRejection() +{ + const double reference24 = capturedTransportToneRms(24000, 1000.0f); + const double tenKhz24 = capturedTransportToneRms(24000, 10000.0f); + const double gain24Db = 20.0 * std::log10( + std::max(tenKhz24 / reference24, 1.0e-15)); + report("24 -> 48 -> 24 path remains within 0.1 dB through 10 kHz", + reference24 > 0.0 && std::abs(gain24Db) <= 0.1, + "gainDb=" + std::to_string(gain24Db)); + + const double reference441 = capturedTransportToneRms(44100, 1000.0f); + const double tenKhz441 = capturedTransportToneRms(44100, 10000.0f); + const double gain441Db = 20.0 * std::log10( + std::max(tenKhz441 / reference441, 1.0e-15)); + report("44.1 -> 48 -> 24 path remains within 0.1 dB through 10 kHz", + reference441 > 0.0 && std::abs(gain441Db) <= 0.1, + "gainDb=" + std::to_string(gain441Db)); + + const double normalizedReference441 = + capturedNormalizedToneRms(44100, 1000.0f); + const double normalizedTenKhz441 = + capturedNormalizedToneRms(44100, 10000.0f); + const double normalizedGain441Db = 20.0 * std::log10( + std::max(normalizedTenKhz441 / normalizedReference441, 1.0e-15)); + report("44.1 -> 48 normalization remains within 0.1 dB through 10 kHz", + normalizedReference441 > 0.0 + && std::abs(normalizedGain441Db) <= 0.1, + "gainDb=" + std::to_string(normalizedGain441Db)); + + // A 6 kHz tone sampled at 24 kHz has an interpolation image at 18 kHz + // after expansion to 48 kHz. Use the exact four-sample-period tone so + // int16 source quantization cannot set the rejection floor. + constexpr int kInputRate = 24000; + constexpr int kInputFrames = kInputRate * 2; + constexpr int kAnalysisFrames = TxVoiceProcessor::kDspRate; + TxVoiceProcessor imageProcessor; + imageProcessor.setMeasurementCaptureEnabled(true); + QByteArray imageInputOutput = makeCanonicalTone( + kInputFrames, kInputRate, 6000.0f); + const bool imageProcessed = imageProcessor.prepare( + kInputRate, kInputFrames) + && imageProcessor.processCapturedInt16(imageInputOutput); + const QByteArray& normalized = imageProcessor.normalizedFloat48Stereo(); + const int normalizedFrames = normalized.size() + / (2 * static_cast(sizeof(float))); + const int analysisStart = normalizedFrames - kAnalysisFrames; + const double fundamentalMagnitude = leftChannelToneMagnitude( + normalized, TxVoiceProcessor::kDspRate, 6000.0f, + analysisStart, kAnalysisFrames); + const double imageMagnitude = leftChannelToneMagnitude( + normalized, TxVoiceProcessor::kDspRate, 18000.0f, + analysisStart, kAnalysisFrames); + const double imageDb = 20.0 * std::log10( + std::max(imageMagnitude / fundamentalMagnitude, 1.0e-15)); + report("24 -> 48 interpolation image remains below -100 dB", + imageProcessed && fundamentalMagnitude > 0.0 && imageDb <= -100.0, + "imageDb=" + std::to_string(imageDb)); + + // 14.7 kHz is exactly one third of 44.1 kHz, giving an int16-periodic + // stop-band probe without broadband quantization residue. If it survived + // the serial SRCs it would fold to 9.3 kHz at the 24 kHz transport rate. + const double alias441 = capturedTransportToneRms(44100, 14700.0f); + const double alias441Db = 20.0 * std::log10( + std::max(alias441 / reference441, 1.0e-15)); + report("44.1 -> 48 -> 24 alias probe remains below -100 dB", + reference441 > 0.0 && alias441Db <= -100.0, + "aliasDb=" + std::to_string(alias441Db)); +} + +void testVoiceSrcLatencyBudgets() +{ + struct ExpectedLatency { + int inputRate; + int frames48; + int budgetFrames48; + }; + constexpr ExpectedLatency kExpected[] = { + {48000, 394, 960}, + {44100, 615, 960}, + {24000, 788, 960}, + // Low-rate capture remains supported for macOS Bluetooth/HFP and the + // Windows fallback ladder. These ratios require longer interpolation + // filters than the common 24/44.1/48 kHz device rates. + {16000, 1240, 1440}, + {8000, 2104, 2160}, + }; + + bool exact = true; + bool commonRatesWithinTwentyMs = true; + bool withinRateSpecificBudgets = true; + std::string detail; + for (const ExpectedLatency expected : kExpected) { + TxVoiceProcessor processor; + const bool prepared = processor.prepare(expected.inputRate, 1024); + const int frames = processor.latencyFrames(); + exact = prepared && frames == expected.frames48 && exact; + if (expected.inputRate >= 24000) { + commonRatesWithinTwentyMs = prepared && frames <= 960 + && commonRatesWithinTwentyMs; + } + withinRateSpecificBudgets = prepared + && frames <= expected.budgetFrames48 + && withinRateSpecificBudgets; + detail += std::to_string(expected.inputRate) + "Hz=" + + std::to_string(frames) + " "; + } + + report("SRC group delay is reported for every capture rate", exact, detail); + report("24/44.1/48 kHz serial SRC delay remains below 20 ms", + commonRatesWithinTwentyMs, + detail); + report("supported low-rate SRC delays remain within documented budgets", + withinRateSpecificBudgets, + detail); +} + +void testReusableBuffersPreserveCapacity() +{ + constexpr int kFrames = 480; + std::vector input(kFrames, 0.0f); + Resampler resampler(48000, 24000, kFrames, + TxVoiceProcessor::kVoiceSrcTransitionBandPercent); + QByteArray output; + output.reserve(16384); + const qsizetype reservedCapacity = output.capacity(); + const int outputFrames = resampler.process(input.data(), kFrames, output); + + report("reusable SRC output preserves caller reservation", + outputFrames == kFrames / 2 + && output.capacity() >= reservedCapacity, + "reserved=" + std::to_string(reservedCapacity) + + " capacity=" + std::to_string(output.capacity())); + + constexpr int kPreparedFrames = 1024; + TxVoiceProcessor processor; + const bool prepared = processor.prepare(48000, kPreparedFrames); + const qsizetype normalizedCapacity = + processor.normalizedFloat48Stereo().capacity(); + const qsizetype postStripCapacity = + processor.postChannelStripFloat48Stereo().capacity(); + const qsizetype transportFloatCapacity = + processor.transportFloat32Stereo().capacity(); + const bool reservationsSurvivedPrepare = prepared + && normalizedCapacity >= kPreparedFrames * 2 * static_cast(sizeof(float)) + && postStripCapacity >= kPreparedFrames * 2 * static_cast(sizeof(float)) + && transportFloatCapacity > 0; + + processor.reset(); + report("TX prepare and reset preserve realtime buffer reservations", + reservationsSurvivedPrepare + && processor.normalizedFloat48Stereo().capacity() + >= normalizedCapacity + && processor.postChannelStripFloat48Stereo().capacity() + >= postStripCapacity + && processor.transportFloat32Stereo().capacity() + >= transportFloatCapacity); +} + +void testCapturedTransportUsesCallerOwnedStorage() +{ + constexpr int kFrames = 480; + TxVoiceProcessor processor; + const bool prepared = processor.prepare(48000, kFrames); + + QByteArray firstBlock = makeCanonicalTone(kFrames, 48000, 997.0f); + const char* const firstInputStorage = firstBlock.constData(); + const bool firstProcessed = processor.processCapturedInt16(firstBlock); + const bool firstStorageReused = firstBlock.constData() == firstInputStorage; + + // Model the queued monitor/recorder consumers that retain the completed + // block after the audio callback returns. + const QByteArray retainedFirstBlock = firstBlock; + + QByteArray secondBlock = makeCanonicalTone(kFrames, 48000, 1201.0f); + const char* const secondInputStorage = secondBlock.constData(); + const bool secondProcessed = processor.processCapturedInt16(secondBlock); + const bool secondStorageReused = secondBlock.constData() == secondInputStorage; + + report("captured transport reuses unique caller-owned input storage", + prepared && firstProcessed && secondProcessed + && firstStorageReused && secondStorageReused); + report("retained transport output does not affect the next callback buffer", + !retainedFirstBlock.isEmpty() + && retainedFirstBlock.constData() != secondBlock.constData()); +} + +void testResamplerResetRestoresFreshState() +{ + constexpr int kFrames = 960; + const std::vector history = makeFloatStereoTone( + kFrames, TxVoiceProcessor::kDspRate, 317.0f); + const std::vector probe = makeFloatStereoTone( + kFrames, TxVoiceProcessor::kDspRate, 997.0f); + std::vector historyMono(kFrames); + std::vector probeMono(kFrames); + for (int frame = 0; frame < kFrames; ++frame) { + historyMono[static_cast(frame)] = + history[static_cast(frame * 2)]; + probeMono[static_cast(frame)] = + probe[static_cast(frame * 2)]; + } + + Resampler resetInstance(48000, 24000, kFrames); + resetInstance.processMonoToStereo(historyMono.data(), kFrames); + resetInstance.reset(); + const QByteArray afterReset = resetInstance.processMonoToStereo( + probeMono.data(), kFrames); + + Resampler freshInstance(48000, 24000, kFrames); + const QByteArray freshOutput = freshInstance.processMonoToStereo( + probeMono.data(), kFrames); + constexpr int kStereoFloatFrameBytes = + 2 * static_cast(sizeof(float)); + const int resetFrames = afterReset.size() / kStereoFloatFrameBytes; + const int freshFrames = freshOutput.size() / kStereoFloatFrameBytes; + + report("resampler reset restores fresh deterministic stream state", + resetFrames == freshFrames && afterReset == freshOutput, + "resetFrames=" + std::to_string(resetFrames) + + " freshFrames=" + std::to_string(freshFrames)); +} + +void testStereoEgressMismatchSalvagesAndRealigns() +{ + constexpr int kFrames = 480; + TxVoiceProcessor recovered; + recovered.prepare(48000, kFrames); + AetherSDR::TxVoiceProcessorTestAccess::dirtyEgressResamplerHistories( + recovered); + const int commonFrames = + AetherSDR::TxVoiceProcessorTestAccess::reconcileEgressFrameCounts( + recovered, 240, 239); + + report("stereo egress mismatch preserves the common aligned prefix", + commonFrames == 239, + "frames=" + std::to_string(commonFrames)); + report("stereo egress mismatch defers expensive SRC recovery", + recovered.egressRecoveryPending()); + + // AudioEngine services this request on its event loop after the realtime + // callback returns. The headless test performs the same control-boundary + // action explicitly. + recovered.recoverEgressAfterMismatch(); + report("deferred stereo egress recovery clears the request", + !recovered.egressRecoveryPending()); + + const std::vector probe = makeFloatStereoTone( + kFrames, TxVoiceProcessor::kDspRate, 997.0f); + QByteArray recoveredOutput; + const bool recoveredProcessed = recovered.processFloat48( + probe.data(), kFrames, recoveredOutput); + + TxVoiceProcessor fresh; + fresh.prepare(48000, kFrames); + QByteArray freshOutput; + const bool freshProcessed = fresh.processFloat48( + probe.data(), kFrames, freshOutput); + + report("stereo egress mismatch resets both channels to fresh alignment", + recoveredProcessed && freshProcessed + && recovered.transportFloat32Stereo() + == fresh.transportFloat32Stereo()); +} + +void test48kBypassAndMeasurementBoundaries() +{ + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + const bool prepared = processor.prepare(48000, 1024); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 1000.0f); + const bool processed = processor.processCapturedInt16(transportOutput); + + report("48 kHz processor prepares", prepared); + report("48 kHz block processes", processed); + report("normalized measurement contains 480 stereo float frames", + processor.normalizedFloat48Stereo().size() + == 480 * 2 * static_cast(sizeof(float))); + report("post-strip measurement contains 480 stereo float frames", + processor.postChannelStripFloat48Stereo().size() + == 480 * 2 * static_cast(sizeof(float))); + report("egress contains 240 stereo float frames", + processor.transportFloat32Stereo().size() + == 240 * 2 * static_cast(sizeof(float))); + report("egress contains 240 stereo int16 frames", + transportOutput.size() + == 240 * 2 * static_cast(sizeof(int16_t))); + report("bypass output remains finite", + finiteFloatBuffer(processor.transportFloat32Stereo())); + report("mono voice remains duplicated stereo through egress", + duplicatedStereo(processor.transportFloat32Stereo(), true) + && duplicatedStereo(transportOutput, false)); +} + +void testDeviceRateNormalization() +{ + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + const bool prepared = processor.prepare(44100, 1024); + QByteArray transportOutput = makeCanonicalTone(441, 44100, 1000.0f); + const bool processed = processor.processCapturedInt16(transportOutput); + const int normalizedFrames = processor.normalizedFloat48Stereo().size() + / (2 * static_cast(sizeof(float))); + const int transportFrames = transportOutput.size() + / (2 * static_cast(sizeof(int16_t))); + + report("44.1 kHz capture rate prepares", prepared); + report("44.1 kHz capture block processes", processed); + report("10 ms at 44.1 kHz normalizes to 480 frames", + normalizedFrames == 480, + "frames=" + std::to_string(normalizedFrames)); + report("normalized 10 ms block exits as 240 transport frames", + transportFrames == 240, + "frames=" + std::to_string(transportFrames)); +} + +void testFloat48OfflineEntryAvoidsInputQuantization() +{ + std::vector input(480 * 2); + for (int frame = 0; frame < 480; ++frame) { + const float sample = 1.0e-6f * static_cast(frame + 1); + input[static_cast(frame * 2)] = sample; + input[static_cast(frame * 2 + 1)] = -sample; + } + + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + processor.prepare(48000, 480); + QByteArray transportOutput; + const bool processed = processor.processFloat48( + input.data(), 480, transportOutput); + + report("native float 48 kHz offline block processes", processed); + report("native float entry preserves sub-int16 input values exactly", + processor.normalizedFloat48Stereo().size() + == static_cast(input.size() * sizeof(float)) + && std::memcmp(processor.normalizedFloat48Stereo().constData(), + input.data(), input.size() * sizeof(float)) == 0); +} + +void testChannelStripRunsAt48k() +{ + ClientTube tube; + tube.setEnabled(true); + tube.setDriveDb(12.0f); + tube.setDryWet(1.0f); + + TxVoiceProcessor processor; + TxVoiceProcessor::Processors processors; + processors.tube = &tube; + processor.setProcessors(processors); + processor.setStageOrder(packedSingleStage(TxVoiceProcessor::Stage::Tube)); + processor.setMeasurementCaptureEnabled(true); + processor.prepare(48000, 1024); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 1000.0f); + const bool processed = processor.processCapturedInt16(transportOutput); + + report("channel-strip block processes", processed); + report("tube is prepared at the canonical 48 kHz rate", + std::fabs(tube.sampleRate() - 48000.0) < 0.1); + report("enabled tube changes the post-strip measurement", + processor.normalizedFloat48Stereo() + != processor.postChannelStripFloat48Stereo()); +} + +void testNonFiniteSamplesCannotPoisonEgressSrc() +{ + std::vector input(480 * 2, 0.1f); + input[20] = std::nanf(""); + input[51] = std::numeric_limits::infinity(); + input[92] = -std::numeric_limits::infinity(); + + TxVoiceProcessor processor; + processor.prepare(48000, 480); + QByteArray transportOutput; + const bool processed = processor.processFloat48( + input.data(), 480, transportOutput); + + report("non-finite input block still processes", processed); + report("non-finite samples cannot poison float transport output", + finiteFloatBuffer(processor.transportFloat32Stereo())); +} + +void testMeasurementCaptureCanBeDisabled() +{ + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(false); + processor.prepare(48000, 1024); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 1000.0f); + processor.processCapturedInt16(transportOutput); + + report("disabled normalized tap holds no copied block", + processor.normalizedFloat48Stereo().isEmpty()); + report("disabled post-strip tap holds no copied block", + processor.postChannelStripFloat48Stereo().isEmpty()); + report("transport output remains available with taps disabled", + !transportOutput.isEmpty()); +} + +void testBlockBoundaryContinuityAndReset() +{ + const QByteArray input = makeCanonicalTone(4800, 48000, 997.0f); + + TxVoiceProcessor whole; + whole.prepare(48000, 4800); + QByteArray wholeOutput = input; + whole.processCapturedInt16(wholeOutput); + + TxVoiceProcessor blocked; + blocked.prepare(48000, 480); + QByteArray blockedOutput; + constexpr int kInputBlockBytes = 480 * 2 * static_cast(sizeof(int16_t)); + for (int offset = 0; offset < input.size(); offset += kInputBlockBytes) { + QByteArray block = input.mid(offset, kInputBlockBytes); + const bool processed = blocked.processCapturedInt16(block); + if (!processed) { + report("streaming blocks all produce output", false); + return; + } + blockedOutput.append(block); + } + + report("48 -> 24 SRC is invariant to 10 ms block boundaries", + blockedOutput == wholeOutput, + "wholeBytes=" + std::to_string(wholeOutput.size()) + + " blockedBytes=" + std::to_string(blockedOutput.size())); + + blocked.reset(); + QByteArray afterReset; + for (int offset = 0; offset < input.size(); offset += kInputBlockBytes) { + QByteArray block = input.mid(offset, kInputBlockBytes); + blocked.processCapturedInt16(block); + afterReset.append(block); + } + report("reset restores deterministic SRC stream state", + afterReset == blockedOutput); +} + +void testOversizedCaptureBlockIsProcessed() +{ + constexpr int kPreparedInputFrames = 1024; + constexpr int kInputFrames = 2000; + constexpr int kFrameBytes = 2 * static_cast(sizeof(int16_t)); + const QByteArray input = makeCanonicalTone(kInputFrames, 48000, 997.0f); + + TxVoiceProcessor oversized; + oversized.prepare(48000, kPreparedInputFrames); + QByteArray oversizedOutput = input; + const bool oversizedProcessed = oversized.processCapturedInt16( + oversizedOutput); + + TxVoiceProcessor partitioned; + partitioned.prepare(48000, kPreparedInputFrames); + QByteArray partitionedOutput; + QByteArray firstBlock = input.left(kPreparedInputFrames * kFrameBytes); + const bool firstProcessed = partitioned.processCapturedInt16(firstBlock); + partitionedOutput.append(firstBlock); + QByteArray secondBlock = input.mid(kPreparedInputFrames * kFrameBytes); + const bool secondProcessed = partitioned.processCapturedInt16(secondBlock); + partitionedOutput.append(secondBlock); + + report("capture block larger than prepared size is processed", + oversizedProcessed); + report("oversized capture block preserves expected transport frame count", + oversizedOutput.size() + == (kInputFrames / 2) * kFrameBytes, + "bytes=" + std::to_string(oversizedOutput.size())); + report("oversized capture processing is stream-continuous", + firstProcessed && secondProcessed + && oversizedOutput == partitionedOutput, + "oversizedBytes=" + std::to_string(oversizedOutput.size()) + + " partitionedBytes=" + + std::to_string(partitionedOutput.size())); +} + +void testTransportTpdfDither() +{ + constexpr int kInputFrames = 48000; + std::vector silence(kInputFrames * 2, 0.0f); + std::vector subLsbProbe(kInputFrames * 2, 1.0e-9f); + + TxVoiceProcessor processor; + processor.prepare(48000, kInputFrames); + QByteArray silentOutput; + const bool processed = processor.processFloat48( + silence.data(), kInputFrames, silentOutput); + const QByteArray floatOutput = processor.transportFloat32Stereo(); + const auto* quantized = reinterpret_cast( + silentOutput.constData()); + const int sampleCount = silentOutput.size() + / static_cast(sizeof(int16_t)); + + bool exactSilence = true; + for (int sample = 0; sample < sampleCount; ++sample) { + exactSilence = quantized[sample] == 0 && exactSilence; + } + + report("silent float transport block processes", processed); + report("dither does not alter the float transport measurement tap", + finiteFloatBuffer(floatOutput) + && std::all_of( + reinterpret_cast(floatOutput.constData()), + reinterpret_cast(floatOutput.constData()) + + floatOutput.size() / static_cast(sizeof(float)), + [](float sample) { return sample == 0.0f; })); + report("exact digital silence remains the zero int16 code", exactSilence); + report("digital silence remains duplicated stereo", + duplicatedStereo(silentOutput, false)); + + QByteArray advancedProbeOutput; + const bool probeProcessed = processor.processFloat48( + subLsbProbe.data(), kInputFrames, advancedProbeOutput); + const auto* probeSamples = reinterpret_cast( + advancedProbeOutput.constData()); + const int probeSampleCount = advancedProbeOutput.size() + / static_cast(sizeof(int16_t)); + int nonZeroProbeSamples = 0; + int64_t probeSampleSum = 0; + for (int sample = 0; sample < probeSampleCount; ++sample) { + nonZeroProbeSamples += probeSamples[sample] != 0 ? 1 : 0; + probeSampleSum += probeSamples[sample]; + } + report("nonzero sub-LSB signal is still TPDF dithered", + probeProcessed && nonZeroProbeSamples > probeSampleCount / 10, + "nonZero=" + std::to_string(nonZeroProbeSamples) + + " samples=" + std::to_string(probeSampleCount)); + report("sub-LSB TPDF remains linked across duplicated channels", + duplicatedStereo(advancedProbeOutput, false)); + report("sub-LSB TPDF retains near-zero DC bias", + std::abs(probeSampleSum) < probeSampleCount / 100, + "sum=" + std::to_string(probeSampleSum)); + + TxVoiceProcessor freshProcessor; + freshProcessor.prepare(48000, kInputFrames); + QByteArray freshProbeOutput; + freshProcessor.processFloat48( + subLsbProbe.data(), kInputFrames, freshProbeOutput); + report("silent frames continue advancing the dither PRNG", + advancedProbeOutput != freshProbeOutput); + + processor.reset(); + QByteArray resetSilentOutput; + processor.processFloat48(silence.data(), kInputFrames, resetSilentOutput); + QByteArray afterReset; + processor.processFloat48(subLsbProbe.data(), kInputFrames, afterReset); + report("reset restores deterministic TPDF stream state", + resetSilentOutput == silentOutput + && afterReset == advancedProbeOutput); +} + +void testDitheredTransportSaturatesAtInt16Rails() +{ + constexpr int kInputFrames = 48000; + const auto verifyRail = [](float inputSample, int16_t expectedRail) { + std::vector input(kInputFrames * 2, inputSample); + TxVoiceProcessor processor; + processor.prepare(48000, kInputFrames); + QByteArray int16Bytes; + if (!processor.processFloat48( + input.data(), kInputFrames, int16Bytes)) { + return false; + } + + const QByteArray& floatBytes = processor.transportFloat32Stereo(); + const auto* floatSamples = reinterpret_cast( + floatBytes.constData()); + const auto* int16Samples = reinterpret_cast( + int16Bytes.constData()); + const int sampleCount = int16Bytes.size() + / static_cast(sizeof(int16_t)); + bool testedOverRangeSample = false; + for (int sample = 0; sample < sampleCount; ++sample) { + const bool overRange = expectedRail > 0 + ? floatSamples[sample] >= 1.0f + : floatSamples[sample] <= -1.0f; + if (overRange) { + testedOverRangeSample = true; + if (int16Samples[sample] != expectedRail) { + return false; + } + } + } + return testedOverRangeSample; + }; + + report("dithered quantizer saturates positive over-range samples", + verifyRail(100.0f, std::numeric_limits::max())); + report("dithered quantizer saturates negative over-range samples", + verifyRail(-100.0f, std::numeric_limits::min())); +} + +void testRnnoiseNative48kIsland() +{ + RNNoiseFilter rnnoise( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); + TxVoiceProcessor processor; + TxVoiceProcessor::Processors processors; + processors.rnnoise = &rnnoise; + processor.setProcessors(processors); + processor.setRnnoiseEnabled(true); + processor.prepare(48000, 480); + + bool allProcessed = true; + bool allSized = true; + bool allDuplicated = true; + for (int block = 0; block < 12; ++block) { + QByteArray transportOutput = makeCanonicalTone( + 480, 48000, 700.0f); + allProcessed = processor.processCapturedInt16(transportOutput) + && allProcessed; + allSized = transportOutput.size() + == 240 * 2 * static_cast(sizeof(int16_t)) && allSized; + allDuplicated = duplicatedStereo( + transportOutput, false) && allDuplicated; + } + + report("RNNoise native 48 kHz island processes complete frames", allProcessed); + report("RNNoise path preserves 10 ms transport framing", allSized); + report("TX RNNoise ProcessedMono remains duplicated stereo", allDuplicated); +} + +void testDisabledRnnoiseIsNotDereferencedDuringPrepare() +{ + auto rnnoise = std::make_unique( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); + TxVoiceProcessor processor; + processor.setRnnoise(rnnoise.get()); + processor.setRnnoiseEnabled(false); + + // Simulate the stale association that exposed the original ownership bug. + // A disabled processor must not touch it while prepare() resets the graph. + rnnoise.reset(); + const bool prepared = processor.prepare(48000, 480); + processor.setRnnoise(nullptr); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 700.0f); + const bool processed = processor.processCapturedInt16(transportOutput); + + report("disabled stale RNNoise association is not reset during prepare", + prepared && processed); +} + +void testLatencyAccounting() +{ + ClientGate gate; + gate.setEnabled(true); + gate.setLookaheadMs(2.0f); + RNNoiseFilter rnnoise( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); + + TxVoiceProcessor processor; + TxVoiceProcessor::Processors processors; + processors.gate = &gate; + processors.rnnoise = &rnnoise; + processor.setProcessors(processors); + processor.setStageOrder(packedSingleStage(TxVoiceProcessor::Stage::Gate)); + processor.setRnnoiseEnabled(true); + processor.prepare(48000, 480); + + report("latency report includes RNNoise frame plus gate lookahead", + processor.latencyFrames() == 394 + 480 + 96, + "frames=" + std::to_string(processor.latencyFrames())); +} + +} // namespace + +int main() +{ + testFixedRateContract(); + testVoiceSrcBandwidthAndAliasRejection(); + testCapturedRateSrcBandwidthAndAliasRejection(); + testVoiceSrcLatencyBudgets(); + testReusableBuffersPreserveCapacity(); + testCapturedTransportUsesCallerOwnedStorage(); + testResamplerResetRestoresFreshState(); + testStereoEgressMismatchSalvagesAndRealigns(); + test48kBypassAndMeasurementBoundaries(); + testDeviceRateNormalization(); + testFloat48OfflineEntryAvoidsInputQuantization(); + testChannelStripRunsAt48k(); + testNonFiniteSamplesCannotPoisonEgressSrc(); + testMeasurementCaptureCanBeDisabled(); + testBlockBoundaryContinuityAndReset(); + testOversizedCaptureBlockIsProcessed(); + testTransportTpdfDither(); + testDitheredTransportSaturatesAtInt16Rails(); + testRnnoiseNative48kIsland(); + testDisabledRnnoiseIsNotDereferencedDuringPrepare(); + testLatencyAccounting(); + + std::printf("\n%s (%d failure%s)\n", + g_failed == 0 ? "PASS" : "FAIL", + g_failed, + g_failed == 1 ? "" : "s"); + return g_failed == 0 ? 0 : 1; +}