From 980e5d219d5d7c4f35cb4b11f7003de34c5a34a4 Mon Sep 17 00:00:00 2001 From: on8st <258096273+on8st@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:48:03 +0000 Subject: [PATCH] feat(hl2): transmit AM, DSB and FM through TXA. Principle II. Rebase the effective change from PR #5977 onto current main, consolidating the obsolete #5906 stack and subsequent merge resolutions. Keep AETHER_HL2_TX_TXA and its phasing fallback intact. Preserve mode-specific TXA filtering, explicit AM/FM parameters, CTCSS-off policy, offline DSP coverage and user documentation. Refs #5678. Co-authored-by: Claude Opus 5.5 --- BUILD-OPTIONS.md | 2 +- CMakeLists.txt | 5 +- docs/HERMES.md | 9 + docs/architecture/radio-capabilities-map.md | 19 +- docs/user/docs/hermes-lite-2.md | 7 +- .../src/components/LogAnalyzer/rules.json | 2 +- src/core/backends/hl2/Hl2Backend.cpp | 30 +- src/core/backends/hl2/Hl2ModeVocabulary.h | 5 +- src/core/backends/hl2/Hl2TxDsp.cpp | 54 ++-- src/core/backends/hl2/Hl2TxDsp.h | 30 +- src/core/backends/hl2/Hl2TxLevelPolicy.h | 8 + src/core/dsp/WdspChannel.cpp | 23 ++ src/core/dsp/WdspChannel.h | 7 + tests/control_receive_test.cpp | 4 +- tests/hl2_family_transition_test.cpp | 43 +-- tests/hl2_fm_controls_declaration_test.cpp | 65 ++--- tests/hl2_mode_vocabulary_test.cpp | 8 +- tests/hl2_txdsp_test.cpp | 272 ++++++++++++++++-- third_party/wdsp/include/aether_wdsp.h | 7 + 19 files changed, 455 insertions(+), 145 deletions(-) diff --git a/BUILD-OPTIONS.md b/BUILD-OPTIONS.md index 0d4f815f67..d68162dc5a 100644 --- a/BUILD-OPTIONS.md +++ b/BUILD-OPTIONS.md @@ -60,7 +60,7 @@ alone. MSVC flags and `Release` builds are unaffected. |---|---|---| | `ENABLE_DEEPFIST_EXPERIMENT` | OFF | Build the experimental DeepFist CW receive decoder. Requires ONNX Runtime at configure time (configuration fails without it) and a separate verified model bundle at runtime; see below. | | `ENABLE_RTL` | ON | Build the experimental receive-only RTL-SDR USB backend when both `librtlsdr` and single-precision FFTW (`fftw3f`) are found. Missing either disables the backend unless `REQUIRE_RTL=ON`. | -| `AETHER_HL2_TX_TXA` | ON | Select WDSP's TXA chain for the Hermes-Lite 2 SSB transmit modulator. OFF builds the in-tree phasing modulator. This choice has no runtime toggle. | +| `AETHER_HL2_TX_TXA` | ON | Select WDSP's TXA chain as the Hermes-Lite 2 transmit modulator: SSB, AM, DSB and FM. OFF builds the in-tree phasing modulator, which is SSB only; AM, DSB and FM then stay receive-only. This choice has no runtime toggle. | **DeepFist:** enabling `-DENABLE_DEEPFIST_EXPERIMENT=ON` alone does not supply its model. The configured model-download URL defaults to empty. For developer diff --git a/CMakeLists.txt b/CMakeLists.txt index c558406510..b7e6ef29d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -179,7 +179,8 @@ option(REQUIRE_RTL "Fail configuration unless the RTL backend and float FFTW are # signed-16-bit wire (MetisProtocol.cpp, ep2WriteTxIq), so TXA's # advantage THERE is below the wire and unusable. # OFF — the in-tree phasing modulator, unchanged. The fallback for a machine or -# a report where TXA misbehaves. +# a report where TXA misbehaves. SSB and digital only: AM, DSB and FM +# stay receive-only in this build. # # THIS LINE USED TO SAY "kept building and kept tested" AND THE FLIP # BELOW MAKES THAT FALSE. `grep AETHER_HL2_TX_TXA .github/workflows/*.yml` @@ -234,7 +235,7 @@ option(REQUIRE_RTL "Fail configuration unless the RTL backend and float FFTW are # radio was keyed. No antenna port was observed." -- now exists, and it is named # rather than asserted. option(AETHER_HL2_TX_TXA - "Hermes-Lite 2 SSB transmit modulator: ON (default) uses WDSP's TXA chain, OFF the in-tree phasing modulator" + "Hermes-Lite 2 transmit modulator: ON (default) uses WDSP's TXA chain, OFF the in-tree phasing modulator (SSB only)" ON) # Lets AETHER_FIRMWARE_CATALOG and AETHER_FIRMWARE_TRUST_KEYS replace the # firmware catalog and add firmware-signing keys, for testing a publisher's own diff --git a/docs/HERMES.md b/docs/HERMES.md index 3e3c9a77f9..60c9425c89 100644 --- a/docs/HERMES.md +++ b/docs/HERMES.md @@ -539,6 +539,15 @@ in `third_party/wdsp/upstream/` and were **not** confirmed on the air. TXA's bandpass both reach it through `CalcBandpassFilter`/`fir_bandpass` with `rtype = 1`, so this one function is the mechanism behind both the RX bullet above and the TX correction here. +- **TX AM, DSB and FM (TXA build only; the phasing build refuses to key them) + take a SYMMETRIC passband, and that is not a sideband rule.** For `TXA_AM`, `TXA_DSB`, `TXA_SAM` and `TXA_FM`, `TXASetupBPFilters` + runs `bp0` *before* `xammod`/`xfmmod`, and both modulators read only the I + output — so the band is an audio low-pass. `Hl2TxDsp::txaPassband()` maps the + positive `{100, 3000}` to `[-3000, +3000]` (piHPSDR's `tx_set_filter` uses the + same `(-high, high)` for AM and DSB); a one-sided band there halves the + modulation. `WdspChannel::open()` also turns off the CTCSS encoder + `create_txa` builds switched **on** (100 Hz, level 0.10), and pushes the AM + carrier (0.5) and FM deviation (5000 Hz) from `Hl2TxLevelPolicy.h`. The trap: RXA and the phasing modulator use **opposite conventions**, and both look plausible. A table written for one and reused for the other is silently wrong on diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index 30d319a75c..ef5a993ef0 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -206,7 +206,7 @@ the production dialog and checks its displayed peak level and frequency. | `tuningMinHz` / `tuningMaxHz` | — (0/0) | 0.1–38.4 MHz | — (0/0) | `MainWindow_Wiring.cpp`, `applyTuningRangeToOverlayMenu` | Refuses band buttons the receiver cannot reach. 0/0 means unconstrained | | `declaredBandRanges` | — (empty) | — (empty) | — (empty) | `SpectrumOverlayMenu::setDeclaredBands` | Optional canonical-name + native coverage list used for backend-authoritative band labels. Icom publishes the IC-9700's three discontinuous deck ranges; empty keeps canonical labels and existing Flex/HL2/Sim presentation | | `canTransmit` | ✅ | `m_txAllowed` | ❌ | `RadioModel::setTransmit`, MOX/TUNE key guards | **TX safety gate.** Fail-closed: false denies any keying intent | -| `receiveOnlyModes` | — (empty) | AM · SAM · DSB · FM · NFM · WBFM · WFM · DRM | — (empty) | `RadioModel::refuseKeyInReceiveOnlyMode` (MOX / TUNE / CW-key / `setTransmit`) | Modes the radio **demodulates but will not transmit in**, in the neutral vocabulary. Empty = transmits in everything it receives. Refusing here (not in the backend) is what rolls back `TransmitModel`'s optimistic MOX/TUNE state — a backend cannot reach `TransmitModel`, so a refusal made down there leaves the TX indicator lit and TUNE latched. Icom: `["WFM"]` on the IC-705, which receives 76–108 MHz broadcast and does not transmit there (#5040). HL2: `Hl2TxDsp` is a phasing SSB modulator whose stored mode is read only by `isLowerSideband()`, so every mode above takes the USB branch and would go on the air as SSB announcing nothing — both spellings of each alias are listed because the guard is a membership test on the string the slice holds, not alias normalisation — `Hl2Backend::setSliceMode` and `applyRestoredState` both canonicalise now, so an HL2 slice should only ever hold the offered spelling, and the alias entries are kept because a list that is only correct while every writer stays canonical is the wrong kind of correct. **Note what a non-empty list costs:** `beginLocalTxActivity` runs this guard for EVERY `TxActivity`, so it withdraws TUNE in those modes as well as MOX and CW keying, even where the tune carrier itself would be clean | +| `receiveOnlyModes` | — (empty) | SAM · WBFM · WFM · DRM (default TXA build); plus AM · DSB · FM · NFM with `-DAETHER_HL2_TX_TXA=OFF` | — (empty) | `RadioModel::refuseKeyInReceiveOnlyMode` (MOX / TUNE / CW-key / `setTransmit`) | Modes the radio **demodulates but will not transmit in**, in the neutral vocabulary. Empty = transmits in everything it receives. Refusing here (not in the backend) is what rolls back `TransmitModel`'s optimistic MOX/TUNE state — a backend cannot reach `TransmitModel`, so a refusal made down there leaves the TX indicator lit and TUNE latched. Icom: `["WFM"]` on the IC-705, which receives 76–108 MHz broadcast and does not transmit there (#5040). HL2: the list depends on the modulator `AETHER_HL2_TX_TXA` selects at build time. The default WDSP TXA build modulates AM, DSB, FM and NFM — a symmetric `bp0` (`Hl2TxDsp::txaPassband`), CTCSS forced off, explicit AM carrier 0.5 and FM deviation 5000 Hz — and `hl2_txdsp_test` reads carrier-plus-sidebands, suppressed carrier and constant-envelope FM off the emitted IQ. SAM stays (a receive detector; TXA would send plain AM), WBFM/WFM stay (WDSP TX defines no WBFM mode, `WdspChannel::validateConfig`), DRM stays (no encoder). The phasing build (`OFF`) reads the stored mode only through `isLowerSideband()`, so every mode on the longer list would go on the air as USB announcing nothing, and that build keeps all eight. Both spellings of each alias are listed because the guard is a membership test on the string the slice holds, not alias normalisation — `Hl2Backend::setSliceMode` and `applyRestoredState` both canonicalise now, so an HL2 slice should only ever hold the offered spelling, and the alias entries are kept because a list that is only correct while every writer stays canonical is the wrong kind of correct. **Note what a non-empty list costs:** `beginLocalTxActivity` runs this guard for EVERY `TxActivity`, so it withdraws TUNE in those modes as well as MOX and CW keying, even where the tune carrier itself would be clean | | `hostModulates` | — (❌) | ✅ | — (❌) | `TciServer`, `MainWindow_Session` | Mic source collapses to PC; PC-audio lock. **Not the same question as `takesTxAudioOverSeam`** — see below | | `takesTxAudioOverSeam` | ❌ | ✅ | ❌ | `MainWindow_Session` (capture, TX stream, PC-audio lock), `AudioEngine::setHostModulation`, `RadioModel::ensureDaxTxStream` | Whether transmit audio leaves through `submitTxAudio` rather than a DAX/VITA-49 stream. Icom: ✅ | | `hasRadioPttReadback` | ❌ | ❌ | ❌ | `RadioModel::publishCommandedBackendTransmitEdge`, `Ax25HfPacketDecodeDialog::beginTransmitWhenReady` | The backend's `transmitChanged` / `keyingStateConfirmed` carry the **radio's own** PTT readback and `setKeying()` is intent only. True suppresses RadioModel's command-edge fallback, and makes AetherModem wait for `radioTransmittingChanged` / `radioTransmitConfirmed` (or an already-keyed radio) before releasing sample zero. Flex: ❌ because its interlock edge is decoded inside RadioModel, not through the seam. Icom: ✅ (decoded CI-V `1C 00`, #5311) | @@ -247,7 +247,7 @@ the production dialog and checks its displayed peak level and frequency. | `lineoutControl` | ✅ | absent | absent | `RadioModel::lineoutThroughSeam`, `lineoutControlsAvailable` (title-bar speaker, Radio Setup Line Out row) | Optional record: the radio has its own line out. Flex by its command plane (`mixer lineout`); otherwise `IRadioBackend::setLineoutGain` / `setLineoutMute` must apply it, and RadioModel sends them typed instead of the text. ANAN: present (speaker silence and host-side scaling). Absent (HL2, Icom, RTL): the title-bar speaker mutes only this computer's output (refused with the unsupported-control notice when PC Audio is off too), Radio Setup's Line Out row is dimmed with its reason, and any other `mixer lineout` text keeps its drop notice (#4665). Sim: absent, but its synthetic command plane takes the text, so it behaves as a Flex here | | `txWaterfallClientFlag` (record) | absent | **engaged**, `clientPersistsFlag` ✅ | absent | `RadioModel::requestLocalShowTxInWaterfall` and `RadioModel::restoreClientShowTxInWaterfall` → `RadioCapabilities::clientPersistsShowTxInWaterfall()` | Who owns "Show TX in Waterfall" (`TransmitModel::showTxInWaterfall()`) on a radio that neither stores nor echoes it. Engaged with `clientPersistsFlag` true makes the client the owner: Radio Setup and the bridge's `txwaterfall` set the transmit model directly instead of sending `transmit set show_tx_in_waterfall=`, the value is written per radio to `ClientDisplay.showTxInWaterfall`, and it is restored on connect, all three through the one accessor. Absent keeps a family's existing behavior. **HL2 only** (no register, no echo, stores nothing itself). Flex: absent, the radio stores and echoes the flag. Icom, ANAN, RTL and Sim: absent, no owner declared; the wire text is sent, nothing answers it and the client stores nothing. Engaging another family is that family's declaration to make | | `hasTransmitFrequencyCheck` | ❌ | ❌ | ❌ | `VfoWidget` and `RxApplet` through `RadioModel` | Replaces persistent REV with momentary XFC on verified radios. Icom: ✅ for IC-705 and IC-9700 (`1C 02`); radio readback/polling owns the visual state, and release always sends OFF | -| `fmTonePresentation` | Legacy | **Hidden** | Legacy | `VfoWidget`, `RxApplet` | Icom is model-profile driven: IC-705 and IC-9700 expose their documented extended CTCSS/DTCS surface; basic repeater models retain Legacy; unattested models hide tone controls. **HL2 is Hidden**: Legacy fills the tone-mode combo from `legacyFmToneModes()`, i.e. it OFFERS `ctcss_tx`, and this radio has no CTCSS encoder — nothing under `src/core/backends/hl2/` mentions CTCSS at all, and `Hl2Backend` overrides none of `setSliceFmToneMode`/`setSliceFmToneValue`/`setSliceFmToneRxValue`/`setSliceFmDtcs`, which are empty virtuals in `IRadioBackend`. The ground that holds in BOTH build configurations is that FM and NFM are on `receiveOnlyModes`, so `RadioModel::refuseKeyInReceiveOnlyMode` refuses every `TxActivity` in them — not an argument from the modulator, which `AETHER_HL2_TX_TXA` selects at build time and which defaults to WDSP's TXA. Receive is untouched; what is withdrawn is a transmit-side control | +| `fmTonePresentation` | Legacy | **Hidden** | Legacy | `VfoWidget`, `RxApplet` | Icom is model-profile driven: IC-705 and IC-9700 expose their documented extended CTCSS/DTCS surface; basic repeater models retain Legacy; unattested models hide tone controls. **HL2 is Hidden**: Legacy fills the tone-mode combo from `legacyFmToneModes()`, i.e. it OFFERS `ctcss_tx`, and this backend exposes no tone-encode verb — `Hl2Backend` overrides none of `setSliceFmToneMode`/`setSliceFmToneValue`/`setSliceFmToneRxValue`/`setSliceFmDtcs`, which are empty virtuals in `IRadioBackend`. The ground that holds in BOTH build configurations is that no verb is behind the control: in the default TXA build FM keys, and TXA's own CTCSS encoder (on by default in `create_txa`) is forced off by `WdspChannel::open()` rather than wired to these controls; in the phasing build (`-DAETHER_HL2_TX_TXA=OFF`) FM and NFM are on `receiveOnlyModes` and cannot key at all. Receive is untouched; what is withdrawn is a transmit-side control | | `fmToneModes` | empty | empty | empty | `VfoWidget`, `RxApplet` | Authoritative selectable access-mode vocabulary. IC-705 and IC-9700 publish all eight states documented for each model at `16 5D`; IC-7300MK2 retains its narrower activated path | | `fmDtcsCodes` | empty | empty | empty | `VfoWidget`, `RxApplet` | Authoritative operator-intent vocabulary for the DTCS selector. Every backend declares empty explicitly; the activated IC-705 and IC-9700 extended profiles publish the standard 104-code set. Empty means no DTCS control, never an invented default | | `hasHostNoiseBlanker` | ❌ | ✅ | ❌ | `RadioModel::hasHostNoiseBlanker()` → `VfoWidget::setHasHostNoiseBlanker` | **THIS HOST** blanks impulse noise in the radio's IQ (WDSP ANB, ahead of the demodulator). OR'd with `hasRadioSideDsp` at the NB button, so a direct-sampling radio gets NB without claiming firmware DSP it does not have — the same exception the manual notch makes. Requires an IQ path this host demodulates: a backend fed finished audio has nothing to blank. Icom: ❌ (the radio's own blanker, under `hasRadioSideDsp`). ANAN: ✅ — the same WDSP ANB, in `AnanRxDsp`. **Not** permissive on disconnect — it can only ADD the button | @@ -925,9 +925,11 @@ predicates the readouts ask before printing. See [`HERMES.md`](../HERMES.md) - **Hl2Backend** reports `canTransmit` from its own TX gate (`m_txAllowed`) so a build with transmit disabled looks RX-only from above the seam. Its `receiveOnlyModes` is hand-listed rather than derived, and the evidence for the - list is a test, not the list: `tests/hl2_txdsp_test.cpp` asserts each declared - mode produces IQ **bit-identical** to USB, and `tests/hl2_family_transition_test.cpp` - asserts the live backend still declares every one of them. + list is a test, not the list. In the default TXA build `tests/hl2_txdsp_test.cpp` + measures AM, DSB and FM off a real TXA channel; in the phasing build it asserts + each declared mode produces IQ **bit-identical** to USB. In both, + `tests/hl2_family_transition_test.cpp` asserts the live backend declares exactly + the list for that build. - **IcomCivBackend** derives `receiveOnlyModes` from `modeListFor()` filtered by `modeIsReceiveOnly()` rather than listing it a third time, so a mode cannot be offered in the combo without a consistent transmit answer for it. It also @@ -963,9 +965,10 @@ prevent. pins the two FM-control declarations this radio must NOT make — `hasFmRepeaterOffset` (whose struct default is the opposite value, so the default is asserted separately and the two facts fail apart) and -`fmTonePresentation`. The reason for both is read from production — -`receiveOnlyModes` and `legacyFmToneModes()` — rather than re-typed, so if the -HL2 ever gains an FM transmit chain that target is the one that fails first. +`fmTonePresentation`. In the default TXA build the HL2 has an FM transmit chain, +and that target asserts it (FM and NFM are off `receiveOnlyModes`) beside the two +withdrawals, which stand because no verb is behind either. In the phasing build +it asserts FM and NFM are still on the list. [`tests/show_tx_in_waterfall_client_flag_test.cpp`](../../tests/show_tx_in_waterfall_client_flag_test.cpp) pins `txWaterfallClientFlag` on the real backends: the HL2 stores and restores diff --git a/docs/user/docs/hermes-lite-2.md b/docs/user/docs/hermes-lite-2.md index c510c60ae8..3e7f963120 100644 --- a/docs/user/docs/hermes-lite-2.md +++ b/docs/user/docs/hermes-lite-2.md @@ -135,7 +135,8 @@ RIT and XIT work per receiver, ±9999 Hz. When transmit passes to another receiv - **Show TX in Waterfall:** **Settings → Radio Setup... → Transmit → Show TX in Waterfall** lets the waterfall draw spectrum rows over the TX passband while transmitting. It starts off, and AetherSDR remembers your choice separately for each HL2 across reconnects and restarts. The control does not key the radio. - **Max Power:** **Settings → Radio Setup... → Transmit → Max Power** shows the HL2's rated watts, read-only. The power gauge uses a 5 W scale, and forward power reads near PEP on speech. - **RF power slider:** the radio has 16 drive steps, so several slider positions do nothing and then one position adds about 1.25 dB. -- **Modes that refuse to transmit:** AM, SAM, DSB, FM, NFM, WBFM and DRM refuse MOX and TUNE, because the HL2 modulator is SSB only. A mode the HL2 cannot demodulate is refused everywhere. +- **AM, DSB and FM** transmit through the same TXA modulator as SSB. FM is simplex and carries no CTCSS tone. +- **Modes that refuse to transmit:** SAM, WBFM and DRM refuse MOX and TUNE. SAM is a receive detector choice; wide FM has no transmit modulator, and DRM has no encoder. A mode the HL2 cannot demodulate is refused everywhere. - **TX drive and LNA gain** are remembered per band. > **Upgrading from an older version:** CW keys at the same carrier level as TUNE, about 6 dB (four times the power) above earlier versions at the same RF power setting. Check your RF power before you key CW into an amplifier or a low-power load. @@ -197,9 +198,9 @@ The receivers share one sample rate, and only one pane controls it. ### MOX or TUNE is refused -The HL2 modulator is SSB only, so AM, SAM, DSB, FM, NFM, WBFM and DRM refuse to transmit. +SAM is receive-only. WBFM has no transmit modulator, and DRM has no encoder, so those modes also refuse to transmit. -1. Switch the slice to USB, LSB, CW or a data mode before keying. +1. Switch the slice to USB, LSB, AM, DSB, FM, CW or a data mode before keying. ### The Calibration controls are disabled diff --git a/docs/user/src/components/LogAnalyzer/rules.json b/docs/user/src/components/LogAnalyzer/rules.json index 730527e4ea..4ffa66126d 100644 --- a/docs/user/src/components/LogAnalyzer/rules.json +++ b/docs/user/src/components/LogAnalyzer/rules.json @@ -890,7 +890,7 @@ "source": [ { "file": "src/core/backends/hl2/Hl2Backend.cpp", - "line": 2844, + "line": 2848, "text": "HL2: could not open the UDP socket" } ] diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index 97ced4c742..64b1bc6561 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -1846,10 +1846,10 @@ RadioCapabilities Hl2Backend::capabilities() const c.firmwareUpdateSource = std::nullopt; // gateware number; no published list covers it. c.manufacturer = QStringLiteral("Hermes-Lite"); c.model = QStringLiteral("Hermes-Lite 2"); - // No repeater duplex and no tone encode: FM/NFM are receive-only below on - // every build, so these would imply FM transmit. hasFmRepeaterOffset - // defaults true and this backend overrides no repeater-offset setter; - // Hidden tone presentation since there is no CTCSS/DCS encoder. + // No repeater duplex and no tone encode, although the TXA build keys FM: + // hasFmRepeaterOffset defaults true and this backend overrides no + // repeater-offset setter, and no tone control reaches TXA's CTCSS encoder, + // which WdspChannel::open() forces off. Simplex FM without a tone. c.hasFmRepeaterOffset = false; c.fmTonePresentation = FmTonePresentation::Hidden; c.fmDtcsCodes = {}; @@ -1939,16 +1939,20 @@ RadioCapabilities Hl2Backend::capabilities() const // Reported from the gate, not hardcoded: the engine's TX guard keys off this, // so a build with transmit disabled must look RX-only from above the seam. c.canTransmit = m_txAllowed; - // Modes this radio demodulates but cannot modulate: Hl2TxDsp only - // distinguishes sidebands, so these would go out as USB. SSB family and CW - // (gateware-keyed at the TX NCO) are not listed. Both spellings are listed - // because refuseKeyInReceiveOnlyMode() compares the slice's string. This - // also refuses TUNE in these modes, since the list has no per-activity - // granularity (same on IC-705, #5040). - c.receiveOnlyModes = {QStringLiteral("AM"), QStringLiteral("SAM"), - QStringLiteral("DSB"), QStringLiteral("FM"), - QStringLiteral("NFM"), QStringLiteral("WBFM"), + // Modes this radio demodulates but does not modulate. SAM is a receive + // detector (TXA would send plain AM), WDSP TX has no WBFM mode, and there + // is no DRM encoder. Both spellings of an alias are listed because + // refuseKeyInReceiveOnlyMode() compares the slice's string. The list also + // refuses TUNE in these modes: it has no per-activity granularity. + c.receiveOnlyModes = {QStringLiteral("SAM"), QStringLiteral("WBFM"), QStringLiteral("WFM"), QStringLiteral("DRM")}; + // AM, DSB and FM (NFM is the same TXA mode) are modulated by the TXA build + // only. The phasing modulator only distinguishes sidebands and would send + // them as USB, so that build keeps refusing them. + if (!AETHER_HL2_TX_TXA) { + c.receiveOnlyModes << QStringLiteral("AM") << QStringLiteral("DSB") + << QStringLiteral("FM") << QStringLiteral("NFM"); + } c.hostModulates = true; // Same tap, same seam — see RadioCapabilities::takesTxAudioOverSeam. c.takesTxAudioOverSeam = true; // PC runs the modulator; no on-radio mic jacks diff --git a/src/core/backends/hl2/Hl2ModeVocabulary.h b/src/core/backends/hl2/Hl2ModeVocabulary.h index 841e888d68..a033b2c748 100644 --- a/src/core/backends/hl2/Hl2ModeVocabulary.h +++ b/src/core/backends/hl2/Hl2ModeVocabulary.h @@ -6,8 +6,9 @@ // aliases CWU/WFM/NFM; WBFM/WFM (RxApplet assumes "WFM" is never in m_modeCombo); // DRM (no decoder). canonicalOfferedMode() maps an alias to its offered spelling // at restore and in setSliceMode() so the combos match; display-only, no DSP -// effect. receiveOnlyModes lists each alias pair both ways (modeIsReceiveOnly() -// is a membership test), so canonicalising cannot weaken a TX refusal. +// effect. receiveOnlyModes lists each alias pair both ways or neither +// (modeIsReceiveOnly() is a membership test), so canonicalising cannot weaken a +// TX refusal. #include #include diff --git a/src/core/backends/hl2/Hl2TxDsp.cpp b/src/core/backends/hl2/Hl2TxDsp.cpp index 8a642d352d..6877e6e594 100644 --- a/src/core/backends/hl2/Hl2TxDsp.cpp +++ b/src/core/backends/hl2/Hl2TxDsp.cpp @@ -77,13 +77,14 @@ bool Hl2TxDsp::buildModulator(std::string* error) // fexchange2's underrun report unreachable; fix underruns by pacing. c.blockForOutput = false; - // Signed, from the mode. See applyModeAndFilter(). - const double lo = std::min(std::abs(m_config.filterLowHz), - std::abs(m_config.filterHighHz)); - const double hi = std::max(std::abs(m_config.filterLowHz), - std::abs(m_config.filterHighHz)); - c.filterLowHz = isLowerSideband() ? -hi : lo; - c.filterHighHz = isLowerSideband() ? -lo : hi; + // Signed, from the mode. See txaPassband(). + const auto [signedLow, signedHigh] = + txaPassband(m_config.mode, m_config.filterLowHz, m_config.filterHighHz); + c.filterLowHz = signedLow; + c.filterHighHz = signedHigh; + // WdspChannel::open() pushes both, and turns TXA's default-on CTCSS off. + c.txAmCarrierLevel = m_config.amCarrierLevel; + c.txFmDeviationHz = m_config.fmDeviationHz; std::string err; m_channel = WdspChannel::create(c, &err); @@ -117,12 +118,8 @@ void Hl2TxDsp::applyModeAndFilter() } // Both calls, every time: SetTXAMode does not pick an SSB sideband (the // passband sign does), so a mode-only USB->LSB change would stay on USB. - const double lo = std::min(std::abs(m_config.filterLowHz), - std::abs(m_config.filterHighHz)); - const double hi = std::max(std::abs(m_config.filterLowHz), - std::abs(m_config.filterHighHz)); - const double lowHz = isLowerSideband() ? -hi : lo; - const double highHz = isLowerSideband() ? -lo : hi; + const auto [lowHz, highHz] = + txaPassband(m_config.mode, m_config.filterLowHz, m_config.filterHighHz); if (!m_channel->setMode(m_config.mode)) { qCWarning(lcTxMod) << "HL2 TXA modulator: mode change refused"; @@ -468,6 +465,18 @@ void Hl2TxDsp::modulate(std::span audio) } } +bool Hl2TxDsp::isLowerSideband() const +{ + switch (m_config.mode) { + case WdspChannel::Mode::Lsb: + case WdspChannel::Mode::Cwl: + case WdspChannel::Mode::Digl: + return true; + default: + return false; + } +} + const char* Hl2TxDsp::modulatorName() noexcept { return "phasing"; } int Hl2TxDsp::wdspChannelId() const noexcept { return -1; } const WdspChannel::Config* Hl2TxDsp::channelConfig() const noexcept { return nullptr; } @@ -555,15 +564,26 @@ void Hl2TxDsp::reset() resetModulatorState(); } -bool Hl2TxDsp::isLowerSideband() const +std::pair Hl2TxDsp::txaPassband(WdspChannel::Mode mode, + double lowHz, + double highHz) noexcept { - switch (m_config.mode) { + const double lo = std::min(std::abs(lowHz), std::abs(highHz)); + const double hi = std::max(std::abs(lowHz), std::abs(highHz)); + switch (mode) { case WdspChannel::Mode::Lsb: case WdspChannel::Mode::Cwl: case WdspChannel::Mode::Digl: - return true; + return {-hi, -lo}; + // TXASetupBPFilters runs bp0 on these edges before the modulator, so they + // are an audio low-pass and must straddle zero. See the header. + case WdspChannel::Mode::Am: + case WdspChannel::Mode::Sam: + case WdspChannel::Mode::Dsb: + case WdspChannel::Mode::Fm: + return {-hi, hi}; default: - return false; + return {lo, hi}; } } diff --git a/src/core/backends/hl2/Hl2TxDsp.h b/src/core/backends/hl2/Hl2TxDsp.h index c221c0b2bd..378e32fd69 100644 --- a/src/core/backends/hl2/Hl2TxDsp.h +++ b/src/core/backends/hl2/Hl2TxDsp.h @@ -2,6 +2,7 @@ #include "core/backends/hl2/Hl2TxAlc.h" #include "core/dsp/WdspChannel.h" +#include "core/backends/hl2/Hl2TxLevelPolicy.h" #include "core/TxCoordinator.h" #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include "core/backends/TxAudioSource.h" @@ -20,9 +22,11 @@ class QTimer; namespace AetherSDR::hl2 { -// SSB transmit chain for the Hermes-Lite 2: processed TX audio in (AudioEngine +// Transmit chain for the Hermes-Lite 2: processed TX audio in (AudioEngine // has already applied tone, compressor, EQ), baseband IQ out for EP2. 24 kHz -// in, fixed 48 kHz out; WDSP's three-rate channel interpolates. +// in, fixed 48 kHz out; WDSP's three-rate channel interpolates. SSB and digital +// in both builds; AM, DSB and FM in the TXA build only (the phasing modulator +// is SSB-only, and Hl2Backend declares those modes receive-only there). // // The modulator is chosen only at build time by AETHER_HL2_TX_TXA (no runtime // switch): 1 (default) = WDSP TXA channel; 0 = in-tree phasing modulator, not @@ -36,8 +40,9 @@ namespace AetherSDR::hl2 { // already has the wire's handedness. The passband SIGN carries the // sideband, since TXASetupBPFilters treats TXA_LSB and TXA_USB identically. // Config::filterLowHz/filterHighHz stay positive audio-domain magnitudes; the -// TXA build applies the sign in applyModeAndFilter(). Keep it there: Hl2Backend's -// table feeds the readback and stored eSSB pair, which want magnitudes. +// TXA build applies the sign in applyModeAndFilter(), from txaPassband(). Keep it +// there: Hl2Backend's table feeds the readback and stored eSSB pair, which want +// magnitudes. // The TXA build depends on caller cadence; see processAudioBlock. class Hl2TxDsp : public QObject { Q_OBJECT @@ -55,6 +60,11 @@ class Hl2TxDsp : public QObject { // splatter outside this is other people's problem, not ours. double filterLowHz = 300.0; double filterHighHz = 2700.0; + // TXA's AM carrier level and FM peak deviation (Hl2TxLevelPolicy.h). + // Inert outside AM/DSB and FM, and in the phasing build. No operator + // control reaches either. + double amCarrierLevel = kTxAmCarrierLevel; + double fmDeviationHz = kTxFmDeviationHz; // ALC, reduction only: unity ceiling, never adds gain (as WDSP's // create_txa `alc`, max_gain=1.0; makeup gain is the separate leveler). @@ -90,6 +100,14 @@ class Hl2TxDsp : public QObject { // Which modulator this binary was built with, for the health snapshot. [[nodiscard]] static const char* modulatorName() noexcept; + // Signed TXA passband for `mode` from the positive audio pair: [+lo, +hi] + // for USB/DIGU/CWU and unlisted modes, [-hi, -lo] for LSB/DIGL/CWL, and + // [-hi, +hi] for AM/SAM/DSB/FM. The symmetric band is an audio low-pass: + // bp0 runs before xammod/xfmmod, which read only I, so a one-sided band + // would halve the modulation. Pure; only the TXA build calls it. + [[nodiscard]] static std::pair + txaPassband(WdspChannel::Mode mode, double lowHz, double highHz) noexcept; + // The WDSP-allocated channel id, or -1 when this build has none. [[nodiscard]] int wdspChannelId() const noexcept; [[nodiscard]] const WdspChannel::Config* channelConfig() const noexcept; @@ -164,7 +182,6 @@ public slots: // outputSampleRateHz to m_iq. Everything above it is shared between builds. void modulate(std::span audio); void resetModulatorState(); - bool isLowerSideband() const; Config m_config; TxCoordinator::Context m_txContext; @@ -225,6 +242,9 @@ public slots: // 150 Hz (measured 22.06 dB on {150, 3000}), which is why TXA is the default. static constexpr std::size_t kTaps = 255; + // The phasing modulator's only reading of the mode: LSB, CWL and DIGL. + bool isLowerSideband() const; + std::vector m_bandpass; // real bandpass std::vector m_hilbert; // quadrature half of the analytic bandpass std::vector m_hist; // shared delay line diff --git a/src/core/backends/hl2/Hl2TxLevelPolicy.h b/src/core/backends/hl2/Hl2TxLevelPolicy.h index 27356beb84..897e71762d 100644 --- a/src/core/backends/hl2/Hl2TxLevelPolicy.h +++ b/src/core/backends/hl2/Hl2TxLevelPolicy.h @@ -107,4 +107,12 @@ defaultTxPassbandForModeName(std::string_view mode) noexcept return {300, 2700}; // USB/LSB and anything else: the voice default } +// TXA's AM carrier level and FM peak deviation: create_txa()'s own values. +// 0.5 makes full-scale audio exactly 100 % modulation at the PEP of a +// full-scale SSB peak (carrier is a quarter of PEP). 5000 Hz matches the RX FM +// detector; it is the deviation for unit audio after TXA's pre-emphasis, so +// speech deviates less. No operator control reaches either value. +inline constexpr double kTxAmCarrierLevel = 0.5; +inline constexpr double kTxFmDeviationHz = 5000.0; + } // namespace AetherSDR::hl2 diff --git a/src/core/dsp/WdspChannel.cpp b/src/core/dsp/WdspChannel.cpp index bab1eb01d4..6ed5c9de0a 100644 --- a/src/core/dsp/WdspChannel.cpp +++ b/src/core/dsp/WdspChannel.cpp @@ -1340,6 +1340,21 @@ bool WdspChannel::validateConfig(const Config& config, std::string* error) noexc setError(error, "WDSP TX does not define a WBFM mode"); return false; } + // The TXA modulator settings open() pushes; a receive Config never uses them. + if (config.direction == Direction::Transmit && + (!std::isfinite(config.txAmCarrierLevel) || config.txAmCarrierLevel < 0.0 || + config.txAmCarrierLevel > 1.0)) { + setError(error, "WDSP TX AM carrier level must be within [0, 1]"); + return false; + } + if (config.direction == Direction::Transmit && + (!std::isfinite(config.txFmDeviationHz) || + config.txFmDeviationHz < Config::kMinFmDeviationHz || + config.txFmDeviationHz > Config::kMaxFmDeviationHz)) { + setError(error, + "WDSP TX FM deviation is outside Config::kMinFmDeviationHz..kMaxFmDeviationHz"); + return false; + } // Receive only: filterTaps reaches WDSP solely through open()'s RXASetNC // and is read only by minimumNotchWidthHz(), both of which are RX-side. A // transmit channel has none of the six cores RXASetNC addresses, so @@ -1577,6 +1592,14 @@ bool WdspChannel::open() noexcept } else { SetTXAMode(m_channelId, wdspMode(m_config.mode)); SetTXABandpassFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); + // Pushed in every transmit mode: a later setMode() into AM or FM must + // find them set, and SetTXAMode touches neither. + SetTXAAMCarrierLevel(m_channelId, m_config.txAmCarrierLevel); + SetTXAFMDeviation(m_channelId, m_config.txFmDeviationHz); + // CTCSS encode off, unconditionally: create_txa() builds fmmod with + // ctcss_run = 1 (100 Hz, level 0.10), and no host-modulated radio here + // has a tone-encode control to carry a value in Config. + SetTXACTCSSRun(m_channelId, 0); } // Cache what this open measured, right now, while we still hold the setup // lock -- a kill or a crash before exit must not throw the measurement away. diff --git a/src/core/dsp/WdspChannel.h b/src/core/dsp/WdspChannel.h index a7521317c3..84787bbe26 100644 --- a/src/core/dsp/WdspChannel.h +++ b/src/core/dsp/WdspChannel.h @@ -149,6 +149,13 @@ class WdspChannel final static constexpr double kMinFmDeviationHz = 100.0; static constexpr double kMaxFmDeviationHz = 100000.0; double fmDeviationHz = 5000.0; + // Transmit-only: TXA's AM carrier level (ammod c_level, [0, 1]) and FM + // peak deviation for unit audio (fmmod; bounded by kMin/kMaxFmDeviationHz). + // Separate from fmDeviationHz above, which is the RXA detector's. open() + // pushes both on every transmit open; each is inert outside its mode. + // The defaults are create_txa()'s own. + double txAmCarrierLevel = 0.5; + double txFmDeviationHz = 5000.0; // true: fexchange waits for each output block. The r1/r2 buffer phase is // still set per channel open (flush_iobuffs() drains with a 1 ms timed // wait), so two opens fed the same burst can differ (#5629). diff --git a/tests/control_receive_test.cpp b/tests/control_receive_test.cpp index a989238841..667bbe052e 100644 --- a/tests/control_receive_test.cpp +++ b/tests/control_receive_test.cpp @@ -777,8 +777,8 @@ void hl2DeclaredModesDriveTheControlPlane() // THE REQUEST SIDE OF THE FM DECLARATION, said out loud because it is what // the change widens: slice.setMode mode="FM" is accepted here where it was // refused "request.out_of_range". Keying is a different list -- - // receiveOnlyModes -- and hl2_fm_controls_declaration_test pins that FM - // and NFM are still on it. + // receiveOnlyModes -- and hl2_fm_controls_declaration_test pins whether FM + // and NFM are on it for this build. expect("USB", "FM", "result", "and FM can be ASKED for, not only sat in"); // THE ALIAS SPELLINGS ARE REFUSED, and that is the intended shape rather // than a gap. "CWU" and "NFM" are not on this list, so a request for one diff --git a/tests/hl2_family_transition_test.cpp b/tests/hl2_family_transition_test.cpp index 3232c61c6a..757c799692 100644 --- a/tests/hl2_family_transition_test.cpp +++ b/tests/hl2_family_transition_test.cpp @@ -90,36 +90,33 @@ int main(int argc, char** argv) // ---- The modes the LIVE HL2 backend declares it will not transmit in ---- // - // hl2_txdsp_test proves the modulator half: AM/SAM/DSB/FM/WBFM/DRM each - // produce IQ bit-identical to USB, so none of them is a distinct - // modulation. What that test cannot see is the DECLARATION — delete a - // string from Hl2Backend::capabilities()'s receiveOnlyModes and it still - // passes. This is the live target that reads it, so the list and the - // evidence cannot part company unnoticed. - // - // EIGHT strings, SIX enumerators: modeFromString() maps NFM onto Mode::Fm - // and WFM onto Mode::Wbfm, and refuseKeyInReceiveOnlyMode() compares the - // string the SLICE holds rather than the enumerator this backend would have - // mapped it to — so dropping either alias leaves that spelling keying while - // its twin is refused. That is the load-bearing claim, and nothing asserted - // it before this block. + // This asserts the DECLARATION; hl2_txdsp_test holds the modulator half + // (TXA build: AM, DSB and FM measured off a real channel; phasing build: + // every mode on the list is bit-identical to USB). The guard compares the + // string the slice holds, so each alias pair is on the list both ways or + // neither: dropping one spelling would leave it keying. { const RadioCapabilities caps = model.backendCapabilities(); - const QStringList declared = { - QStringLiteral("AM"), QStringLiteral("SAM"), - QStringLiteral("DSB"), QStringLiteral("FM"), - QStringLiteral("NFM"), QStringLiteral("WBFM"), + // AM, DSB, FM and NFM transmit in the TXA build only. + const QStringList txaOnly = { + QStringLiteral("AM"), QStringLiteral("DSB"), + QStringLiteral("FM"), QStringLiteral("NFM"), + }; + QStringList declared = { + QStringLiteral("SAM"), QStringLiteral("WBFM"), QStringLiteral("WFM"), QStringLiteral("DRM"), }; + if (!AETHER_HL2_TX_TXA) { + declared += txaOnly; + } for (const QString& m : declared) { check(modeIsReceiveOnly(caps, m), qPrintable(QStringLiteral("HL2 declares %1 receive-only").arg(m))); } // Exactly these. An ADDITION is a mode in which the operator silently - // loses MOX, CW keying and TUNE, so it must not arrive without the - // bit-identity evidence landing beside it. + // loses MOX, CW keying and TUNE. check(caps.receiveOnlyModes.size() == declared.size(), - "HL2 declares exactly the modes hl2_txdsp_test carries evidence for"); + "HL2 declares exactly these modes receive-only in this build"); // The deliberate exclusions: SSB modulates correctly, and CW keys the // gateware NCO through MetisClient::setCwKeyDown without ever reaching // Hl2TxDsp. If one of these ever appears on the list it takes an @@ -131,6 +128,12 @@ int main(int argc, char** argv) check(!modeIsReceiveOnly(caps, m), qPrintable(QStringLiteral("HL2 still transmits in %1").arg(m))); } + // Case-insensitively too: the guard compares whatever the slice holds. + for (const QString& m : txaOnly + QStringList{QStringLiteral("nfm")}) { + check(modeIsReceiveOnly(caps, m) == !AETHER_HL2_TX_TXA, + qPrintable(QStringLiteral("HL2 transmits in %1 exactly when " + "the TXA modulator is built").arg(m))); + } } // ── The normalized RX-audio bus ──────────────────────────────────────── diff --git a/tests/hl2_fm_controls_declaration_test.cpp b/tests/hl2_fm_controls_declaration_test.cpp index 357eda8b60..10269b1cd1 100644 --- a/tests/hl2_fm_controls_declaration_test.cpp +++ b/tests/hl2_fm_controls_declaration_test.cpp @@ -11,21 +11,14 @@ // // * fmTonePresentation WAS declared, as Legacy — the capability map's row was // wrong about this half. Legacy is the value that fills the tone-mode combo -// from legacyFmToneModes(), i.e. it offers CTCSS ENCODE, on a backend that -// has no CTCSS encoder anywhere and cannot key an FM carrier at all. +// from legacyFmToneModes(), i.e. it offers CTCSS ENCODE, on a backend with +// no tone-encode verb. // -// THE ARGUMENT RESTS ON receiveOnlyModes, NOT ON THE MODULATOR, and that is a -// deliberate choice this target enforces. The HL2's transmit modulator is -// selected at BUILD time by AETHER_HL2_TX_TXA (default ON = a WDSP TXA channel, -// whose chain does carry fmmod; OFF = the in-tree phasing SSB modulator), so -// any assertion phrased about the modulator would be half wrong in every build. -// What holds in both is that FM and NFM are declared receive-only, which is -// what RadioModel::refuseKeyInReceiveOnlyMode() reads. -// -// WHY THESE TWO AND NOT FM ITSELF. Nothing here says the HL2 cannot RECEIVE FM; -// it can, and this test asserts that it still declares FM receive-only rather -// than absent. What is withdrawn is a transmit-side control surface for a mode -// the backend already refuses to key in. +// Both withdrawals hold in both builds because there is no verb behind either +// control. The modulator is selected at BUILD time by AETHER_HL2_TX_TXA: ON +// (default) is a WDSP TXA channel that keys FM, with TXA's CTCSS encoder forced +// off by WdspChannel::open(); OFF is the phasing SSB modulator, where FM and +// NFM stay receive-only. Nothing here says the HL2 cannot RECEIVE FM. // // SOCKET-FREE. Constructs a backend and reads capabilities(); binds nothing, // connects nothing, pumps no event loop, and reaches no radio. It does read one @@ -103,41 +96,23 @@ int main(int argc, char** argv) // the rule. The constructed hl2::Hl2Backend above is what makes this the // HL2 descriptor; every line below reads a capability. - // ---- the reason both controls are dead, read from production ---- + // ---- whether FM keys, read from production ---- // - // Not a re-typed premise: receiveOnlyModes is the SAME list the key-on - // guard reads (RadioModel::refuseKeyInReceiveOnlyMode). If FM ever leaves - // it — which is what a real TX chain landing would mean — this assertion is - // the one that fails, and the two declarations below become re-openable - // questions rather than settled ones. - check(caps.receiveOnlyModes.contains(QStringLiteral("FM")) - && caps.receiveOnlyModes.contains(QStringLiteral("NFM")), - "FM and NFM are RECEIVE-ONLY here — the radio will not key in them"); - // AND THAT IS THE WHOLE SAFETY ARGUMENT FOR DECLARING THEM ON - // receiveModeControl, so the two facts are pinned side by side rather than - // one of them being left to a comment. FM is on the RECEIVE control list - // (an operator who picks it out of the mode menu can be steered back out; - // before that it latched the slice shut) and on the RECEIVE-ONLY list (the - // radio still refuses to key in it). Those are different lists read by - // different code: receiveModeControl reaches ModelReceiveControlTarget and - // RadioResourceAdapter and nothing on the transmit side, while - // receiveOnlyModes is what RadioModel::refuseKeyInReceiveOnlyMode() reads - // inside beginTxActivity(). If a future change ever moves FM off the - // second list — which is what a real FM transmit chain landing would mean - // — this line fails and the two declarations below become re-openable. + // receiveOnlyModes is the list the key-on guard reads + // (RadioModel::refuseKeyInReceiveOnlyMode). FM and NFM key exactly when the + // TXA modulator is built; the withdrawals below hold either way. + check(modeIsReceiveOnly(caps, QStringLiteral("FM")) == !AETHER_HL2_TX_TXA + && modeIsReceiveOnly(caps, QStringLiteral("NFM")) == !AETHER_HL2_TX_TXA, + "FM and NFM transmit exactly when the TXA modulator is built"); + // receiveModeControl is a different list, read only on the receive side. check(caps.receiveModeControl && caps.receiveModeControl->modes.contains(QStringLiteral("FM")), - "FM is STEERABLE and still unkeyable — receive control and receive-only agree"); - // NFM is on receiveOnlyModes and NOT on receiveModeControl, and the - // asymmetry is deliberate rather than an oversight. receiveOnlyModes is a - // membership test run on whatever string a slice holds, so it lists every - // alias both ways and stays correct however the mode got there; - // receiveModeControl is a REQUEST surface, and an alias on it would be - // rewritten by Hl2Backend::setSliceMode and then never match the pending - // observation ModelReceiveControlTarget is waiting for. Asserted, because - // "the lists differ" is exactly the shape a careless edit would repair. + "FM is STEERABLE on the receive control surface"); + // NFM is deliberately not requestable: receiveModeControl is a REQUEST + // surface, and Hl2Backend::setSliceMode would rewrite the alias so it never + // matched the pending observation ModelReceiveControlTarget waits for. check(!caps.receiveModeControl->modes.contains(QStringLiteral("NFM")), - "and the NFM alias is receive-ONLY but not requestable — the two lists differ on purpose"); + "and the NFM alias is not requestable — the two lists differ on purpose"); // ---- no repeater duplex ---- check(!caps.hasFmRepeaterOffset, diff --git a/tests/hl2_mode_vocabulary_test.cpp b/tests/hl2_mode_vocabulary_test.cpp index 91aff5a991..1c23b9e679 100644 --- a/tests/hl2_mode_vocabulary_test.cpp +++ b/tests/hl2_mode_vocabulary_test.cpp @@ -150,10 +150,16 @@ int main(int argc, char** argv) // review raised, answered against the declaration rather than by assertion. hl2::Hl2Backend backend; const RadioCapabilities caps = backend.capabilities(); - static const QStringList kTransmittable = { + // AM, DSB and FM are transmittable in the TXA build only; SAM stays + // receive-only (see Hl2Backend::capabilities()). + QStringList kTransmittable = { QStringLiteral("LSB"), QStringLiteral("USB"), QStringLiteral("CW"), QStringLiteral("CWL"), QStringLiteral("DIGU"), QStringLiteral("DIGL"), }; + if (AETHER_HL2_TX_TXA) { + kTransmittable << QStringLiteral("AM") << QStringLiteral("DSB") + << QStringLiteral("FM"); + } for (const QString& m : std::as_const(published)) { check(kTransmittable.contains(m) || caps.receiveOnlyModes.contains(m), QStringLiteral("%1 is offered and is either transmittable or " diff --git a/tests/hl2_txdsp_test.cpp b/tests/hl2_txdsp_test.cpp index d98deeada7..3dfcc51c60 100644 --- a/tests/hl2_txdsp_test.cpp +++ b/tests/hl2_txdsp_test.cpp @@ -881,31 +881,10 @@ int main(int argc, char** argv) } #if !AETHER_HL2_TX_TXA - // ---- PHASING BUILD ONLY, and the reason is the block's own ---- - // - // The evidence below is "these modes are BIT-IDENTICAL to USB", which is - // true because Hl2TxDsp::setMode()'s only reader is isLowerSideband(). A - // TXA channel does not work that way: it implements AM, SAM, FM and DSB as - // real WDSP transmit modes, and refuses WBFM outright ("WDSP TX does not - // define a WBFM mode"). So in the TXA build every assertion here fails -- - // seven of them -- while nothing is wrong. - // - // The block says what to do about that in its own words: "If someone later - // teaches this chain a real AM or FM modulator, this block FAILS, which is - // the point: the failure is the reminder to take that mode back off the - // receive-only list." That reminder is hereby taken, and it is NOT this - // PR's to act on. Whether TXA makes AM and FM genuinely transmittable on a - // Hermes-Lite 2 is a CAPABILITY decision -- it changes what - // Hl2Backend::capabilities() declares and what - // RadioModel::refuseKeyInReceiveOnlyMode() will let an operator key -- and - // it belongs with the TXA migration rows (#5678 1.2, 6.2, 6.3), behind a - // flag that is not yet on by default and a modulator nobody has keyed on - // the air. - // - // Guarding rather than deleting, because the phasing build is what ships - // today and this is its evidence. Found by building the integration branch: - // #5680 landed this block on main after this branch was cut, so neither - // change could see the collision alone. + // Phasing build only. The evidence below is "these modes are BIT-IDENTICAL + // to USB", true only of the phasing modulator. TXA implements AM, DSB and + // FM as real modes; that build takes them off receiveOnlyModes and + // measures them at the end of this file. // ---- THE MODES Hl2Backend DECLARES RECEIVE-ONLY ARE BIT-IDENTICAL TO USB ---- // // This is the evidence behind `Hl2Backend::capabilities`'s @@ -2450,6 +2429,249 @@ int main(int argc, char** argv) } } + // AM, DSB and FM through the TXA chain. + // The TXA build takes these modes off receiveOnlyModes, so this reads the + // emission off the modulator's IQ: carrier plus equal sidebands (AM), no + // carrier (DSB), constant envelope with no CTCSS (FM). Magnitudes only: + // the modes are symmetric, so wire handedness is deliberately not tested. + + // The passband mapping, pinned from the function the modulator runs. Pure, + // so it is checked in both builds. + { + using M = WdspChannel::Mode; + const auto [txLo, txHi] = defaultTxPassbandForModeName("AM"); + auto is = [](std::pair p, double lo, double hi) { + return p.first == lo && p.second == hi; + }; + for (const M m : {M::Am, M::Dsb, M::Fm, M::Sam}) { + check(is(Hl2TxDsp::txaPassband(m, txLo, txHi), -txHi, +txHi), + "AM/DSB/FM/SAM take a SYMMETRIC TXA passband [-hi, +hi]"); + } + check(is(Hl2TxDsp::txaPassband(M::Usb, 300, 2700), 300, 2700), + "USB keeps the positive pair"); + check(is(Hl2TxDsp::txaPassband(M::Lsb, 300, 2700), -2700, -300), + "LSB keeps the negated, swapped pair"); + check(is(Hl2TxDsp::txaPassband(M::Am, -3000, -100), -3000, 3000), + "a negative pair handed in for AM still yields the symmetric band"); + // The defaults are Hl2TxLevelPolicy.h's; the Config inherits them. + const Hl2TxDsp::Config d{}; + check(d.amCarrierLevel == kTxAmCarrierLevel && kTxAmCarrierLevel == 0.5, + "the AM carrier level defaults to create_txa's 0.5"); + check(d.fmDeviationHz == kTxFmDeviationHz && kTxFmDeviationHz == 5000.0, + "the FM deviation defaults to create_txa's 5000 Hz"); + } + +#if AETHER_HL2_TX_TXA + // What the open channel holds, per mode: channelConfig() is the WDSP + // channel's own record of what it accepted. + for (const WdspChannel::Mode m : {WdspChannel::Mode::Am, WdspChannel::Mode::Dsb, + WdspChannel::Mode::Fm}) { + Hl2TxDsp tx; + Hl2TxDsp::Config cfg; + cfg.mode = m; + cfg.filterLowHz = 100.0; + cfg.filterHighHz = 3000.0; + std::string err; + const bool ok = tx.configure(cfg, &err); + check(ok, "AM/DSB/FM: the modulator configures"); + const WdspChannel::Config* c = tx.channelConfig(); + check(ok && c && c->mode == m, "the TXA channel holds the requested mode"); + check(ok && c && c->filterLowHz == -3000.0 && c->filterHighHz == 3000.0, + "the TXA channel holds the symmetric [-3000, +3000] passband"); + check(ok && c && c->txAmCarrierLevel == 0.5 && c->txFmDeviationHz == 5000.0, + "the TXA channel was opened with the policy's carrier and deviation"); + } + // A mode change on a running channel gets the symmetric band too: the + // path the live backend takes when the TX slice goes from USB to AM. + { + Hl2TxDsp tx; + Hl2TxDsp::Config cfg; + cfg.mode = WdspChannel::Mode::Usb; + std::string err; + if (tx.configure(cfg, &err)) { + tx.setFilter(100.0, 3000.0); + tx.setMode(WdspChannel::Mode::Am); + const WdspChannel::Config* c = tx.channelConfig(); + check(c && c->mode == WdspChannel::Mode::Am + && c->filterLowHz == -3000.0 && c->filterHighHz == 3000.0, + "setMode(Am) on an open USB channel moves mode AND passband"); + tx.setMode(WdspChannel::Mode::Usb); + check(c && c->filterLowHz == 100.0 && c->filterHighHz == 3000.0, + "and back to USB restores the one-sided band"); + } else { + check(false, "mode-change passband case configures"); + } + } + + // One paced run at an explicit Config, retried if starved. Trimmed to + // kSettleSamples dropped plus a whole number of 100 Hz periods (480 + // samples), which is whole for every frequency probed below. + auto runAt = [&](const Hl2TxDsp::Config& base, double toneHz, double amplitude, + double seconds) { + std::vector> out; + for (int attempt = 0; attempt < 3; ++attempt) { + out.clear(); + Hl2TxDsp tx; + TxTestAuthority auth; + Hl2TxDsp::Config cfg = base; + cfg.alcEnabled = false; + std::string err; + if (!tx.configure(cfg, &err)) { + check(false, "AM/DSB/FM spectral case configures"); + return out; + } + tx.setMicGain(1.0); + QObject::connect(&tx, &Hl2TxDsp::iqReady, &tx, + [&out](const std::vector>& iq) { + out.insert(out.end(), iq.begin(), iq.end()); + }); + const int fs = cfg.inputSampleRateHz; + const int total = static_cast(seconds * fs); + constexpr int kChunk = 240; + std::vector chunk(kChunk); + for (int off = 0; off + kChunk <= total; off += kChunk) { + for (int n = 0; n < kChunk; ++n) + chunk[static_cast(n)] = static_cast( + amplitude * std::sin(2.0 * M_PI * toneHz * (off + n) / fs)); + feed(tx, chunk, TxAudioSource::Microphone, auth.context); + } + if (tx.modulatorFaultBlocks() == 0) + break; + std::fprintf(stderr, "AM/DSB/FM case starved (%llu blocks), retrying\n", + tx.modulatorFaultBlocks()); + out.clear(); + } + check(!out.empty(), "AM/DSB/FM capture reached the wire unstarved"); + if (out.size() <= kSettleSamples) + return std::vector>{}; + const std::size_t n = ((out.size() - kSettleSamples) / 480) * 480; + return std::vector>( + out.begin() + static_cast(kSettleSamples), + out.begin() + static_cast(kSettleSamples + n)); + }; + // Complex bin magnitude at `hz` over an already-trimmed capture. + auto bin = [](const std::vector>& iq, double hz) { + std::complex acc{0.0, 0.0}; + const double w = -2.0 * M_PI * hz / kFsOut; + for (std::size_t n = 0; n < iq.size(); ++n) { + const double ph = w * static_cast(n); + acc += std::complex(iq[n].real(), iq[n].imag()) + * std::complex(std::cos(ph), std::sin(ph)); + } + return iq.empty() ? 0.0 : std::abs(acc) / static_cast(iq.size()); + }; + auto db = [](double a, double b) { + return 20.0 * std::log10((a + kSidebandEps) / (b + kSidebandEps)); + }; + Hl2TxDsp::Config voice; + voice.filterLowHz = 100.0; + voice.filterHighHz = 3000.0; + + // AM. ammod: I = Q = (c + (1 - c) * x) / sqrt(2), so the carrier bin reads + // c and each sideband (1 - c) * A / 2: 0.5 and 0.125 at c = A = 0.5, i.e. + // -12.04 dBc. The magnitude is asserted, not just the shape: a one-sided + // bp0 would halve the sidebands (-18.06 dBc) and keep the shape. + { + Hl2TxDsp::Config cfg = voice; + cfg.mode = WdspChannel::Mode::Am; + const auto iq = runAt(cfg, kTone, 0.5, 1.0); + const double carrier = bin(iq, 0.0); + const double up = bin(iq, +kTone); + const double lo = bin(iq, -kTone); + const double h2 = std::max(bin(iq, +2 * kTone), bin(iq, -2 * kTone)); + std::fprintf(stderr, "AM: carrier %.5f, sidebands %.5f / %.5f (%.2f dBc), " + "2nd harmonic %.2f dBc\n", + carrier, up, lo, db(up, carrier), db(h2, carrier)); + check(std::fabs(db(carrier, 0.5)) < 1.0, + "AM: the carrier sits at ammod's c_level (0.5) within 1 dB"); + check(std::fabs(db(up, carrier) - (-12.04)) < 1.0 + && std::fabs(db(lo, carrier) - (-12.04)) < 1.0, + "AM: both sidebands sit 12 dB under the carrier (50 % modulation)"); + check(std::fabs(db(up, lo)) < 0.5, "AM: the two sidebands are equal"); + check(db(h2, carrier) < -40.0, "AM: no second-harmonic sidebands"); + } + + // DSB: the same two sidebands with the carrier gone. + { + Hl2TxDsp::Config cfg = voice; + cfg.mode = WdspChannel::Mode::Dsb; + const auto iq = runAt(cfg, kTone, 0.5, 1.0); + const double carrier = bin(iq, 0.0); + const double up = bin(iq, +kTone); + const double lo = bin(iq, -kTone); + std::fprintf(stderr, "DSB: carrier %.3g, sidebands %.5f / %.5f, " + "carrier %.1f dB under a sideband\n", + carrier, up, lo, db(up, carrier)); + // ammod mode 1: I = Q = x / sqrt(2), so each sideband reads A / 2. + check(std::fabs(db(up, 0.25)) < 1.0 && std::fabs(db(lo, 0.25)) < 1.0, + "DSB: each sideband is A/2 (0.25) within 1 dB"); + check(std::fabs(db(up, lo)) < 0.5, "DSB: the two sidebands are equal"); + check(db(up, carrier) > 60.0, "DSB: the carrier is suppressed by > 60 dB"); + } + + // FM. Instantaneous frequency is arg(z[n] conj(z[n-1])) * fs / 2pi. Its + // component at the tone is the deviation; its component at 100 Hz would be + // the CTCSS create_txa switches on by default (about 450 Hz), which + // WdspChannel::open() turns off. + struct FmRead { double envSpread; double devHz; double ctcssHz; }; + auto readFm = [&](const std::vector>& iq, double toneHz) { + FmRead r{1.0, 0.0, 1e9}; + if (iq.size() < 2) + return r; + double mn = 1e9, mx = 0.0, sum = 0.0; + for (const auto& z : iq) { + const double a = std::abs(z); + mn = std::min(mn, a); mx = std::max(mx, a); sum += a; + } + r.envSpread = (mx - mn) / (sum / static_cast(iq.size())); + std::vector f(iq.size() - 1); + for (std::size_t n = 1; n < iq.size(); ++n) + f[n - 1] = std::arg(std::complex(iq[n]) * + std::conj(std::complex(iq[n - 1]))) + * kFsOut / (2.0 * M_PI); + // Whole cycles of 100 Hz again, one sample shorter after differencing. + const std::size_t len = (f.size() / 480) * 480; + auto comp = [&](double hz) { + std::complex acc{0.0, 0.0}; + for (std::size_t n = 0; n < len; ++n) { + const double ph = -2.0 * M_PI * hz * static_cast(n) / kFsOut; + acc += f[n] * std::complex(std::cos(ph), std::sin(ph)); + } + return len ? 2.0 * std::abs(acc) / static_cast(len) : 0.0; + }; + r.devHz = comp(toneHz); + r.ctcssHz = comp(100.0); + return r; + }; + { + Hl2TxDsp::Config cfg = voice; + cfg.mode = WdspChannel::Mode::Fm; + constexpr double kFmTone = 2000.0; + const FmRead a = readFm(runAt(cfg, kFmTone, 0.5, 1.0), kFmTone); + const FmRead b = readFm(runAt(cfg, kFmTone, 0.25, 1.0), kFmTone); + Hl2TxDsp::Config narrow = cfg; + narrow.fmDeviationHz = 2500.0; + const FmRead c = readFm(runAt(narrow, kFmTone, 0.5, 1.0), kFmTone); + std::fprintf(stderr, "FM: A=0.5 dev %.1f Hz (envelope spread %.4f, " + "100 Hz component %.2f Hz); A=0.25 dev %.1f Hz; " + "2.5 kHz setting dev %.1f Hz\n", + a.devHz, a.envSpread, a.ctcssHz, b.devHz, c.devHz); + check(a.envSpread < 0.05, "FM: the envelope is constant (spread < 5 %)"); + // Pre-emphasis sits between the audio and fmmod (emph.c: -20 dB at + // 300 Hz rising to 0 dB at 3 kHz), so a 2 kHz tone at 0.5 deviates + // somewhat under 0.5 x 5000. Bounded, not predicted to the hertz. + check(a.devHz > 1000.0 && a.devHz < 2500.0, + "FM: a 2 kHz tone at 0.5 deviates between 1.0 and 2.5 kHz"); + check(std::fabs(a.devHz / b.devHz - 2.0) < 0.1, + "FM: deviation is linear in audio level"); + check(std::fabs(a.devHz / c.devHz - 2.0) < 0.1, + "FM: halving the deviation setting halves the deviation " + "(SetTXAFMDeviation reached fmmod)"); + check(a.ctcssHz < 5.0, + "FM: no 100 Hz CTCSS on the air -- TXA's default-on encoder is off"); + } +#endif // AETHER_HL2_TX_TXA + if (g_failures == 0) std::fprintf(stderr, "hl2_txdsp_test: all checks passed\n"); return g_failures == 0 ? 0 : 1; diff --git a/third_party/wdsp/include/aether_wdsp.h b/third_party/wdsp/include/aether_wdsp.h index 89015a89bc..7f4e78de11 100644 --- a/third_party/wdsp/include/aether_wdsp.h +++ b/third_party/wdsp/include/aether_wdsp.h @@ -355,6 +355,13 @@ void SetRXAAMSQRun(int channel, int run); void SetRXAAMSQThreshold(int channel, double thresholdDb); void SetTXAMode(int channel, int mode); void SetTXABandpassFreqs(int channel, double lowHz, double highHz); +// TXA's AM and FM modulator stages (ammod.c, fmmod.c). create_txa() defaults: +// carrier 0.5, deviation 5000 Hz, and ctcss_run = 1 (100 Hz, level 0.10), so +// FM carries a CTCSS tone unless SetTXACTCSSRun turns it off. +// WdspChannel::open() pushes all three on every transmit open. +void SetTXAAMCarrierLevel(int channel, double carrierLevel); +void SetTXAFMDeviation(int channel, double deviationHz); +void SetTXACTCSSRun(int channel, int run); // RXA meter readouts. RXA_S_PK / RXA_S_AV are the real signal-strength // meters. RXA_ADC_PK / RXA_ADC_AV measure the POST-DDC slice, which is a // different question from the HL2's own pre-DDC full-spectrum clip