fix(tci): seed master AF volume in connect init burst#3245
Merged
Conversation
The init burst already seeds drive: (RF power) and mic_level:, but never emitted volume: (master AF gain). A TCI control client (Ulanzi / Elgato / StreamController gain stepper) therefore had no way to learn the radio's current AF level on connect, so its local mirror started at a hardcoded default. The first AF Gain ±step was computed against that default and snapped the slider to ~50/55 regardless of the real level; only after the first echo did subsequent steps track correctly. Emit volume:<MasterVolume>; (whole-radio, no trx prefix) alongside the existing drive/mic_level seeds, reading the same saved value cmdVolume's GET returns. Fixes the first-press jump for every TCI gain client. Verified: before, the init burst had no volume: line; after, it emits volume:87 (the radio's real level). Confirmed live on the Ulanzi D100H deck (first AF press now steps from the actual value) and via a server-side init-burst capture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nigelfenton
added a commit
to nigelfenton/AetherSDR
that referenced
this pull request
May 28, 2026
On TCI connect the plugin now sends GET requests `volume;`/`drive;`/ `mic_level;` so the AF/RF/mic local mirrors start from the radio's real values instead of the hard-coded defaults (50/100/50). AE's init burst does not carry `volume:`, so the first AF press previously jumped from 50; seeding on connect closes that for all three verbs. Verified live 2026-05-28: all three step from the real value with no jump. Pairs with the server-side init-burst fix in aethersdr#3245 (belt and suspenders). Reverse the "Mic Gain NON-FUNCTIONAL" tooltips and the matching code comment. `mic_level:` is an AE extension that is in fact honoured — verified live 2026-05-28 to drive TX mic gain. The earlier 2026-05-27 "silently ignored" finding was an artifact of a stale/contaminated build, not real AE behaviour. Bumps plugin version 0.1.4 -> 0.1.5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Looks good — thanks Nigel.
The fix is symmetric with cmdVolume()'s GET path at src/core/TciProtocol.cpp:870-871, which reads the exact same AppSettings::instance().value("MasterVolume", "100").toInt(). So a client's local mirror seeded from the init burst will match what it would receive from an explicit volume; GET — no drift between the two paths.
Conventions check:
- Uses
AppSettings, notQSettings✓ - Placement inside the
if (m_model)block alongside the other Phase 2 global TX seeds (drive,tune_drive,mic_level,trx) is consistent ✓ - Whole-radio (no
trxprefix) matches the rest of the codebase's treatment of master volume as global ✓ - The comment correctly explains the why (first-step jump against default-guess mirror) rather than restating the what ✓
No null risk (AppSettings is a singleton with a default), no resource concerns, scope is exactly what the title says. Nice tight fix.
🤖 Generated with Claude Code
🤖 aethersdr-agent · cost: $3.2557 · model: claude-opus-4-7
11 tasks
jensenpat
approved these changes
May 29, 2026
jensenpat
left a comment
Collaborator
There was a problem hiding this comment.
Thanks for TCI volume improvement, Nigel
jensenpat
added a commit
that referenced
this pull request
Jun 2, 2026
## Summary Adds **`plugins/ulanzi-aethersdr/`** as the third Stream-Deck-style sibling alongside `elgato-aethersdr` and `streamcontroller-aethersdr`. Drives AetherSDR over TCI WebSocket from Ulanzi-Studio-compatible macro keypads / dials. - **22 actions**, as both keypad buttons and rotary (Encoder) knobs: - **Encoder (dial):** VFO Tune, plus rotary **AF Gain / RF Power / Mic Gain / Filter Width / Squelch Level** (rotate to adjust, press+rotate = coarse). VFO dial press toggles MOX; level-knob press is a deliberate no-op so a knob can't key TX. - **TX:** MOX Toggle, TUNE / ATU, RIT Toggle. - **Modes:** Mode Cycle, plus direct USB / LSB / CW / DIGU. - **Bands:** Band Up, Band Down. - **Gain (keypad):** AF / RF / Mic ±5 per press. - TCI command vocabulary kept faithful to `elgato-aethersdr` so both behave identically against the same radio. - Iconography generated programmatically from a single config (`scripts/Generate-Icons.ps1`, PowerShell + `System.Drawing`, no npm/Node deps) → 196×196 PNGs matching Ulanzi Studio's marketplace icon convention. Currently Windows-only; a cross-platform port (ImageMagick / SkiaSharp) is future work. - 5 coordinated shack-companion launcher tiles (AetherSDR / TCI Monitor / ShackLog / IQ Capture / aether-pad) for pairing with Studio's built-in `System → Open` action. - Bundled D100H default profile (`profiles/aethersdr-d100h-default.ulanziDeckProfile`) — one-click Studio import. - **License: GPL-3.0-or-later**, matching the parent project. The vendored Ulanzi SDK in `libs/common-node/` and `libs/common-html/` keeps its own license. The change is fully self-contained under `plugins/ulanzi-aethersdr/` — no C++, CMake, or CI changes. ## Status - **D100H / KEHWIN Dial_Lite** (6 keys + 1 dial, BLE HOGP): smoke-tested end-to-end; MOX, AF, RF and **mic gain all confirmed live** against the radio. (I run this unit on CAD duty now, but it remains a fully supported radio surface for anyone who wants it.) - **D200 / Zkswe "ulanzi"** (USB): used for the gain-seeding and coexistence verification below. - **D200H** (14 keys): plugin code complete, profile layout in progress. - **D200X** (14 keys + 3 knobs): unit arriving shortly; profile follows once on the bench. ### Gain seeding (fixes first-press jump) On TCI connect the plugin issues GET `volume;` / `drive;` / `mic_level;` so the AF / RF / mic local mirrors start from the radio's real values. Without this the first ±5 press jumped from the hard-coded defaults (50 / 100 / 50). AE's init burst does not emit `volume:`, so this pairs with the server-side init-burst fix in **#3245** — the GET-on-connect makes the plugin robust regardless of which AE build it talks to. Verified live 2026-05-28: all three step smoothly with no jump. ### Mic gain `mic_level:` is an AE extension (not in the published TCI spec) but it **is honoured** — verified live 2026-05-28 to actually drive TX mic gain. ## Coexistence with the native UlanziDial backend AetherSDR `main` now ships a native UlanziDial HID backend (**#3238** Linux evdev, **#3239** Windows hidapi + macOS IOKit). This plugin and that backend do **not** collide — verified live 2026-05-28: - The native Windows matcher keeps only HID devices whose hidapi `product_string` contains the case-sensitive substring `"Ulanzi Dial"` (`src/core/UlanziDialWindowsManager.cpp`, `kProductMatch`). - `hid.enumerate()` on this machine reports: D100H → product `Dial_Lite` (manuf `KEHWIN`, VID `0xFFF1` / PID `0x0082`, BLE HOGP); D200 → product `ulanzi` (manuf `Zkswe`, VID `0x2207` / PID `0x0019`, USB). **Neither contains `"Ulanzi Dial"`**, so the native backend claims neither device — both stay free for this plugin via Ulanzi Studio. - Tested against a clean build of this branch (rebased on `main`, `-DHAVE_HIDAPI`, native backend compiled in) with both Ulanzi units plugged in: no double-claim, no conflict. - Trap for reviewers: Windows Device Manager may display the D100H's PnP *FriendlyName* as "Ulanzi Dial", but that is a **different field** from the hidapi `product_string` the matcher reads (which is `Dial_Lite`). ## Repository relationship Independent public-facing releases / Ulanzi Marketplace path lives at **[nigelfenton/aethersdr-ulanzi-plugin](https://github.com/nigelfenton/aethersdr-ulanzi-plugin)**. Both repos stay alive — the standalone is canonical for end-user downloads; this in-tree copy is bundled context for AetherSDR developers. The README inside the plugin documents the relationship explicitly and is the authoritative description of the plugin. ## Test plan - [x] Plugin source loads in Ulanzi Studio without errors (D100H, Windows) - [x] TCI WebSocket connects to AetherSDR on `ws://127.0.0.1:40001` - [x] MOX action flips radio state (`trx:0,<bool>;`) on D100H - [x] AF / RF / mic gain seed from the radio's real values on connect — no first-press jump (2026-05-28) - [x] `mic_level:` verified to actually drive TX mic gain on AE (2026-05-28) - [x] Coexists with the native UlanziDial backend — neither Ulanzi unit matches `kProductMatch` (2026-05-28) - [x] D100H default profile imports cleanly into a fresh Studio - [x] Icon generator (`scripts/Generate-Icons.ps1`) regenerates the PNGs idempotently - [ ] D200H full-keypad layout validated (in progress) - [ ] D200X layout validated (waiting on hardware) - [ ] macOS Studio compatibility re-confirmed against this in-repo path ## Out of scope / future work - Per-app sub-profiles (TCI Monitor / ShackLog / IQ Capture / aether-pad) using Studio's built-in `System.Hotkey` — will land here once each app's hotkey table is known. - Cross-platform icon generator (port `Generate-Icons.ps1` to ImageMagick / SkiaSharp so macOS/Linux contributors can regenerate without `pwsh`). - Marketplace submission (independent of this PR). - A properly-stateful slice-cycle action (the earlier local-index version was removed in v0.1.7; a future one should be driven by AE's slice-list event rather than a local counter). - Broadening the native backend's device match so it also recognises OEM-rebranded Ulanzi units (`Dial_Lite` / `ulanzi`) — raised separately on #3239. --- <sub>_PR description refreshed to match v0.1.7 (rotary knobs, slice-cycle removed, `Generate-Icons.ps1`, GPL-3.0-or-later). The in-plugin README remains the authoritative source of truth._</sub> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: jensenpat <patjensen@gmail.com>
2 tasks
NF0T
pushed a commit
that referenced
this pull request
Jun 13, 2026
…#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 (#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 #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 #3245 clients): these mirror whatever value we seed and step it (#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 (#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 #1764) that needs its own design decision. - Complements #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>
G6PWY-Chris
pushed a commit
to G6PWY-Chris/AetherSDR
that referenced
this pull request
Jun 22, 2026
## Summary The TCI connect init burst already seeds `drive:` (RF power) and `mic_level:`, but never emitted `volume:` (master AF gain). A TCI control client that steps AF gain (the Ulanzi / Elgato / StreamController gain steppers) therefore had no way to learn the radio's current AF level on connect — its local mirror started at a hardcoded default, so the **first** AF Gain ±step was computed against that default and snapped the slider to ~50/55 regardless of the real level. Only after the first echo did subsequent steps track correctly. ## Change One line in `generateInitBurst()`: emit `volume:<MasterVolume>;` (whole-radio, no trx prefix) alongside the existing `drive`/`mic_level` seeds, reading the same saved value `cmdVolume`'s GET returns (`AppSettings` `MasterVolume`). `+8 / -0` incl. comment. This fixes the first-press jump for **every** TCI gain client, not just the one that surfaced it. ## Verification - **Server-side:** before the fix the init burst had no `volume:` line; after, it emits `volume:87` (the radio's real level), confirmed via a WebSocket init-burst capture. - **Live on hardware:** confirmed on the **Ulanzi D100H** deck — the first AF Gain press now steps from the radio's actual value instead of jumping to the default. RF and MIC unaffected. ## Test plan - [x] Init burst now contains `volume:<current>;` (server-side capture) - [x] First AF-gain step relative to real value — live on Ulanzi D100H - [x] RF / MIC seeds unchanged - [ ] CI matrix (auto) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
G6PWY-Chris
pushed a commit
to G6PWY-Chris/AetherSDR
that referenced
this pull request
Jun 22, 2026
…r#3227) ## Summary Adds **`plugins/ulanzi-aethersdr/`** as the third Stream-Deck-style sibling alongside `elgato-aethersdr` and `streamcontroller-aethersdr`. Drives AetherSDR over TCI WebSocket from Ulanzi-Studio-compatible macro keypads / dials. - **22 actions**, as both keypad buttons and rotary (Encoder) knobs: - **Encoder (dial):** VFO Tune, plus rotary **AF Gain / RF Power / Mic Gain / Filter Width / Squelch Level** (rotate to adjust, press+rotate = coarse). VFO dial press toggles MOX; level-knob press is a deliberate no-op so a knob can't key TX. - **TX:** MOX Toggle, TUNE / ATU, RIT Toggle. - **Modes:** Mode Cycle, plus direct USB / LSB / CW / DIGU. - **Bands:** Band Up, Band Down. - **Gain (keypad):** AF / RF / Mic ±5 per press. - TCI command vocabulary kept faithful to `elgato-aethersdr` so both behave identically against the same radio. - Iconography generated programmatically from a single config (`scripts/Generate-Icons.ps1`, PowerShell + `System.Drawing`, no npm/Node deps) → 196×196 PNGs matching Ulanzi Studio's marketplace icon convention. Currently Windows-only; a cross-platform port (ImageMagick / SkiaSharp) is future work. - 5 coordinated shack-companion launcher tiles (AetherSDR / TCI Monitor / ShackLog / IQ Capture / aether-pad) for pairing with Studio's built-in `System → Open` action. - Bundled D100H default profile (`profiles/aethersdr-d100h-default.ulanziDeckProfile`) — one-click Studio import. - **License: GPL-3.0-or-later**, matching the parent project. The vendored Ulanzi SDK in `libs/common-node/` and `libs/common-html/` keeps its own license. The change is fully self-contained under `plugins/ulanzi-aethersdr/` — no C++, CMake, or CI changes. ## Status - **D100H / KEHWIN Dial_Lite** (6 keys + 1 dial, BLE HOGP): smoke-tested end-to-end; MOX, AF, RF and **mic gain all confirmed live** against the radio. (I run this unit on CAD duty now, but it remains a fully supported radio surface for anyone who wants it.) - **D200 / Zkswe "ulanzi"** (USB): used for the gain-seeding and coexistence verification below. - **D200H** (14 keys): plugin code complete, profile layout in progress. - **D200X** (14 keys + 3 knobs): unit arriving shortly; profile follows once on the bench. ### Gain seeding (fixes first-press jump) On TCI connect the plugin issues GET `volume;` / `drive;` / `mic_level;` so the AF / RF / mic local mirrors start from the radio's real values. Without this the first ±5 press jumped from the hard-coded defaults (50 / 100 / 50). AE's init burst does not emit `volume:`, so this pairs with the server-side init-burst fix in **aethersdr#3245** — the GET-on-connect makes the plugin robust regardless of which AE build it talks to. Verified live 2026-05-28: all three step smoothly with no jump. ### Mic gain `mic_level:` is an AE extension (not in the published TCI spec) but it **is honoured** — verified live 2026-05-28 to actually drive TX mic gain. ## Coexistence with the native UlanziDial backend AetherSDR `main` now ships a native UlanziDial HID backend (**aethersdr#3238** Linux evdev, **aethersdr#3239** Windows hidapi + macOS IOKit). This plugin and that backend do **not** collide — verified live 2026-05-28: - The native Windows matcher keeps only HID devices whose hidapi `product_string` contains the case-sensitive substring `"Ulanzi Dial"` (`src/core/UlanziDialWindowsManager.cpp`, `kProductMatch`). - `hid.enumerate()` on this machine reports: D100H → product `Dial_Lite` (manuf `KEHWIN`, VID `0xFFF1` / PID `0x0082`, BLE HOGP); D200 → product `ulanzi` (manuf `Zkswe`, VID `0x2207` / PID `0x0019`, USB). **Neither contains `"Ulanzi Dial"`**, so the native backend claims neither device — both stay free for this plugin via Ulanzi Studio. - Tested against a clean build of this branch (rebased on `main`, `-DHAVE_HIDAPI`, native backend compiled in) with both Ulanzi units plugged in: no double-claim, no conflict. - Trap for reviewers: Windows Device Manager may display the D100H's PnP *FriendlyName* as "Ulanzi Dial", but that is a **different field** from the hidapi `product_string` the matcher reads (which is `Dial_Lite`). ## Repository relationship Independent public-facing releases / Ulanzi Marketplace path lives at **[nigelfenton/aethersdr-ulanzi-plugin](https://github.com/nigelfenton/aethersdr-ulanzi-plugin)**. Both repos stay alive — the standalone is canonical for end-user downloads; this in-tree copy is bundled context for AetherSDR developers. The README inside the plugin documents the relationship explicitly and is the authoritative description of the plugin. ## Test plan - [x] Plugin source loads in Ulanzi Studio without errors (D100H, Windows) - [x] TCI WebSocket connects to AetherSDR on `ws://127.0.0.1:40001` - [x] MOX action flips radio state (`trx:0,<bool>;`) on D100H - [x] AF / RF / mic gain seed from the radio's real values on connect — no first-press jump (2026-05-28) - [x] `mic_level:` verified to actually drive TX mic gain on AE (2026-05-28) - [x] Coexists with the native UlanziDial backend — neither Ulanzi unit matches `kProductMatch` (2026-05-28) - [x] D100H default profile imports cleanly into a fresh Studio - [x] Icon generator (`scripts/Generate-Icons.ps1`) regenerates the PNGs idempotently - [ ] D200H full-keypad layout validated (in progress) - [ ] D200X layout validated (waiting on hardware) - [ ] macOS Studio compatibility re-confirmed against this in-repo path ## Out of scope / future work - Per-app sub-profiles (TCI Monitor / ShackLog / IQ Capture / aether-pad) using Studio's built-in `System.Hotkey` — will land here once each app's hotkey table is known. - Cross-platform icon generator (port `Generate-Icons.ps1` to ImageMagick / SkiaSharp so macOS/Linux contributors can regenerate without `pwsh`). - Marketplace submission (independent of this PR). - A properly-stateful slice-cycle action (the earlier local-index version was removed in v0.1.7; a future one should be driven by AE's slice-list event rather than a local counter). - Broadening the native backend's device match so it also recognises OEM-rebranded Ulanzi units (`Dial_Lite` / `ulanzi`) — raised separately on aethersdr#3239. --- <sub>_PR description refreshed to match v0.1.7 (rotary knobs, slice-cycle removed, `Generate-Icons.ps1`, GPL-3.0-or-later). The in-plugin README remains the authoritative source of truth._</sub> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: jensenpat <patjensen@gmail.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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The TCI connect init burst already seeds
drive:(RF power) andmic_level:, but never emittedvolume:(master AF gain). A TCI control client that steps AF gain (the Ulanzi / Elgato / StreamController gain steppers) therefore had no way to learn the radio's current AF level on connect — its local mirror started at a hardcoded default, so the first AF Gain ±step was computed against that default and snapped the slider to ~50/55 regardless of the real level. Only after the first echo did subsequent steps track correctly.Change
One line in
generateInitBurst(): emitvolume:<MasterVolume>;(whole-radio, no trx prefix) alongside the existingdrive/mic_levelseeds, reading the same saved valuecmdVolume's GET returns (AppSettingsMasterVolume).+8 / -0incl. comment.This fixes the first-press jump for every TCI gain client, not just the one that surfaced it.
Verification
volume:line; after, it emitsvolume:87(the radio's real level), confirmed via a WebSocket init-burst capture.Test plan
volume:<current>;(server-side capture)🤖 Generated with Claude Code