Protocol v2: controller audio (mic + speaker endpoints) - #88
Conversation
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.
… 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.
|
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. |
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.
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>
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.
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>
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
Levelvalue 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.hwith no<windows.h>, pinned by a suite that runs on every CI platform; COM, cfgmgr32 and the one undocumented write live inaudio_endpoint_com.cpp.Two parts worth review attention:
ROOT\HIDMAESTRO_UDE, a real pad at a PCI xHCI controller. A positive test, not a heuristic.SetDefaultEndpointhas no documented API. It isIPolicyConfigvtable slot 13 on an undocumented coclass. Rather than trust the ordinal, the binding first calls slot 7 asGetProcessingPeriodand requires two plausible periods back — which is what separates the 12-method layout from the 11-method Vista one, whose slot 13 isSetEndpointVisibilityand 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
controllerAudiowas one boolean covering both directions, so a host that wanted the pad's microphone had to accept its speaker too. AddscontrollerAudioMicandcontrollerAudioSpeaker.These gate the wire, not the persona. HIDMaestro has no mic-only USB Audio function — all three of its audio composites declare both an
audioStreamingOutand anaudioStreamingIninterface — 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/capabilitiesgains an additivecontrollerAudio: {enabled, mic, speaker}block reporting what will actually flow —enabledANDs 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 isserverVersion+localeand 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
makeMicDecoderandmakeSpeakerEncoder, 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 setsOPUS_SET_DTX(1)on its own encoder. Verified compatible in advance: DTX emits 1-byte packets, exactlyAUDIO_WIRE_MIN_PAYLOAD_BYTESminus the header, and a real DTX stream through this repo'sAudioJitterWindowgave 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 viadecode_fecvs −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
changelistener on the audio toggle.Build script
Separate commit:
install-dependencies.batstill installed the pre-CMake toolchain — no pkg-config (which CMakeLists uses to resolve OpenSSL and libopus), no libopus at all, and no CMake, even thoughbuild-satellite.batandbuild-tests.batare 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_servicealone; 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,IPolicyConfigvalidation returningusablefor a live endpoint andprobe-call-failedfor a bogus one, defaults unchanged before and after.Route tests only build on the Linux/macOS lanes; they were checked with
g++ -fsyntax-onlyunder 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:
protocolVersionstays 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
controllerAudioblock 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
SetDefaultEndpointwrite 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.