Skip to content

fix(tci): seed master AF volume in connect init burst#3245

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
nigelfenton:fix/tci-init-burst-af-volume
May 29, 2026
Merged

fix(tci): seed master AF volume in connect init burst#3245
ten9876 merged 1 commit into
aethersdr:mainfrom
nigelfenton:fix/tci-init-burst-af-volume

Conversation

@nigelfenton

Copy link
Copy Markdown
Contributor

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

  • Init burst now contains volume:<current>; (server-side capture)
  • First AF-gain step relative to real value — live on Ulanzi D100H
  • RF / MIC seeds unchanged
  • CI matrix (auto)

🤖 Generated with Claude Code

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 nigelfenton marked this pull request as ready for review May 28, 2026 19:18
@nigelfenton nigelfenton requested a review from a team as a code owner May 28, 2026 19:18
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>

@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.

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, not QSettings
  • 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 trx prefix) 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

@jensenpat jensenpat 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.

Thanks for TCI volume improvement, Nigel

@jensenpat jensenpat enabled auto-merge (squash) May 29, 2026 04:08
@ten9876 ten9876 merged commit d261dfd into aethersdr:main May 29, 2026
5 checks passed
@nigelfenton nigelfenton deleted the fix/tci-init-burst-af-volume branch May 29, 2026 13:01
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>
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>
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.

3 participants