Skip to content

Commit 5607b56

Browse files
on8stclaudeten9876
authored
fix(hl2): make the ALC reduction-only and widen the mic slider (#5463 change 3) (#5646)
> ### ⚠️ This PR must not merge on its own > On its own it is a **known regression on every unattended transmission** — a > WSPR beacon at the shipped default goes out **18.59 dB down, a factor of 72 in > power**, on a transmission that keys for 111.6 s with nobody watching. It is > the first of a stack — **#5647 is the second** — and see > **[Do not merge this alone](#do-not-merge-this-alone)** for exactly which > branch closes which half. ## What this changes **This changes the transmit level of every Hermes-Lite 2 installation at Mic Level 50 or above.** The stage `Hl2TxDsp` calls an ALC is not one. It applied up to **40 dB of upward makeup gain** (`Hl2TxDsp::Config::alcMaxGainDb`) with an absolute hold threshold (`alcHoldBelowDbfs`, −45 dBFS) that sits *below* a real shack's noise floor. So between words the loop went on raising gain until the fan and the mic hiss reached the same target peak as the speech. After this PR the ALC only ever reduces. The **Mic Level slider becomes the operator's transmit level**, and its upper leg is widened so that it can be. | authored speech, Mic Level at its default of 50 | before | after | |---|---|---| | speech leaving the modulator | **−1.18 dBFS** (at the ceiling) | **−21.08 dBFS** | | speech-to-room contrast in the pauses between words | **0.21 dB** | **18.10 dB** | About **20 dB quieter at the default slider position**, and the room noise that used to be lifted level with the operator's voice between words is gone. The 20 dB is not a fixed offset — it is the gap between how loud a given operator actually speaks and full modulation, and it moves with the microphone, the input gain and the voice. **Both of those figures are simulator measurements.** See [What is measured on what](#what-is-measured-on-what). Part of #5463 — this is **change 3** of four. Not `Closes`: #5505 (change 1) and #5506 (change 2) are already merged against the same umbrella, and change 4 — the leveller, which a thread correction relocated into AetherVoice — is unfiled. Auto-closing the umbrella on this merge would orphan that. WDSP draws the same line: `create_txa()` in `third_party/wdsp/upstream/TXA.c` builds the stage it names `alc` with `run=1` and `max_gain=1.0` — always on, structurally incapable of adding gain — and puts the gain that *can* be added in a separate `leveler`, built `run=0` with `max_gain=1.778` (+5 dB). Two stages, two jobs. ## Who is affected, and who is not **Hermes-Lite 2 only. Flex and Icom are untouched**, and receive is untouched on every family. All of this is host-side HL2 transmit DSP: `Hl2TxDsp` is constructed only inside `Hl2Backend` (`new Hl2TxDsp` occurs nowhere else in the tree), the Flex backend has no `submitTxAudio` override at all, and `IcomCivBackend::submitTxAudio` ships PCM to a radio that runs its own transmit processing. **WSJT-X, fldigi and anything over TCI or DAX: no change.** That path already had a unity ceiling — it was fixed separately in #4796 — and its regression case in `hl2_txdsp_test` (*"the reduction half must RELEASE"*) passes **untouched at −0.15 dB, identical before and after**. That case is the evidence this is a no-op on client-leveled audio. Level is still set in the client. **Operators who have never moved the Mic Level slider** lose up to 40 dB of automatic makeup and must set a level for the first time. That is the point of the change and also its migration hazard — *it wants a release note, not a silent ship*. **Operators who had already moved the slider above 50** keep the level they chose. The number moves instead: a stored 75 was +10 dB on the old curve and restores as 63, which is +10.4 dB on the new one. See [A stored slider position is migrated, not reinterpreted](#a-stored-slider-position-is-migrated-not-reinterpreted). ## The slider mapping The widening is **asymmetric on purpose**: 0.4 dB per step below 50 (unchanged, down to −20 dB), **0.8 dB per step above, reaching +40 dB at 100** instead of the old +20 dB. **50 stays exactly unity**, because `TransmitModel` constructs `m_micLevel` at 50 and that is where every operator who has never touched the control transmits from — a symmetric widening would move unity off 50 and silently change the transmit level of every existing install. (#5505 has since landed, so a slider position now survives a launch as well; that widened the population this pin protects rather than retiring it.) `hl2_tx_level_policy_test` pins the join: `micSliderToGainDb(50) == 0.0`, `(49) == −0.4`, `(51) == +0.8`. For the bench's speech recording, roughly **slider 74** would have put the peak on the ALC's target. *That number is an inference from the mapping, not a measurement* — legs were run at 50 and at 100 and nothing in between. ## One instrument the quiet operator gets **`TX:ALC`** now reads the actual transmit peak and tracks its input one-for-one until it reaches the target, then limits at −1.41 dBFS. Sitting far below −1.41 dBFS while speaking means you are quiet. At unity, speech leaves the modulator **19.56–19.67 dB below `alcTargetPeak`** — `d81b-speech-pauses-alc`'s `result.json` gives `speech_output_dbfs` of −21.08 dBFS (fault leg) and −20.97 dBFS (control leg), both at mic level 50, against `20*log10(0.85) = −1.4116 dBFS`. An unkey log line naming the slider position would be the natural companion, and it is **not in this branch**: it must not fire on audio the operator's microphone did not produce, and WSPR, AX.25 and RADE reach `submitTxAudio` with `clientLeveled` false exactly like a microphone. Distinguishing them needs the three-state `TxAudioSource`, which is #5647's substance. #5647 carries the advice with its gate. ## Keeping the widening inside the modulator's headroom `reset()` starts the ALC at unity on every unkey, and the stage's hard clamp sits right behind it, so at the slider's new 100x any large step arrives with the loop still far above where it needs to be. The modulator flat-tops, and no meter reports it — `TX:ALC` is measured *after* the clamp. **Reduction is instantaneous.** The block that needs less gain simply takes the target; only the release is smoothed. That is the shape a splatter guard has to have, and the attack constant was never buying smoothing here: at a 512-sample block on 24 kHz — 21.3 ms — `1 - exp(-21.3/5)` already closed 98.6% of the error in one block. It was leaving 1.4% of the step above the clamp, and 1.4% of 40 dB is not small. `alcAttackSec` is deleted with the mechanism, like `alcMaxGainDb` and `alcHoldBelowDbfs` before it. A one-shot key-on seed was tried first and covers only the **first** reduction of an over. @on8st tested it against a crescendo and it held; the shape that breaks it is a source that crosses the target *gently* — an ordinary quiet word — which spends the seed on a fraction of a dB and leaves the next syllable unprotected. Measured at slider 100: | stimulus (whole-run \|IQ\|, slider 100) | seeded attack | instantaneous | |---|---|---| | full scale from sample 0 | 0.8594, 0 clipped | 0.8594, 0 | | 100 ms of −60 dBFS room, then full scale | 0.8593, 0 | 0.8593, 0 | | −41 dBFS plateau, then a step to full scale | **1.5297, 448 at the clamp** | **0.8592, 0** | | −38 dBFS word, then a −12 dBFS syllable | **1.0768, 223** | **0.8586, 0** | | quiet, one loud burst, quiet again | **1.5045, 727 (15.2 ms)** | **0.8594, 0** | All eighteen probe shapes settle at ~0.859 with nothing at the clamp. **The release is untouched** — #4796's case still measures −0.15 dB and the over-level client's crest is still 1.0001. `hl2_txdsp_test` pins four stimuli, and they are four different openings: full scale from sample 0, a 100 ms −60 dBFS room lead-in, a 50 → 100 slider move mid-over, and a quiet word followed by a loud one. The last fails on a 5 ms attack and none of the other three can see it. It pins the observable property rather than the mechanism — a 0.5 ms attack passes it too — and that is the argument for instantaneous: whether a constant is short enough is a function of `dspBlockSize` and `inputSampleRateHz`, so it is a guarantee that expires silently the day either moves. ## A stored slider position is migrated, not reinterpreted #5505 persists the mic level, and this PR changes what a stored number means, so a position stored against the old curve is re-expressed against the new one. An operator who parked at 80 asked for +12 dB; 80 means +24 dB now, so the document restores as **65** — the same gain, a different position. The document says which curve it was written on with `micLevelCurve`, and **absent means curve 1**: the key did not exist while curve 1 was the only curve, so its absence is a positive statement about the writer. Writing the level back stamps the curve beside it, which is what makes the migration one-shot — the arithmetic deliberately is not, and `hl2_state_restore_test` asserts the stamp so an unstamped round-trip cannot start a ratchet. Only the upper leg needs it; at and below 50 both curves agree and the migration is the identity, including the mute at 0. A curve number this build does not know restores as written rather than being re-derived on a guess. ## It rewrites two certification rows and a meter face `docs/radio-certification.md`'s `TX:ALC` row has said since `76a52403`: *"sweep the input 20 dB → reading does **not** move, ±1 dB across the sweep"*, with recorded evidence of −1.41 dBFS at −10, −20 and −30 dBFS injected tone. That no-movement is not a property of a post-ALC peak meter. It is the observable signature of `alcMaxGainDb` — 40 dB of makeup dragging any input from about −41 dBFS upward onto `alcTargetPeak` — and −1.41 dBFS is exactly `20*log10(0.85)`. **After this PR lands, that row fails a correctly behaving radio, by 28.6 dB.** Correcting it is this PR's job rather than the next reader's. The replacement is **measured rather than reasoned**, because a guessed pass criterion in a certification table is worse than a stale one — it looks measured. The **2026-08-10 block is kept**, not deleted: it is correct for the build it was run on, it is the control for the new one, and its verdict cell now says which build that was. The new tolerance is **±0.25 dB against a worst measured deviation of 0.0065 dB** — two orders of magnitude looser than the data supports, deliberately, because one radio on one host on one night does not earn a tolerance that only passes on the machine it was taken on. **`TX:ALCGAIN`, three rows further down, had the same defect and is corrected here too.** It swept *"between `alcHoldBelowDbfs` and the makeup ceiling"* — neither of which exists after this change — so it would fail a correct radio for exactly the reason the `TX:ALC` row would. With the ceiling at unity the gain that meter reports can only be zero or negative, so the criterion is now a sign and a knee rather than a window. **And the meter's own face moved with it.** `TX:ALCGAIN` was defined `-20..+40 dB` and its comment justified the top as `Hl2TxDsp::Config::alcMaxGainDb`. Deleting that field without moving the face would have left #5636 inheriting a meter whose needle can never leave the bottom third of its own scale; it is `-20..0` now. ## Do not merge this alone On its own this PR changes the level of **unattended** transmissions — WSPR, AX.25 packet and RADE — because they reach `submitTxAudio` on the same path as a microphone and were relying on the same 40 dB of makeup to normalise them. Measured with the application's own WSPR beacon against `hpsdrsim` (`wspr-real-beacon-source-ab`, beacon at the shipped −20 dBFS default): | build | beacon `TX:ALC` peak | |---|---| | today's `main` | **−1.412 dBFS** | | **this PR alone** | **−20.002 dBFS** | **18.59 dB down, a factor of 72 in power, on a transmission that keys for 111.6 s with nobody watching.** And on this PR alone the **Mic Level slider moves the beacon**: the same run measured a 50 → 100 slider move shifting it by **18.59 dB**. A microphone control has no business moving an unattended beacon. The repair takes **two** further branches, and it is worth being exact about which one does what, because it is natural to assume the next PR closes both halves and **it does not**: | | mic slider moves the beacon? | beacon level at the shipped default | |---|---|---| | this PR alone | **yes — up to +40 dB** | **−20 dBFS** (18.59 dB down) | | **+ #5647**, `hl2/engine-generated-tx-source` | **no** — measured **0.0 dB** across a 50 → 100 move | still **−20 dBFS** | | + the WSPR default branch, `hl2/wspr-host-modulated-level` (not yet filed) | no | **−3.0 dBFS** | #5647 removes the slider coupling and makes engine-generated audio carry the level its generator chose; it deliberately sets **no defaults**. The level shortfall itself is closed by the third branch, which moves the WSPR Level spinbox default from −20 to −3 dBFS on host-modulating backends. `wspr-unattended-ab` measures this separation directly: for a −20 dBFS engine-generated signal, the second branch's own `repair_db` is **0.0**. AX.25 and RADE get the slider bypass in #5647 and **no** default correction anywhere — AX.25 computes a level per frame and RADE carries its own `PcMicGain`, so neither has a single default to set. Neither was exercised on the bench; only the WSPR source was. **So: merge this with #5647, and land the WSPR default branch immediately after.** Between this PR and that one there is a window in which every unattended transmission is ~18.6 dB down. ## #5198 — offered as a candidate, not as a claim #5198 (`priority: high`, open since 2026-08-23) carries **two faults in one report, and only one of them is this one.** - *"the ALC meter swings nearly full-scale in response to normal transmitted speech"* — same defect class as #5463, and the mechanism this PR deletes. It would be worth @darkclassical re-testing on this branch. - *"opposite-sideband spillover at approximately −30 dB"* — **this PR does not touch it and must not be credited with it.** The host modulator was measured separately on the EP2 wire and agrees with `Hl2TxDsp::designFilters`, evaluated independently in double precision, to 0.02 dB (87.15 dB on a steady 1 kHz tone). The reporter's own drive sweep shows a fixed modulator/IQ image tracking the wanted signal 1:1. That half of his report is open. **Nobody has tested his configuration.** What connects the two is the defect class and his own title: a stage riding his gain by tens of dB when he has deliberately set his levels is what this PR removes, and 40 dB of makeup driven into a clamp is a plausible source of the distortion and the amplifier IMD he describes. That is a reasonable thing to check, not a diagnosis. It is offered as something for him to try. ## What is measured on what Read this before believing any number above. ### On the real radio — one run `d90-alc-input-sweep`, on ON8ST's Hermes-Lite 2, MAC `00:1C:C0:A2:13:DD` read from the radio's own response packet, gateware 74, into a **dummy load**, under the operator's own time-bounded transmit approval. 16 keyed windows, 165.3 s of keying, **drive register 0 — nothing was radiated**. It establishes, on both builds: that `TX:ALC` tracks its input one-for-one under the new behaviour (max deviation 0.0065 dB over 53.5 dB, fitted slope 0.99997), that it limits at −1.413 dBFS where the knee sits, that approach direction changes the reading by 0.0 dB, and — the part that makes the rest trustworthy — that the **old** documented triple reproduces on the **old** build to within **0.023 dB**. ### On the simulator — everything else `d78`/`d78b` (silent ratchet), `d81`/`d81b` (speech pauses) and both WSPR A/Bs ran against `hpsdrsim`, serial `AA:BB:CC:DD:88:FF`, under a `loopback` approval. **The two headline figures at the top of this PR — the −1.18 → −21.08 dBFS speech level and the 0.21 → 18.10 dB pause contrast — are simulator measurements.** The argument that they transfer is real and is recorded in each run: `Hl2TxDsp` is host-side DSP and executes on `m_keyed` regardless of peer, so the stage under test runs identically. But it is an argument, not a measurement. **Nothing in this series has ever been radiated.** No on-air contact, no WSPRnet spot, no receiving station has heard either build. Every claim about what a correspondent would hear is inference. The commit messages on this branch were corrected before filing, because an earlier version of them said *"measured on the air"* and *"measured on the radio"* of runs that were neither. ### Reasoned from code only - The slider position that lands on target (~74) — the mapping, not a leg. - The hop from `TransmitModel` to `Hl2Backend::setMicGain`. Every bench leg drove `AETHER_HL2_MIC_LEVEL` into `setMicGain` directly. (The **widget**'s half was since settled through the automation bridge: `invoke "Microphone gain" setValue 100` moves `transmit.micLevel` to 100, and 76 to 76.) - **The limiter change and the migration have no bench leg.** Both were measured against the real `Hl2TxDsp` and the real `Hl2Backend` in a harness rather than on hardware or `hpsdrsim`, and both are pinned by the tests above. Every |IQ| figure quoted on this page comes from that harness. ## Localization check a lab-side checker of mine — not in this repo — that makes executable the rule in `docs/HERMES.md` (*"For coding agents — keep bring-up inside the family backend"*), reports **1 hit, 0 violations** on this branch. Exit 2 is advisory: hits are to be explained, not failures. **`src/gui/MainWindow_Session.cpp` — comment only.** Three lines inside `MainWindow::wireRadioModel()`, correcting a comment that said the ALC *"needs the mic pushed UP past its hold threshold"* — a claim this PR makes false. **No executable line changes.** ## What this does NOT fix, and might be mistaken for it - **Drive and power out.** Nothing here touches the drive control or the PA. Forward power is still set by drive. - **The `TX:ALCGAIN` meter.** Separate work, #5506 and #5636. It exists on neither build measured here. - **Nothing about drive or the speech processor.** Mic Level is the modulator's input level; it is not a substitute for either. - **Splatter protection is unchanged.** The ALC still reduces on a transient, with the same attack and release, on every path. It simply never lifts. - `clientLeveled` has no remaining use in `processAudioBlock`; it is kept and marked unused here rather than deleted, and #5647 gives it a real job as `TxAudioSource`. ## Test results Full `ctest` (RelWithDebInfo, macOS): **416 tests, 416 passed, 0 failed**, 5 skipped (`crdv_quarantined_test`, `app_settings_safety_explicit-profile-path-isolation`, `weather_radar_texture_gl_test`, `range_slider_a11y_test`, `relay_bar_a11y_test`). On this head (RelWithDebInfo, Linux): the whole `hl2_*` family plus `radio_state_memory_test`, **35/35 pass**, and so do `meter_model_test` and `meter_surfaces_test`. Note that CI's green does **not** include any of them — `.github/ci-test-gate.txt` is frozen and carries none of the HL2 tests, so they first run on `full-suite.yml` after merge. Each guard on this branch was inverted to check it is a guard rather than a restatement. Reverting the seed's `reducing` gate fails the lead-in case and nothing else; reverting `setMicGain`'s re-arm fails the mid-over case and nothing else; deleting the `micLevelCurve` stamp fails the new `hl2_state_restore_test` assertion and nothing else; replacing `micLevelFromCurve1` with the identity fails both `hl2_tx_level_policy_test` and `hl2_dsp_readback_test`. The one change here with **no** test guarding it is the `TX:ALCGAIN` face range, which is a display bound checked against `alcGainDb()`'s ceiling by inspection. One claim from the original report did **not** survive its own falsifier and is withdrawn on this branch rather than quietly dropped: *"within one over the ALC gain can only rise"* is refuted by 148 downward steps across `d78`'s ten stock legs. What survives is narrower and sufficient — reduction is never held, so the fault is the level during a pause, not a level that runs away across the over. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
1 parent 0322c25 commit 5607b56

12 files changed

Lines changed: 1123 additions & 333 deletions

docs/automation-bridge.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,8 +1392,8 @@ inside AetherSDR on the host, not in radio firmware (#5401).
13921392
{"chain":"hl2-tx","level":"dsp-config",
13931393
"inputRateHz":48000,"outputRateHz":48000,"dspBlockSize":512,
13941394
"filterLowHz":300,"filterHighHz":2700,
1395-
"alcEnabled":true,"alcTargetPeak":0.9,"alcMaxGainDb":20,
1396-
"alcAttackSec":0.005,"alcReleaseSec":0.25,"alcHoldBelowDbfs":-45,
1395+
"alcEnabled":true,"alcTargetPeak":0.9,
1396+
"alcReleaseSec":0.25,
13971397
"micGainLinear":1}]}}}
13981398
```
13991399

@@ -3398,7 +3398,7 @@ other side has.
33983398
{"key":"micLevel","section":"Transmit voice chain",
33993399
"label":"Mic slider (0-100, 50 = unity)","value":80},
34003400
{"key":"micGainAppliedLinear",
3401-
"label":"Mic gain at the modulator (linear)","value":3.98},
3401+
"label":"Mic gain at the modulator (linear)","value":15.849},
34023402
{"key":"rfPowerPercent","label":"Drive requested (0-100)","value":60},
34033403
{"key":"txDriveRegister","label":"Drive written (raw 0-255)","value":153},
34043404
{"key":"txDriveGated","label":"Drive held at 0 by the TX gate","value":false},

docs/radio-certification.md

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ Hermes-Lite 2 can physically produce it.
3939
| `TX:SWR` idle | SWR | yes | **absent** | key with no audio | present-while-idle means the ratio saturated |
4040
| `TX:FWDPWR` | dBm | yes | rises with drive | raise the drive **one nibble** → rises | see the note below on halving |
4141
| `TX:REFPWR` | dBm | yes | ≪ forward into a load | key into a dummy load | ≥15 dB below forward |
42-
| `TX:ALC` | dBFS | **host-side** | post-ALC transmit peak | sweep the input 20 dB → reading does **not** move | **±1 dB across the sweep** |
43-
| `TX:ALCGAIN` | dB | **host-side** | gain the ALC is applying | sweep the MIC input 20 dB, between `alcHoldBelowDbfs` and the makeup ceiling → reading moves 20 dB the other way | **±1 dB inside that window only** — the gain is frozen below the hold threshold (`Hl2TxDsp.cpp`, `processAudioBlock`) and capped at unity for `clientLeveled` audio, so a TCI/DAX sweep moves it 0 dB by construction and a whole-range criterion would report a healthy meter as out of tolerance |
42+
| `TX:ALC` | dBFS | **host-side** | post-ALC transmit peak | sweep the input 20 dB **below** the ALC target → reading **tracks the input 1:1** | **within ±0.25 dB of `TX:MICPEAK` at every level** |
43+
| `TX:ALC` at the limit | dBFS | **host-side** | the ALC's target | raise the input **above** the target → reading stops at `20·log10(alcTargetPeak)` | **−1.41 dBFS ±0.25 dB** |
44+
| `TX:ALCGAIN` | dB | **host-side** | gain the ALC is applying | sweep the input from below the ALC target to above it → reading sits at **0 dB** until the target is crossed, then goes negative as the input rises | **0.00 dB ±0.25 below the target; strictly decreasing above it**, and never positive on any path |
4445
| `TX:COMPPEAK` | dB | host-side | compression applied | PROC on → rises above 0 | reads 0 with PROC off |
4546
| `TX:MIC` | dBFS | host-side | pre-gain mic level || not yet wired |
4647
| `TX:HWALC` | dBFS | **no** || Flex RCA jack; no HL2 equivalent ||
@@ -63,9 +64,19 @@ would notice the meter regressing (CERTIFICATION.md 1.37).
6364

6465
Both of the changed stimulus cells above are measurements, not preferences —
6566
`TX:FWDPWR`'s nibble step replaces a halving the hardware refutes, and
66-
`TX:ALC`'s no-movement sweep replaces envelope tracking that would fail a
67-
correct meter by ~17 dB. The numbers are under *Certified by effect,
68-
2026-08-10* below.
67+
`TX:ALC`'s tracking sweep replaces a no-movement expectation that was the
68+
observable signature of the ALC's 40 dB of upward makeup — a stage since made
69+
reduction-only, so the old expectation would fail a correct meter by 28.6 dB.
70+
`TX:ALCGAIN`'s row moved for the same reason and in the same direction: its old
71+
stimulus swept "between `alcHoldBelowDbfs` and the makeup ceiling", and neither
72+
of those exists any more. With the ceiling at unity on every path the gain this
73+
meter reports can only be zero or negative, so the criterion is now a sign and a
74+
knee rather than a window — and the meter's own face was narrowed to -20..0 dB
75+
to match (`Hl2Backend.cpp`, `defineMeters`).
76+
The numbers are under *Certified by effect, 2026-08-10* and *2026-09-09* below;
77+
both are kept, because the 2026-08-10 figures are still correct for the build
78+
they were taken on and reproducing them there is what makes the newer block
79+
trustworthy.
6980

7081
### Radio / hardware
7182

@@ -88,7 +99,7 @@ a readback.
8899
| `TX:SWR` idle | unkeyed | **absent** (null, age −1) | **CERTIFIED** — the `kMinForwardCountsForSwr` gate does its job; no 255.99:1 |
89100
| `TX:REFPWR` | 5.57 W forward into a dummy load | **0.001 W**, ≈37 dB below forward | **CERTIFIED** — requirement is ≥15 dB |
90101
| `RAD:PATEMP` | 10 s key | 32.70 → **43.06 °C** (+10.36) | **CERTIFIED** — requirement is a ≥0.5 °C rise |
91-
| `TX:ALC` | tone swept −10 → −30 dBFS | **−1.41 dBFS at every level** | **CERTIFIED** — see the normalisation check below |
102+
| `TX:ALC` | tone swept −10 → −30 dBFS | **−1.41 dBFS at every level** | **CERTIFIED FOR THE BUILD IT WAS RUN ON**, which had the makeup ALC — see the normalisation check below, and the 2026-09-09 block after it |
92103
| `TX:COMPPEAK` | PROC off | **0 dB**, age 0–28 ms | **LIVE** — reads zero with the compressor off, which is correct |
93104
| `SLC:LEVEL` | receiving | −105.7 dBm, age 15–78 ms | **LIVE** |
94105
| `RAD:+13.8A` || not defined | **correct** — the HL2 reports no supply voltage |
@@ -118,6 +129,19 @@ This is the HL2 analogue of the IC-705's live-voice run below, with a synthetic
118129
stimulus instead of speech — weaker as an audio-quality check and stronger as a
119130
level check, since the tone's amplitude is exactly known.
120131

132+
**What that check was actually measuring, established 2026-09-09.** The
133+
constancy above is not a property of a post-ALC peak meter in general. It is the
134+
observable signature of `Hl2TxDsp::Config::alcMaxGainDb`, 40 dB of upward makeup
135+
that drags any input from about −41 dBFS upward onto `alcTargetPeak` — and
136+
−1.41 dBFS is exactly `20·log10(0.85)`. Two consequences, both measured rather
137+
than reasoned. It does **not** hold over the whole input range even here: below
138+
about −42 dBFS the makeup's ceiling binds, the reading starts tracking again at a
139+
fixed +40 dB offset, and below the −45 dBFS `alcHoldBelowDbfs` threshold it
140+
becomes **path-dependent** — the same stage at comparable inputs was measured at
141+
+33.17 dB and +39.996 dB of applied gain depending only on how the level was
142+
approached. And once the stage is made reduction-only the signature disappears
143+
entirely, which is the 2026-09-09 block below.
144+
121145
`TX:FWDPWR` is **live and uncertified**, and the distinction is the point.
122146
It reads 5.57 W at full drive and 1.17 W at 25 %, so it plainly tracks drive.
123147
But the certification stimulus in the table above — halve the control, expect
@@ -143,6 +167,62 @@ drive by effect on this radio needs either a per-unit power calibration or an
143167
external power meter; until then the honest claim is "monotonic in drive",
144168
which is what the nibble sweep in `HERMES.md` 17.7 shows.
145169

170+
### Certified by effect, 2026-09-09 (Hermes-Lite 2, gateware 74, 7.100 MHz USB, dummy load)
171+
172+
The ALC became reduction-only in this series, so the 2026-08-10 normalisation
173+
check above no longer describes a correct radio. **Both builds were measured**,
174+
and the older figures reproduce on the older build — which is what makes this
175+
block trustworthy rather than merely newer.
176+
177+
Stimulus: the host-side 1 kHz test tone, mic slider at 50 (unity, and the one
178+
slider position where the old and new `micSliderToGainDb` mappings agree by
179+
construction), speech processor off (`TX:COMPPEAK` 0.00 dB at every level),
180+
drive register 0 so the transmit DSP runs while the PA emits nothing.
181+
`TX:MICPEAK` is the control: it is measured inside `Hl2TxDsp` **after** the mic
182+
gain and **before** the ALC, so it sits downstream of the whole client TX chain
183+
and upstream of the stage under test, and it is what proves the stimulus varied
184+
rather than being flattened by the channel strip or the final limiter.
185+
186+
| build | input span (`TX:MICPEAK`) | `TX:ALC` | verdict |
187+
|---|---|---|---|
188+
| the makeup ALC (pre-series) | −40.97 … −0.97 dBFS | **−1.435 … −1.412 dBFS**, span **0.023 dB** | the 2026-08-10 row, reproduced to 0.023 dB |
189+
| reduction-only (this series) | −54.89 … −1.41 dBFS | **tracks `TX:MICPEAK`**: max deviation **0.0065 dB**, sd 0.0015, fitted slope **0.99997** | **CERTIFIED** |
190+
| reduction-only, above the target | −1.11 … −0.97 dBFS | **−1.4136 … −1.4125 dBFS**, span **0.0011 dB** | **CERTIFIED** — the target is `20·log10(0.85)` = −1.4116 |
191+
192+
The knee was **located, not assumed**: the reading still tracks at an input of
193+
−1.406 dBFS and is already at the target at −1.112 dBFS, bracketing the target
194+
itself. Approaching a level from above rather than from below changes the
195+
reading by **0.0 dB**.
196+
197+
**The ±0.25 dB in the table above is margin, not measurement, and the two are
198+
worth keeping apart.** The worst deviation measured anywhere in the tracking
199+
region is 0.0065 dB, its standard deviation 0.0015 dB, the worst within-level
200+
scatter 0.0047 dB and the path dependence 0.0 dB; ±0.05 dB would be supported by
201+
every one of those. The tolerance is set two orders of magnitude looser so the
202+
row survives a different host, audio device and block alignment, none of which
203+
were varied — one radio, one host, one night.
204+
205+
**Two things about running this row that the old one did not say.**
206+
207+
- **It cannot be certified without transmitting.** `Hl2Backend::submitTxAudio`
208+
returns early unless `m_keyed`, so the transmit DSP — and with it both
209+
meters — is dead in a receive-only session. A load and a keyed window are
210+
required. The drive may be zero, and was: the quantity is host-side, and on
211+
this gateware the bottom nibble of the drive register is silence.
212+
- **Let the reading settle.** On the reduction-only build it is settled within
213+
one meter update. On the makeup build it is not: after a step down in level
214+
the gain climbs on the 0.5 s release constant, and a reading averaged across
215+
the first half of a 3 s dwell sits up to **0.78 dB** from the settled one —
216+
most of the old row's own ±1 dB budget spent on the instrument rather than on
217+
the radio.
218+
219+
**Where this expectation stops holding, measured rather than assumed:** it holds
220+
for inputs from −54.9 dBFS up to the ALC target. Below −54.9 dBFS is untested —
221+
the host test tone clamps at −60 dBFS (`ClientTxTestTone::setLevelDb`). Above
222+
roughly −1 dBFS the test tone itself saturates, so the limiting clause rests on
223+
three points between −1.11 and −0.97 dBFS. Nothing was radiated at any point, so
224+
no RF figure is claimed here.
225+
146226
### Icom (IC-705) — measured with `controls meters`, radio idle on 20 m
147227

148228
Ages are from one live run; the point is the STATUS column, not the numbers.

0 commit comments

Comments
 (0)