Commit 5607b56
> ### ⚠️ 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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1392 | 1392 | | |
1393 | 1393 | | |
1394 | 1394 | | |
1395 | | - | |
1396 | | - | |
| 1395 | + | |
| 1396 | + | |
1397 | 1397 | | |
1398 | 1398 | | |
1399 | 1399 | | |
| |||
3398 | 3398 | | |
3399 | 3399 | | |
3400 | 3400 | | |
3401 | | - | |
| 3401 | + | |
3402 | 3402 | | |
3403 | 3403 | | |
3404 | 3404 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
43 | | - | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
44 | 45 | | |
45 | 46 | | |
46 | 47 | | |
| |||
63 | 64 | | |
64 | 65 | | |
65 | 66 | | |
66 | | - | |
67 | | - | |
68 | | - | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
69 | 80 | | |
70 | 81 | | |
71 | 82 | | |
| |||
88 | 99 | | |
89 | 100 | | |
90 | 101 | | |
91 | | - | |
| 102 | + | |
92 | 103 | | |
93 | 104 | | |
94 | 105 | | |
| |||
118 | 129 | | |
119 | 130 | | |
120 | 131 | | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
121 | 145 | | |
122 | 146 | | |
123 | 147 | | |
| |||
143 | 167 | | |
144 | 168 | | |
145 | 169 | | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
146 | 226 | | |
147 | 227 | | |
148 | 228 | | |
| |||
0 commit comments