fix(hl2): make the ALC reduction-only and widen the mic slider (#5463 change 3) - #5646
Conversation
There was a problem hiding this comment.
1. Issue fit
Partially — the mechanism is fixed; the sequencing the issue itself insists on is not satisfied.
#5463's reported symptom is real and this diff removes its cause: the held/holdThreshold expression and the alcMaxGainDb ceiling are gone from Hl2TxDsp::processAudioBlock, the ceiling is a hard std::min(wanted, 1.0), and the room-vs-speech contrast the report measured at 0.33 dB now survives the stage. I traced the "a hold under a unity ceiling strands the gain" argument through the rewritten loop (Hl2TxDsp.cpp:260-273) and it holds: with the hold deleted, a quiet block wants target = 1.0 and releases toward it on alcReleaseSec, with nothing suppressing the upward move.
But #5463 specifies four changes in a stated dependency order, and its own text and the maintainer triage both say the order is load-bearing: "3 without 1 loses the operator's setting every launch", and "the mic-range widening and the persistence work have to ship in the same release, and the release notes have to say so. That is a product call, not a code one." Change 1 is not in the tree (see Blocker 1), and the maintainer never answered the sequencing question you asked on 2026-09-08 — the thread's last word on change 3 is your own "still not opening them unbidden." That is why the headline recommendation below is a maintainer decision rather than a code objection.
2. Scope
| File(s) | What it changes | Claimed? | Verdict |
|---|---|---|---|
Hl2TxDsp.{h,cpp} |
unity ceiling; delete alcMaxGainDb, alcHoldBelowDbfs, the held branch and the clientLeveled ceiling ternary |
yes (#5463 change 3) | in scope |
Hl2TxLevelPolicy.h |
mic slider −20/+40 dB, asymmetric slopes, unity pinned at 50 | yes — the issue's arithmetic requires it in the same commit | in scope |
Hl2Backend.{h,cpp} |
m_alcHoldBelowDbfs → m_alcTargetPeak mirror, static_assert re-point, health row swap, unkey diagnostic re-point |
yes | in scope |
Hl2Backend.cpp gatherDspChains |
drops two fields from the dspchains bridge response |
not in the body | in scope but undocumented — see Blocker 3 |
MainWindow_Session.cpp |
comment only, verified no code change | yes | in scope |
docs/radio-certification.md |
replaces the TX:ALC row, keeps the 2026-08-10 block as control |
yes | in scope, and correctly reasoned |
tests/hl2_{txdsp,dsp_readback,tx_level_policy}_test.cpp |
rewrite the cases that asserted the removed behaviour | yes | in scope |
Nothing in the diff is unexplained by the issue. No new settings key, no CHANGELOG.md entry, no UI/visual change, no protocol verb added. The one public-surface removal is the dspchains pair below.
3. Blockers
1. Change 1 (persist phone.micLevel) is not in the tree, so the 20 dB the operator must now supply themselves is lost on every launch, not once. — inline on Hl2TxLevelPolicy.h:50
TransmitModel.h:513 is still int m_micLevel{50}, and RadioModel.cpp:1469-1470 states in its own comment that "micLevel is not persisted". #5505 is open, not merged — and its own body says of this very branch: "deliberately held back: both change the transmit level of every existing HL2 install, which the triage on #5463 called a product decision rather than a code one."
The consequence is sharper than "set a level for the first time", which is how the commit message frames it. Hl2TxLevelPolicy.h:26-28 keeps unity at 50 precisely because nothing restores the slider, so every launch an HL2 operator starts at unity — which on this build is your own measured 19.56–19.67 dB below alcTargetPeak. They must re-set the slider every session, and the only instrument telling them so is a qCInfo line in aether.hl2, which is below the default QtWarningMsg threshold in a support bundle. That is a per-session regression for every existing install, not a one-time migration. Either land #5505 first (or stack this on it), or get an explicit maintainer ruling that shipping in this order is acceptable with a release note.
2. closes #5463 will auto-close a four-change umbrella issue while two of its changes are still open PRs. — inline on docs/radio-certification.md:42
#5505 and #5506 both describe themselves as "part of #5463", and change 4 (the optional leveller, relocated to AetherVoice per your own correction in the thread) is unfiled. Auto-closing the issue on merge strands them. Suggest Fixes the mechanism reported in #5463 or a plain reference, and let the maintainer close the umbrella when the series lands.
3. docs/automation-bridge.md:1395-1396 still documents two dspchains fields this PR deletes. — inline on Hl2Backend.cpp:4225
The sample hl2-tx chain object in the bridge docs lists "alcMaxGainDb":20 and "alcHoldBelowDbfs":-45. gatherDspChains() no longer emits either. This is a documented response shape third parties read; the doc is now wrong in the same direction the certification row was, which is exactly the class of staleness the docs/radio-certification.md half of this PR exists to prevent. Small fix, same PR.
4. Nits (non-blocking)
kQuietMarginBelowTargetDb = 12.0(Hl2Backend.cpp:3403) puts the trigger at a whole-over mic peak below −13.41 dBFS. Your derivation bounds it from one side only — you say so — but 12 dB below target is a normal, perfectly audible SSB level, so this will log on many healthy overs. It isqCInfoin a category, so the cost is log noise rather than a false alarm; worth revisiting once the slider-74 leg you name exists.clientLeveledkept as[[maybe_unused]]is disclosed and justified (queuedQ_INVOKABLEsignature), and I agree it does not belong in this diff. Worth a follow-up issue so it does not linger as a parameter nobody dares remove.- Ordering hazard you already flagged in the thread and which is not present here, recorded so it is not lost: if #5506 merges after this, its
TX:ALCGAINmeter is declared −20…+40 dB over a stage that can never exceed 0 dB. I confirmed noALCGAINdefinition exists on this branch, so the conflict is cross-PR, not in this diff.
5. What I tried to break
- Latching / path-dependent gain on the mic path. The strongest claim in the diff is that the hold had to be deleted rather than narrowed. I walked
Hl2TxDsp.cpp:255-273with a loud block followed by quiet ones:targetclamps to 1.0,reducingis false,tau = alcReleaseSec, and the one-pole moves the gain back to unity with no branch able to suppress it. The failure the deletedheldterm would have caused is real and the deletion avoids it. Survived. - Regression on the TCI/DAX path (#4796). For
clientLeveled = truethe old ceiling was already1.0andheldwas alreadyfalse, so the arithmetic is byte-identical. The two #4796 cases inhl2_txdsp_test.cppare unchanged and still discriminating (the quiet leg still lands at −50 dBFS at the measurement point, and the comment at line 626 explicitly tells the next reader not to raise it). Survived. - Are the rewritten tests vacuous? Attacked both new assertions.
"at unity mic gain the ALC passes speech-level audio through unchanged"(<1 dB) fails ~30 dB wide against restored makeup. The separation case is the one that matters: on the unfixed code the room leg settles at 0 dB because the hold froze it, so the|room.gainDb| < 1.0assertion alone would pass for the wrong reason — theoutSeparationDb ∈ (19,21)assertion is what catches it, and the comment says so. Not self-asserting. Survived. - Leftover consumers of the deleted
Configfields. Greppedsrc/,tests/,docs/,tools/foralcMaxGainDb/alcHoldBelowDbfs: every remaining hit is a comment exceptdocs/automation-bridge.md(Blocker 3). No compile-time consumer left behind; the re-pointedstatic_assertatHl2Backend.cpp:40still does its anti-drift job againstalcTargetPeak. - Meter scale.
TX:ALCis defined-100.0, 0.0dBFS indefineMeters(), so a reading that now tracks down to −55 dBFS stays on scale instead of pinning at −1.41. No gauge rescale needed in this PR. - Silence.
blockPeak > 1e-6fleaves the gain frozen through digital silence rather than releasing;Hl2TxDsp::reset()setsm_alcGain = 1.0per transmission, so an over cannot open mid-reduction. Pre-existing and unchanged. - Could not check: anything at runtime. I have no build and no radio — every finding above is reasoned from the code in
/tmp/aetherclaude/pr-5646, not reproduced. I also cannot verify your bench figures (d78/d81/d81b/d90); I checked only that the numbers quoted in comments and docs are internally consistent with each other and withalcTargetPeak = 0.85, which they are. CI is green oneaa9ac20(build, check-macos, check-windows, Static checks, Sanitizer configure), which by AGENTS.md gates a filtered subset only.
Socket tests: none added, modified, or removed. The three touched test files are socket-free and construct Hl2TxDsp directly.
6. Recommendation
Needs maintainer decision. The code is correct, the deletion is better argued than most features are, the tests were rewritten rather than relaxed, and the certification row was measured on both builds before being changed — I could not break the DSP change. What is outstanding is not code: this is change 3 of a four-change series whose author and whose triage both say it must not ship before change 1, change 1 is an open PR, and the sequencing question you asked in the issue on 2026-09-08 was never answered. @jensenpat, the call is whether to merge #5505 first (or stack this on it) or to accept a build where every HL2 operator re-sets their mic gain each launch, with a release note saying so.
Concrete next steps regardless of that ruling: fix docs/automation-bridge.md:1395-1396, and change closes #5463 to a reference so #5505/#5506 are not orphaned.
🤖 aethersdr-agent · cost: $8.7701 · model: claude-opus-5
K5PTB
left a comment
There was a problem hiding this comment.
Issue fit
The DSP change is correct, and I could not break it. #5463's mechanism is gone. The ceiling is a hard std::min(wanted, 1.0), the hold and alcMaxGainDb are deleted, and the rewritten tests pin the reported fault directly. The PR's own body says it must not merge alone, though, and nothing on the PR enforces that. That is Blocker 1; the other three are the earlier aethersdr-agent blockers, which I re-checked and which still stand at eaa9ac2.
Scope
| File(s) | What it changes | Claimed? | Verdict |
|---|---|---|---|
Hl2TxDsp.{h,cpp} |
Unity ceiling; delete alcMaxGainDb, alcHoldBelowDbfs and the hold |
Yes | In scope (#5463 change 3) |
Hl2TxLevelPolicy.h |
Mic slider −20/+40 dB, unity pinned at 50 | Yes | In scope |
Hl2Backend.{h,cpp} |
m_alcTargetPeak mirror, health row, re-pointed unkey diagnostic, two dspchains fields removed |
Yes, except the dspchains removal |
In scope; see Blocker 3 |
MainWindow_Session.cpp |
Comment only | Yes | In scope |
docs/radio-certification.md |
TX:ALC row replaced, 2026-08-10 block kept as control |
Yes | In scope |
tests/hl2_{txdsp,tx_level_policy,dsp_readback}_test.cpp |
Cases rewritten for the new behaviour | Yes | In scope |
Everything in the diff is explained by #5463 change 3. No socket tests. No CHANGELOG.md entry.
Blockers
1. This PR is mergeable on its own, and its body says that is a regression. The body measures a WSPR beacon at the shipped default going out 18.59 dB down on this branch alone, and says to merge it together with #5647. Nothing enforces that. The PR is not a draft and has no label. #5647 targets main and carries these three commits, so merging #5647 is safe, but a plain merge of this PR ships the regression.
There is also a second coupling the body does not list. On this branch alone, the unkey diagnostic fires on unattended engine audio (inline on Hl2Backend.cpp:3413). WSPR, AX.25 and RADE arrive with clientLeveled = false, and they feed the same TX:MICPEAK maximum. A beacon at −20 dBFS sits about 18.6 dB under the −1.41 dBFS target, which trips the 12 dB margin. So at unkey the log tells the operator to "Raise mic gain (currently 50 of 100)". On this branch that control also moves the beacon. #5647 is what fixes it: its diff adds !m_txAudioEngineGenerated to exactly this condition. Either mark this PR draft or do-not-merge until it lands with #5647, or close it in favour of #5647 as the merge vehicle.
2. The prerequisite is still unmerged and still unruled. #5505, which persists phone.micLevel, is OPEN. #5463's triage called the order a product decision. Its last three comments are all from the author, and there is no maintainer comment on #5463 or on this PR. So every launch still resets the slider to unity, 19.56–19.67 dB under the target by the body's own figures. This needs @jensenpat's ruling or #5505 first.
3. docs/automation-bridge.md:1395-1396 still documents alcMaxGainDb and alcHoldBelowDbfs in the sample hl2-tx dspchains object. gatherDspChains() no longer emits either field. It is a two-line doc fix in this PR.
4. closes #5463 would close an issue with open children. #5505 and #5506 are both OPEN and both describe themselves as part of #5463. A reference rather than a closing keyword leaves the issue for the maintainer to close when the series lands.
What I tried to break
- Ran the rewritten tests against five mutations of the transmit DSP and slider mapping. All five were caught:
| Mutation | Caught by |
|---|---|
| restore makeup (ceiling 100x) | hl2_txdsp_test: "at unity mic gain the ALC passes speech-level audio through unchanged", "the ALC adds no gain to room-level audio" |
| re-add a hold below −45 dBFS under the unity ceiling | hl2_txdsp_test: "the limiter releases to unity after an over-level excursion (reduction does not latch)" |
| gain only moves when reducing | hl2_txdsp_test: the same release case |
| symmetric 0.8 dB/step slider (unity still at 50) | hl2_tx_level_policy_test |
| slider top back to +20 dB | hl2_tx_level_policy_test and hl2_txdsp_test: "the mic slider at 100 brings quiet audio near full modulation" |
- Reproduced the body's headline DSP figures at eaa9ac2. At unity, quiet input passes through at +0.0 dB and slider 100 lifts it to 0.8501, the target. 20.0 dB of room/speech separation in comes out as 20.00 dB. The #4796 client-path release case is unchanged at −0.15 dB. All three tests pass.
- Grepped for consumers of the deleted
Configfields. Outside comments, the only remaining references are the bridge doc (Blocker 3) and the historical certification paragraph, which is meant to cite them.
Not driven: the automation bridge. Every behaviour here is on the HL2 transmit path, and it only runs while keyed. A review session never keys, and the demo simulator is not an HL2.
|
You are right, and I checked it on this branch rather than taking it: the gate is So this is not something I can fix inside this PR, and that is the useful part of your finding: it is a third independent reason these two cannot merge separately, and the first one that shows up as noise rather than as a level. The two we already knew: on this branch alone an unattended beacon at the −20 dBFS default goes out 18.58 dB down ( Worth saying plainly since you are the second reviewer to reach it from a different direction: #5646 alone is worse than Not measured: every figure above is against |
THE DEFECT. The stage Hl2TxDsp calls an ALC is not one. It applied up to 40 dB of UPWARD makeup gain (alcMaxGainDb) with an absolute hold threshold (alcHoldBelowDbfs, -45 dBFS) below which it stopped lifting. That threshold 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. Measured against hpsdrsim on loopback, NOT on the air: 20.5 dB of speech-to-floor separation went in and 0.33 dB came out. A stage that erases 20 dB of contrast is not protecting anything. WDSP draws the line this change restores. 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 (off by default) with max_gain=1.778, which is +5 dB. Two stages, two jobs. WHY THE TWO HALVES CANNOT LAND SEPARATELY. Speech sits around -32 dBFS and alcTargetPeak is 0.85 (-1.41 dBFS): a 30.6 dB gap, against a mic slider that spanned only +/-20 dB. Remove the makeup without widening the slider and the chain is 10.6 dB short at maximum travel — the same fault pointing the other way, "the radio went quiet and there is no control left". So the DSP change and the mapping change are one commit. The widening is ASYMMETRIC — 0.4 dB per step below 50, 0.8 dB per step above, reaching +40 dB at 100 — because 50 must stay unity. TransmitModel constructs m_micLevel at 50 and nothing restores it at startup, so a symmetric widening would move unity off 50 and silently change the transmit level of every existing install. (piHPSDR, which has no upward makeup anywhere in its chain, gives its operators -12 to +50 dB; this is the same pairing, narrower.) WHY THE HOLD DELETES ENTIRELY rather than partially. Setting alcMaxGainDb to 0 is not sufficient, and dropping only the `!clientLeveled` term is actively harmful. Under a unity ceiling a quiet block wants target = 1.0; if an earlier loud block left the gain below unity then target > m_alcGain, `reducing` is false, the block is under the threshold, `held` is true, and the gain is stranded at whatever reduction the loudest block called for. That is aethersdr#4796's own defect class mirrored onto the mic path — precisely the failure the `!clientLeveled` term was added to keep off the client path. The source already said so in its own comment ("holding would be actively harmful"). A hold is only coherent when there is makeup gain to hold back, so alcMaxGainDb, alcHoldBelowDbfs and the clientLeveled ceiling branch delete together. What remains is a reduction-only loop, attack/release split and post-ALC hard clamp intact. `clientLeveled` has no remaining use in processAudioBlock and is kept and marked unused; dropping it is a clean follow-up. WHAT AN EXISTING OPERATOR SEES. Below slider 50, nothing changes at all — the lower leg of the mapping is untouched and the ALC never lifted a signal it was already reducing. At and above 50 they 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. The mirror is substituted rather than deleted. alcTargetPeak was not previously mirrored into Hl2Backend or reported by healthSnapshot(); now that the pre-ALC mic peak IS the on-air level up to the target, it is what the snapshot row and the unkey diagnostic have to compare against. So m_alcHoldBelowDbfs becomes m_alcTargetPeak, the file-scope static_assert re-points to Config{}.alcTargetPeak == 0.85, and the connectRadio() assignment and the snapshot row follow — which keeps the documented txMicPeakDbfs/threshold pair intact instead of stranding one row of it. The unkey "Raise mic gain" diagnostic is re-pointed, not deleted: it is the one instrument telling a quiet operator what to do, in the very change that makes them quiet. Three corrections. Its constant is re-derived against alcTargetPeak less a margin, and THAT MARGIN IS PROVISIONAL AND UNMEASURED — introduced as the named constant kQuietMarginBelowTargetDb with a comment saying so and naming the d81-speech-pauses bench as what sets it. The advice now checks the slider's remaining travel against the shortfall, because "peak still low at slider 100" is a real operating state under this change and "raise mic gain" is wrong advice there. The !m_txAudioClientLeveled gate stays with a rewritten reason: the mic and client paths are identical now, but a TCI/DAX client's remedy is still its own level control. Tests. hl2_txdsp_test's "ALC lifts speech-level audio" case is re-pointed at the slider rather than deleted — the same -34 dBFS tone reaches full modulation at micSliderToLinear(100) and passes through unchanged at unity, and the measured evidence in its comment stays because it is still why the gap must be closed by something. "The ALC holds through pauses" is rewritten, not adjusted: its first assertion would have passed for the wrong reason, so the replacement asserts the property the report is actually about — 0 dB of gain at both levels, and 20 dB of input separation arriving as 20 dB of output separation (measured: 20.00 dB). That regression test did not exist. The "aethersdr#4796 review: the reduction half must RELEASE" case passes UNTOUCHED at -0.15 dB, identical before and after, which is the evidence this is a no-op on TCI/DAX; half its rationale retires and its kSlider100 constant is renamed kHotMicGain, since 10.0 is no longer micSliderToLinear(100). hl2_dsp_readback_test also referenced alcMaxGainDb and moves to alcReleaseSec as its "it moves" field. Bench discipline: MEASURED ON A SIMULATOR, NOT ON THE AIR. The figures above are from bench runs rather than from arithmetic, and an earlier version of this message said those runs were on the radio. They were not. d78-silent-ratchet and d81-speech-pauses both keyed against hpsdrsim, serial AA:BB:CC:DD:88:FF, under a `loopback` approval issued by the orchestrator and NOT by ON8ST; each run's own result.json records that class and that issuer verbatim. NOTHING IN THIS SERIES HAS EVER BEEN RADIATED. The only run that touched the radio is d90-alc-input-sweep -- 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 at DRIVE REGISTER 0, so no RF left the socket there either. It is the subject of the docs commit at the end of this branch, and it is what makes these simulator figures worth believing, because on the OLD build it reproduced the standing certification triple to within 0.023 dB. The measurements stand. The attribution did not, and an attribution error is exactly the kind that compounds: it is how a simulator figure becomes a hardware figure becomes a fact. The argument that these transfer is real and is stated in each run record -- Hl2TxDsp is host-side DSP and executes on m_keyed regardless of peer, so the stage under test runs identically against a simulator -- but it is an argument, not a measurement, and no receiving station has heard either build. d78-silent-ratchet swept sliders 50-100 over ten shuffled legs: peak makeup reached 39.97 dB on stock and 0.00 dB on every fixed leg. d81-speech-pauses ran the same authored stimulus on both builds at unity mic gain, with the delivered floor identical to within 0.02 dB, and measured pause contrast 0.21 dB stock against 18.10 dB fixed. That pair also measured the cost this change imposes, reported in the paragraph above rather than left to be discovered: speech went from -1.18 dBFS to -21.08 dBFS at unity. d81b set the diagnostic's margin and is the subject of the commit that follows this one. One claim from the original report did NOT survive its own falsifier and is withdrawn here rather than quietly dropped: "within one over the ALC gain can only rise" is refuted by 148 downward steps across the ten stock legs. What survives is narrower and sufficient - reduction is never held, so the fault is the level during a pause and not a level that runs away across the over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
kQuietMarginBelowTargetDb 20.0 -> 12.0, and the comment now separates what was measured from what was chosen. D81b (d81b-speech-pauses-alc, this build, four legs, twelve bursts, two mic gains 20 dB apart) measured speech crest at 18.87 dB (sd 0.80) and burst-to-burst level spread at <= 0.91 dB. It ran against hpsdrsim under a `loopback` approval issued by the orchestrator and NOT by ON8ST -- simulator, not on the air, and nothing radiated. It also measured where the slider actually puts an operator: at unity, speech leaves the modulator 19.56-19.67 dB BELOW alcTargetPeak. THAT FIGURE IS READ OFF THE RECORD, NOT OFF A SUMMARY OF IT. An earlier version of this message said 19.23 dB and called it a hard upper bound. d81b's own result.json gives speech_output_dbfs of -21.08 on the fault leg and -20.97 on the control leg, both at mic_level 50, against 20*log10(0.85) = -1.4116 dBFS -- so 19.67 dB and 19.56 dB. The bound is real and the conclusion is unaffected; it is ~0.4 dB further from 12.0 than the number it was derived against, which strengthens it slightly. AND THERE IS NO BRACKET. The same earlier version paired unity with "slider 100 is 8.68 dB above it", as though one signal had been measured at two slider positions. It had not. 8.675 dB is the ALC's APPLIED REDUCTION at a pause start on the op-remedy leg, and that leg runs a DIFFERENT STIMULUS -- sp-53.wav, floor -53 dBFS -- from the unity legs, which run sp-38.wav and sp-50.wav. The arithmetic says so out loud: slider 100 is +40 dB on this build, so the unity legs' own stimulus at slider 100 would sit about 20.4 dB OVER the target, not 8.7 dB. The whole of that ~11.6 dB difference is the stimulus, sp-53 being that much quieter than sp-38 at the same slider. d81b's record already carries the warning in its own op_leg_caveat field -- "op-fault/op-remedy run at mic_level 100 ... NOT comparable leg-to-leg" -- and op-remedy's measured speech_output_dbfs is -1.35 dBFS, which is AT the target because the ALC clamped it there, not 8.68 dB above it. So the pair does not bracket one signal and is not presented as if it does. The unity leg alone carries the conclusion, and it is enough. THE PLACEHOLDER WAS OUTSIDE ITS OWN BOUND. 20.0 dB is greater than the 19.56 dB shortfall at unity, so the diagnostic would have stayed silent on an operator sitting at the default -- the exact case it was written for. The guess failed in the dangerous direction, which is the argument for not shipping guesses with names. 12.0 dB fires on unity with 7.6 dB to spare, holds off until a station is two S-units down, and clears the measured scatter by an order of magnitude. STILL BOUNDED RATHER THAN MEASURED, and the comment says so: d81b bounds the correct setting from ONE side only. A further leg at slider ~74, where the peak lands on the target, would give this constant data on both sides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
NOT SUBMITTED, NOT PUSHED, NO PR OPENED. This sits on hl2/alc-unity-ceiling so it travels with the change that makes it necessary; ON8ST authorises what leaves the machine. THE ROW THIS SERIES BREAKS IS UPSTREAM'S OWN. docs/radio-certification.md has said of TX:ALC, since 76a5240, "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. That no-movement is not a property of a post-ALC peak meter. It is the observable signature of Hl2TxDsp::Config::alcMaxGainDb -- 40 dB of upward makeup dragging any input from about -41 dBFS upward onto alcTargetPeak, and -1.41 dBFS is exactly 20*log10(0.85). 83b8af1a removes the makeup, so after it lands the row FAILS A CORRECT RADIO. Correcting it is this PR's job, not the next reader's. MEASURED, NOT REASONED, because a guessed pass criterion in a certification table is worse than a stale one -- it looks measured. Bench run D90 (hl2-lab streams/bench-runner/runs/d90-alc-input-sweep), on ON8ST's Hermes-Lite 2 00:1C:C0:A2:13:DD into a dummy load, under a time-bounded transmit approval. THE OLD BUILD WAS MEASURED FIRST, AND THAT IS WHAT MAKES THE NEW BLOCK TRUSTWORTHY. On 52c1ced -- which IS origin/main and is also the exact parent of 83b8af1a -- the documented triple reproduces at -1.4181 / -1.4347 / -1.4349 dBFS: span 0.0168 dB where the row allows 1 dB, within 0.0233 dB of the documented value, nine days later on the same radio. Had it not reproduced, the instrument or the document would have been wrong and the second leg would have measured nothing. So the 2026-08-10 block is 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 REPLACEMENT. On the reduction-only build TX:ALC tracks its own input one-for-one: over 18 levels spanning 53.5 dB the deviation from TX:MICPEAK never exceeds 0.0065 dB (mean -0.0005, sd 0.0015), fitted slope 0.99997, and above the target the reading limits at -1.4136..-1.4125 dBFS -- within 0.002 dB of 20*log10(0.85). The knee was LOCATED rather than assumed: still tracking at an input of -1.406 dBFS, already at the target at -1.112. Approach direction changes the reading by 0.0 dB. THE CONTROL IS WHY ANY OF THAT IS ATTRIBUTABLE. The test tone is injected at the head of TxVoiceProcessor::processWorkBuffer and then passes the channel strip, mic gain, Quindar and ClientFinalLimiter before reaching Hl2TxDsp; a stimulus flattened by any of those yields a flat TX:ALC that looks exactly like the answer. TX:MICPEAK -- measured inside Hl2TxDsp after the mic gain and before the ALC -- tracked the requested level to 0.108 dB on both builds, and TX:COMPPEAK read 0.000 dB at all 24 levels. THE TOLERANCE IS MARGIN AND THE DOC SAYS SO. Measured worst deviation 0.0065 dB, sd 0.0015, worst within-level scatter 0.0047, path dependence 0.0. +/-0.05 dB would be supported by every one of those. +/-0.25 dB is chosen instead, two orders of magnitude looser than the measurement, because one radio on one host on one night does not earn a tolerance that only passes on the machine it was taken on. TWO FACTS ADDED ABOUT THE OLD ROW, and they are about it rather than about the change. It never held over the whole input range even on origin/main: below about -42 dBFS the makeup's ceiling binds, the reading tracks again at a fixed +40 dB offset, and at -42.96 dBFS it is 1.55 dB off target -- outside its own +/-1 dB. And below the -45 dBFS alcHoldBelowDbfs threshold it is PATH-DEPENDENT: the same stage at comparable inputs measured +33.17 dB and +39.996 dB of applied gain depending only on how the level was approached. Outside a band it never stated, the row has no single value to be right about. TWO OPERATIONAL NOTES THE OLD ROW OWED ITS READER AND DID NOT PAY. The row cannot be certified without transmitting -- Hl2Backend::submitTxAudio returns early unless m_keyed, so both meters are dead in a receive-only session; drive may be zero, and was. And on the makeup build the reading must be allowed to settle: the gain climbs on a 0.5 s release after a step down, and a reading averaged over the first half of a 3 s dwell sits up to 0.78 dB from the settled one, spending most of the row's own tolerance on the instrument. WHAT THE BLOCK SAYS IT DID NOT MEASURE, in the doc itself: anything radiated (drive 0 throughout); below -54.9 dBFS of input, which is the test tone's [-60, 0] clamp and not the stage's limit; above about -1 dBFS, where the tone saturates, so the limiting clause rests on three points. TX:ALCGAIN is deliberately NOT touched here. FIND-32 records it as under-specified rather than wrong, and the meter exists on neither build measured -- it is PR 2's, and this branch does not contain PR 2 (FIND-34). A wording for it is drafted in the lab, marked DERIVED, to be measured before it is pasted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
`gatherDspChains()` stopped emitting `alcMaxGainDb` and `alcHoldBelowDbfs` when the ALC's makeup half was removed — both belonged to it. The bridge document's `hl2-tx` example still listed them, so a caller reading the document would key on fields the snapshot no longer carries. Reported by aethersdr-agent on aethersdr#5646. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
eaa9ac2 to
12a338e
Compare
|
Rebased onto Blocker 1 — the prerequisite is in the tree now. #5505 merged at 01:11Z as Blocker 2 — retitled. The PR is now "#5463 change 3", and the body says Blocker 3 — fixed. The nit, and K5PTB's second half of Blocker 1 — not taken, deliberately. You are both right that Full ctest 423/423, no failures. |
The advice fires on audio the operator's microphone did not produce. WSPR, AX.25 and RADE reach submitTxAudio() with `clientLeveled` false, exactly like the microphone, and a beacon at its -20 dBFS default sits about 18.6 dB under the ALC target — past the 12 dB margin. Every beacon unkey would tell the operator to raise a mic gain that has nothing to do with the level. It cannot be aimed from this branch. The seam here is `submitTxAudio(..., bool clientLeveled)` — two states, and engine-generated audio is not one of them. Distinguishing it means the three-state TxAudioSource, which is aethersdr#5647's substance and reaches four files above the HL2 backend; importing it would move aethersdr#5647 into aethersdr#5646 and take this change's localization from one hit to four. So the advice goes, and aethersdr#5647 reintroduces it gated on `!m_txAudioEngineGenerated`. The measurement stays: `m_txMicPeakMaxDbfs` still tracks and still reaches the health snapshot, so the number is readable even while nothing volunteers it. Merge order matters one way only: aethersdr#5647 carries the advice with its gate, and this removal must not be applied over it. Reported by K5PTB on aethersdr#5646. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
|
@K5PTB — this still carries your No rush from my side, and no reply needed if you would rather come back to it later — I would just rather you knew it was waiting on that one thing rather than on work. 🤖 Generated with Claude Code |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
#5463 says the "ALC" collapses speech-to-room contrast because 40 dB of makeup with an absolute hold cannot tell a breath from a syllable. This PR deletes the makeup half and the hold, ceilings the stage at unity on every path, and widens the mic slider to carry the gain the stage used to supply. That is the right shape, it matches the WDSP split the issue cites, and I reproduced both halves against the real Hl2TxDsp from the merge base and from this head — the fix works, on the issue's own numbers. The merge-order framing in the body is honest and I have nothing to add to it.
What I did find is a second consequence of the widening that the body does not cover and the tests cannot see: the new upper half of the slider travel is outside the modulator's own overload guard, on every path including the TCI/DAX one the body says is unchanged. Blocker 1.
Scope
Everything in the diff is explained by the issue or by a review thread on this PR. No unrelated files, no new public surface, no vendored churn, no commit predating the branch, and CHANGELOG.md is correctly untouched.
| File / group | Claimed in title or body? | Verdict |
|---|---|---|
Hl2TxDsp.{h,cpp} — delete alcMaxGainDb + alcHoldBelowDbfs, unity ceiling |
yes, the headline | in scope |
Hl2TxLevelPolicy.h — asymmetric widening to +40 dB |
yes, "The slider mapping" | in scope — see Blocker 1 |
Hl2Backend.{h,cpp} — static_assert re-point, m_alcTargetPeak mirror, health row rename, gatherDspChains key removal |
yes / thread | in scope — stale comments, nits 1–3 |
Hl2Backend.cpp — removal of the unkey quiet-mic advice |
yes, and answers K5PTB's thread | in scope; correctly routed to #5647 |
docs/radio-certification.md — TX:ALC row + 2026-09-09 block |
yes, "It rewrites a certification row" | in scope — incomplete, nit 4 |
docs/automation-bridge.md — drop two ALC keys |
thread (agent blocker 3), not the body | in scope — incomplete, nit 5 |
MainWindow_Session.cpp — 3 comment lines |
yes, "Localization check" | in scope, comment only, verified no executable change |
tests/hl2_txdsp_test.cpp, hl2_tx_level_policy_test.cpp, hl2_dsp_readback_test.cpp |
yes | in scope — see Blocker 1 and nit 6 |
The body's own claims held up where I could test them: new Hl2TxDsp occurs only in Hl2Backend, IcomCivBackend is the only other submitTxAudio override and there is no Flex one, so "Hermes-Lite 2 only" is right; the -0.15 dB release figure reproduces exactly (limiter release: swept 0.003106 vs fresh-key 0.003160 (delta -0.15 dB)); and the 18.59 dB WSPR shortfall reproduces at 18.57 dB on my own A/B. One paragraph has gone stale — nit 7.
Blockers
1. Slider 76–100 is outside the modulator's overload guard — measured, and on the TCI/DAX path too
Anchored inline on Hl2TxLevelPolicy.h:54. Short version: the mic multiplier is applied before the ALC on every path, Hl2TxDsp::reset() puts m_alcGain back to 1.0 on every unkey, and the ALC has to attack down from unity at the start of each over. Doubling the slider's top doubles how far it has to attack, and past the old +20 dB ceiling the hard clamp is reached before it gets there.
I built the real Hl2TxDsp from 87b80c65 (merge base) and from 1d9d063d (this head) into a standalone A/B harness — same stimulus, same 240-sample chunking the test helper uses, clientLeveled=true, full-scale 700 Hz source, 1.5 s:
=== merge base 87b80c65 === (clientLeveled=true, i.e. the TCI/DAX path)
slider 50 = 1.000x : peak |IQ| 0.8615 clipped 0 (0.0 ms)
slider 75 = 3.162x : peak |IQ| 0.8922 clipped 0 (0.0 ms)
slider 100 = 10.000x : peak |IQ| 0.9891 clipped 0 (0.0 ms)
=== PR head 1d9d063d ===
slider 74 = 9.120x : peak |IQ| 0.9767 clipped 0 ( 0.0 ms)
slider 75 = 10.000x : peak |IQ| 0.9891 clipped 0 ( 0.0 ms) <- old maximum
slider 76 = 10.965x : peak |IQ| 1.0028 clipped 16 ( 0.3 ms) <- guard breaks here
slider 77 = 12.023x : peak |IQ| 1.0168 clipped 661 (13.8 ms)
slider 80 = 15.849x : peak |IQ| 1.0630 clipped 991 (20.6 ms)
slider 100 = 100.000x : peak |IQ| 1.5391 clipped 800 (16.7 ms)
ep2WriteTxIq (src/core/backends/hl2/MetisProtocol.cpp:205-213) clamps I and Q to ±1.0 before the int16 conversion, so that overshoot is hard-clipped on the wire — the flat-topped SSB modulator input this PR's own comments call "the one failure mode here that harms other operators rather than the operator who caused it". reset() fires on every unkey (Hl2Backend.cpp:3611), so it is every over, not just the first.
Three things make this reachable rather than theoretical:
- #5505 landed, so
m_restoredMicLevelre-applies a saved slider position at connect (Hl2Backend.cpp:5410-5420) through the new curve with no migration. An operator who parked at 80 under the old build — where the body itself says it "made little practical difference, because the makeup normalised everything to the same on-air level anyway" — comes back to 15.85x. - It is not the mic path only. The multiplier runs ahead of the ALC for client-leveled audio too;
Hl2TxLevelPolicy.hsays so in its own SCOPE paragraph. So "WSJT-X, fldigi and anything over TCI or DAX: no change" holds at slider ≤ 75 and stops holding at 76. The-0.15 dBrelease case that the body offers as evidence for that claim runs at a fixed 10x and cannot see it. - Nothing reports it.
TX:ALCis fed fromalcPeak, measured after the ±1.0 clamp, so it reads ≤ 0 dBFS no matter how far past full scale the envelope went.
I am not asking for the widening to be abandoned — the ~30 dB has to come from somewhere and the slider is the right place. What I think needs a decision before this merges is which of these closes it:
- seed
m_alcGainfrom the first block's peak at key-on instead of 1.0, so the attack does not have to traverse the new range; - or bound the effective gain into the ALC so the worst case stays inside the clamp;
- or a one-shot migration that re-maps a persisted
micLevel > 50through the old curve (which the +40 dB widening arguably needs anyway); - and, whichever it is, pin it — see the inline note on
hl2_txdsp_test.cpp:545.
Nits — all non-blocking
Hl2Backend.h:887—m_txAudioClientLeveledis write-only since1d9d063d: set atHl2Backend.cpp:3490and:3837, read nowhere. Its comment still says it gates a diagnostic that commit deleted. Inline, with a suggestion.Hl2Backend.h:1041— same commit;m_alcTargetPeaknow has one reader, not two. Inline, with a suggestion.Hl2Backend.cpp:4088—setMicGain's comment still says "the one path-dependent thing left is setKeying()'s raise mic gain diagnostic". There is nothing path-dependent left. Inline, with a suggestion.docs/radio-certification.md:44— theTX:ALCGAINrow, three lines under the one this PR rewrites, still reads "sweep the MIC input 20 dB, betweenalcHoldBelowDbfsand the makeup ceiling" with "the gain is frozen below the hold threshold … and capped at unity forclientLeveledaudio". Both constants are deleted by this PR and the cap is now unity everywhere, so the row fails a correct radio exactly as theTX:ALCrow did. By the body's own standard ("Correcting it is this PR's job rather than the next reader's") it belongs here. Related:Hl2Backend.cpp:5532-5546sets the meter's range to-20.0, 40.0and justifies the top asHl2TxDsp::Config::alcMaxGainDb— after this PR the meter can only ever read ≤ 0 dB, so #5636 would inherit a face whose needle never leaves the bottom third.docs/automation-bridge.md:3390-3392— thehealthexample still showsmicLevel80 paired withmicGainAppliedLinear3.98. 3.98 = 10^(12/20) = the old (80−50)×0.4; the new mapping gives 15.849. Inline, with a suggestion.tests/hl2_txdsp_test.cpp:324(the separation case) — the new separation case is a genuine regression test (I confirmed it fails on the merge base), but it uses −54/−34 dBFS, where the old code held the quiet leg, so it fails by asserting 52.55 dB of separation rather than by reproducing the report. At #5463's own −37.05/−12.4 dBFS the merge base gives 0.01 dB out from 24.65 dB in — the reported fault, verbatim. Worth being the leg, or an extra one.- PR body — "Mic Level still does not persist across launches — #5505. Until that lands an operator is back at 50 every session" is stale; #5505 merged at
0f2aa148, as your own reply on theHl2TxLevelPolicy.hthread says. Same for the mapping section's justification "nothing restores it at startup". The conclusion (50 stays unity) is still right; the reason no longer is, and the affected population is larger than the body implies.
What I tried to break and could not
- "Hermes-Lite 2 only." Held.
new Hl2TxDspoccurs only inHl2Backend;IcomCivBackend::submitTxAudiois the only other override and there is no Flex one. - The hold's deletion stranding the gain. The header argues the hold could not merely lose its
!clientLeveledterm. I walked it: under a unity ceiling a quiet block wantstarget = 1.0,reducingis false after any reduction, and a hold would suppress the move back — the argument is correct, andhl2_txdsp_test's release case measures −0.15 dB, matching the body. - Silence and mute.
blockPeak > 1e-6freezes the gain on a digitally silent block, so a silent tail cannot release — pre-existing, and bounded byreset()on unkey. Slider 0 givesblockPeak == 0and a 0.0x multiply; nothing transmits either way. alcEnabled == falsewith 100x.alcEnabledis written nowhere insrc/— defaulttrue, read inprocessAudioBlock, echoed ingatherDspChains. Unreachable for an operator, so the "ALC off, clamp is the only backstop" path is not a live worst case.- Mutation-tested the new assertions. Rewriting
micSliderToGainDbas a single symmetric× 0.8— the exact "tidy" the new comments say the join prevents — failshl2_tx_level_policy_teston "one step below unity is -0.4 dB" and two more. The join is pinned. The separation case likewise fails against the real merge-base DSP (52.55 dB out, asserted 19–21). - Ran the three targets on this head, Linux/RelWithDebInfo,
ninja -j8:hl2_txdsp_test,hl2_tx_level_policy_test,hl2_dsp_readback_test— 3/3 pass. Note CI's green does not include them:.github/ci-test-gate.txtis frozen and carries none of the three, so they first run onfull-suite.ymlafter merge. - Drove the app, offscreen, isolated
AETHER_SETTINGS_DIR,AETHER_AUTOMATION_NO_TX=1, connected explicitly toDEMO-0001("model": "AetherSDR Demo", familysim) — never the FLEX.Hl2TxDspis unreachable from asimbackend (healthreturnsrows: []), so the HL2 surfaces of this PR cannot be exercised through the bridge; the A/B harness above is what stands in for that, and it drives the real class. What the bridge did settle is one of the two things the body lists as reasoned-from-code-only: the Mic Level widget does reachTransmitModel—invoke setValue 80on the "Microphone gain" QSlider, thenassert_state transmit.micLevel == 80→pass: true. The remaining hop (TransmitModel→Hl2Backend::setMicGain) is still code-only. - Not tested: anything on the air, the AX.25 and RADE paths (no bench leg exists and none is reachable here), and the certification rows, which need the radio.
🤖 Generated with Claude Code
…he clamp The mic multiplier runs ahead of the ALC on every path, and reset() puts the gain back to unity on every unkey. The loop therefore has to attack DOWN from 1.0 at the start of each over, and one 5 ms block on a 5 ms attack closes 63% of that distance. While the slider stopped at +20 dB that cost nothing measurable: 10x on a full-scale source still peaks inside the modulator's hard limit. The widening to +40 dB ends that. Measured on this branch, full-scale 700 Hz, whole run: slider 75 = 10.0x -> peak |IQ| 0.9891, 0 samples clipped slider 100 = 100.0x -> peak |IQ| 1.5391, 800 samples (16.7 ms) That is a flat-topped SSB modulator input at the start of every over, on the mic path and the TCI/DAX one alike, since the multiplier is applied before the ALC for client-leveled audio too. No meter reports it: TX:ALC is fed from a peak measured AFTER the hard limit, so it reads at most 0 dBFS however far past full scale the envelope went. The loop now seeds at its own target on the first block that carries signal, instead of ramping to it. That is not a shortcut around the time constants; on the first block there is no history to smooth from. The target is already ceilinged at unity, so the seed can only ever go downward, and a silent key-up keeps the unity reset() had set. Keyed on the first block WITH SIGNAL rather than the first block at all, so a silent lead-in cannot latch unity and hand the next block the ramp. with the seed: whole-run peak |IQ| 0.8594, 0 samples at the clamp The guard in hl2_txdsp_test measures the WHOLE run rather than the settled tail. The existing splatter case uses settledTail(), which drops the first half -- the entire key-on window -- and so is structurally unable to see this; it also stops at 10x, the old ceiling. Reverting the seed fails the new case and no other check in hl2_txdsp_test, hl2_tx_level_policy_test or hl2_dsp_readback_test. Hl2TxLevelPolicy.h claimed the ALC limits rather than the clamp flat-topping. That claim was false as written and is true with the seed; it now says which mechanism holds it up. Reported by ten9876 in review of aethersdr#5646, with the reproduction above. Not measured: anything on the air. This is the unit-level modulator only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
The upper leg of the mic slider went from +20 dB to +40 dB in this branch, so the same number now means twice the gain. aethersdr#5505 landed on 2026-09-13 and re-applies a saved position at connect, through the new curve, with nothing to say the document was written against the old one. An operator who parked the slider at 80 asked for +12 dB. Restoring 80 hands them +24 dB -- 15.849x where they chose 3.981x -- on the first over after an upgrade, with nothing on the panel to explain it. The PR body itself notes there was little reason NOT to park it high on the old build, because the makeup gain normalised everything to the same on-air level anyway, so this is not a corner of the travel. A stored document now carries micLevelCurve beside micLevel. ABSENT MEANS CURVE 1: the key did not exist while curve 1 was the only curve, so its absence is a statement about the writer rather than a gap. That is what makes the migration one-shot -- writing the level back stamps the curve, and the next read takes the identity branch. The POSITION moves so that the LEVEL does not: 80 restores as 65, which is +12 dB on curve 2. Only the upper leg needs it; below unity both curves are 0.4 dB per step and moving a position there would be inventing a setpoint, which is what this restore path already refuses for out-of-range values. Curve 2 has half the resolution above unity, so an odd position rounds up, at most 0.4 dB -- the other direction rounds back toward the unity the operator moved away from. A curve number the reader does not know is taken as written rather than guessed at, for the same reason: a level written by a build that knows a curve this one does not is a level this build cannot re-derive. hl2_tx_level_policy_test asserts the GAIN is preserved rather than re-typing the formula, plus monotonicity and range across the travel. hl2_dsp_readback_test covers the three ways it can go wrong: an unstamped upper position migrates, an unstamped position at or below unity does not, and a stamped position is taken as written. Raised by ten9876 in review of aethersdr#5646 as one of three ways to close the widening's blocker; the seed in the parent commit is another, and both are wanted -- the seed bounds the clamp, this bounds the surprise. Not measured: anything on the air, and no upgrade was performed against a real settings file. The document shapes are the test's own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Review nits on aethersdr#5647. No executable change: comments and one certification row. Hl2TxDsp.h carried both halves of the story on the declaration of the function this PR changes. It opened by naming `clientLeveled` and asserting "THE FLAG IS NOW INERT HERE ... nothing left for the flag to select", then said the opposite eighteen lines later. The stale half was written for aethersdr#5646, when the flag really was inert, and the replacement started one line too far down. The aethersdr#4796 history that still explains why the type is a source and not a bool is kept; the claim that it does nothing is gone. Hl2Backend.cpp named tciAudioFresh() as what keeps two sources out of one transmission. It is not, for engine audio: feedDaxTxAudioInternal arms m_tciAudioTimer under markExternalSource alone, and both engine-generated feeds pass it false. The exclusion comes from setDaxTxMode(true) in startWsprPump() and in Ax25HfPacketDecodeDialog, and from the radeMode early return. That distinction did not matter while the two buckets differed only in a ceiling they now share; with EngineGenerated bypassing m_micGain, misattributed m_inBuffer residue is worth the slider's full range, so the comment now names the real guards and says a fourth such feed owns re-checking. radio-certification.md's TX:ALCGAIN row set its acceptance criterion "between alcHoldBelowDbfs and the makeup ceiling" and "capped at unity for clientLeveled audio". aethersdr#5646 removed the first two and this branch removes the last, so the row named nothing that exists. It now states what a reduction-only stage with a unity ceiling actually does, and records that EngineGenerated moves it 0 dB by construction. IcomCivBackend.cpp still called the parameter "the flag". Every remaining `clientLeveled` in the tree is explicitly historical narration. Tests unchanged and re-run on this head: hl2_txdsp_test, tx_audio_source_wiring_test, hl2_tx_level_policy_test, hl2_dsp_readback_test, hl2_tx_gate_test, icom_identity_test and hl2_tci_signaling_test all pass; hl2_tx_loopback_test builds with AETHER_ENABLE_HL2_TX_LOOPBACK_TEST=ON and skips at exit 77 with no simulator present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@ten9876 — the slider blocker is answered at the branch head. Both halves you Your figures reproduce exactly in our own suite. With the seed reverted, the new You were also right that the test could not see it: Not measured: anything on the air. This is the unit-level modulator only. 🤖 Generated with Claude Code |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
#5463's mechanism is gone and I could not bring it back: the ceiling is a hard std::min(wanted, 1.0), alcMaxGainDb and alcHoldBelowDbfs are deleted rather than turned down, and the rewritten separation case pins the reported fault (20 dB in → 20 dB out) instead of the property the old case asserted. The WDSP split the body cites is the right authority for the shape. I rebuilt the real Hl2TxDsp from the merge base (87b80c65) and from this head (6d3d12f8) into a standalone A/B harness and the fix works — at the old +20 dB gain on the mic path, the same room-then-speech stimulus goes from peak |IQ| 1.6786 with 728 clipped samples on the base to 0.9884 with 0 on this head.
What I did not get past is @ten9876's Blocker 1. The two commits pushed since that review (356dacf3 seed, 6d3d12f8 migration) are the right two ideas, and the migration is correct, but the key-on seed does not close the case it was added for — it is spent by the first block that is not bit-exactly zero, which on every real path is the lead-in rather than the loud block. Measured below. Blocker 1.
Scope
Everything in the diff is explained by the issue or by a review thread here. No unrelated files, no commit predating the branch (all seven are 2026-09-07…09-15 by authoredDate), no vendored churn, and CHANGELOG.md is correctly untouched. The one scope problem is in the body, not the diff: the two newest commits are substantive and the body describes neither, while two of its sections still describe a mechanism 1d9d063d removed.
| File / group | What it changes | Claimed in title or body? | Verdict |
|---|---|---|---|
Hl2TxDsp.{h,cpp} |
unity ceiling; delete alcMaxGainDb + alcHoldBelowDbfs + the hold; clientLeveled → [[maybe_unused]] |
yes, the headline | in scope |
Hl2TxDsp.{h,cpp} |
key-on seed m_alcSeedPending (356dacf3) |
no — absent from the body | in scope (answers a review blocker); see Blocker 1 and nit 1 |
Hl2TxLevelPolicy.h |
asymmetric −20/+40 dB mapping, unity pinned at 50 | yes, "The slider mapping" | in scope |
Hl2TxLevelPolicy.h |
kMicLevelCurve + micLevelFromCurve1 (6d3d12f8) |
no — absent from the body | in scope (answers a review blocker); see nit 1 |
Hl2Backend.{h,cpp} |
static_assert re-point, m_alcTargetPeak mirror, health row rename, gatherDspChains key removal, unkey advice removed |
yes / thread | in scope; stale comments, nits 4–5 |
Hl2Backend.{h,cpp} |
migrateRestoredMicLevel + the micLevelCurve stamp |
no — absent from the body | in scope; nits 2–3 |
MainWindow_Session.cpp |
3 comment lines | yes, "Localization check" | in scope — verified comment-only, no executable change |
docs/radio-certification.md |
TX:ALC row replaced, 2026-09-09 block added, 2026-08-10 kept as control |
yes | in scope — incomplete, nit 6 |
docs/automation-bridge.md |
drop two dspchains ALC keys |
thread, not the body | in scope — incomplete, nit 7 |
tests/hl2_{txdsp,tx_level_policy,dsp_readback}_test.cpp |
cases rewritten; slider-top, join and migration legs added | yes / thread | in scope — see Blocker 1 and nit 2 |
New persisted surface — checked and I think it is fine. micLevelCurve is a new key in the per-radio operating-state document. It rides inside the capability-gated txSetpoints object (Domain::TxSetpoints), so it is Principle V/III-shaped, and I checked whether it should have bumped extensionSchemaVersion instead: RadioStateMemory::load does not version-gate the HL2 read path at all (the strict != 1 check at RadioStateMemory.cpp:292 is the RTL RF-gain helper), so a bump would have bought no downgrade protection. A separate curve number is the right call and the header's reasoning for it is sound.
Blockers
1. The key-on seed is spent by the first non-silent block, so the widening still flat-tops the modulator — on the TCI/DAX path too
Anchored inline on Hl2TxDsp.cpp:291, with a tested suggestion.
The seed sits inside if (blockPeak > 1e-6f) and assigns target, which is already std::min(wanted, 1.0). So when the first signal-bearing block is quieter than alcTargetPeak — which is every over that does not begin at full modulation — target is 1.0, the seed writes the unity reset() had already set, and clears the flag. The ramp the seed exists to avoid then happens on the next loud block, unprotected.
blockPeak is measured after m_micGain, so at slider 100 the 1e-6 gate corresponds to an input of 1e-8 (−160 dBFS). In practice the test is "not bit-exactly zero", not "carries signal".
Real Hl2TxDsp at 6d3d12f8, same 240-sample chunking and same whole-run |IQ| instrument hl2_txdsp_test uses, 1.5 s runs:
F. What lead-in actually spends the seed? (slider 100, full-scale source)
digital silence (exact 0.0) peak |IQ| 0.8594 clipped 0 ( 0.0 ms)
-120 dBFS dither peak |IQ| 1.5391 clipped 239 ( 5.0 ms)
-100 dBFS dither peak |IQ| 1.5391 clipped 239 ( 5.0 ms)
-80 dBFS floor peak |IQ| 1.5391 clipped 239 ( 5.0 ms)
-60 dBFS floor peak |IQ| 1.5391 clipped 239 ( 5.0 ms)
1.5391 is the same number @ten9876 measured on the un-seeded head. Only a digitally silent lead-in preserves the seed.
It is not confined to full scale or to the top of the travel:
G. source level (slider 100, 100 ms -60 dBFS lead-in)
0 dBFS 1.5391 | -6 1.3679 | -10 1.2356 | -14 1.1137 | -18 1.0228 (all clipped)
H. slider position (full-scale source, 100 ms lead-in)
slider 90 (39.8x) 1.3030 | 85 (25.1x) 1.1731 | 80 (15.8x) 1.0632 (all clipped)
And it is a regression on the TCI/DAX path specifically. clientLeveled is inert now, so the numbers are identical with the flag set; at the same slider position the base was clean there and this head is not:
| 300 ms of −60 dBFS room, then a full-scale source | merge base, slider 100 | this head, slider 100 |
|---|---|---|
TCI/DAX (clientLeveled=true) |
10x → peak 0.9884, 0 clipped | 100x → peak 1.5393, 750 clipped (15.7 ms) |
mic (clientLeveled=false) |
10x → peak 1.6786, 728 clipped | 100x → peak 1.5393, 750 clipped |
The mic row is the wind-up this PR removes, so the head is no worse there — but the client row is clean on main and clipped here, which is what makes "WSJT-X, fldigi and anything over TCI or DAX: no change" stop holding above slider 75. reset() fires on every unkey (Hl2Backend.cpp:3611), so on FT8 that is every 15-second over. And TX:ALC is fed from postAlcPeak, measured after the std::clamp(…, -1.0f, 1.0f) at Hl2TxDsp.cpp:337, so nothing reports it.
hl2_txdsp_test's new slider-top case cannot see this, because its stimulus is a full-scale tone from sample 0 — the seed's one block is the loud block. I confirmed the case is otherwise a real guard: reverting the seed fails exactly its two assertions and nothing else in the file.
The fix I suggested inline is two lines — keep the seed armed while target is still at the ceiling. I applied it, and it closes every row of B, C, F (all back to 0.8594, 0 clipped at 100x) with hl2_txdsp_test, hl2_tx_level_policy_test and hl2_dsp_readback_test all still green. Whatever shape you pick, the case wants a leg whose stimulus has a lead-in — the current one passes against the un-seeded build too once you put 100 ms of room in front of it.
2. Standing, from @K5PTB and not yet addressed: nothing enforces the merge order
Not a new finding and I have nothing to add to the analysis, but it is still true at this head, so it should not quietly lapse: the PR is not a draft, carries no label, and its own body opens by saying that merging it alone is a known regression on every unattended transmission. mergeStateStatus is BLOCKED only because of the two outstanding review decisions. Draft, a label, or landing #5647 as the merge vehicle — a maintainer call, but one of them should be in place before the reviews clear.
Nits — all non-blocking
-
The body is out of date in three ways, and it is the coordination document for a three-PR stack. (a) "## Two instruments the quiet operator gets" and the whole
kQuietMarginBelowTargetDb = 12.0derivation describe an unkey log line and a constant that1d9d063dremoved —grep -r kQuietMarginBelowTargetDb src/is empty at this head, so the body advertises a diagnostic the PR does not ship. (b) "75 used to be +10 dB and is now +20 dB" is now falsified by your own6d3d12f8: a persisted 75 restores as 63, the same gain. (c) The seed and the migration — the two newest commits, one of them a change to transmit DSP behaviour — appear nowhere in the body, and neither has a bench measurement in "What is measured on what". @ten9876's nit 7 (the stale #5505 paragraph) is also still open. -
The
micLevelCurvestamp is written but never tested. Anchored inline onHl2Backend.cpp:5182. I deleted thetxSetpoints.insert(micLevelCurve)line andhl2_dsp_readback_test,hl2_state_restore_testand the rest of the HL2 suite all stayed green. The migration arithmetic is mutation-covered (breakingmicLevelFromCurve1fails both tests), and the readback case correctly reasons that the stamp is what makes the migration one-shot — it just builds the stamp by hand in its input document and never checks the backend emits one. Failure mode if it ever regresses: the level ratchets toward unity across sessions,80 → 65 → 58 → 54 → 52 → 51, +12 dB down to +0.8 dB in five launches.hl2_state_restore_test.cpp:851'smicLevelIn(backend.currentOperatingState())harness is one assertion away from closing it. I checked whether it could happen today:store()is the only HL2 writer and it always goes throughcurrentOperatingState(), so the shipped code is correct — this is purely the missing guard. -
A non-positive
micLevelCurvesilently suppresses the migration with no log. Inline onHl2Backend.cpp:4200, with a suggestion.curve.toInt() != 1sends0and negatives down the "curve from the future, read as written" branch, while the siblingmicLevelis range-validated and dropped when unreadable (Principle VII). Treating a non-positive curve as malformed matches the function's own reasoning and costs one comparison. -
The seed does not cover a mid-over mic gain change, which the widening also doubles. Slider 50 → 100 during an over on a full-scale source: peak |IQ| 1.4952, 816 samples (17.1 ms) at the clamp; the same move to the old +20 dB maximum is clean (0.9527, 0). At realistic mic levels (−22 dBFS speech) it is clean too, so this is narrower than Blocker 1 — a groove-click, an End keypress or a bridge/CAT
micgain 100rather than a drag. Worth a sentence insetMicGain's comment even if you leave the behaviour. -
@ten9876's nits 1–3 are still open at this head, and there is a fourth of the same family:
m_txAudioClientLeveledis write-only (Hl2Backend.cpp:3490,:3837; read nowhere) and its comment atHl2Backend.h:890still says it gates a diagnostic1d9d063ddeleted — inline;m_alcTargetPeak's comment at:1041still names two readers;setMicGain's atHl2Backend.cpp:4088still says "the one path-dependent thing left is setKeying()'s raise mic gain diagnostic"; andsubmitTxAudio's atHl2Backend.cpp:3825-3826still explains the sticky OR in terms of "the unkey diagnostic". -
@ten9876's nit 4 is still open.
docs/radio-certification.md:44— theTX:ALCGAINrow three lines below the one this PR rewrites still reads "betweenalcHoldBelowDbfsand the makeup ceiling" with "frozen below the hold threshold … capped at unity forclientLeveledaudio". Both constants are deleted here and the cap is unity everywhere, so that row now fails a correct radio exactly as theTX:ALCrow did — by the body's own standard ("Correcting it is this PR's job rather than the next reader's") it belongs in this diff. Inline. Related and also still open:Hl2Backend.cpp:5576justifies the ALCGAIN meter's+40top asHl2TxDsp::Config::alcMaxGainDb, a field this PR deletes; after it the meter can only read ≤ 0 dB. -
@ten9876's nit 5 is still open.
docs/automation-bridge.md:3390-3392— thehealthexample still pairsmicLevel80 withmicGainAppliedLinear3.98 (= 10^(12/20), the old curve). On curve 2 that position is 15.849; after the migration a restored 80 is 65. Not CI-detectable —tools/gen_bridge_docs.py --checkonly validates the generated verb table, not the prose examples. -
CI green does not include any of this.
.github/ci-test-gate.txtis frozen and carries none ofhl2_txdsp_test,hl2_tx_level_policy_testorhl2_dsp_readback_test, so all three first run onfull-suite.ymlafter merge. Noting it because the three checks above read as coverage they are not.
What I tried to break and could not
- "Hermes-Lite 2 only." Held.
new Hl2TxDspoccurs only inHl2Backend;IcomCivBackend::submitTxAudiois the only other override and there is no Flex one. - The migration arithmetic. Gain-preserving on every even offset, rounds up by ≤0.4 dB on odd ones, identity at and below 50, monotone and in-travel across 0…100 — and mutation-covered: replacing
micLevelFromCurve1with the identity fails bothhl2_tx_level_policy_testandhl2_dsp_readback_test. The three-leg readback case (unstamped-above-unity, unstamped-below-unity, stamped) is the right three legs. I also checked the migration is actually reachable rather than defensive:0f2aa148(#5505) is contained inv26.9.3, so curve-1 documents exist in the field. - Whether
micLevelCurveshould have been anextensionSchemaVersionbump. It should not — see the scope section; I read the load path rather than assuming. - Thread-safety of
m_alcSeedPending. Fine.m_txDsplives onm_ioThread(Hl2Backend.cpp:403) and bothresetandprocessAudioBlockreach it by queued connection, so the flag is single-threaded and FIFO-ordered behind the last block of the over. configure()mid-over. It clearsm_inBufferbut leavesm_alcGainand the seed flag — deliberate-looking and harmless, since a mode or passband change mid-transmission keeps the settled gain rather than re-ramping.- Silence, mute and
alcEnabled == false.blockPeak > 1e-6freezes the gain on a digitally silent block (pre-existing, bounded byreset()); slider 0 is a 0.0x multiply so nothing transmits either way;alcEnabledis written nowhere insrc/, so the clamp-only path is not operator-reachable. - Ran the full HL2 suite plus
radio_state_memory_teston this head, Linux/RelWithDebInfo,ninja -j16: 35/35 pass. Also inverted the new seed guard (fails its two assertions and no others) and the migration (fails in two files) — and the stamp write (fails nothing, which is nit 2). - Drove the app, offscreen,
HOME/XDG_CONFIG_HOME/AETHER_SETTINGS_DIRall isolated,AETHER_AUTOMATION_NO_TX=1(whoami→"txAllowed": false), explicit socket/tmp/aethersdr-pr5646, connected explicitly toDEMO-0001("model": "AetherSDR Demo", familysim) — the FLEX-8600 was visible inconnect listand was never touched.healthon the demo returns{"connected":true,"ok":true,"rows":[]}, so every HL2 surface this PR changes is unreachable through the bridge and the A/B harness above is what stands in for it. What the bridge did settle is the widget hop the body lists as reasoned-from-code:invoke "Microphone gain" setValue 100→get transmit micLevel→100, andsetValue 76→76, so the slider reachesTransmitModelacross the newly meaningful part of its travel. The remaining hop (TransmitModel→Hl2Backend::setMicGain) is still code-only. Instance disconnected and closed. - Not tested: anything on the air, the AX.25 and RADE paths, and the certification rows, which need the radio.
🤖 Generated with Claude Code
…th signal The key-on seed did not cover the case it was added for. It was cleared by the first block with `blockPeak > 1e-6`, and blockPeak is measured AFTER m_micGain — so at the slider's new 100x that test corresponds to an input of 1e-8 (-160 dBFS). In practice it asked "is this block bit-exactly zero?", not "does this block carry signal". Every over that does not open at full modulation therefore spent the seed on a block that wanted unity — the value reset() had already set — and handed the next loud block the full 40 dB ramp anyway. Measured on the real class: 100 ms of -60 dBFS room ahead of a full-scale source gives |IQ| 1.5391 with 239 samples at the clamp, the same figure as having no seed at all. It reproduces down to a -18 dBFS source and down to slider 80, and identically with clientLeveled true — so it was a regression on the TCI/DAX path, which is clean on the merge base (0.9884, 0 clipped) at the same slider position. Gate the seed on `reducing` instead. A block that wants unity is not the block the seed exists for, so a quiet lead-in now stays armed and the first block that actually needs reduction gets the jump. Because the branch requires `reducing`, the seed can only ever move the gain DOWN. The same reasoning applies to a mid-over gain change, which the widening also doubled: the loop's history describes the old gain and the ALC cannot attack across +40 dB in one block. A 50 -> 100 move during an over on a full-scale source flat-topped the modulator for 17.1 ms (|IQ| 1.4952, 816 samples). setMicGain() re-arms the seed on an upward move only — a decrease cannot reach the clamp, and keeps its normal release. Two cases in hl2_txdsp_test pin both. The existing slider-top case cannot: its stimulus is full scale from sample 0, so the seed's first reduction is its first block — the one opening the old gate also handled. Reverting either fix fails the matching new case and nothing else in the file. Principle VIII. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing alcMaxGainDb and alcHoldBelowDbfs left several surfaces still describing them, and one of them fails a correct radio. - docs/radio-certification.md: the TX:ALCGAIN row still swept "between alcHoldBelowDbfs and the makeup ceiling", neither of which exists. With the ceiling at unity the gain this meter reports can only be zero or negative, so the criterion is a sign and a knee rather than a window. This is the same defect as the TX:ALC row this branch already rewrote, three lines further down. - The TX:ALCGAIN meter's own face was -20..+40 dB and justified its top as Hl2TxDsp::Config::alcMaxGainDb. That field is gone and the needle can no longer leave the bottom third, so the face is now -20..0. - Four comments still explained themselves through the unkey "raise mic gain" diagnostic that this branch deleted: m_txAudioClientLeveled (now write-only until aethersdr#5647 gives it a job), m_alcTargetPeak (one reader, not two), setMicGain's "the one path-dependent thing left", and submitTxAudio's sticky-OR. - docs/automation-bridge.md's health example paired micLevel 80 with micGainAppliedLinear 3.98, which is the old curve; on curve 2 that position is 15.849. - migrateRestoredMicLevel treated a zero or negative micLevelCurve as a curve number from the future and silently skipped the migration. Those are a damaged document, not a newer build, so they take the malformed branch and say so — the sibling micLevel is range-validated for the same reason (Principle VII). Two test gaps, both found by mutation: - Deleting the micLevelCurve stamp from currentOperatingState() passed the entire suite, while the migration arithmetic is deliberately NOT idempotent. An unstamped round-trip would re-migrate on every connect: 80 -> 65 -> 58 -> 54 -> 52 -> 51, +12 dB walking down to +0.8 dB over five launches. hl2_state_restore_test now asserts the stamp. - The separation case used a round -54/-34 dBFS. aethersdr#5463's own loopback table is -37.05/-12.4 dBFS, where the merge base turned 24.65 dB of input separation into 0.33 dB. That leg is now asserted verbatim, so the test reproduces the report rather than a tidied version of it. Principle VIII. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks again Review 5218387337 found three blockers. All are fixed here, and every new guard was broken on purpose to confirm it fires. 1. THE aethersdr#4796 BEHAVIOURAL GUARD HAD GONE INERT. tests/hl2_tci_signaling_test.cpp asserted the property with monitor.first().at(1).toBool(). That was exact while the argument was a bool; when it became TxAudioSource the call kept compiling and stopped meaning anything, because toBool() goes enum -> int -> bool and reads BOTH ClientLeveled(1) and EngineGenerated(2) as true. Tagging TCI audio EngineGenerated -- the exact regression tx_audio_source_wiring_test exists to catch -- left the suite green. It compares the enum now, and that mutation fails it. 2. AX.25 HAD LOST EVERY UPWARD DRIVE CONTROL. It was tagged EngineGenerated, which bypasses the mic slider. Its AFSK amplitude is a compile-time constant (kTxAfskAmplitude = 0.35, -9.12 dBFS) and Ax25HfPacketDecodeDialog has no level control, so the slider was the only thing in the product that could move a packet frame -- leaving HF packet 7.71 dB under alcTargetPeak (0.85, -1.41 dBFS) with nothing able to raise it. sendModemTxAudio tags Microphone now, and icom_identity_test asserts it on a live txFinalMonitorPcmReady. The tag is no longer DERIVED from markExternalSource either. That flag means "a TCI/DAX client is feeding" and arms the TCI active-audio timer; reading it as "not a beacon" is what swept AX.25 into the beacon's bucket. feedDaxTxAudioInternal takes the source explicitly, so each entry point states its own origin. EngineGenerated now has exactly one producer: AudioEngine::startWsprPump(). RADE was never affected and is no longer described as if it were: activateRADE() refuses a radio that cannot provide DAX audio, so it is Flex-only, and a Flex modulates on its own side. 3. THE RETIRED SAFETY CLAIM WAS STILL IN Hl2Backend.cpp. setMicGain's doc comment -- on the function the slider actually calls -- still said "a slider at the bottom of its travel means off", the same claim Hl2TxLevelPolicy.h was corrected for, plus "the same on every path" and "the one path-dependent thing left", both falsified by this branch. Nits, all taken: - TxAudioSource moves to src/core/backends/TxAudioSource.h. AudioEngine.h did not include IRadioBackend.h before this branch and is itself included by 75 files; Hl2TxDsp.h picked it up too. 1378 lines of backend seam for a three-value enum. - tx_audio_source_wiring_test's three "declares X" checks matched the whole file, which the doc comment satisfies in prose -- they passed against an enum gutted to one enumerator. Scoped to the enum body. - Hl2TxDsp::processAudioBlock drops carried m_inBuffer residue when the source changes mid-transmission. The upstream fences are real (startWsprPump's setDaxTxMode(true) for the mic path, feedDaxTxAudio's m_wsprBeacon->isActive() for the client path) but they are an argument about call sites in another class; this makes the property structural. Without it the new test case measures the residue arriving at +20.0 dBFS mic peak. - healthSnapshot() gains a txAudioSource row; micGainAppliedLinear says it is what the modulator holds, not always what it applies. - qRegisterMetaType uses the qualified name, as AetherClockEngine.cpp and KiwiSdrManager.cpp do. - tx_audio_source_wiring_test drops a target_include_directories it never used; hl2_txdsp_test asserts the pre-ALC mic peak per call instead of threading one scratch float through five and discarding it. Not taken: TX:ALCGAIN's meter face is still declared -20..+40 naming the deleted Hl2TxDsp::Config::alcMaxGainDb. Real, and aethersdr#5646's to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… TX-level stack; #5646 and #5647 do not restore the level) (#5651) > ###⚠️ #5646 and #5647 do not restore the beacon level. **This PR does.** > A maintainer who merges the first two and believes unattended transmission is > fixed would ship every WSPR beacon **18.59 dB down — a factor of 72 in power**, > on a transmission that keys for 111.6 s with nobody watching. This is the > **third** of a stack of three and it is the one that closes that gap. > > It is **not** stacked in git: it branches from plain `origin/main`. The > dependency is behavioural, not textual. ## Where this sits in the stack | build | beacon `TX:ALC` peak | mic slider moves it? | |---|---|---| | today's `main` | **−1.412 dBFS** | — | | **#5646** alone (`hl2/alc-unity-ceiling`) | **−20.002 dBFS** | **yes**, up to +40 dB | | **+ #5647** (`hl2/engine-generated-tx-source`) | **still −20.002 dBFS** | no — measured **0.0 dB** | | **+ this PR** | **−3.0 dBFS** | no | That third row is the one worth pausing on. #5647 fixes *which control moves the beacon*; it deliberately sets no defaults and its body says so. The **level** comes back only here, by moving the WSPR Level spinbox default from −20 to −3 dBFS on host-modulating backends. **Merge order: #5646 → #5647 → this.** Between #5646 and this one there is a window in which every unattended transmission is ~18.6 dB down. ## The dependency, established from the code rather than asserted **Hard dependency on #5646. It is an exact no-op without it**, and the mechanism is arithmetic rather than opinion. On today's `main`, `Hl2TxDsp::processAudioBlock` computes `ceiling = clientLeveled ? 1.0 : pow(10, alcMaxGainDb/20)` — 40 dB for the WSPR pump, which reaches `submitTxAudio` with `clientLeveled=false` because `AudioEngine::startWsprPump` calls `feedDaxTxAudioInternal` with `markExternalSource=false`. It then drives the block peak onto `alcTargetPeak` (0.85 = −1.4116 dBFS). A −20 dBFS source and a −3 dBFS source are both well inside that 40 dB of makeup, so both arrive at the same −1.412 dBFS. `alcEnabled` defaults true and has no UI, so there is no reachable configuration in which this PR changes anyone's on-air level today. **No dependency on #5647 for correctness at the shipped slider position, and this is reasoned, not measured.** With #5646 + this PR but *without* #5647, the beacon still takes the microphone path: at the default Mic Level of 50 (`micGain` 1.0) it leaves at −3 dBFS, which is right; at 100 (+40 dB) it is driven into the ALC and limits at −1.412 dBFS. So without #5647 the level is correct but still slider-dependent. **No bench leg covers that combination** — `wspr-real-beacon-source-ab` has no #5646+this-PR-without-#5647 leg — so that paragraph is read off the code, and is offered as such. ## What was measured, and on what **Everything below is `hpsdrsim` on loopback**, serial `AA:BB:CC:DD:88:FF`, under a loopback approval issued by the orchestrator and **not** by ON8ST. **Nothing in this series has ever been radiated** — no on-air contact, **no WSPRnet spot**, no receiving station has heard any of these builds. Every claim about what a correspondent would hear is inference. This is stated because an earlier version of this series' commit messages said "measured on the air" of runs that were nothing of the kind. **`wspr-real-beacon-source-ab`** drove the application's own `PskReporterMapDialog` beacon button through the automation bridge. Six legs, all usable. Its `with-pr5` binary (`af434a95`) **contains this branch's default change** — that is why its default leg reads −3.0 dBFS. The honest proxy for "#5646 and #5647 without this PR" is its **`with-pr5-forced-20`** leg, the same binary with the level held at −20: **−20.002 dBFS**. `repair_db` for the default move is **17.002 dB**. The residual against stock is **−1.588 dB and is not expected to be zero**: stock put the beacon at `alcTargetPeak` because a makeup stage hunted for it, and this PR puts it at the level the generator chose, 1.6 dB below that target. Different mechanisms arriving near the same place. **`wspr-unattended-ab`** measures the separation head-on with a constant −20.000 dBFS stimulus: `repair_db` **0.0**, `residual_vs_stock_db` **−18.577**, with a `txraw` control whose spread across the binaries is **0.000 dB** — which is what makes the numbers attributable to the binaries rather than to three different inputs. That run delivers its stimulus through BlackHole rather than through `feedDaxTxAudioInternal`, so it is sound about level and silent about the pump's timing. ## A defect found while filing this, and fixed here The branch as first written read **`AudioEngine::hostModulation()`**, which is `takesTxAudioOverSeam && canTransmit`. That is the wrong predicate, and it comes apart on exactly one backend: **`IcomCivBackend` sets `takesTxAudioOverSeam = true` with `hostModulates = false`**, and its own source comment spells out why — *"The RADIO modulates ... but the host still SHIPS the audio."* So the original branch would have handed **every Icom a −3 dBFS beacon default** — 17 dB into a modulator the operator has already levelled, which is the precise harm the change claims to avoid. It now reads **`TransmitModel::hostModulation()`** (`hostModulates && canTransmit`), which is the question actually being asked: does *our* ALC see this audio. `Hl2TxDsp` is referenced nowhere outside `src/core/backends/hl2/`, so on Flex and Icom it genuinely never runs. Capability matrix, for the record: HL2 `hostModulates=true`; Flex, Icom and RTL `false`; ANAN `true` but `canTransmit=false`. **This fix is reasoned from the capability declarations, not measured — no Icom was connected.** ## What review changed (head `7d18ba4b`) Three things in the original two commits did not survive review, and the fixes are in the third commit rather than in a follow-up. **Re-evaluated on connect — the previous commit's claim was wrong.** `13874b1a` said `applyBeaconLevelDefault()` was "also called from `updateBeaconDefaults()`, which already re-runs on every radio status change". It does not. `updateBeaconDefaults()` has two call sites — the constructor, and a lambda on `RadioModel::callsignChanged` — and that signal is emitted only by the operator editing their own callsign (`RadioModel.cpp:3207`), the Flex `info` reply (`:7408`), and a `RadioDelta` carrying a callsign (`:11457`). `git grep -c callsign -- src/core/backends/hl2/` returns nothing, so **connecting an HL2 with this window already open fired nothing at all** and the operator kept −20 dBFS for the session. Reproduced offscreen against the demo radio: open-then-connect left the beacon callsign field empty on a radio reporting `"DEMO"`, while connect-then-open filled it. `applyBeaconLevel()` now rides `RadioModel::connectionStateChanged` (which carries the identity change) plus `TransmitModel::hostModulationChanged` (for a capability republish inside a live session). The second is **not sufficient on its own**, and driving it is what showed that: it is a change signal, so connecting a Flex leaves `hostModulation()` false either side and emits nothing. **Per radio, not per installation.** The level's correct value had just been made backend-dependent while its storage stayed one app-global key, so a level chosen on a Flex silently became the HL2's unattended beacon level and the "never override a stored value" rule made that permanent — the same silent shortfall this PR exists to prevent, reached from the other side. It now lives in the radio-scoped `WsprBeacon` feature document keyed by `RadioModel::settingsScope()` (`AGENTS.md`, "Radio-Scoped Feature Documents"), with the write result checked and the family-wide row guarded against. The legacy app-global key is claimed per scope on first access and then left frozen — **but only where it described an on-air level.** On a host-modulating backend it did not: while `Hl2TxDsp`'s ALC still had its makeup half the control was inert, so whatever sits in that key was never a choice *about the air*, and importing it into an HL2's document would freeze a non-choice into the one place that now decides an unattended transmit level. Verified offscreen against the demo radio, `AETHER_AUTOMATION_NO_TX=1`, never the FLEX: a dialog opened **before** connecting read −20 dBFS, and connecting to `DEMO-0001` moved it to the −40 dBFS stored for that radio. The store holds `('sim', 'DEMO-0001', 'WsprBeacon', 1, '{"levelDbFs":-40}')`. **One more disclosure.** The new default sits at the top of the control's range (`[-60, -3]`), so on a host-modulating backend the knob now only attenuates. That is deliberate and matches WSJT-X, whose Pwr slider is also attenuate-only, but it does mean the complaint this change opens with — a knob that cannot help an underdriven operator — is moved 17 dB rather than removed. ## What this does not do - **It does not touch an operator's own setting.** A stored level is read back ahead of the default and is never overridden — see "Per radio" below for what "an operator's own setting" now means. - **AX.25 and RADE get no default correction here or 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. - **Drive and power out are untouched.** Forward power is still set by drive. - **Whether −3 dBFS is the right drive for any particular station** is not established by anything here. ## Tests Both decisions now live in **`src/gui/PskBeaconLevelPolicy.h`** as pure functions the dialog *evaluates* rather than copies, pinned by **`psk_beacon_level_policy_test`** — a registered, socket-free CTest (`tests/tests.cmake`). It covers the capability default both ways, the armed guard, a stored value outranking either default, and which radios may claim the legacy key. Mutation-checked: reverting the default, dropping the armed guard, or claiming the legacy key everywhere each fail it. What the policy test does **not** pin is the signal wiring, which is what actually broke here — `PskReporterMapDialog.cpp` is in no test target, and a dialog-level target would need `MapDisplayWidget` + `MapView` + the weather radar + QGeoView listed in one place. That gap is covered by the offscreen bridge run recorded under "Re-evaluated on connect" instead, and named rather than hidden. Full `ctest` on this head (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`) — the same five skipped as on #5646 and #5647. This branch adds no test, so the count matches #5646's 416 rather than #5647's 417. ## Localization check A lab-side checker of mine (not in this repo), implementing `docs/HERMES.md`'s pre-PR grep plus the family-string prohibition, reports **clean — 0 hits, 0 violations, exit 0.** Stronger than either predecessor (#5646 had 1 hit, #5647 had 4), and worth saying why rather than claiming virtue: `src/gui/PskReporterMapDialog.cpp` is simply not on the document's pre-PR grep list. The substantive half does pass on merit — check 2 covers all of `src/gui`, and this branch adds no family string test, because it asks a **capability** (`hostModulation()`), never `family == "hl2"`. --- 🤖 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>
ten9876
left a comment
There was a problem hiding this comment.
Approving at b6dbc789
Every blocker and nit from this review, @K5PTB's and aethersdr-agent's is now either fixed on the branch or answered on its thread — all 20 threads replied to and resolved. I pushed the fixes myself rather than handing them back; the two commits are 49c4d1d3 and b6dbc789.
One thing is deliberately NOT done: auto-merge is not armed. See the last section.
What changed since my last review
Blocker 1 — the ALC seed (49c4d1d3). The seed was cleared by the first block with blockPeak > 1e-6, and blockPeak is measured after m_micGain, so at the slider's new 100x that test corresponds to an input of −160 dBFS: in practice "is this block bit-exactly zero", not "does it carry signal". Any lead-in spent the seed on a block that wanted unity and handed the next loud block the full 40 dB ramp anyway.
It is gated on reducing now — a block that wants unity is not the block the seed exists for. Because the branch requires reducing, the seed can only ever move the gain down, which is the invariant the original comment claimed and which a naive re-arm would have broken.
The same reasoning covers a mid-over gain change, which the widening also doubled: setMicGain re-arms the seed on an upward move only, since a reduction cannot reach the clamp and should keep its normal release.
Measured on the real Hl2TxDsp, whole-run |IQ|, same instrument as the tests:
| stimulus at slider 100 | before | after |
|---|---|---|
| full scale from sample 0 | 0.8594, 0 clipped | 0.8594, 0 clipped |
| 100 ms of −60 dBFS room, then full scale | 1.5391, 239 at the clamp | 0.8594, 0 |
| slider 50 → 100 mid-over, full scale | 1.4952, 816 at the clamp (17.1 ms) | 0.8594, 0 |
The functional diff is four lines. Everything else in that commit is the reasoning and the two new test cases.
The sweep the makeup ALC's removal left half-done (b6dbc789). docs/radio-certification.md's TX:ALCGAIN row still swept "between alcHoldBelowDbfs and the makeup ceiling" — the same defect as the TX:ALC row this branch already rewrote, three rows further down, and it would have failed a correct radio for the same reason. Rewritten. The meter's own face went with it: it was -20..+40 justified by alcMaxGainDb, so deleting that field would have left #5636 inheriting a needle that can never leave the bottom third; it is -20..0 now. Four comments that still explained themselves through the deleted unkey diagnostic are corrected, including one at submitTxAudio nobody had flagged. The bridge health example's micGainAppliedLinear is 15.849 rather than the old curve's 3.98. And migrateRestoredMicLevel no longer treats a zero or negative micLevelCurve as a curve from the future — that is a damaged document, so it logs.
Two test gaps, both found by mutation. Deleting the micLevelCurve stamp passed the entire suite, while the migration arithmetic is deliberately not idempotent — an unstamped round-trip would have walked an operator's +12 dB down to +0.8 dB over five launches. hl2_state_restore_test asserts the stamp now. And the separation case used a round −54/−34 dBFS; it now also asserts #5463's own −37.05/−12.4 dBFS loopback pair verbatim, where the merge base turned 24.65 dB of input separation into 0.33 dB.
The PR body is rewritten: the kQuietMarginBelowTargetDb section and the unkey-log-line section are erased rather than annotated (1d9d063d removed both), the "75 used to be +10 dB and is now +20 dB" paragraph is replaced by what the migration actually does, the two #5505-is-still-open paragraphs are corrected, and the seed and the migration each have a section — they had none.
Verification
- Full
hl2_*family plusradio_state_memory_test: 35/35 pass, Linux/RelWithDebInfo.meter_model_testandmeter_surfaces_testpass too, since the meter definition moved. - Every new guard mutation-tested in isolation, against the commits: reverting the
reducinggate fails the lead-in case and nothing else; reverting thesetMicGainre-arm fails the mid-over case and nothing else; deleting themicLevelCurvestamp fails the new state-restore assertion and nothing else. The earlier round of this review found the stamp mutation came back green — it does not now. - CI green on
b6dbc789: build, check-macos, check-windows, Static checks, Sanitizer option configures. Worth repeating that this does not exercise the HL2 tests —.github/ci-test-gate.txtis frozen and carries none of them, so they first run onfull-suite.ymlafter merge. The local 35/35 above is the real evidence. - One change here has no test guarding it: the
TX:ALCGAINface range.meter_surfaces_testcovers that meter's registration and units but not its bounds, so the new-20..0is checked againstalcGainDb()'s ceiling by inspection rather than by assertion.
What I did not touch
The merge-order question, which is not a code problem and is not mine to close: on its own this PR still changes the level of unattended transmissions, and #5647 is what removes the slider coupling. So auto-merge is deliberately not armed, and #5647 is not ready to be the vehicle either — it is CHANGES_REQUESTED and is stacked on an older #5646 commit, so it does not contain 6d3d12f8, let alone this head. It needs a rebase onto b6dbc789 and its own review cleared before either can land.
Approving so that this PR is no longer the thing in the way.
🤖 Generated with Claude Code
The seed under a crescendo — measured, because neither review tested that shape
A crescendo is a different shape and it worried me. The seed fires on the first block that wants any reduction — so a source rising gradually could land a block in the narrow window where It does not happen. Real Zero clipped samples at every rise time, and the peak sits where the settled cases do. The reason, as far as I can tell from the numbers rather than from reading: on a slow rise the signal changes little across one 5 ms block, so the ordinary attack tracks it without ever needing the jump. The seed's window matters only when the step is large, which is exactly the case it was written for. On a fast rise the first reducing block already wants heavy reduction and the seed does its job. Not measured: anything on the air. This is the unit-level modulator only, on macOS/arm64, and the probe was temporary — it is not in the branch. I did not test a crescendo that stops partway, or one that rises and falls inside a single over. I would not add this as a test case unless you want it: it asserts the absence of a failure in a shape the existing lead-in case already covers in spirit, and a case that cannot fail is the thing this project keeps arguing against. 🤖 Generated with Claude Code |
@on8st red-teamed the key-on seed against a crescendo and found it clean, and flagged two shapes as untested. One of them reproduces. The seed protects only the FIRST reduction of an over. A source that crosses the ALC target gently spends it on a fraction of a dB, and every louder block after that gets the smoothed attack with nothing in front of the modulator's hard clamp. Measured on the real class at b6dbc78, slider 100, whole-run |IQ|: plateau -41 dBFS then a step to full scale 1.5297, 448 at the clamp plateau -30 dBFS then a step to full scale 1.1912, 554 -38 dBFS word then a -12 dBFS syllable 1.0768, 223 quiet, one loud burst, quiet again 1.5045, 727 (15.2 ms) The last two are speech, not corner cases. @on8st's rise sweep reproduces exactly as reported (0 clipped at every rise time) — a monotonic rise keeps the per-block change small enough for the attack to track, so it never exercises the spend-then-step path. The attack constant was never buying smoothing here. At a 512-sample block on 24 kHz — 21.3 ms — `1 - exp(-21.3/5)` closes 98.6% of the error in one block. It was leaving 1.4% of the step above the clamp. So reduction now simply takes the target, and the seed, its flag and setMicGain's re-arm are all deleted along with the now-dead alcAttackSec and its bridge-doc key. Release is untouched: aethersdr#4796's case still measures -0.15 dB, and the over-level client's crest is still 1.0001, so nothing about the smoothing that matters has changed. All 18 probe shapes now settle at 0.859 with nothing at the clamp. A quiet-then-loud case joins the three step cases in hl2_txdsp_test; it 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 depends on dspBlockSize and inputSampleRateHz, so it is a guarantee that expires silently the day either moves. Principle VIII. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It reproduces — but not on the shape you droveYour rise sweep reproduces exactly here, all six rise times, 0 clipped. And your The path is reachable from the other shape you named as untested — the one that C and the second D row are speech, not corner cases. So the seed is gone, and reduction is instantaneous
All eighteen shapes settle at ~0.859 with nothing at the clamp, including every You were right not to want a test that cannot fail, so the one I added is the one One honest limit, found by inverting it: it pins the observable property, not Same caveat as yours: unit-level modulator only, nothing on the air. 🤖 Generated with Claude Code |
Review nits on aethersdr#5647. No executable change: comments and one certification row. Hl2TxDsp.h carried both halves of the story on the declaration of the function this PR changes. It opened by naming `clientLeveled` and asserting "THE FLAG IS NOW INERT HERE ... nothing left for the flag to select", then said the opposite eighteen lines later. The stale half was written for aethersdr#5646, when the flag really was inert, and the replacement started one line too far down. The aethersdr#4796 history that still explains why the type is a source and not a bool is kept; the claim that it does nothing is gone. Hl2Backend.cpp named tciAudioFresh() as what keeps two sources out of one transmission. It is not, for engine audio: feedDaxTxAudioInternal arms m_tciAudioTimer under markExternalSource alone, and both engine-generated feeds pass it false. The exclusion comes from setDaxTxMode(true) in startWsprPump() and in Ax25HfPacketDecodeDialog, and from the radeMode early return. That distinction did not matter while the two buckets differed only in a ceiling they now share; with EngineGenerated bypassing m_micGain, misattributed m_inBuffer residue is worth the slider's full range, so the comment now names the real guards and says a fourth such feed owns re-checking. radio-certification.md's TX:ALCGAIN row set its acceptance criterion "between alcHoldBelowDbfs and the makeup ceiling" and "capped at unity for clientLeveled audio". aethersdr#5646 removed the first two and this branch removes the last, so the row named nothing that exists. It now states what a reduction-only stage with a unity ceiling actually does, and records that EngineGenerated moves it 0 dB by construction. IcomCivBackend.cpp still called the parameter "the flag". Every remaining `clientLeveled` in the tree is explicitly historical narration. Tests unchanged and re-run on this head: hl2_txdsp_test, tx_audio_source_wiring_test, hl2_tx_level_policy_test, hl2_dsp_readback_test, hl2_tx_gate_test, icom_identity_test and hl2_tci_signaling_test all pass; hl2_tx_loopback_test builds with AETHER_ENABLE_HL2_TX_LOOPBACK_TEST=ON and skips at exit 77 with no simulator present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks again Review 5218387337 found three blockers. All are fixed here, and every new guard was broken on purpose to confirm it fires. 1. THE aethersdr#4796 BEHAVIOURAL GUARD HAD GONE INERT. tests/hl2_tci_signaling_test.cpp asserted the property with monitor.first().at(1).toBool(). That was exact while the argument was a bool; when it became TxAudioSource the call kept compiling and stopped meaning anything, because toBool() goes enum -> int -> bool and reads BOTH ClientLeveled(1) and EngineGenerated(2) as true. Tagging TCI audio EngineGenerated -- the exact regression tx_audio_source_wiring_test exists to catch -- left the suite green. It compares the enum now, and that mutation fails it. 2. AX.25 HAD LOST EVERY UPWARD DRIVE CONTROL. It was tagged EngineGenerated, which bypasses the mic slider. Its AFSK amplitude is a compile-time constant (kTxAfskAmplitude = 0.35, -9.12 dBFS) and Ax25HfPacketDecodeDialog has no level control, so the slider was the only thing in the product that could move a packet frame -- leaving HF packet 7.71 dB under alcTargetPeak (0.85, -1.41 dBFS) with nothing able to raise it. sendModemTxAudio tags Microphone now, and icom_identity_test asserts it on a live txFinalMonitorPcmReady. The tag is no longer DERIVED from markExternalSource either. That flag means "a TCI/DAX client is feeding" and arms the TCI active-audio timer; reading it as "not a beacon" is what swept AX.25 into the beacon's bucket. feedDaxTxAudioInternal takes the source explicitly, so each entry point states its own origin. EngineGenerated now has exactly one producer: AudioEngine::startWsprPump(). RADE was never affected and is no longer described as if it were: activateRADE() refuses a radio that cannot provide DAX audio, so it is Flex-only, and a Flex modulates on its own side. 3. THE RETIRED SAFETY CLAIM WAS STILL IN Hl2Backend.cpp. setMicGain's doc comment -- on the function the slider actually calls -- still said "a slider at the bottom of its travel means off", the same claim Hl2TxLevelPolicy.h was corrected for, plus "the same on every path" and "the one path-dependent thing left", both falsified by this branch. Nits, all taken: - TxAudioSource moves to src/core/backends/TxAudioSource.h. AudioEngine.h did not include IRadioBackend.h before this branch and is itself included by 75 files; Hl2TxDsp.h picked it up too. 1378 lines of backend seam for a three-value enum. - tx_audio_source_wiring_test's three "declares X" checks matched the whole file, which the doc comment satisfies in prose -- they passed against an enum gutted to one enumerator. Scoped to the enum body. - Hl2TxDsp::processAudioBlock drops carried m_inBuffer residue when the source changes mid-transmission. The upstream fences are real (startWsprPump's setDaxTxMode(true) for the mic path, feedDaxTxAudio's m_wsprBeacon->isActive() for the client path) but they are an argument about call sites in another class; this makes the property structural. Without it the new test case measures the residue arriving at +20.0 dBFS mic peak. - healthSnapshot() gains a txAudioSource row; micGainAppliedLinear says it is what the modulator holds, not always what it applies. - qRegisterMetaType uses the qualified name, as AetherClockEngine.cpp and KiwiSdrManager.cpp do. - tx_audio_source_wiring_test drops a target_include_directories it never used; hl2_txdsp_test asserts the pre-ALC mic peak per call instead of threading one scratch float through five and discarding it. Not taken: TX:ALCGAIN's meter face is still declared -20..+40 naming the deleted Hl2TxDsp::Config::alcMaxGainDb. Real, and aethersdr#5646's to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Re-approving at 37ecd25f
My earlier approval was dismissed by my own push, so this replaces it. The change since then is 37ecd25f, which came out of @on8st's crescendo challenge.
The seed is gone; reduction is instantaneous. on8st tested the key-on seed against a monotonic crescendo and found it clean — I reproduced their sweep exactly, 0 clipped at every rise time, and their explanation holds. The failure is on the other shape they flagged: one that stops partway. The seed only ever protects the first reduction of an over, so a source crossing the ALC target gently spends it on a fraction of a dB and the next syllable is unprotected. Measured at b6dbc789, slider 100, whole-run |IQ|: a −38 dBFS word then a −12 dBFS syllable reached 1.0768 with 223 samples clipped, and a quiet passage with one loud burst reached 1.5045 with 727 (15.2 ms). Those are speech.
The attack constant was never buying smoothing at this block size — 512 samples on 24 kHz is 21.3 ms, so 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. A reducing block now takes the target. m_alcSeedPending, reset()'s arming, setMicGain's re-arm and the dead alcAttackSec are all deleted. The functional diff is a net deletion — this stage is simpler than the one I approved yesterday.
All 18 probe shapes settle at ~0.859 with nothing at the clamp. The release half is untouched: #4796's case still measures −0.15 dB and the over-level client's crest is still 1.0001.
Verified
- 37/37 — the full
hl2_*family plusradio_state_memory_test,meter_model_testandmeter_surfaces_test, Linux/RelWithDebInfo. - CI green on
37ecd25f: build, check-macos, check-windows, Static checks, Sanitizer option configures. As before this does not run the HL2 tests —.github/ci-test-gate.txtis frozen and carries none of them. - Mutation: reintroducing a 5 ms smoothed attack fails all four whole-run cases. Reintroducing a 0.5 ms attack passes them — so the new case pins the observable property, not the mechanism. I corrected the test comment after finding that, and said so in the code: whether a constant is short enough is a function of
dspBlockSizeandinputSampleRateHz, which is the argument for instantaneous rather than against it. If you or @jensenpat would rather keep a fast constant, that is a reasonable call and I would not re-block on it. - All 20 review threads from the earlier rounds remain resolved.
Still not armed, and why
Auto-merge stays off, and there is now a second reason beyond the original merge-order one.
#5647 is APPROVED and CLEAN, and it is branched from f6f5fed7 — six commits behind this head. I checked its files directly: it carries the +40 dB slider widening but contains neither the limiter work (m_alcSeedPending / instantaneous reduction: zero occurrences) nor the curve migration (micLevelFromCurve1: zero occurrences). Merging #5647 today would ship the widened slider with the flat-topping and the silently-reinterpreted stored slider position that this PR exists to prevent.
So: #5647 wants a rebase onto 37ecd25f before either lands. That is the remaining blocker on the pair, along with @K5PTB's standing review here, whose four points are all either fixed on the branch or answered on their threads.
🤖 Generated with Claude Code
Review nits on aethersdr#5647. No executable change: comments and one certification row. Hl2TxDsp.h carried both halves of the story on the declaration of the function this PR changes. It opened by naming `clientLeveled` and asserting "THE FLAG IS NOW INERT HERE ... nothing left for the flag to select", then said the opposite eighteen lines later. The stale half was written for aethersdr#5646, when the flag really was inert, and the replacement started one line too far down. The aethersdr#4796 history that still explains why the type is a source and not a bool is kept; the claim that it does nothing is gone. Hl2Backend.cpp named tciAudioFresh() as what keeps two sources out of one transmission. It is not, for engine audio: feedDaxTxAudioInternal arms m_tciAudioTimer under markExternalSource alone, and both engine-generated feeds pass it false. The exclusion comes from setDaxTxMode(true) in startWsprPump() and in Ax25HfPacketDecodeDialog, and from the radeMode early return. That distinction did not matter while the two buckets differed only in a ceiling they now share; with EngineGenerated bypassing m_micGain, misattributed m_inBuffer residue is worth the slider's full range, so the comment now names the real guards and says a fourth such feed owns re-checking. radio-certification.md's TX:ALCGAIN row set its acceptance criterion "between alcHoldBelowDbfs and the makeup ceiling" and "capped at unity for clientLeveled audio". aethersdr#5646 removed the first two and this branch removes the last, so the row named nothing that exists. It now states what a reduction-only stage with a unity ceiling actually does, and records that EngineGenerated moves it 0 dB by construction. IcomCivBackend.cpp still called the parameter "the flag". Every remaining `clientLeveled` in the tree is explicitly historical narration. Tests unchanged and re-run on this head: hl2_txdsp_test, tx_audio_source_wiring_test, hl2_tx_level_policy_test, hl2_dsp_readback_test, hl2_tx_gate_test, icom_identity_test and hl2_tci_signaling_test all pass; hl2_tx_loopback_test builds with AETHER_ENABLE_HL2_TX_LOOPBACK_TEST=ON and skips at exit 77 with no simulator present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks again Review 5218387337 found three blockers. All are fixed here, and every new guard was broken on purpose to confirm it fires. 1. THE aethersdr#4796 BEHAVIOURAL GUARD HAD GONE INERT. tests/hl2_tci_signaling_test.cpp asserted the property with monitor.first().at(1).toBool(). That was exact while the argument was a bool; when it became TxAudioSource the call kept compiling and stopped meaning anything, because toBool() goes enum -> int -> bool and reads BOTH ClientLeveled(1) and EngineGenerated(2) as true. Tagging TCI audio EngineGenerated -- the exact regression tx_audio_source_wiring_test exists to catch -- left the suite green. It compares the enum now, and that mutation fails it. 2. AX.25 HAD LOST EVERY UPWARD DRIVE CONTROL. It was tagged EngineGenerated, which bypasses the mic slider. Its AFSK amplitude is a compile-time constant (kTxAfskAmplitude = 0.35, -9.12 dBFS) and Ax25HfPacketDecodeDialog has no level control, so the slider was the only thing in the product that could move a packet frame -- leaving HF packet 7.71 dB under alcTargetPeak (0.85, -1.41 dBFS) with nothing able to raise it. sendModemTxAudio tags Microphone now, and icom_identity_test asserts it on a live txFinalMonitorPcmReady. The tag is no longer DERIVED from markExternalSource either. That flag means "a TCI/DAX client is feeding" and arms the TCI active-audio timer; reading it as "not a beacon" is what swept AX.25 into the beacon's bucket. feedDaxTxAudioInternal takes the source explicitly, so each entry point states its own origin. EngineGenerated now has exactly one producer: AudioEngine::startWsprPump(). RADE was never affected and is no longer described as if it were: activateRADE() refuses a radio that cannot provide DAX audio, so it is Flex-only, and a Flex modulates on its own side. 3. THE RETIRED SAFETY CLAIM WAS STILL IN Hl2Backend.cpp. setMicGain's doc comment -- on the function the slider actually calls -- still said "a slider at the bottom of its travel means off", the same claim Hl2TxLevelPolicy.h was corrected for, plus "the same on every path" and "the one path-dependent thing left", both falsified by this branch. Nits, all taken: - TxAudioSource moves to src/core/backends/TxAudioSource.h. AudioEngine.h did not include IRadioBackend.h before this branch and is itself included by 75 files; Hl2TxDsp.h picked it up too. 1378 lines of backend seam for a three-value enum. - tx_audio_source_wiring_test's three "declares X" checks matched the whole file, which the doc comment satisfies in prose -- they passed against an enum gutted to one enumerator. Scoped to the enum body. - Hl2TxDsp::processAudioBlock drops carried m_inBuffer residue when the source changes mid-transmission. The upstream fences are real (startWsprPump's setDaxTxMode(true) for the mic path, feedDaxTxAudio's m_wsprBeacon->isActive() for the client path) but they are an argument about call sites in another class; this makes the property structural. Without it the new test case measures the residue arriving at +20.0 dBFS mic peak. - healthSnapshot() gains a txAudioSource row; micGainAppliedLinear says it is what the modulator holds, not always what it applies. - qRegisterMetaType uses the qualified name, as AetherClockEngine.cpp and KiwiSdrManager.cpp do. - tx_audio_source_wiring_test drops a target_include_directories it never used; hl2_txdsp_test asserts the pre-ALC mic peak per call instead of threading one scratch float through five and discarding it. Not taken: TX:ALCGAIN's meter face is still declared -20..+40 naming the deleted Hl2TxDsp::Config::alcMaxGainDb. Real, and aethersdr#5646's to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks again Review 5218387337 found three blockers. All are fixed here, and every new guard was broken on purpose to confirm it fires. 1. THE aethersdr#4796 BEHAVIOURAL GUARD HAD GONE INERT. tests/hl2_tci_signaling_test.cpp asserted the property with monitor.first().at(1).toBool(). That was exact while the argument was a bool; when it became TxAudioSource the call kept compiling and stopped meaning anything, because toBool() goes enum -> int -> bool and reads BOTH ClientLeveled(1) and EngineGenerated(2) as true. Tagging TCI audio EngineGenerated -- the exact regression tx_audio_source_wiring_test exists to catch -- left the suite green. It compares the enum now, and that mutation fails it. 2. AX.25 HAD LOST EVERY UPWARD DRIVE CONTROL. It was tagged EngineGenerated, which bypasses the mic slider. Its AFSK amplitude is a compile-time constant (kTxAfskAmplitude = 0.35, -9.12 dBFS) and Ax25HfPacketDecodeDialog has no level control, so the slider was the only thing in the product that could move a packet frame -- leaving HF packet 7.71 dB under alcTargetPeak (0.85, -1.41 dBFS) with nothing able to raise it. sendModemTxAudio tags Microphone now, and icom_identity_test asserts it on a live txFinalMonitorPcmReady. The tag is no longer DERIVED from markExternalSource either. That flag means "a TCI/DAX client is feeding" and arms the TCI active-audio timer; reading it as "not a beacon" is what swept AX.25 into the beacon's bucket. feedDaxTxAudioInternal takes the source explicitly, so each entry point states its own origin. EngineGenerated now has exactly one producer: AudioEngine::startWsprPump(). RADE was never affected and is no longer described as if it were: activateRADE() refuses a radio that cannot provide DAX audio, so it is Flex-only, and a Flex modulates on its own side. 3. THE RETIRED SAFETY CLAIM WAS STILL IN Hl2Backend.cpp. setMicGain's doc comment -- on the function the slider actually calls -- still said "a slider at the bottom of its travel means off", the same claim Hl2TxLevelPolicy.h was corrected for, plus "the same on every path" and "the one path-dependent thing left", both falsified by this branch. Nits, all taken: - TxAudioSource moves to src/core/backends/TxAudioSource.h. AudioEngine.h did not include IRadioBackend.h before this branch and is itself included by 75 files; Hl2TxDsp.h picked it up too. 1378 lines of backend seam for a three-value enum. - tx_audio_source_wiring_test's three "declares X" checks matched the whole file, which the doc comment satisfies in prose -- they passed against an enum gutted to one enumerator. Scoped to the enum body. - Hl2TxDsp::processAudioBlock drops carried m_inBuffer residue when the source changes mid-transmission. The upstream fences are real (startWsprPump's setDaxTxMode(true) for the mic path, feedDaxTxAudio's m_wsprBeacon->isActive() for the client path) but they are an argument about call sites in another class; this makes the property structural. Without it the new test case measures the residue arriving at +20.0 dBFS mic peak. - healthSnapshot() gains a txAudioSource row; micGainAppliedLinear says it is what the modulator holds, not always what it applies. - qRegisterMetaType uses the qualified name, as AetherClockEngine.cpp and KiwiSdrManager.cpp do. - tx_audio_source_wiring_test drops a target_include_directories it never used; hl2_txdsp_test asserts the pre-ALC mic peak per call instead of threading one scratch float through five and discarding it. Not taken: TX:ALCGAIN's meter face is still declared -20..+40 naming the deleted Hl2TxDsp::Config::alcMaxGainDb. Real, and aethersdr#5646's to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e (stacked on #5646) (#5647) > ### ✅ Unstacked — #5646 has merged, this is now a plain PR against `main` > **Rebased onto `main` on 2026-09-16**, after #5646 merged as `5607b565f`. The > stacking warnings this body used to carry are retired: there are no borrowed > commit objects left, the diff below is this branch's own content and nothing > else, and the merge-order caveat about carrying #5646's unreviewed work no > longer applies. > > Its five commits: `feat(tx): keep engine-generated audio at its level`, > `test(tx): pin which source tags transmit audio`, `fix(tx): the sibling call > site the weekly sanitizer lane would have caught`, `docs(tx): correct four > places that still describe the bool`, and `fix(tx): AX.25 keeps the mic slider, > and the #4796 guard works again`. > > **#5651 still lands after this one**, or beacons sit at −20 dBFS. > > **The rebase changed this branch's substance, not just its base.** #5646 > removed the unkey "raise mic gain" diagnostic (`1d9d063d`) because its two-state > seam could not aim it, and delegated the restoration here in as many words: > *"#5647 reintroduces it gated on `!m_txAudioEngineGenerated`"*. It does — see > **The diagnostic #5646 handed over** below. ## What this changes **It changes the level of unattended transmissions on the Hermes-Lite 2.** The WSPR beacon is no longer re-levelled by the ALC on the way in, and **is no longer moved by the Mic Level slider at all**. > **Scope narrowed after review.** An earlier revision of this branch tagged the > AX.25 modem `EngineGenerated` too, which bypassed the slider for it as well. > That was wrong and is fixed: AX.25's AFSK amplitude is a compile-time constant > (`kTxAfskAmplitude = 0.35`, −9.12 dBFS) and the packet dialog has no level > control, so the slider is the **only** thing in the product that can move a > packet frame — bypassing it pinned HF packet 7.71 dB under `alcTargetPeak` > with nothing able to raise it. `sendModemTxAudio` tags `Microphone` now. RADE > never reached an HL2 at any point: `activateRADE()` refuses a radio that cannot > provide DAX audio, so it is Flex-only, and a Flex modulates on its own side. > **`EngineGenerated` has exactly one producer: `AudioEngine::startWsprPump()`.** A microphone control has no business moving an unattended beacon. That defect **predates** #5646: `Hl2TxLevelPolicy`'s own comment already records that the multiplier applied to *"digital-mode and WSPR-beacon audio arriving through `submitTxAudio`, not only voice"*. It was merely invisible while 40 dB of makeup normalised every source onto the same target. ### The root cause is a bool that could not say enough `clientLeveled` answered *"did an external client set this level?"* — true for TCI/DAX, false for **everything else** — which put the operator's microphone and the engine's own generators into one bucket. `AudioEngine`'s own comment claimed engine audio *"keeps the ALC so its on-air level does not change"*; that was never a property of this code. It depended entirely on the makeup. So the bool becomes an enum, `TxAudioSource { Microphone, ClientLeveled, EngineGenerated }`, and that also gives #5646's now-vestigial `processAudioBlock` parameter a real job rather than deleting it as that PR anticipated. `TxAudioSource` is a **registered metatype**: it crosses `AudioEngine`'s thread on `txFinalMonitorPcmReady`, and a queued connection cannot marshal a type Qt was never told about. That failure mode is a runtime warning and a dropped signal, not a compile error — no transmit audio, and nothing to catch it. **The ALC itself is unchanged for all three sources**: reduction-only, unity ceiling, so engine audio is still protected from splatter. It is simply not *re-levelled* on the way in. The unkey *"raise mic gain"* diagnostic is gated off for engine-generated transmissions, because the slider is not in that path and the advice would name a control that cannot help. ## What is measured, and what this PR does and does not repair Measured with the application's **own** WSPR beacon — its real `PskReporterMapDialog` beacon button, driven through the automation bridge — against `hpsdrsim` (`wspr-real-beacon-source-ab`, six legs, all usable): | build | beacon `TX:ALC` peak | |---|---| | today's `main` | **−1.412 dBFS** | | #5646 alone | **−20.002 dBFS** | | #5646 + **this PR**, WSPR level stored at −20 dBFS | **−20.002 dBFS** | | + the WSPR default branch (`hl2/wspr-host-modulated-level`) | **−3.0 dBFS** | **The mic-slider bypass is what this PR repairs, and it is measured:** the same +40 dB slider move (50 → 100) shifts the beacon by **18.59 dB** on the #5646-only build and by **0.0 dB** on this one. Both sides were run — a single-sided result would be consistent with the slider having been inert on both. **The 18.59 dB level shortfall is NOT repaired here, and this PR should not be credited with it.** This branch is the `TxAudioSource` work and nothing else; it sets no defaults. `wspr-unattended-ab` measures that separation head-on: driven with a −20 dBFS engine-generated signal, its `repair_db` is **0.0** and its `residual_vs_stock_db` is **−18.577**. The level comes back only when the WSPR Level spinbox default moves from −20 to −3 dBFS, which is `hl2/wspr-host-modulated-level` and **must land after this**, or beacons sit at −20 dBFS. An earlier version of this commit carried that default change. It is an unrelated change to an unattended transmit level and had no business riding inside an enum refactor, so it was split out and carries its own measurement and its own sequencing note. ### Not fixed, and named so **AX.25 keeps the mic slider; it is not bypassed and needs no default.** An earlier revision of this body claimed *"AX.25 computes a level per frame and RADE carries its own `PcMicGain`, so neither has a single default to set in any case"*. Neither half was true of the code. AX.25's amplitude is the constant `kTxAfskAmplitude = 0.35` in `AetherAx25LibmodemShim.cpp`, and on an HL2 the slider never reaches `setPcMicGain` at all — `MainWindow_Session.cpp` returns early on `hostModulatesTxAudio()`. Bypassing the slider for AX.25 therefore left it with **no** level control. It is tagged `Microphone` and behaves exactly as it did before this branch. **The AX.25 level itself is still −9.12 dBFS and still has no operator control.** That is pre-existing, it is unchanged by this PR, and it is not this PR's to fix. Giving the shim its own persisted level (the shape #5651 uses for WSPR) is the right follow-up. **Nothing on the AX.25 path was exercised on the bench** — only WSPR was. The tag now has a behavioural assertion at the AudioEngine seam (`icom_identity_test`), which is what the source-text test used to stand in for. ## The diagnostic #5646 handed over **New in this branch as of the 2026-09-16 rebase.** #5646 removed the unkey "raise mic gain" advice in `1d9d063d`, and was explicit about why and about who should bring it back: > The advice must not fire for audio the operator's microphone did not produce: > WSPR, AX.25 and RADE reach `submitTxAudio()` with `clientLeveled` false, > exactly like the microphone… The information needed to aim it does not exist > here. This branch's seam is `submitTxAudio(..., bool clientLeveled)` — two > states, and engine audio is not one of them… **#5647 reintroduces it gated on > `!m_txAudioEngineGenerated`.** That is exactly what this branch now does. The advice is restored in `setKeying()`, gated on `!m_txAudioClientLeveled && !m_txAudioEngineGenerated`. It is safe to restore *only* because the seam carries `TxAudioSource`. **The gate is narrower than #5646 anticipated, and deliberately so.** #5646 assumed AX.25 would be gated off with WSPR. It is not: AX.25 is tagged `Microphone`, so a quiet packet frame **does** draw the advice. That is correct — the mic slider is the only control in the product that can move an AX.25 frame (`kTxAfskAmplitude` is a constant and the packet dialog has no level control), so "raise mic gain" is precisely the right instrument there. Only the WSPR beacon has no slider in its path, and only the WSPR beacon is gated off. ### The other three conflicts, and how each was resolved | file | conflict | resolution | |---|---|---| | `Hl2Backend.cpp` `setMicGain` | #5646 wrote *"NOTHING IS PATH-DEPENDENT HERE ANY MORE"* | Took this branch's text. That claim is false once `EngineGenerated` bypasses the multiplier — which is this PR's substance | | `Hl2TxLevelPolicy.h` | #5646 added a key-on-seed paragraph; this branch narrowed the SCOPE paragraph | **Both kept.** #5646's seed paragraph explains why the widened slider stays inside the clamp and is still true; it now sits inside this branch's narrowed scope | | `docs/radio-certification.md` `TX:ALCGAIN` | both rewrote the row | Took **#5646's** measured criterion (`0.00 dB ±0.25 below the target; strictly decreasing above it`) — it is tighter than this branch's `±1 dB` — and appended this branch's `EngineGenerated` sentence | **FOUR `processAudioBlock(..., bool)` call sites were added in `hl2_txdsp_test.cpp` while this branch sat** — three by #5646, one more by another change already on `main`. All are converted to the enum here. That is the same sibling-call-site class as `6e526b4e` (originally `f99e386a`), and it has now recurred three times: once caught by review, twice by the build during a rebase. Widening a seam type keeps colliding with call sites written in parallel, which is an argument for landing this branch rather than letting it age further. ## Who is affected, and who is not **Hermes-Lite 2 only.** The enum is *consumed* inside `Hl2TxDsp`. `IcomCivBackend::submitTxAudio` takes the new parameter and explicitly `Q_UNUSED`s it — *"this backend ships PCM to a radio that runs its own transmit processing, so there is no host ALC here to bypass"* — and the Flex backend has no `submitTxAudio` override at all. Receive is untouched everywhere. TCI/DAX clients are unaffected: `ClientLeveled` behaves exactly as `clientLeveled == true` did. **Mic slider 0 no longer silences an unattended transmission on an HL2.** This is user-visible, it is new in this PR, and nobody would predict it from the title, so it is here rather than in a footnote. `EngineGenerated` never reaches `micSliderToLinear`, so **a WSPR beacon** goes out at the level its generator chose whatever the Mic Level control reads — **including 0**. Before this PR the multiplier was in every path and 0 was a plain `0.0x` multiply on all of them. Voice, TCI/DAX and AX.25 all still mute at 0. That is the behaviour we want, and it is the same argument the rest of the PR makes: a microphone mute has no business muting a beacon. But `Hl2TxLevelPolicy.h` told the operator the opposite — *"At 0 nothing transmits, as a plain 0.0x multiply on every path"*, and that this was *"worth knowing before parking the control at 0 between voice sessions"*. That is a **safety claim about transmission that this PR falsifies**, and leaving it in shipped source was the worst of the findings below. **It is corrected in this branch**, in `Hl2TxLevelPolicy.h` and — after round 3 of review found the twin copy still standing — in `Hl2Backend.cpp`'s `setMicGain` doc comment, which is the function the slider actually calls. `micSliderToLinear` itself is unchanged and still returns `0.0`: voice, TCI/DAX and AX.25 still mute at 0. Stopping an unattended transmission is the generator's own control, not this slider. ## Tests **New `hl2_txdsp_test` case.** Engine-generated output is unmoved by a **1000× span of mic gain** (spread **0.00 dB**) and sits **+0.000 dB** against a *measured* control — the mic path at unity, which applies no gain either. The control is measured rather than asserted against a constant because the modulator has its own scale factor (amplitude 0.5 leaves as |IQ| 0.52), and dividing by a guessed number would have tested the guess. The mic path still moves with its slider, −6.02 dB for a 2:1 cut. **Two of the three tags are asserted BEHAVIOURALLY, at the AudioEngine seam.** `icom_identity_test` stands up a real `AudioEngine` with `hostModulation()` true and reads the tag off a live queued `txFinalMonitorPcmReady`: | entry point | asserted tag | mutation result | |---|---|---| | `feedDaxTxAudio` | `ClientLeveled` | tag it `EngineGenerated` → **fails** | | `sendModemTxAudio` | `Microphone` | tag it `EngineGenerated` → **fails** | AGENTS.md §*Test-layer boundary* asks for exactly this (*"Prefer behavioral seams over source-text assertions"*), and an earlier revision of this branch reached for source text instead on a premise that was simply false — that `AudioEngine.cpp` is compiled into no test target. It is in `CORE_SOURCES` and therefore in `aethercore`, which `icom_identity_test` already links. **`tx_audio_source_wiring_test` now covers only what a running test cannot reach**: `startWsprPump()`'s call site, and the metatype. Driving that one behaviourally needs a prepared beacon and a timer tick for a frame that keys for 111.6 s, against a bench loopback approval that permits 35 s; raising a rail to fit a convenience is what that ceiling's own comment forbids. **It does not prove the code runs**, and it does not retire the end-to-end beacon leg, which stays open. It also pins that nobody derives the tag from `markExternalSource` again — that flag means *"a TCI/DAX client is feeding"*, it is not *"not a beacon"*, and reading it as one is what swept AX.25 into the beacon's bucket. Its enum assertions are scoped to the **enum body**. Matching a name anywhere in the file is satisfied by the doc comment that spells all three names in prose, and the first version of those three checks duly passed against an enum gutted down to one enumerator. A mis-tag is otherwise **silent**: a beacon goes out 18.58 dB down again, unattended, with every existing test still green. **Test runs, with the count each one was actually taken on — the 417/417 figure this section used to quote predates two rebases and is no longer this head's.** Before the 2026-09-13 rebase onto `87b80c65`: 417 tests, 417 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`). After that rebase, which picked up #5505 and #5506: **423 of 424**, the one failure `vkamp_connection_test`, which fails the same way on an untouched tree (2 of 3 isolated runs) and is one of the four legacy fake-peer socket tests AGENTS.md already tracks for extraction in #5254. `tx_audio_source_wiring_test` is the target new here. **Re-run after the review fixes: still 417/417, 0 failed, same 5 skipped** — the count is unchanged because the loopback target is not in the default graph, which is the whole point of the blocker below. **Separately**, configured with `-DAETHER_ENABLE_HL2_TX_LOOPBACK_TEST=ON` and built `hl2_tx_loopback_test`: **clean, exit 0.** That build is the evidence for the fix; the 417 is not, and never could have been. ## A judgement call, flagged rather than decided **This PR widens a seam type.** `IRadioBackend::submitTxAudio`'s third parameter changes from `bool clientLeveled` to `TxAudioSource source`, and the enum is declared in its own header, `src/core/backends/TxAudioSource.h`, which `IRadioBackend.h` includes. Our own lab-side checker (see **Localization check** below — it is *not* in this repository) deliberately does not check *"that a missing seam verb was split into its own capability-shaped PR"*. **Whether you would rather have the enum as its own small seam PR ahead of the HL2 behaviour is your call** — say so and it will be split, rather than waiting to be asked. The round-3 review made that cheaper either way: the type is a self-contained four-line header now, so splitting it out is a file move rather than a surgery. **Namespace, and then its own header.** `TxAudioSource` was briefly declared at global scope while everything else in `IRadioBackend.h` lives in `namespace AetherSDR`. It moved inside that namespace in round 1, with `Q_DECLARE_METATYPE(AetherSDR::TxAudioSource)` to match `Q_DECLARE_METATYPE(AetherSDR::IRadioBackend::LinkStats)` beside it, at a cost of two lines in two test files — every production translation unit that names the type is already inside `namespace AetherSDR` or a nested one. Round 3 moved it again, out of `IRadioBackend.h` entirely. The reason is the one that motivated checking the cost in the first place: `AudioEngine.h` and `Hl2TxDsp.h` both name the type, `AudioEngine.h` is included by **75** files, and neither had any other reason to parse 1378 lines of backend seam — nor did they before this branch, which introduced the dependency. `src/core/backends/` already keeps one-type headers for exactly this (`MeterDef.h`, `SliceDelta.h`, `TransmitDelta.h`), so `TxAudioSource.h` joins them and carries the whole contract for the three states. ## Localization check **`check_localization.py` is ours and is NOT in this repository — a reviewer cannot re-run it.** Earlier revisions of this body cited it as `tools/check_localization.py` and quoted its docstring as though it were a repo gate; `tools/` here has `check_engine_boundary.py`, `check_test_registration.py`, `check_ci_test_gate.py` and friends, and no localization checker. Correcting that, plainly: it is a **lab-side script of ours** that makes executable two things the maintainer already wrote in `docs/HERMES.md` §*"For coding agents — keep bring-up inside the family backend"* — the **pre-PR grep, verbatim** (and per that document a hit is *not* automatically a violation: it has to be **explained in the PR body**, which is what this section is), and the one named prohibition that is mechanically detectable, a **family string test above the seam** (`family == "hl2"` / `usesFlexCommandPlane()` outside `src/core/backends/`). **The rule is the maintainer's; only the script is ours**, and nothing in this section rests on the script rather than on the diff. It reports **4 hits, 0 violations** on this branch (exit 2 — advisory). One of the four, `src/gui/MainWindow_Session.cpp`, is inherited from #5646 and is comment-only; it is explained there. The other three are **type propagation only**: **`src/gui/MainWindow.cpp`, `src/models/RadioModel.cpp`, `src/models/RadioModel.h`** — they pass the widened seam parameter through and nothing else: `MainWindow`'s two `txFinalMonitorPcmReady` lambdas, and `RadioModel::submitTxAudio`'s forward to `m_backend`. **No family test, no family branch, no new above-seam logic.** The enum is consumed inside `Hl2TxDsp`. ## What is measured on what Everything quoted above was measured against **`hpsdrsim`**, serial `AA:BB:CC:DD:88:FF`, under a `loopback` approval. **Nothing in this series has ever been radiated** — no on-air contact, no WSPRnet spot, no receiving station has heard either build. The beacon itself is real and took the operator's own path, so there is no stand-in for it; but the run's own `not_measured` list names what it is silent on, and it is worth repeating here: - the GUI wiring between the Mic Level slider **widget** and `TransmitModel` — the slider was driven through `AETHER_HL2_MIC_LEVEL` into `Hl2Backend::setMicGain`, the operator's own entry point, not through the widget; - the frame's timing, DT, symbol pacing or packetisation; - **anything on the air** — the peer is `hpsdrsim`; - what the beacon does on a radio that modulates on its own side; - the first and last ~30 s of each 111.6 s frame (15 s of it is captured); - whether −3 dBFS is the right drive for any particular station. ## Review response (`aethersdr-agent`, review 5188163057) **The blocker was real and is fixed.** `tests/hl2_tx_loopback_test.cpp` still passed `/*clientLeveled=*/false` to `Hl2Backend::submitTxAudio`, which a scoped enum will not take. Now `TxAudioSource::Microphone`. That target sits behind `AETHER_ENABLE_HL2_TX_LOOPBACK_TEST`, `OFF` by default, which is exactly why the 417/417 run and the green checks did not see it — and `.github/workflows/sanitizers.yml` turns it `ON` weekly for the reason its own comment gives: *"Building it weekly keeps the source honest against API drift now that it is out of the default graph."* **That lane earned its keep here.** A build that only runs weekly is the one nobody checks. **Proved rather than asserted**, because the whole point of the finding is that this target is not in the default graph: configured with `-DAETHER_ENABLE_HL2_TX_LOOPBACK_TEST=ON` and built the target — **clean, exit 0.** Then reverted the single token and rebuilt, to confirm the fix is load-bearing and not a tidy-up: ``` tests/hl2_tx_loopback_test.cpp:418:65: error: cannot initialize a parameter of type 'TxAudioSource' with an rvalue of type 'bool' ``` — the diagnostic class the review predicted, from the same line. Restored, rebuilt clean. **The four retired calls are updated too.** `tests/icom_backend_test.cpp` had the same stale call four times. That target is inside the `#[==[ … ]==]` bracket in `tests/tests.cmake`, so it **is retired and does not compile, and this change is not verified by any build** — it is done anyway so that reviving it is not spent on this. Leaving known-broken calls for whoever comes back to a retired target is the same trap this PR hit in the loopback test. ### Nits taken - **`Hl2TxLevelPolicy.h`** — the false safety claim. See **Who is affected** above; this was the finding that mattered most, because it was wrong *in shipped source, about transmitting*. - **`AudioEngine.h`** — the `txFinalMonitorPcmReady` comment still described the non-`ClientLeveled` bucket as *"the mic chain or the engine's own tone generators"*, the exact conflation this PR exists to split. Rewritten around the three states rather than patched on top of the old bool prose. - **`IRadioBackend.h`** — enum moved into `namespace AetherSDR`. Cost measured first; see **A judgement call** above. - **`tests/tx_audio_source_wiring_test.cpp`** — the header's justification was stronger than the facts. **The review is right and the correction is in the file.** `AudioEngine.cpp` is not in `tests/tests.cmake` by filename, but it is in `CORE_SOURCES` and therefore in `aethercore`, which `icom_identity_test` links — and this PR's own commit asserts `source == TxAudioSource::ClientLeveled` on a live `AudioEngine` there. The 111.6 s frame argument holds for the **end-to-end beacon** leg and not for the modem branch, which `sendModemTxAudio` reaches in one public call. The test is unchanged and stays as a reversal tripwire; only its stated reason for existing is corrected, which is the honest repair. - **The `check_localization.py` citation** — see **Localization check** above. It is ours, lab-side, and not in this tree. It was cited here in a way that read as a repo gate a reviewer could re-run, which is misleading; corrected. ### One found while fixing those `RadioModel.h`'s `submitTxAudio` comment still described the parameter as `clientLeveled`, *"true for external TCI/DAX client audio"* — a bool that no longer exists, in the same class as the `AudioEngine.h` nit. Corrected to name the three states and to say that this seam only forwards the tag. ### Not taken Nothing. Every finding held when checked against the source. ## Review response — round 2 (`ten9876`, review 5205796898) That pass built this head, ran every touched target, and broke the code on purpose four times. **No blockers.** Six nits, all taken; `6fbc9703` carries the four that are code or docs, and this body carries the other two. - **`Hl2TxDsp.h`** — the declaration of the function this PR exists to change still opened with `clientLeveled` and *"THE FLAG IS NOW INERT HERE … nothing left for the flag to select"*, eighteen lines above its own *"THE PARAMETER HAS A JOB AGAIN"*. The stale half is #5646's, written when the flag really was inert; the replacement started one line too far down. The #4796 history that still explains why the type is a source and not a bool is kept. - **`Hl2Backend.cpp`** — the comment named `tciAudioFresh()` as what keeps two sources out of one transmission. It is not, for engine audio: `feedDaxTxAudioInternal` arms `m_tciAudioTimer` under `markExternalSource` alone, and both engine feeds pass it false. `startWsprPump()` says as much in its own words. The real guards are `setDaxTxMode(true)` in `startWsprPump()` and in `Ax25HfPacketDecodeDialog`, and the `radeMode` early return. Not a live defect — the reviewer tried all three and could not interleave them — but the residue hazard cost nothing while both buckets shared a ceiling and is now worth the slider's full range, so the comment names the real guards. - **`docs/radio-certification.md`** — the `TX:ALCGAIN` row set its criterion *"between `alcHoldBelowDbfs` and the makeup ceiling"* and *"capped at unity for `clientLeveled` audio"*. #5646 removed the first two, this branch removes the last, so the row named nothing that exists. - **`IcomCivBackend.cpp`** — still called it "the flag". - **This body** — it told reviewers to *"review only `a9415acc` and `3d7e5187`"*. Neither object exists; both were rebased away on 2026-09-13, along with the `13b634b0` quoted in the round-1 reply. It also said five commits, and quoted a 417/417 run from before that rebase. Corrected above, and it matters more than its size: this repository squash-merges with `PR_BODY` as the commit message, so this text is what lands in `main`'s history. - **The `f99e386a` headline** naming one token while the commit also carried the namespace move and three comment rewrites: left as is, deliberately. Under squash-merge the per-commit headlines do not reach `main` at all — the PR title does — so rewriting published history to fix a message that gets discarded would cost more than it buys. **Re-run on `6fbc9703`** (RelWithDebInfo, Arch, Qt 6.11.2): `hl2_txdsp_test`, `tx_audio_source_wiring_test`, `hl2_tx_level_policy_test`, `hl2_dsp_readback_test`, `hl2_tx_gate_test`, `icom_identity_test` and `hl2_tci_signaling_test` all pass. `hl2_tx_loopback_test` builds with `-DAETHER_ENABLE_HL2_TX_LOOPBACK_TEST=ON` and skips at exit 77 with no simulator present. `check_engine_boundary.py` 0 would block, `check_test_registration.py` OK, `check_ci_test_gate.py` ok. The change is comments and one documentation row; there is no executable difference from `f99e386a`. ## Review response — round 3 (`ten9876`, review 5218387337) That pass built this head, ran every touched socket-free target, drove the app offscreen against the demo simulator, and broke the code on purpose four times. **Three blockers, two of them in code this branch does not touch.** All fixed. **Blocker 1 — the #4796 behavioural guard had gone inert.** `tests/hl2_tci_signaling_test.cpp` asserted the property with `monitor.first().at(1).toBool()`. That was exact while the argument was a `bool`; when it became `TxAudioSource` the call kept compiling and stopped meaning anything, because `toBool()` goes enum → int → bool and reads **both** `ClientLeveled`(1) and `EngineGenerated`(2) as true. The reviewer applied the exact regression this branch's own wiring test exists to catch — tagging `markExternalSource == true` as `EngineGenerated` — rebuilt, and got `hl2_tci_signaling_test: all checks passed`, exit 0. This branch updated `icom_identity_test` to compare the enum and missed the sibling, and because the file is not in the diff nothing flagged it. It compares the enum now, and the same mutation fails it. **Blocker 2 — AX.25 had lost every upward drive control.** See *Scope narrowed after review* at the top and *Not fixed, and named so*. The justification this body gave for leaving it (*"AX.25 computes a level per frame"*) was not what the code does, and is corrected above. **Blocker 3 — the retired safety claim was still in `Hl2Backend.cpp`.** `6fbc9703` was titled *"correct four places that still describe the bool"*, and this body calls the identical sentence in `Hl2TxLevelPolicy.h` the worst of the round-1 findings. It survived 130 lines away on `setMicGain()` itself — the function the slider calls — along with *"the same on every path"* and *"the one path-dependent thing left"*, both falsified by this branch. Rewritten. ### Nits taken — all eight - **`TxAudioSource` has its own header.** `AudioEngine.h` did not include `IRadioBackend.h` before this branch and is itself included by 75 files; `Hl2TxDsp.h` picked it up too. 1378 lines of backend seam for a three-value enum. `src/core/backends/TxAudioSource.h` now carries the type, its metatype and the whole contract. This is also the cheapest possible answer to *A judgement call* below: the enum gets its own header without getting its own PR. - **The wiring test's three `declares X` checks could not fail.** Scoped to the enum body; verified by gutting the enum, which now fails all three. - **The behavioural seam is used.** See *Tests*. - **RADE is gone from the description of what this changes.** It cannot reach an HL2: `MainWindow::activateRADE()` refuses a radio that cannot provide DAX audio (*"only a FlexRadio"*, and `:307` records that not guarding it was a segfault on a Hermes-Lite 2). Corrected in `TxAudioSource.h`, `Hl2TxDsp.h`, `Hl2TxLevelPolicy.h`, `AudioEngine.h` and `docs/radio-certification.md`. - **The exclusion argument named only the mic-facing half.** `setDaxTxMode(true)` and the `radeMode` early return both act on `onTxAudioReady`. The client path is fenced by something else entirely — `feedDaxTxAudio` returns early while `m_wsprBeacon->isActive()`. Both are named now, **and it is no longer only an argument**: `Hl2TxDsp::processAudioBlock` drops carried `m_inBuffer` residue when the source changes mid-transmission, so a future feed that forgets the fences gets a warning and a dropped block instead of a silent 40 dB level flap. With the guard removed, the new test case measures the residue arriving at **+20.0 dBFS mic peak** — a −20 dBFS beacon multiplied by the slider's 100×. - **The tag is surfaced.** `healthSnapshot()` gains a `txAudioSource` row beside `txMicPeakDbfs`, and `micGainAppliedLinear` now says it is what the modulator *holds*, not always what it *applies*. - **`qRegisterMetaType` uses the qualified name**, as `AetherClockEngine.cpp:293` and `KiwiSdrManager.cpp:376` do. - **Two small ones**: the dead `target_include_directories` on `tx_audio_source_wiring_test` (it includes only Qt headers), and `hl2_txdsp_test`'s `mp`, which was threaded through five calls and discarded — it is a mic peak per call now, and asserted: on the `EngineGenerated` path it reports the pre-slider level, which is the one number separating *"the slider was bypassed"* from *"the slider happened to sit at unity"*. ### Not taken **`TX:ALCGAIN`'s meter face.** `Hl2Backend.cpp` still declares it `-20.0, 40.0` with a comment naming `Hl2TxDsp::Config::alcMaxGainDb`, a field #5646 deleted; under a unity ceiling the top 40 dB is unreachable. Real, and **#5646's to fix** — left there rather than widening this diff into the branch below it. ### Verification of this round RelWithDebInfo, Arch, Qt 6.11.2. `hl2_txdsp_test`, `tx_audio_source_wiring_test`, `icom_identity_test`, `hl2_tci_signaling_test`, `hl2_tx_level_policy_test`, `hl2_tx_gate_test`, `hl2_dsp_readback_test` all pass, and every new guard was broken on purpose to confirm it fires: | mutation | result | |---|---| | TCI tagged `EngineGenerated` | `hl2_tci_signaling_test` **fails** (passed green before blocker 1's fix) | | AX.25 tagged `EngineGenerated` | `icom_identity_test` **fails** | | residue guard removed | `hl2_txdsp_test` **fails**, residue at +20.0 dBFS | | enum gutted to one enumerator | `tx_audio_source_wiring_test` **fails** all three | `check_engine_boundary.py` 0 would block, `check_test_registration.py` OK, `check_ci_test_gate.py` ok. No `CHANGELOG.md` entry and no workflow edits. --- 🤖 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>
`37ecd25f` is the commit on aethersdr#5646's BRANCH. It never landed: against `main` it is `status=diverged`, ten ahead and twenty-nine behind, so `git show 37ecd25` fails for every reader who did not have that branch checked out. The squashed commit that actually reached main is `5607b565`, and its patch is the one that removes both halves this sentence describes -- the `reducing ? m_config.alcAttackSec` ternary and the `double alcAttackSec = 0.005` declaration. The whole point of this PR is that a comment should be checkable against the tree. A citation that cannot be resolved is the same defect one level up, so it is fixed here rather than left for later. Two sites, because the bad SHA propagated: Hl2TxDsp.cpp is this PR's own line, and Hl2TxLevelPolicy.h is where the citation was copied from -- pre-existing on main, and the source of the error rather than a separate one. Caught by aethersdr-agent on aethersdr#5737. Verified through the GitHub compare API rather than this clone, whose shallow history reports absence and presence equally badly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
What this changes
This changes the transmit level of every Hermes-Lite 2 installation at Mic Level 50 or above.
The stage
Hl2TxDspcalls an ALC is not one. It applied up to 40 dB of upwardmakeup gain (
Hl2TxDsp::Config::alcMaxGainDb) with an absolute hold threshold(
alcHoldBelowDbfs, −45 dBFS) that sits below a real shack's noise floor. Sobetween 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.
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.
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()inthird_party/wdsp/upstream/TXA.cbuilds the stage it names
alcwithrun=1andmax_gain=1.0— always on,structurally incapable of adding gain — and puts the gain that can be added in
a separate
leveler, builtrun=0withmax_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:
Hl2TxDspisconstructed only inside
Hl2Backend(new Hl2TxDspoccurs nowhere else in thetree), the Flex backend has no
submitTxAudiooverride at all, andIcomCivBackend::submitTxAudioships PCM to a radio that runs its own transmitprocessing.
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.
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
TransmitModelconstructsm_micLevelat 50 and that is where every operator who has never touched thecontrol 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_testpins 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:ALCnow reads the actual transmit peak and tracks its inputone-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'sresult.jsongivesspeech_output_dbfsof −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
submitTxAudiowithclientLeveledfalse exactly like a microphone. Distinguishing them needs thethree-state
TxAudioSource, which is #5647's substance. #5647 carries the advicewith 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 sitsright 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:ALCis 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 errorin one block. It was leaving 1.4% of the step above the clamp, and 1.4% of 40 dB
is not small.
alcAttackSecis deleted with the mechanism, likealcMaxGainDband
alcHoldBelowDbfsbefore 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:
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_testpins four stimuli, and they are four different openings: fullscale 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
dspBlockSizeandinputSampleRateHz, so it is a guarantee that expiressilently 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, andabsent 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_testasserts the stamp soan 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'sTX:ALCrow has said since76a52403: "sweepthe 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 exactly20*log10(0.85). After this PR lands, that row fails a correctly behavingradio, 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 correctedhere too. It swept "between
alcHoldBelowDbfsand the makeup ceiling" —neither of which exists after this change — so it would fail a correct radio for
exactly the reason the
TX:ALCrow would. With the ceiling at unity the gainthat 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:ALCGAINwas defined-20..+40 dBand its comment justified the top as
Hl2TxDsp::Config::alcMaxGainDb. Deletingthat 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..0now.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
submitTxAudioon the same path as amicrophone 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):TX:ALCpeakmain18.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:
hl2/engine-generated-tx-sourcehl2/wspr-host-modulated-level(not yet filed)#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-abmeasuresthis separation directly: for a −20 dBFS engine-generated signal, the second
branch's own
repair_dbis 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 thebench; 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 onereport, and only one of them is this one.
speech" — same defect class as HL2 TX: the "ALC" applies up to 40 dB of upward makeup with an absolute hold threshold, so room noise is transmitted level with speech #5463, and the mechanism this PR deletes. It
would be worth @darkclassical re-testing on this branch.
touch it and must not be credited with it. The host modulator was measured
separately on the EP2 wire and agrees with
Hl2TxDsp::designFilters, evaluatedindependently 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, MAC00:1C:C0:A2:13:DDreadfrom 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:ALCtracks its input one-for-one underthe 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 ranagainst
hpsdrsim, serialAA:BB:CC:DD:88:FF, under aloopbackapproval.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:
Hl2TxDspishost-side DSP and executes on
m_keyedregardless of peer, so the stage undertest 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
TransmitModeltoHl2Backend::setMicGain. Every bench leg droveAETHER_HL2_MIC_LEVELintosetMicGaindirectly. (The widget's half wassince settled through the automation bridge:
invoke "Microphone gain" setValue 100movestransmit.micLevelto 100, and 76 to 76.)against the real
Hl2TxDspand the realHl2Backendin a harness rather thanon 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 familybackend"), 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 insideMainWindow::wireRadioModel(), correcting a comment that said the ALC "needs themic 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
Forward power is still set by drive.
TX:ALCGAINmeter. Separate work, feat(meters): publish TX:ALCGAIN, the gain the HL2 ALC is applying (meter only — gauge in #5636) #5506 and feat(gui): show the ALC Gain the HL2 reports, gated on the meter existing #5636. It exists on neitherbuild measured here.
input level; it is not a substitute for either.
the same attack and release, on every path. It simply never lifts.
clientLeveledhas no remaining use inprocessAudioBlock; it is kept andmarked unused here rather than deleted, and feat(tx): keep engine-generated audio at the level its generator chose (stacked on #5646) #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 plusradio_state_memory_test, 35/35 pass, and so dometer_model_testandmeter_surfaces_test. Note that CI's green does not include any of them —.github/ci-test-gate.txtis frozen and carries none of the HL2 tests, so theyfirst run on
full-suite.ymlafter merge.Each guard on this branch was inverted to check it is a guard rather than a
restatement. Reverting the seed's
reducinggate fails the lead-in case andnothing else; reverting
setMicGain's re-arm fails the mid-over case and nothingelse; deleting the
micLevelCurvestamp fails the newhl2_state_restore_testassertion and nothing else; replacing
micLevelFromCurve1with the identityfails both
hl2_tx_level_policy_testandhl2_dsp_readback_test. The one changehere with no test guarding it is the
TX:ALCGAINface range, which is adisplay 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 stocklegs. 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.ai/code/session_014TtnKQu6QGrSeBirGeAsAs