Skip to content

fix(tci): use dB (−60..0) on the wire for the VOLUME command per spec#3502

Merged
NF0T merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/tci-volume-db-scale
Jun 13, 2026
Merged

fix(tci): use dB (−60..0) on the wire for the VOLUME command per spec#3502
NF0T merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/tci-volume-db-scale

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

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 (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.
  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()masterVolumeRequestedapplyMasterVolume contract is unchanged).

Emit in dBcmdVolume 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 ≥ 1legacy 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 fix(tci): seed master AF volume in connect init burst #3245 clients): these mirror whatever value we seed and step it (fix(tci): seed master AF volume in connect init burst #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 (fix(tci): seed master AF volume in connect init burst #3245 regression check)

Related / follow-ups

💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat

🤖 Generated with Claude Code

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>
@jensenpat jensenpat marked this pull request as ready for review June 10, 2026 03:28
@jensenpat jensenpat requested a review from a team as a code owner June 10, 2026 03:28

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the clamp(pct, 1, 100) floor kicks in. The ordering matters and it's right.
  • volumeDbFromPercent is safe against pct ≤ 0 (early return) so log10 never sees a non-positive input.

Dual-scale parserargs[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-checkvolume: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):

  • volumeDbFromPercent and volumePercentFromDb are pure static functions and would be a clean ~10-line addition to whatever covers TciProtocol (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 in broadcastMasterVolume before calling volumeDbFromPercent (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 NF0T self-assigned this Jun 13, 2026
@NF0T NF0T enabled auto-merge (squash) June 13, 2026 13:56

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@NF0T NF0T merged commit 6a98dd7 into aethersdr:main Jun 13, 2026
6 checks passed
nigelfenton added a commit to nigelfenton/aethersdr-ulanzi-plugin that referenced this pull request Jun 14, 2026
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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants