fix(tci): use dB (−60..0) on the wire for the VOLUME command per spec#3502
Conversation
TCI Protocol v2.0 defines VOLUME as main volume in dB, range -60..0, where -60 dB is silence (real ExpertSDR3 sends e.g. VOLUME:-12;). AetherSDR sent and parsed it as the internal 0-100 percent amplitude: our emissions (init-burst seed, GET reply, SET echo, title-bar slider broadcast) read as out-of-range garbage to spec-conforming clients, and a spec client's SET of e.g. volume:-12 was clamped to 0 percent — i.e. muted the operator's audio instead of setting -12 dB. Convert at the wire, keep everything inboard percent: - Emit dB everywhere: cmdVolume GET, SET echo, the init-burst volume seed, and TciServer::broadcastMasterVolume now report volumeDbFromPercent(pct) (20*log10(pct/100), 0 percent -> -60). - Parse SET dual-scale: values <= 0 are spec dB (10^(dB/20) mapping, -60 -> mute, floor 1 percent above -60 so only the spec's explicit silence point mutes); values >= 1 cannot be dB (the spec range is non-positive) and remain accepted as the legacy AetherSDR percent scale, so existing percent senders keep working unchanged. - volume:0 now means 0 dB = full volume per spec (previously percent mute). Muting belongs to the TCI mute: command. - pendingMasterVolume()/masterVolumeRequested/applyMasterVolume keep their 0-100 contract; conversion helpers are static on TciProtocol and shared with TciServer. Round-trip (dB -> percent store -> dB) is exact from -24 dB to 0 dB, within +/-1 dB to -36 dB, and floors at 1 percent (-40 dB) below that - the resolution limit of the integer percent store, affecting only whisper-quiet settings. WSJT-X/JTDX ignore VOLUME entirely (case Cmd_Volume: break;) and never send it, so the digital-mode population is unaffected. Value-mirroring steppers (Ulanzi/Elgato/StreamController, aethersdr#3245) mirror whatever scale we seed and now step in dB space; legacy percent SETs still work via the dual-scale parse. Spec mismatch surfaced by Yuri UT4LW's init-burst capture (volume:33;) during the ready-ordering report (aethersdr#3498). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
There was a problem hiding this comment.
Thanks for the careful write-up @jensenpat — the spec citation, dual-scale rationale, and the explicit acknowledgement of the volume:0 semantics shift made this easy to follow. The diff is correctly scoped to the TCI subsystem (TciProtocol.{h,cpp}, TciServer.cpp) and leaves the inboard percent contract (pendingMasterVolume/broadcastMasterVolume callers in MainWindow) untouched.
A few observations:
Conversion math — sanity-checked against the published table and edges:
- 100→0, 50→−6, 25→−12, 10→−20, 1→−40, 0→−60 all round-trip stable.
volumePercentFromDb(-60.0)correctly takes the early-return path → 0 (mute) before theclamp(pct, 1, 100)floor kicks in. The ordering matters and it's right.volumeDbFromPercentis safe againstpct ≤ 0(early return) solog10never sees a non-positive input.
Dual-scale parser — args[args.size() == 1 ? 0 : 1].toDouble() keeps the legacy 2-arg volume:trx,N; shape working and gives ≥1 → percent / ≤0 → dB unambiguously within the spec range. Good.
One callout to double-check — volume:0 semantics flipped from "0% = mute" to "0 dB = full". The PR notes this and points users at the mute: command, which is the right answer per spec, but it does mean any local automation that sent volume:0 to mute will now turn the radio up to full. Worth a release-note callout when this lands.
Minor (non-blocking):
volumeDbFromPercentandvolumePercentFromDbare pure static functions and would be a clean ~10-line addition to whatever coversTciProtocol(or a new unit) — would pin the round-trip table you've documented in the PR body. Not blocking; the analysis you've done is enough for a hotfix.- The redundant
[0,100]clamp inbroadcastMasterVolumebefore callingvolumeDbFromPercent(which also clamps) is harmless, just noting it for awareness.
The Ulanzi D100H hardware re-verification you flagged is the right open item to hold for — the code path itself looks correct. LGTM as a spec-correctness fix.
🤖 aethersdr-agent · cost: $7.8461 · model: claude-opus-4-7
NF0T
left a comment
There was a problem hiding this comment.
Claimed; read full diff, PR body, conversion table, and bot review.
Evidence basis — Principle VIII satisfied. UT4LW's init-burst wire capture (volume:33; emitted by #3245's seed, visible in the #3498 report) is an unambiguous external artefact proving the unit mismatch. The inbound failure mode — spec-conforming client sends volume:-12;, AetherSDR clamps to 0 % and mutes the operator — is equally deterministic and reproducible. No assertion-without-evidence here.
Conversion math independently verified. Spot-checked the published table and both edges:
| internal % | wire dB |
|---|---|
| 100 | 0 |
| 50 | −6 |
| 25 | −12 |
| 10 | −20 |
| 0 | −60 |
volumePercentFromDb(-60.0) returns 0 via its early-return before the clamp — correct, that's the spec's explicit silence point. All other values clamp to [1, 100] keeping only −60 as the mute sentinel, which matches mute: being the spec's intended muting mechanism.
Dual-scale parse is clean. The spec's non-positive dB range makes the discriminator unambiguous: value ≥ 1 is an impossible dB but a valid legacy percent, so no heuristic guessing. Existing percent senders (stream controller gain steppers) continue to work unchanged via that path. Elegant design.
Scope discipline is correct. rx_volume / mon_volume have the same mismatch but their argument-shape divergence (documented in the PR) warrants a separate design decision. Excluding them here avoids scope creep while not sweeping the bug under the rug.
Hardware re-verification — @ten9876 @nigelfenton: This PR is being merged to bring VOLUME into compliance with the TCI wire spec (dB, −60..0). The Ulanzi D100H integration (#3239, #3227) was built against the old percent-on-the-wire behavior and will need a live check to confirm the dial's first-step tracking still works correctly against the dB seed, and potentially an update to the plugin if it assumes 0–100 percent values from the wire. Please flag a follow-up if the D100H behavior needs adjustment.
Release note callout: volume:0 semantics flip (was 0 % mute, now 0 dB = full volume per spec) should land in the next CHANGELOG / release notes so integrators relying on volume:0 as a mute signal know to migrate to mute:1;.
Approved. The fix is evidence-backed, internally consistent, backwards-compatible for the affected client population, and correctly scoped. Complements #3498 — together they bring the connect handshake into spec conformance for both ordering and units.
AetherSDR PR #3502 moved the TCI VOLUME command to dB (-60..0) on the wire. Two breaks for this plugin, which models volume as 0-100 percent: 1. Read: AE now echoes volume in dB (negative). The parser stored that dB value as if it were percent, corrupting the local mirror and breaking the dial's first-step tracking (exactly the concern raised in #3502). 2. Write: stepping AF gain to the bottom sent `volume:0`, which AE now reads as 0 dB = FULL volume (was 0% mute) - turning the dial down jumped to max. Fix (internal model stays percent; convert only at the wire): - Read: an echoed value <=0 is dB -> dbToPercent(); >=1 stays legacy percent. Handles both old (percent-echo) and new (dB-echo) AetherSDR. - Write: emit -60 dB for the silence floor instead of 0; 1-100 still go as legacy percent, which AE's compat shim accepts. Follow-up to the hardware re-verification flagged in aethersdr/AetherSDR#3502. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…aethersdr#3502) ## Problem TCI Protocol v2.0 defines `VOLUME` as the main (master AF) volume **in dB, range −60..0, where −60 dB is silence** — real ExpertSDR3 sends e.g. `VOLUME:-12;`. AetherSDR has been sending and parsing it as our internal **0–100 percent** amplitude instead. Two concrete breakages: 1. **Outbound:** every `volume:` we emit — the connect init-burst seed (aethersdr#3245), GET replies, SET echoes, and the title-bar-slider broadcast — carries a 0–100 percent value (`volume:33;`, visible in Yuri UT4LW's init-burst capture from the aethersdr#3498 report). To a spec-conforming client that's an out-of-range value on a −60..0 scale. 2. **Inbound (worse):** a spec-conforming client setting `volume:-12;` (a normal listening level) got clamped to 0 percent — i.e. **we muted the operator's audio** instead of setting −12 dB. This only affects the local monitor volume path (`applyMasterVolume` → PC audio RX volume or radio lineout gain). The TCI audio streams themselves (TciRxGain/TciTxGain) and TX levels (`drive`/`mic_level`) are unrelated and untouched. ## Fix Convert at the wire; everything inboard stays 0–100 percent (the `pendingMasterVolume()` → `masterVolumeRequested` → `applyMasterVolume` contract is unchanged). **Emit in dB** — `cmdVolume` GET, SET echo, the init-burst seed, and `TciServer::broadcastMasterVolume()` all now send `volumeDbFromPercent(pct)`: | internal % | wire dB | |---|---| | 100 | 0 | | 50 | −6 | | 25 | −12 | | 10 | −20 | | 0 | −60 | **Parse SET dual-scale** — the spec range is non-positive, so positive values are unambiguous: - `value ≤ 0` → spec dB, amplitude mapping `10^(dB/20)`; −60 → mute; values between −59 and −41 floor at 1 % so only the spec's explicit silence point (−60) mutes. - `value ≥ 1` → **legacy AetherSDR percent, still accepted** — existing percent senders keep working unchanged. - `volume:0` now means 0 dB = full volume per spec (previously: percent mute). Muting is the job of the TCI `mute:` command. The SET echo is round-tripped through the percent store so it always matches what a subsequent GET returns. ## Precision note (honest limits) The internal store is integer percent, so dB→%→dB round-trip is **exact from −24 dB up to 0 dB**, within ±1 dB down to −36 dB, and floors at 1 % (≈−40 dB) below that. This affects only whisper-quiet settings; the practical knob range is unaffected. ## Client compatibility - **WSJT-X / JTDX**: recognize `volume` and explicitly ignore it (`case Cmd_Volume: break;` in `Transceiver/TCITransceiver.cpp`), and never send it. Unaffected. - **Spec-conforming clients** (ExpertSDR-native panels, SDC ecosystem): now see correct dB values and their dB SETs take effect instead of muting. This is the population the fix targets. - **Value-mirroring gain steppers** (Ulanzi D100H / Elgato / StreamController, the aethersdr#3245 clients): these mirror whatever value we seed and step it (aethersdr#3245 verified one happily mirroring `volume:87`). With a dB seed they now step in dB space; if a given plugin clamps its mirror to 0..100, its percent SETs still work via the legacy parse path.⚠️ **Hardware re-verification of the Ulanzi D100H first-step behavior is the one open test** — flagged below. - **eesdr-tci-based parsers** (RF2K-S): `VOLUME` is a known command name with no client-side range validation on the float; no parse risk. ## Testing - Builds clean (RelWithDebInfo, Ninja, macOS). - Conversion table + round-trip stability verified numerically (above). - [ ] Live check: spec client SET `volume:-12;` → audible level drop, GET returns `-12` - [ ] Live check: Ulanzi D100H AF-gain step still tracks from the seeded value (aethersdr#3245 regression check) ## Related / follow-ups - `rx_volume` and `mon_volume` have the same percent-vs-dB mismatch per spec (`RX_VOLUME`/`MON_VOLUME`: dB −60..0). They're **not** bundled here because `rx_volume` also has an argument-shape divergence (spec is 3-arg `trx,channel,dB`, and the spec's 2-arg GET `trx,channel` collides with our documented legacy 2-arg SET `trx,value` from aethersdr#1764) that needs its own design decision. - Complements aethersdr#3498 (init-burst `ready;` ordering) — together they make the connect handshake read correctly to spec-conforming clients. 💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Codex <noreply@openai.com>
Problem
TCI Protocol v2.0 defines
VOLUMEas the main (master AF) volume in dB, range −60..0, where −60 dB is silence — real ExpertSDR3 sends e.g.VOLUME:-12;. AetherSDR has been sending and parsing it as our internal 0–100 percent amplitude instead. Two concrete breakages:volume:we emit — the connect init-burst seed (fix(tci): seed master AF volume in connect init burst #3245), GET replies, SET echoes, and the title-bar-slider broadcast — carries a 0–100 percent value (volume:33;, visible in Yuri UT4LW's init-burst capture from the fix(tci): send ready; after the full settings dump, not mid-burst #3498 report). To a spec-conforming client that's an out-of-range value on a −60..0 scale.volume:-12;(a normal listening level) got clamped to 0 percent — i.e. we muted the operator's audio instead of setting −12 dB.This only affects the local monitor volume path (
applyMasterVolume→ PC audio RX volume or radio lineout gain). The TCI audio streams themselves (TciRxGain/TciTxGain) and TX levels (drive/mic_level) are unrelated and untouched.Fix
Convert at the wire; everything inboard stays 0–100 percent (the
pendingMasterVolume()→masterVolumeRequested→applyMasterVolumecontract is unchanged).Emit in dB —
cmdVolumeGET, SET echo, the init-burst seed, andTciServer::broadcastMasterVolume()all now sendvolumeDbFromPercent(pct):Parse SET dual-scale — the spec range is non-positive, so positive values are unambiguous:
value ≤ 0→ spec dB, amplitude mapping10^(dB/20); −60 → mute; values between −59 and −41 floor at 1 % so only the spec's explicit silence point (−60) mutes.value ≥ 1→ legacy AetherSDR percent, still accepted — existing percent senders keep working unchanged.volume:0now means 0 dB = full volume per spec (previously: percent mute). Muting is the job of the TCImute:command.The SET echo is round-tripped through the percent store so it always matches what a subsequent GET returns.
Precision note (honest limits)
The internal store is integer percent, so dB→%→dB round-trip is exact from −24 dB up to 0 dB, within ±1 dB down to −36 dB, and floors at 1 % (≈−40 dB) below that. This affects only whisper-quiet settings; the practical knob range is unaffected.
Client compatibility
volumeand explicitly ignore it (case Cmd_Volume: break;inTransceiver/TCITransceiver.cpp), and never send it. Unaffected.volume:87). With a dB seed they now step in dB space; if a given plugin clamps its mirror to 0..100, its percent SETs still work via the legacy parse path.VOLUMEis a known command name with no client-side range validation on the float; no parse risk.Testing
volume:-12;→ audible level drop, GET returns-12Related / follow-ups
rx_volumeandmon_volumehave the same percent-vs-dB mismatch per spec (RX_VOLUME/MON_VOLUME: dB −60..0). They're not bundled here becauserx_volumealso has an argument-shape divergence (spec is 3-argtrx,channel,dB, and the spec's 2-arg GETtrx,channelcollides with our documented legacy 2-arg SETtrx,valuefrom TCI: DRIVE and VOLUME commands have no effect when sent by client #1764) that needs its own design decision.ready;ordering) — together they make the connect handshake read correctly to spec-conforming clients.💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat
🤖 Generated with Claude Code