Skip to content

Protocol v2: controller audio (mic + speaker endpoints) - #88

Merged
emir-hasanbegovic merged 17 commits into
mainfrom
feat/controller-audio
Sep 2, 2026
Merged

Protocol v2: controller audio (mic + speaker endpoints)#88
emir-hasanbegovic merged 17 commits into
mainfrom
feat/controller-audio

Conversation

@emir-hasanbegovic

@emir-hasanbegovic emir-hasanbegovic commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Extends the controller-audio work on this branch with the four changes that came out of asking why the feature behaved as though it forwarded the whole desktop.

It does not, and never did — Satellite has no system-audio capture path anywhere. What actually happens is that Windows reroutes the desktop into the pad. A newly arrived endpoint with no persisted Level value wins the "newest device" bucket in Windows' default-endpoint selection, and USB bus type plus Speakers form factor both rank top. So the moment a composite pad materializes it becomes the default playback device, and every sound on the machine is genuinely being rendered into it.

Don't steal the default playback device

New controllerAudioKeepDefaultDevice (default on). Snapshots the default render endpoint for all three roles before a composite plug and puts it back if the pad takes it.

Split pure/IO like the rest of the HIDMaestro backend: every rule lives in audio_default_guard.h with no <windows.h>, pinned by a suite that runs on every CI platform; COM, cfgmgr32 and the one undocumented write live in audio_endpoint_com.cpp.

Two parts worth review attention:

  • Identifying our own endpoint. Name or VID matching would be actively harmful — a real DualSense is byte-identical to the emulated one on VID/PID, hardware id, friendly name, form factor and device description, so matching on any of those would yank the default away from a physical controller. The discriminator is the PnP parent chain: ours terminates at the usbip-win2 root devnode the SDK stamps ROOT\HIDMAESTRO_UDE, a real pad at a PCI xHCI controller. A positive test, not a heuristic.
  • SetDefaultEndpoint has no documented API. It is IPolicyConfig vtable slot 13 on an undocumented coclass. Rather than trust the ordinal, the binding first calls slot 7 as GetProcessingPeriod and requires two plausible periods back — which is what separates the 12-method layout from the 11-method Vista one, whose slot 13 is SetEndpointVisibility and would hide an endpoint instead of selecting it. Failed validation disables the feature for the run rather than guessing at another slot. No elevation needed: the call RPCs to the audio service, which does the privileged write itself.

The guard respects a deliberate choice — if the previous default was already the pad, it does nothing.

It does not latch after one restore. Two composite pads in one session upsert plug milliseconds apart and Windows promotes each endpoint as it enumerates, so one window sees two promotions; a once-per-plug latch handed the second pad the desktop, and a second plug landing in the gap between the first pad's promotion and the next poll snapshotted that pad as the user's choice and defended it. Inside the five-second window a stolen default is always the plug's doing, so every poll judges what it sees on its own, the budget bounds it, and a plug that lands while an earlier window is open keeps that window's snapshot. Single pad: one promotion, one restore, unchanged.

Split the switch per direction

controllerAudio was one boolean covering both directions, so a host that wanted the pad's microphone had to accept its speaker too. Adds controllerAudioMic and controllerAudioSpeaker.

These gate the wire, not the persona. HIDMaestro has no mic-only USB Audio function — all three of its audio composites declare both an audioStreamingOut and an audioStreamingIn interface — so mic-without-speaker cannot be done at the persona level. The endpoints still exist in Windows; only the network traffic stops. That turns out better than the alternative: they apply to a stream already playing, where the master switch has to wait for a replug because it decides whether a kernel transport is installed at all.

Absent keys read as on, so an upgraded config keeps the behaviour its owner chose.

GET /api/server/capabilities gains an additive controllerAudio: {enabled, mic, speaker} block reporting what will actually flow — enabled ANDs the host setting with whether any enumerated backend can carry audio at all, so a host whose only backend has no audio-capable type reports false however the switches are set. Deliberately not in /api/catalog, whose ETag is serverVersion+locale and must stay static identity.

Stop paying for silence

Windows renders digital zeros into the pad's endpoint whenever nothing is playing to it — ~28 kbps of Opus, ~52 kbps on the wire once the 59-byte per-packet framing is counted, for a stream carrying nothing. Those windows are now not encoded and not sent.

Suppressed frames deliberately do not advance seq. A suppressed window is not a hole; advancing would ask the client to conceal a gap with no signal in it, and Opus would invent noise where the game wrote none. Skipping also leaves encoder and decoder resting on the same last real frame.

DTX goes on the mic encoder only. A live microphone never goes digitally silent, so a VAD gate is the only thing that can collapse a quiet room (measured on libopus 1.6.1: 123 of 250 frames gated at −50 dBFS after speech, 30.0 → 16.4 kbps). The speaker declines it because that gate cuts anything ~26–30 dB below the recent peak — on game audio that replaces a reverb tail or quiet ambience with comfort noise at −2.3 dB SNR.

Note on scope: the factory only builds makeMicDecoder and makeSpeakerEncoder, so the mic encoder this touches is the reference/client-side one, instantiated here only by tests. Mic DTX is now defined and pinned by a behavioural test, but the bandwidth it saves lands when dish-android sets OPUS_SET_DTX(1) on its own encoder. Verified compatible in advance: DTX emits 1-byte packets, exactly AUDIO_WIRE_MIN_PAYLOAD_BYTES minus the header, and a real DTX stream through this repo's AudioJitterWindow gave 250 pushed / 250 accepted / 0 rejects.

Corrects a wrong comment while here. The source claimed the speaker stream runs CELT and therefore carries no in-band FEC. Measurement says otherwise: OPUS_SET_PACKET_LOSS_PERC(10) forces SILK in, both streams are Hybrid fullband, and the speaker really does carry FEC (8.4 dB recovery via decode_fec vs −1.3 dB for blind PLC). Anyone "fixing" the config toward CELT would have silently deleted working FEC.

Say what the setting actually does

The dashboard hint described controller audio as though Satellite chose what plays through it. Rewritten to say what it really is — the pad gets real Windows audio devices, and Windows decides what goes there, the same as for any headset — plus where to point individual apps. All six locales updated. Also fixes a duplicate change listener on the audio toggle.

Build script

Separate commit: install-dependencies.bat still installed the pre-CMake toolchain — no pkg-config (which CMakeLists uses to resolve OpenSSL and libopus), no libopus at all, and no CMake, even though build-satellite.bat and build-tests.bat are now thin CMake wrappers. A fresh machine following it could not configure, let alone build.

Verification

33 suites green from a clean configure, 1048 assertions in session_service alone; 409 in the new pure guard suite, including an exhaustive sweep of the restore decision matrix, a two-pad walkthrough that pins six restores against the one prior, a real-DualSense chain that must be rejected, cycle and hop-cap guards.

DTX and silence behaviour are asserted behaviourally against the real encoder rather than by reading the config back, since the asymmetry is invisible from the header.

The guard's read-only paths were run against this machine's real audio devices: three roles read, parent chain walked and correctly classified not-ours, IPolicyConfig validation returning usable for a live endpoint and probe-call-failed for a bogus one, defaults unchanged before and after.

Route tests only build on the Linux/macOS lanes; they were checked with g++ -fsyntax-only under the same flags, and the only diagnostics are the pre-existing POSIX calls that are why the suite is gated.

Client impact (dish-android)

No protocol change: protocolVersion stays 2, no new opcodes, no wire-format change, cap semantics unchanged. An existing client keeps working.

One observable behaviour change: speaker packets now stop during digital silence. The jitter window is purely sequence-driven with no timers, and suppression does not advance seq, so a correctly-implemented client sees no gap events and no PLC — the sink simply underruns and plays silence, which it must already handle today whenever nothing has the endpoint open. Worth confirming the Android sink plays silence on underrun rather than looping or erroring.

Optional: reading the new controllerAudio block lets the client skip allocating a decoder for a direction the host switched off, and say so instead of going unexplainably quiet.

Not verified without hardware: the SetDefaultEndpoint write path and the positive parent-chain match. No composite has been created on the test machine, so every real endpoint there correctly classifies as not-ours and slot 13 is never reached. Confirming those needs a live composite plug — the same one that installs the kernel USB transport.

An emulated DualSense (or DualShock 4 v2) should present the audio endpoints
the physical pad would, so a game's chat audio reaches the player's ears and
the player's voice reaches the game. This lands the wire contract and every
gate around it; the Opus codec (SAT-2) and the HIDMaestro composite personas
(SAT-3) follow. Companion client work: TinkerNorth/dish-android
(feat/controller-audio).

Scope, because the name invites the wrong reading: this carries the emulated
pad's OWN endpoints, never host game audio.

## Protocol (v2, extended in place; 2 was written but never released)

| Opcode | Name | Payload |
|---|---|---|
| 0x0012 | MIC_AUDIO (up) | ctrlIdx(1) + seq(u16 BE) + one 20 ms Opus packet, mono 48 kHz |
| 0x0013 | SPEAKER_AUDIO (down) | same framing, stereo 48 kHz |
| 0x0014 | MIC_LED (down) | ctrlIdx(1) + state(1: off/on/pulse) |

Gated on new descriptor caps `mic` (0x0040) and `speaker` (0x0080), which
advertise the CLIENT's source/actuator per the house rule; MIC_LED rides
CAP_MIC because a mute lamp with no microphone behind it has nothing to
report. `seq` wraps and marks gaps and late frames only: no acks, no
retransmits, Opus in-band FEC plus PLC conceal loss, per the contract's
lossy-telemetry doctrine.

`GamepadReport.wButtons` bit 0x0800 is now WBUTTON_MIC_MUTE, the one value
XINPUT leaves free (0x0400 is XUSB's Guide). Only the DualSense identity will
consume it. Mute stays the client's to enforce: muted means zero mic packets
on the wire, and the bit exists so host-side software can still see the state.

The datagram ceiling rises from 256 to 1500 bytes in both directions (recv
buffer and the framed send buffer), one Ethernet MTU, so an Opus packet fits
without fragmenting. A 20 ms packet is ~80 bytes (mic) to ~240 (speaker), so
the ceiling absorbs a VBR spike rather than being approached.

## Server

- `IGamepadPort` gains submitMicAudioPcm plus speaker-audio and mic-LED sinks
  (default inert); `GamepadMux` fans them out and routes mic PCM to the
  serial's owner. `IClientPort` gains sendSpeakerAudio / sendMicLed.
- `SessionService::handleMicAudio` validates session, slot and CAP_MIC, then
  rate-limits to ~75 packets/s per controller (nominal 50) and hands the packet
  to the SAT-2 decode seam. Every rejection is a silent drop on the wire, with
  one log line per session per cause so a misconfigured client is diagnosable
  without becoming a 50 Hz log flood; a re-PUT rearms it.
- Mic-LED and speaker callbacks mirror the lightbar forwarder, including the
  try_lock + drop-frame rule (a blocking backend callback deadlocked live
  sessions once already); replug resets the coalesce state and the rate-limit
  window.
- The four serial-to-controller scans in the backend callbacks collapse into
  one findBySerialLocked helper rather than gaining two more copies.
- Catalog and the `backends` array advertise the `mic`/`speaker` feature slugs
  for the two Sony types via HIDMaestro, the only backend that can materialize
  a pad carrying real audio endpoints. The catalog stays backend-derived: the
  runtime switch is the `controllerAudio` setting, which lands in SAT-3.
- contract.md documents the messages, caps, catalog slugs, the datagram
  ceiling, the mute privacy invariant and the extend-2-in-place rationale.

## Tests

All 28 suites pass (ctest, MinGW and MSVC): receiver 72 -> 118 (audio header
codec byte vectors and round-trip, dispatch guard at every length 0..3, the
service actually reached, rate limit through dispatch, unbound slot and
unknown token), session_service 428 -> 502 (cap gating both directions,
per-controller rate limit, replug allowance, once-per-session-per-cause
logging, mic-LED coalescing and bad-state drops, speaker gating, backend
callbacks drop rather than block, capability-bit and opcode pinning),
backend_registry 239 -> 289, catalog 219 -> 249 (feature matrix plus slug
invariance across all six locales), gamepad_mux 113 -> 128. routes_client
gains an independent-direction caps round-trip.
SAT-1 landed the wire contract with two stubs behind it: a mic frame passed
every gate and was discarded, and speaker PCM never reached the wire. This
fills both, so an emulated pad's microphone reaches the game and the game's
audio reaches the player.

## Inbound: reorder, then conceal

`core/audio/audio_jitter.h` is a 2-frame window keyed on the wrapping u16
`seq`. Two frames is the whole design: a packet arriving that far ahead of the
one we are waiting for is proof the missing one is lost rather than merely out
of order, which is what lets a one-frame LAN reorder heal instead of being
concealed. It emits in-order packets plus explicit gap signals, and a gap
carries a pointer to packet seq+1 when the window already holds it, because
Opus hides a redundant low-rate copy of frame N inside packet N+1 and that
pointer is the difference between recovering a lost frame and guessing at it.

A frame older than what has already played is dropped rather than spliced in
behind it. A dropout longer than the concealment cap resynchronises to the
newest packet in hand instead of synthesizing an ever-longer tail and holding
the stream that far behind live.

The window is header-only and dependency-free, and its storage is sized by a
proven invariant (at most WINDOW-1 packets survive a push), so the whole thing
is a fixed two slots with no heap traffic on a clean stream.

## Outbound: re-frame, then encode

A backend hands over whatever its audio ring held, which is a batch boundary
and not a codec boundary. `encodeAndSendSpeakerAudioLocked` re-windows partial
and multi-window batches into exact 20 ms frames, keeping the leftover tail for
the next call, and gives each one a per-controller wrapping sequence. The seq
advances even when an encode fails: the 20 ms happened, and saying so lets the
client conceal a hole rather than play the stream short.

## Where the codec lives

`scripts/check_core_purity.sh` forbids any third-party header under src/core,
so libopus cannot live there. The core declares the shape
(`core/audio/audio_codec.h`: IAudioDecoder / IAudioEncoder / a factory) and
`adapters/audio/opus_codec.*` implements it, injected into SessionService by
main.cpp — the arrangement KeyDeriver already uses to keep HKDF out of the
core. A null factory stays a supported state: the gates, the rate limit and
the reorder window all still run, there is just nowhere to put the samples.

Both codecs are created on a controller's first audio frame and released with
the pad (replug, unbind, or connection teardown), because an Opus decoder is
~18 KB and most controllers never carry audio.

Formats are pinned, never negotiated: mic mono VOIP at 32 kbps, where Opus
runs SILK and in-band FEC actually exists; speaker stereo AUDIO at 96 kbps.
Both request FEC with a 10% expected-loss hint. At the speaker rate Opus picks
CELT, which has no in-band FEC to give — the flag is set anyway so the intent
survives a bitrate change, and the FEC decode entry degrades to concealment by
itself, which is why a caller can take it unconditionally on a gap.

## Build

libopus joins libsodium and OpenSSL as a required dependency on every platform
rather than an optional one: a codec on the data plane is not the tray icon,
and an optional dependency would mean the Linux and macOS lanes quietly stop
compiling the wrapper that portability regressions show up in. vcpkg.json, all
CI lanes, the deb/rpm/snap/AUR/flatpak recipes and the build scripts pick it
up; resolution mirrors satellite_link_sodium (vcpkg config package under MSVC,
pkg-config elsewhere).

## Tests

30/30 ctest suites pass on MinGW and MSVC (satellite.vcxproj still needs the
Spectre-libs VS component this box lacks; every test target builds).

- audio_jitter, new, 157 assertions: in-order pass-through, one-frame reorder
  healing, gap declaration with and without an FEC carrier, back-to-back
  losses, late and duplicate drops, malformed and oversize refusals, the wrap
  at 0xFFFF in-order and mid-reorder, long-dropout resync, and the two
  structural invariants the fixed storage depends on.
- opus_codec, new, 140 assertions: mic and speaker round-trips (including the
  channel imbalance, which a downmix would flatten), window-size and malformed
  input refusals, concealment producing real audio, and FEC recovery. The FEC
  test decodes the same run twice from identical decoder state, once through
  the carrier's FEC and once through blind concealment: 7 of 8 frames differ
  with in-band FEC on and 2 of 8 with it off, so the strict-majority assertion
  fails the moment the encoder stops carrying FEC — a regression nothing else
  would catch, since a stream with no FEC sounds fine until the first lost
  packet. A forged 40 ms packet (TOC frame-count code 1 over a duplicated
  body) is refused rather than written past the fixed 20 ms buffer.
- session_service 502 -> 902: real encoder output decoded through to the
  backend's mic endpoint with the PCM energy asserted (a call count alone
  cannot tell a decode from a zeroed buffer), the concealed frame in a gap
  asserted to be audio, reorder healing, late-frame refusal, streams past the
  rate window and across the seq wrap, per-controller codec state, fresh state
  after replug and unbind, one window becoming one decodable wire packet,
  partial-batch buffering (480 + 480 frames -> exactly one packet), and the
  seq wrapping through 0xFFFF via a test seam rather than 65536 encodes.
  SAT-1's placeholder assertion that a gated speaker frame reaches nothing now
  asserts the two packets it produces.
- The fuzz harness runs the real codec on the mic path, so attacker-chosen
  bytes reach opus_decode instead of stopping at the reorder window, with two
  new corpus seeds (a real Opus frame and a header-only short frame).
SAT-1 gave controller audio a wire contract and SAT-2 gave it a codec, but
nothing on the host actually had a microphone or a speaker: the emulated pad
was still an input-only HID device. This makes it the pad it is impersonating.
A real DualSense is a composite USB device, a gamepad plus a USB Audio Class
function, and HIDMaestro can materialize that composite too. So satellite asks
for it, and the endpoints Windows then sees are the pad's own.

## Which persona, and who decides

`profileForIdentity` gains an `audio` argument: DualSense and DualShock 4 v2
map to `dualsense-composite` / `dualshock-4-v2-composite`, everything else is
unchanged, and an identity with no audio function never asks. The argument is
explicit rather than inferred because saying yes is expensive (see the honesty
section below). `HidMaestroAdapter` reads the `controllerAudio` setting through
a callback at every plug, so the dashboard toggle applies to the next pad
without a restart and without the adapter having to be told; a refused
composite falls back to the plain persona rather than failing the plug, because
a pad without audio beats no pad.

## Two more rings

The helper's JSON pipe brokers device lifecycle and is not a PCM transport: one
20 ms window is ~2 KB, fifty times a second, in each direction. A composite
plug therefore hands back two more shared sections and their doorbells
(`hidmaestro_audio_wire.h`), each a 32-slot seqlock ring with the same drain
loop, lap rule and torn-slot retry as the driver's own output ring. The
protocol carries no version field, so as with the driver's rings the section
SIZES are the layout check: satellite maps exactly AUDIO_SECTION_SIZE, and a
helper built against a different layout fails the map instead of misreading
PCM. Unlike the driver's ring we own both ends, so a producer marks a slot in
progress by zeroing its SeqNo before touching the payload, which makes a
mid-write slot explicitly unreadable rather than merely stale.

The rings speak the WIRE's channel layout at the PERSONA's sample rate, and
that split is where the work lands. Channel selection is trivially correct and
sits next to the SDK in the helper, which takes lanes 1/2 of the DualSense's
4-channel OUT stream and drops the HD-haptics lanes 3/4 on the floor. Rate
conversion is not trivially correct, so it sits in satellite where ctest
reaches it: the DualSense composite is 48 kHz both ways and needs none, but the
DualShock 4 v2 composite is 32 kHz out and 16 kHz in, exactly like the hardware
it impersonates. Handing 32 kHz samples to a 48 kHz consumer plays the stream
half again too fast, and decimating 48 kHz to 16 kHz without a lowpass folds
everything above 8 kHz into the voice band. `core/audio/audio_resampler.h` is a
polyphase FIR for the exact rational ratio, header-only and dependency-free
like the reorder window beside it.

The helper's audio work is deliberately mechanical for one reason: this repo
has no C# test harness. Lane selection, a fixed-layout ring write, a ring read
and a channel spread are all it does, on the SDK's own pump thread and one
background drain thread that dies with the controller. It coalesces the SDK's
1 ms OUT frames into 5 ms batches, because a doorbell per millisecond would be
a thousand cross-process wakeups a second and satellite re-windows into 20 ms
Opus frames anyway.

## Mute button and mute lamp

`wButtons` 0x0800 reaches the DualSense input report at RID-stripped byte 9 bit
0x04, where hid-playstation reads it; every other identity leaves the bit
alone, which the suite now asserts byte-for-byte rather than assuming. The
game's mute-lamp writes come back through the existing output ring: DS5 report
0x02, valid_flag1 bit 0 gating the lamp mode. That byte is 8, not 9. The plan's
map said 9, but 9 is `power_save_control` in both SDL's DS5EffectsState_t and
hid-playstation's dualsense_output_report_common, and 8 is forced by the
offsets this decoder already anchors (trigger blocks at 10/21, valid_flag2 at
38, player LEDs at 43, lightbar at 44-46). Decoding 9 would have reported
power-save bits as a lamp state. A mode above PULSE is dropped at the decoder
rather than forwarded for the service to reject: an unknown lamp mode is not a
mute state to guess at.

## Kernel-driver honesty

A composite persona is served over HIDMaestro's bundled WHLK-certified
usbip-win2 kernel USB transport, which installs the first time such a
controller is created. Satellite described its Windows path as user-mode
throughout. That was true of input and is now stated as being true of input
only, in README.md (prerequisites, the installer bullet, and a new Controller
audio section), installer.iss (header comment, component description, and the
components-page prompt), docs/architecture.md, SECURITY.md and redist/README.md
(which now inventories the second, kernel-mode payload embedded in
HIDMaestro.Core.dll). The helper calls `InstallUsbipBackend` explicitly so a
blocked or declined install is a reportable plug failure instead of a surprise
inside device creation.

The new `controllerAudio` setting is the off switch: on by default, persisted
with the other settings, a dashboard toggle in all six locales, and with it off
the transport is never installed. `GET /api/server/capabilities` reports the
live state per backend as `audio`, next to `kernelMode`, which keeps describing
the input submit path and stays false; the per-controller `mic`/`speaker`
columns stay static identity, because the catalog is cached on server version
plus locale and must not move with an install-time switch.

SECURITY.md also states the thing a reader should not have to discover: a
composite persona is deliberately un-identifiable as virtual (it enumerates
under ROOT\HIDMAESTRO_UDE, four parents above the HID node), so Satellite
cannot satisfy an attestation requirement and "software could not tell it was
virtual" is not a vulnerability report.

## Tests

32/32 ctest suites pass on MinGW and MSVC (satellite.vcxproj still wants the
Spectre-libs VS component this box lacks; every test target builds and runs).

- audio_resampler, new, 28 assertions: identity pass-through, the 32 kHz and
  16 kHz conversions with their frame counts, tone frequency and level, a
  15 kHz tone attenuated below 5% instead of aliasing into the voice band (a
  stride implementation passes every count-based assertion and fails only that
  one), chunked input producing byte-identical output to one-shot input, a
  cleared filter matching a fresh one, channel independence, and full-scale
  input clamping rather than wrapping.
- hidmaestro_audio_wire, new, 234 assertions: every offset and both sizes, the
  round trip, full-scale negative samples, in-order drain, the capacity
  refusal and the exactly-full batch, the 32-slot lap skip and the drain that
  follows it, a half-written slot refused with the reader's buffer left
  untouched, a foreign sequence refused, slot reuse modulo 32, the u16 stream
  sequence through 0xFFFF, and a bogus sample count clamped rather than
  over-read.
- hidmaestro_report 185 -> 310: the profile matrix over setting x identity
  including the audio-off default and the agreement between
  identityHasAudioPersona and the mapping it guards, the mute-button bit set
  and cleared with every other byte held identical, the same bit proven inert
  in the x360/GIP/DS4/Switch packers, and mute-lamp vectors for all three
  states, out-of-range states, the flag not set, a short report, byte 9 proven
  not to be the lamp, non-DualSense identities, and a full write decoding
  every field at once.
- hidmaestro_adapter 111 -> 223: the audio request matrix (setting x identity,
  including no provider at all meaning off), a refused composite falling back
  to the plain persona with input still working, speaker PCM arriving
  byte-identical at 48 kHz and rate-converted at 32 kHz with both channel
  levels checked, a 20 ms mic window crossing unchanged and downsampled to
  16 kHz across ten windows, mic submits refused where there is no endpoint,
  the mute lamp reaching the callback with an out-of-range write producing
  nothing, worker teardown on unplug with fresh rings on replug, and a
  concurrent producer hammering the ring while the drain worker asserts no
  batch is ever internally inconsistent (the multi-threaded half of the
  seqlock contract a single-threaded suite cannot force).
- backend_registry 289 -> 311: `audio` true only when the setting is on and the backend
  can, false for every backend with no audio-capable type, the static columns
  and kernelMode unchanged by the switch, and the field parsed as a real
  boolean.
- config_json 71 -> 78: the default, a pre-audio config loading as on, both explicit
  values round-tripping, and a non-boolean value ignored rather than coerced.
- status_json 3 -> 7 and routes_admin: the key present in /api/status and absent from
  the debug and SSE payloads (neither drives the form and both are hot), and
  the POST applying, showing up in /api/status, moving the capabilities array,
  and staying put when a later POST omits it.
The hint listed only 054c:05c4 (v1), which was written when ViGEm was the
only DS4 materializer. HIDMaestro's dualshock-4-v2 profiles present the v2
identity (054c:09cc), and only the v2 hardware carries the USB audio
function the mic/speaker feature slugs describe, so a client matching pads
by the hint would miss exactly the revision the audio feature targets. The
array was designed to admit more revisions without a version bump.
@emir-hasanbegovic
emir-hasanbegovic marked this pull request as ready for review September 1, 2026 14:32
… list

The script still installed the pre-CMake toolchain: no pkg-config (which
CMakeLists uses to resolve OpenSSL and libopus), no libopus at all, and no
CMake even though build-satellite.bat and build-tests.bat are now thin CMake
wrappers. A fresh machine following it could not configure, let alone build.

Renumbers the steps 1/6 -> 1/9 and splits MSYS2 itself from the gcc package
so the failure hint names the right pacman line.
… silence

The single controllerAudio switch turned both directions on together, so a
host that wanted the pad's microphone had to accept its speaker as well --
and the speaker carries whatever Windows renders into the endpoint, which is
usually everything on the desktop.

Splits it into controllerAudioMic and controllerAudioSpeaker. These gate the
WIRE, not the persona: HIDMaestro has no mic-only USB Audio function (all
three of its audio composites declare both directions), so the endpoints
still exist in Windows and only the network traffic stops. That also makes
them apply to a stream already playing rather than at the next replug, which
the master switch cannot do because it decides whether a kernel transport is
installed at all. Absent keys read as on, so an upgraded config keeps the
behaviour its owner chose.

Also stops sending digital silence on the speaker path. Windows renders zeros
into the endpoint whenever nothing is playing to it, which cost ~28 kbps of
Opus and ~52 kbps on the wire once framing is counted, for a stream carrying
nothing. Suppressed frames deliberately do not advance seq: a suppressed
window is not a hole, and asking the client to conceal one would have Opus
invent noise where the game wrote none.

DTX goes on the mic encoder only. A live microphone never goes digitally
silent, so a VAD gate is the only thing that can collapse a quiet room
(measured on libopus 1.6.1: 123 of 250 frames gated at -50 dBFS after speech,
30.0 -> 16.4 kbps). The speaker declines it because that gate cuts anything
~26-30 dB below the recent peak, which on game audio turns a reverb tail into
comfort noise at -2.3 dB SNR.

Corrects the encoder comment while here: measurement shows the loss hint, not
the application, picks the mode. Both streams are Hybrid fullband and both do
carry in-band FEC (8.4 dB recovery vs -1.3 dB for blind PLC), so dropping the
hint to reach CELT would silently delete it.
Route tests only build on the Linux/macOS lanes, so these were verified with
g++ -fsyntax-only against the same flags the CMake recipe uses; the only
diagnostics are the pre-existing setenv/mkdir POSIX calls that are why the
suite is gated in the first place.
This is the actual reason controller audio looked like it forwarded
everything. Windows promotes a newly arrived endpoint to the default: one
with no persisted Level value wins the newest-device bucket, and USB bus type
plus Speakers form factor both rank top. So the moment the composite pad
materializes, every sound on the desktop is being rendered into it and
streamed over the network -- not because Satellite captures system audio, but
because Windows rerouted the system to the pad.

The guard snapshots the default render endpoint for all three roles before the
composite plug and puts it back if the pad takes it. Split pure/IO the way the
rest of the HIDMaestro backend is: every rule lives in audio_default_guard.h
with no <windows.h>, so it is pinned by a portable suite on every CI platform,
and COM, cfgmgr32 and the one undocumented write live in the shell.

Identifying our own endpoint is the delicate part, and name or VID matching
would be actively wrong: a real DualSense is byte-identical to the emulated
one on VID/PID, hardware id, friendly name, form factor and device
description, so matching on any of those would yank the default away from a
physical controller. The discriminator is the PnP parent chain, which for ours
terminates at the usbip-win2 root devnode the SDK stamps ROOT\HIDMAESTRO_UDE,
and for a real pad at a PCI xHCI controller. That makes it a positive test.

SetDefaultEndpoint has no documented API; it is IPolicyConfig vtable slot 13
on an undocumented coclass. Rather than trust the ordinal, the binding first
calls slot 7 as GetProcessingPeriod and requires two plausible periods back --
which is what separates the 12-method layout from the 11-method Vista one,
whose slot 13 is SetEndpointVisibility and would hide an endpoint instead of
selecting it. A failed validation disables the feature for the run rather than
guessing at another slot. No elevation is needed: the call RPCs to the audio
service, which does the privileged write itself.

Everything fails soft -- this is a nicety, never a reason to fail a plug.

Not verified without hardware: the write path itself, and the positive
parent-chain match. No composite has been created on the test machine, so
every real endpoint there correctly classifies as not-ours and slot 13 is
never reached. The read-only probe that gates it does return usable live.
The hooks capture it by reference, so it has to outlive the adapter's
teardown, not the other way round.
The README said controller audio was 'a single switch'; it is now four, with
different blast radii -- the master decides whether a kernel transport is ever
installed and lands at the next plug, the two direction switches gate the wire
and land immediately, and the default-device guard explains the behaviour that
started all this.

Corrects two things the docs had wrong. The speaker stream is not CELT and does
carry in-band FEC, so nothing should read as though reaching CELT were a fix.
And the DualSense composite does not present a 'Wireless Controller' speaker --
that is the DualShock 4 v2 product string; the DualSense reports 'DualSense
Wireless Controller', verified against the endpoint on a live machine. The DS4
v2 endpoint names are deliberately left unasserted, since that persona has never
been created on the test machine and its output terminal is a headset rather
than a speaker.

Also makes the capabilities block mean what every doc sentence wanted it to
mean. It now ANDs in whether any enumerated backend can carry audio, so a host
whose only backend has no audio-capable type reports false however the switches
are set; the per-backend audio field stays the place to learn which backend can.
The route tests assert relationships rather than absolutes because of it -- on
the lanes that run them, no backend carries audio at all.

Fixes a stale declaration comment on sendSpeakerFrameLocked while here: a
digitally-silent window is neither encoded, nor sent, nor advances the seq.
…first

Two composite pads in one session upsert plug milliseconds apart, and
Windows promotes each endpoint as it enumerates, so one guard window sees
two promotions. The once-per-role-per-plug latch handed the second pad the
desktop: pad A promoted, restored, latched; pad B promoted, "already
restored", kept. And a second plug landing in the gap between pad A's
promotion and the next poll took a fresh snapshot with pad A as the prior
default, which read as the user's choice and defended it against pad B.

Inside the window a stolen default is always the plug's doing, so every
poll now judges what it sees on its own and the budget bounds the fight.
The runner keeps the live snapshot when a plug arrives while an earlier
plug's window is still open, and the re-arm restarts the budget from that
plug. Nothing changes for a single pad: one promotion, one restore, the
same log line.

Tests: the decision matrix drops the latch dimension, and a two-pad
walkthrough pins six restores against the one prior, plus the mistaken
mid-window snapshot that the runner rule exists to prevent.
install-dependencies.bat installed the UCRT64 toolchain while windows-ci.yml
builds MINGW64: two different compilers, ABIs and dependency trees, so a
green local build proved nothing about CI. Every lane's configure line now
lives in CMakePresets.json (windows-mingw, linux, macos, windows-msvc, plus
debug variants), and new scripts drive those presets so a local command and
a CI step cannot diverge again:

  scripts/install-deps.ps1 (-Msvc) / install-deps.sh
      install exactly the toolchain CI uses per lane; the Windows default is
      now MINGW64 (pacman mingw-w64-x86_64-*), and the stale ucrt64 PATH
      entry is migrated to mingw64
  scripts/build.ps1 / build.sh [debug|release] [test] (-Msvc)
      configure + build + optional ctest via the presets, matching the dish
      repos' build-script shape
  scripts/ci-local.ps1 / ci-local.sh [--allow-missing]
      every PR gate in CI's order (format, action-pin lint, core purity on
      POSIX, preset configure/build/ctest, helper publish on Windows); a
      missing tool fails unless --allow-missing, because a green run that
      silently skipped a gate is worse than no run
  scripts/check-format.sh
      the clang-format gate every CI lane runs, one copy
  scripts/build-installer.ps1
      the six installer steps, now passing /DMyAppVersion from /VERSION the
      way release.yml passes the tag (build-installer.bat omitted it)
  scripts/build-deb.sh / build-appimage.sh
      cpack and AppImage recipes aligned with release.yml's jobs

The windows-mingw presets also turn on CMAKE_COMPILE_WARNING_AS_ERROR: the
MinGW build is warning-clean on GCC 16, and the Linux/macOS lanes already
gate on it, so Windows no longer gets a free pass.

The old root entry points remain as thin forwarders so documented commands
and muscle memory keep working.
The PR workflows carried their own copies of the configure, build, test and
format commands; those inline copies are exactly what drifted away from the
local scripts. Each lane now calls the same single source the local scripts
call:

  * windows-ci / linux-ci / macos-ci: configure via cmake --preset
    (windows-mingw / linux / macos), build and ctest via the matching build
    and test presets, and the clang-format step runs
    scripts/check-format.sh instead of an inline find/xargs pipeline
  * windows-msvc-ci: build and test through the windows-msvc presets (the
    configure step already used the preset; its -G override via
    scripts/windows-vs-generator.ps1 is unchanged)
  * release: the Windows build step uses the windows-msvc build preset, and
    the AppImage job's inline recipe moves to scripts/build-appimage.sh
    (every flag preserved, LDAI_UPDATE_INFORMATION included) so a local
    AppImage and the released one come off the same path

Runner setup, caches, artifact uploads, the uinput smoke, fuzz and
reproducibility jobs, and all signing/SBOM/provenance/publish logic stay in
the workflows: only steps that duplicate a local command moved.

The windows-mingw preset carries CMAKE_COMPILE_WARNING_AS_ERROR=ON, so this
also promotes the Windows lane to warnings-as-errors alongside Linux and
macOS (the MinGW build is warning-clean on GCC 16).
vcpkg.json sat at 1.0.0 while /VERSION said 1.1.0: vcpkg reads only its own
manifest, so nothing ever noticed. Bump it and teach version-consistency.yml
to compare vcpkg.json's version field against /VERSION (and to trigger on
vcpkg.json changes), the same way it already covers src/core/version.h.

No builtin-baseline is added here: pinning one requires the consuming vcpkg
checkout to be at least that commit, and the MSVC lane builds with the
runner image's own vcpkg checkout (VCPKG_INSTALLATION_ROOT), whose age we
do not control. A baseline newer than the image's checkout would break that
lane outright; leaving the manifest baseline-less keeps it building against
the image's ports as it does today.
Rewrite the README build/test/installer/code-quality sections around one
story (install-deps, build, ci-local, build-installer) and fix what had
drifted from reality:

  * the Windows toolchain section pointed at winlibs MinGW while CI builds
    MSYS2 MINGW64; it now names the CI lane and the script that installs it
  * the test recipe showed a generator-less cmake invocation that does not
    match any CI lane; it now goes through the presets
  * the clang-format section suggested a floating winget LLVM.ClangFormat
    while every CI lane pins 22.1.4 from PyPI; it now installs the pin and
    checks via scripts/check-format.sh, CI's exact file set
  * the installer section described fetch-redist + iscc but not the helper
    publish, signing gate or version pass-through that CI performs

CONTRIBUTING points setup at the scripts and names ci-local as the
pre-push mirror of CI; CHANGELOG records the build-system unification.
@emir-hasanbegovic

Copy link
Copy Markdown
Contributor Author

Four build-system commits ride along here per the one-source-of-truth push across the desktop repos: local builds and CI now share the same entry points. CMakePresets.json names every lane's configure (windows-mingw, linux, macos, windows-msvc, plus debug variants), scripts/ gains install-deps / build / ci-local / build-installer / build-deb / build-appimage / check-format, the workflows call those instead of inline command lines, and the root .bat/.sh files forward. Headline fixes: install-dependencies.bat used to install the UCRT64 toolchain while CI builds MINGW64 (now fixed, including migrating the user PATH entry); build-installer now passes /DMyAppVersion like release CI; vcpkg.json version resynced to /VERSION and gated by version-consistency; the Windows lane gains warnings-as-errors (MinGW build proven warning-clean locally on GCC 16). scripts/ci-local.ps1 runs CI's gates in CI's order and passed end to end on the dev box (format, purity, full MINGW64 build, 33/33 ctest, helper publish, installer round-trip). Linux/macOS preset plumbing gets its first real proof on this PR's CI run.

@emir-hasanbegovic
emir-hasanbegovic enabled auto-merge (squash) September 2, 2026 17:59
@emir-hasanbegovic
emir-hasanbegovic merged commit 9f91fd5 into main Sep 2, 2026
15 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the feat/controller-audio branch September 2, 2026 18:09
emir-hasanbegovic added a commit to TinkerNorth/dish-linux that referenced this pull request Sep 2, 2026
Ports controller audio (protocol 2 extension; satellite
TinkerNorth/satellite#88, dish-android TinkerNorth/dish-android#178,
dish-windows TinkerNorth/dish-windows#66) to the Linux client. Physical
Direct-claimed pads only; no virtual controller exists here.

This is the file-for-file port of both Windows waves in one commit: wire
(MIC_AUDIO 0x0012 up / SPEAKER_AUDIO 0x0013 down / MIC_LED 0x0014 down,
caps mic 0x0040 / speaker 0x0080, receive buffer 256 to 1500, 1472 send
guard), the capability fold with first-time consumption of GET
/api/server/capabilities (probed per session PUT, conservative-false),
the Opus codec and shared 2-frame jitter mirror, SDL audio
capture/playout engines with the zero-packets-while-muted invariant,
product-string pad-to-endpoint matching (ambiguity publishes nothing),
the DualSense mute button and wButtons 0x0800 mute state, MIC_LED
actuation with the FeedbackState lamp shadow, and slot-card mute
controls showing local truth.

Linux-specific deltas: the pad string comes from the USB product
attribute (iProduct) with HID_NAME as fallback, since pipewire/alsa
names derive from iProduct while HID_NAME prepends the manufacturer;
opus arrives via pkg-config; Moonlight cannot see 0x0800 structurally
(the explicit button map has no such row, pinned by test); packaging
grows libopus across CI, deb/rpm (via shlibdeps), AppImage (SDL built
with audio + libpulse backend), and both Flatpak manifests gain
--socket=pulseaudio.

Of the 33 new source files, 29 are byte-identical to dish-windows; the
four that differ are three cross-platform comment variants and the POSIX
loopback test. 12 new test suites plus 10 extended ones. This box has no
Linux toolchain, so beyond syntax/format/translation gates the proof is
this PR's CI: the compile+ctest lane, the stricter clang-tidy, TSan over
the engines, and the package job's shlibdeps.
emir-hasanbegovic added a commit to TinkerNorth/dish-windows that referenced this pull request Sep 2, 2026
Ports the controller-audio half of protocol 2 (satellite PR
TinkerNorth/satellite#88, dish-android PR TinkerNorth/dish-android#178)
to the Windows client. Physical Direct-claimed pads only; there is no
virtual controller here.

This first commit lands the wire, the capability model, the host
verdict, and the codec cores. A second wave adds the SDL audio engines,
pad-to-audio-device routing, the DualSense mute button, and MIC_LED
actuation; until then no slot advertises an audio cap (pinned by test).

**Wire additions** (protocol stays 2, everything caps-gated):

| Op | Dir | Payload |
|---|---|---|
| MSG_MIC_AUDIO 0x0012 | c to s | ctrlIdx + seq u16 BE + one 20 ms Opus
packet (mono 48 kHz, VOIP, ~32 kbps, FEC, DTX) |
| MSG_SPEAKER_AUDIO 0x0013 | s to c | same header, stereo 48 kHz, AUDIO,
~96 kbps, FEC |
| MSG_MIC_LED 0x0014 | s to c | ctrlIdx + state (0 off / 1 on / 2
pulse), coalesced |

Caps mic 0x0040 / speaker 0x0080; wButtons 0x0800 reserved as the
DualSense mute-state bit (never set yet, never leaks through the
Moonlight mapping). Datagram ceiling raised to 1500 both directions: the
receive buffer was 256 bytes and would have truncated every audio frame
into an AEAD failure, and sendEncrypted now refuses inner payloads over
1472 instead of emitting fragments.

**First-time host-verdict wiring**: GET /api/server/capabilities had
zero callers; it is now probed after every successful session PUT (open
and rekey) and folded per direction (controllerAudio block, per-backend
audio flags) into the capability solver, conservative-false until a
probe says yes.

**New cores**: AudioJitter.h (third mirror of satellite/android's
2-frame reorder window, edit together), Opus codec wrappers pinned to
the contract formats, HostAudioVerdict fold, per-binding MicEnabledStore
(default off) / SpeakerEnabledStore (default on).

95 new tests (+6 solver, +3 routing, +3 models extensions), 2025 total
green locally with clean format/tidy/qml/translation gates. Six locales
updated.

---------

Co-authored-by: Emir Hasanbegovic <1190336+emir-hasanbegovic@users.noreply.github.com>
emir-hasanbegovic added a commit to TinkerNorth/dish-linux that referenced this pull request Sep 2, 2026
Completes the fleet-wide build unification (satellite's landed inside
TinkerNorth/satellite#88, dish-windows in TinkerNorth/dish-windows#67):
local builds and CI share the same entry points so they cannot drift.
This repo already had the fleet's best piece (ci_local.sh,
build-appimage.sh); this converges the rest.

CMakePresets.json (new) holds debug/release/package configures, with
QT_QPA_PLATFORM=offscreen moving into the test presets; scripts/build.sh
rides the presets; ci_local.sh becomes scripts/ci-local.sh (old name
forwards) and closes its known gaps against CI: the missing
DISH_REQUIRE_TRANSLATIONS=ON, the missing qmllint -I $QT_ROOT_DIR/qml
include, and single-compiler blindness (new --compiler gcc|clang).
scripts/install-deps.sh lands with a --ci-qt flag for CI's aqtinstall
6.9.3, since distro lupdate under 6.9 breaks the translation gate. The
three near-identical inline cmake+cpack blocks (PR package job, release
deb, release rpm) become scripts/build-deb.sh + build-rpm.sh over a
shared package preset. The pre-commit hook and docs pointed clang-tidy
at build-debug while CI used build; everything now agrees on CI's tree
names.

Compiler matrix and ccache stay workflow-side by design (a preset naming
a compiler would break the other matrix leg); sanitizer/coverage
instrumentation stays lane-specific, mirrored by ci-local's
--with-sanitizers.

No Linux toolchain exists on the dev box, so beyond bash -n, YAML/preset
validation, the pin-lint awk, and a real check-format.sh run, the preset
plumbing proves out on this PR's CI.

Stacked on #48 (both edit linux-ci.yml); will be rebased onto main once
that merges.
emir-hasanbegovic added a commit to TinkerNorth/dish-windows that referenced this pull request Sep 2, 2026
Part of the fleet-wide build unification (satellite's landed inside
TinkerNorth/satellite#88): local builds and CI now run through the same
entry points, so they cannot drift. CMakePresets.json (new here) holds
both configure lines; scripts/ gains install-deps, ci-local,
check-format, check-qml, check-tidy, and stage-bundle; build.ps1 rides
the presets; windows-ci.yml, codeql.yml, and release.yml call the shared
scripts and keep only cache/artifact/signing glue.

Drift this killed: the local debug tree was build-debug while CI's was
build (hook and clang-tidy now agree on build); local release builds
forced tests ON where CI builds them OFF; DISH_REQUIRE_TRANSLATIONS
differed; the local clang-format was unpinned. The staging unification
also found a real shipping bug: the release zip never staged
libcrypto-3-x64.dll (dish.exe imports it directly) and its smoke test
passed only because GitHub runners carry one on PATH via Strawberry Perl
- both lanes now stage the identical bundle from
scripts/stage-bundle.ps1, which also brings the licence texts into the
CI artifact.

scripts/ci-local.ps1 ran end to end on the dev box: format (456 files),
pins, debug preset build, 2087/2087 ctest, qmllint (72), literal
scanner, translations 6x1082, clang-tidy (93), release build, staged
bundle (160 files, 14 portable-smoke checks), and build-installer
produced dish-setup.exe 1.1.0. The installer round-trip stays CI-only by
design (it really installs).

Stacked on #66 (both edit windows-ci.yml); will be rebased onto main
once that merges.

---------

Co-authored-by: Emir Hasanbegovic <1190336+emir-hasanbegovic@users.noreply.github.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.

1 participant