diff --git a/.github/actions/setup-build-deps/action.yml b/.github/actions/setup-build-deps/action.yml
index f26206d..bd71435 100644
--- a/.github/actions/setup-build-deps/action.yml
+++ b/.github/actions/setup-build-deps/action.yml
@@ -34,7 +34,7 @@ runs:
# shellcheck disable=SC2086
sudo apt-get install -y --no-install-recommends \
build-essential cmake ninja-build pkg-config ccache \
- libsodium-dev libsdl2-dev libssl-dev \
+ libsodium-dev libsdl2-dev libssl-dev libopus-dev \
catch2 clang-tidy ${EXTRA_PACKAGES}
- name: Restore compiler cache
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 7b11469..895b24a 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -52,7 +52,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential cmake ninja-build pkg-config \
- libsodium-dev libsdl2-dev libssl-dev
+ libsodium-dev libsdl2-dev libssl-dev libopus-dev
# Not apt: noble ships Qt 6.4.2 and this project's floor is 6.7. The
# action pulls the official binaries and exports CMAKE_PREFIX_PATH.
diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml
index 82f8da5..9caf354 100644
--- a/.github/workflows/linux-ci.yml
+++ b/.github/workflows/linux-ci.yml
@@ -260,7 +260,7 @@ jobs:
build-essential cmake ninja-build pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \
qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools \
- libsodium-dev libsdl2-dev libssl-dev libdbus-1-dev \
+ libsodium-dev libsdl2-dev libssl-dev libopus-dev libdbus-1-dev \
dpkg-dev fakeroot file gzip \
librsvg2-bin desktop-file-utils appstream lintian
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7bd3c64..47582b1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -164,7 +164,7 @@ jobs:
build-essential cmake ninja-build pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \
qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools \
- libsodium-dev libsdl2-dev libssl-dev libdbus-1-dev \
+ libsodium-dev libsdl2-dev libssl-dev libopus-dev libdbus-1-dev \
dpkg-dev fakeroot file gzip \
librsvg2-bin desktop-file-utils appstream lintian
@@ -323,7 +323,7 @@ jobs:
git-core ca-certificates \
gcc-c++ cmake ninja-build pkgconf-pkg-config rpm-build \
qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qtsvg-devel qt6-qttools-devel \
- libsodium-devel SDL2-devel openssl-devel dbus-devel \
+ libsodium-devel SDL2-devel openssl-devel opus-devel dbus-devel \
librsvg2-tools desktop-file-utils libappstream-glib gzip
- name: Checkout
@@ -466,7 +466,7 @@ jobs:
# resolve on the build host.
sudo apt-get install -y --no-install-recommends \
build-essential cmake ninja-build pkg-config \
- libsodium-dev libssl-dev libudev-dev libusb-1.0-0-dev \
+ libsodium-dev libssl-dev libopus-dev libpulse-dev libudev-dev libusb-1.0-0-dev \
libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 \
libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 \
libxcb-xinerama0 libxcb-xkb1 \
@@ -480,8 +480,12 @@ jobs:
# Not apt for the same reason as Qt: jammy ships SDL 2.0.20 and the code
# uses SDL_GameControllerPath, which needs 2.24. Same version, checksum
- # and gamepad-only feature set as the Flatpak manifest's sdl2 module;
- # linuxdeploy bundles the resulting library into the AppImage. Runs
+ # and feature set as the Flatpak manifest's sdl2 module: gamepads plus
+ # the audio subsystem controller audio opens the pads' own endpoints
+ # through (libpulse-dev above is what lets its pulse backend configure;
+ # SDL dlopen's the host's libpulse at run time, so nothing extra is
+ # bundled). linuxdeploy bundles the resulting library into the AppImage.
+ # Runs
# after setup-qt because that action overwrites CMAKE_PREFIX_PATH and
# this step prepends to it.
- name: Build SDL2 (jammy's predates SDL_GameControllerPath)
@@ -498,7 +502,7 @@ jobs:
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="${RUNNER_TEMP}/sdl-prefix" \
-DSDL_STATIC=OFF -DSDL_SHARED=ON -DSDL_TEST=OFF \
- -DSDL_RENDER=OFF -DSDL_AUDIO=OFF \
+ -DSDL_RENDER=OFF -DSDL_AUDIO=ON \
-DSDL_HIDAPI=ON -DSDL_JOYSTICK=ON -DSDL_HAPTIC=ON
cmake --build "${RUNNER_TEMP}/sdl-build" --parallel
cmake --install "${RUNNER_TEMP}/sdl-build"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0693db9..0786886 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,64 @@ the repos share a version number.
### Added
+- **Controller audio, wave 1: wire + capability model** `[wire-coordinated]`
+ (satellite's `MSG_MIC_AUDIO`/`MSG_SPEAKER_AUDIO`/`MSG_MIC_LED`; dish-android
+ shipped the client reference; dish-windows companion). This lands the
+ protocol-2 audio extension's plumbing without yet turning any audio on:
+ - the wire: `MSG_MIC_AUDIO` (0x0012) send path, `MSG_SPEAKER_AUDIO` (0x0013)
+ and `MSG_MIC_LED` (0x0014) dispatch, the `mic`/`speaker` descriptor caps,
+ and the datagram ceilings (1500-byte receive buffer, 1472-byte inner
+ payload guard) a full-size audio frame needs;
+ - the pure cores: the 2-frame reorder window (`core/audio/AudioJitter.h`,
+ the third mirror of satellite's — edit together) and the pinned Opus
+ formats (mic mono VOIP 32 kbps DTX, speaker stereo AUDIO 96 kbps, both
+ VBR + in-band FEC) behind `core/audio/AudioCodec.h`, libopus-backed in
+ `source/audio/OpusAudioCodec.*` (new system dependency: `libopus`, found
+ through `pkg-config` like SDL2 and libsodium);
+ - the host verdict: `GET /api/server/capabilities` is now probed after every
+ session PUT for the `controllerAudio` block (per-backend `audio` fallback),
+ so the capability table's mic/speaker host layer reflects what the host
+ will actually carry — conservative "no audio" until a probe says yes;
+ - the model and UI: Microphone and Controller sound rows in the capability
+ matrix and per-binding toggles (mic defaults OFF for privacy, speaker ON),
+ persisted like the motion toggle; `wButtons` bit 0x0800 reserved as the
+ DualSense mic-mute state.
+- **Controller audio, wave 2: the audio itself** `[wire-coordinated]`. A
+ Direct-claimed DualSense (or DS4 v2) now carries real audio end to end:
+ - pad-to-endpoint routing: the claimed pad's USB product string (iProduct,
+ read from sysfs — the same string pipewire and pulse embed in the pad's
+ audio device names) is matched against the endpoints SDL enumerates, with
+ every ambiguity resolving to "no route" (two pads sharing a name,
+ duplicate endpoint names, a name containing two pads' strings) — routes
+ re-resolve on claim changes and audio hotplug, and a change re-declares
+ the slot's descriptor;
+ - the capture engine: the pad's own headset mic, windowed to exact 20 ms
+ frames, Opus-encoded and sent as `MSG_MIC_AUDIO`, one seq per window
+ including failed encodes. THE PRIVACY INVARIANT: muted, toggled off,
+ unrouted, unstreaming or unwelcome at the host means the capture device is
+ CLOSED and zero packets leave — never silence in their place;
+ - the playout engine: `MSG_SPEAKER_AUDIO` through the reorder window and
+ Opus FEC/PLC to the pad's own speaker endpoint, with a two-frame start
+ cushion rebuilt as silence after the satellite's suppressed-silence
+ stretches;
+ - the DualSense mute button: decoded as an edge onto a latch that folds the
+ mute STATE into `wButtons` (0x0800) on the read thread, mirrored to the
+ app's mute state, stripped from Moonlight's button words; the slot card
+ and Configure binding show the local truth with a click-to-toggle control,
+ and the pad's mute lamp answers locally at once (a later host `MSG_MIC_LED`
+ repaints it — last writer wins on the pad);
+ - `MSG_MIC_LED` actuation: the DS5 lamp + mic-amp power-save bit, shadowed in
+ the per-claim feedback state so rumble/lightbar/player-LED/trigger writes
+ re-assert it instead of stomping it;
+ - `SDL_INIT_AUDIO` is owned by the audio gateway, not the gamepad bridge, so
+ gamepad re-inits never take a live stream down; the bridge's event loop
+ forwards audio hotplug. The Flatpak gains the `pulseaudio` socket (served
+ by `pipewire-pulse` on PipeWire desktops), and the AppImage's bundled SDL
+ is now built with its audio subsystem on.
+ Mute is deliberately session-scoped (not persisted): it clears when the pad
+ leaves, the way the hardware's own mute does; the durable off-switch is the
+ per-binding Microphone toggle, which still defaults OFF.
+
- **Protocol 2** `[wire-coordinated]` (satellite #86, #87; dish-android #174,
#175; dish-windows companion). The version is now negotiated rather than
assumed: the client offers 2, the satellite settles the session on that
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1c32893..2e20a79 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -137,6 +137,9 @@ find_package(Qt6 6.7 QUIET OPTIONAL_COMPONENTS LinguistTools)
find_package(PkgConfig REQUIRED)
pkg_check_modules(SODIUM REQUIRED IMPORTED_TARGET libsodium)
pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2)
+# Controller audio (source/audio/OpusAudioCodec): the reference Opus codec,
+# found the same pkg-config way as the two above — every distro ships opus.pc.
+pkg_check_modules(OPUS REQUIRED IMPORTED_TARGET opus)
# OpenSSL's libcrypto backs the Moonlight-host pairing (AES-128, RSA, self-
# signed X.509) and the control-stream AES-GCM, none of which libsodium offers.
# Only libcrypto is used; the TLS client itself is Qt Network's.
@@ -222,6 +225,24 @@ set(DISH_CORE_SOURCES
src/core/reducer/LatencyWindow.h
src/core/reducer/CapabilitySolver.h
src/core/reducer/CapabilitySolver.cpp
+ src/core/reducer/HostAudioVerdict.h
+ # Controller audio: the reorder window, the pure codec seam, the
+ # pad-to-endpoint matcher and the engine rules in core; the libopus link,
+ # the SDL device gateway and the engines at the source edge with the other
+ # third-party-facing code.
+ src/core/audio/AudioJitter.h
+ src/core/audio/AudioCodec.h
+ src/core/audio/PadAudioMatcher.h
+ src/core/audio/AudioEnginePolicy.h
+ src/source/audio/OpusAudioCodec.h
+ src/source/audio/OpusAudioCodec.cpp
+ src/source/audio/AudioDeviceGateway.h
+ src/source/audio/SdlAudioGateway.h
+ src/source/audio/SdlAudioGateway.cpp
+ src/source/audio/MicCaptureEngine.h
+ src/source/audio/MicCaptureEngine.cpp
+ src/source/audio/SpeakerPlayoutEngine.h
+ src/source/audio/SpeakerPlayoutEngine.cpp
src/core/reducer/ApplyBindingMachine.h
src/core/reducer/CatalogFeatureGate.h
src/core/reducer/CatalogPrewarm.h
@@ -308,6 +329,10 @@ set(DISH_CORE_SOURCES
src/source/store/ControllerTypeStore.cpp
src/source/store/MotionEnabledStore.h
src/source/store/MotionEnabledStore.cpp
+ src/source/store/AudioEnabledStore.h
+ src/source/store/AudioEnabledStore.cpp
+ src/source/store/MicMuteStore.h
+ src/source/store/MicMuteStore.cpp
src/source/store/SatelliteMotionBackendStatusStore.h
src/source/store/SatelliteMotionBackendStatusStore.cpp
src/source/store/UsbPathPreferenceStore.h
@@ -388,6 +413,8 @@ set(DISH_CORE_SOURCES
src/repository/DeadzoneRepository.cpp
src/repository/MotionPreferenceRepository.h
src/repository/MotionPreferenceRepository.cpp
+ src/repository/AudioPreferenceRepository.h
+ src/repository/AudioPreferenceRepository.cpp
src/repository/TouchpadModeRepository.h
src/composer/ConnectionsComposer.h
src/composer/ConnectionsComposer.cpp
@@ -465,6 +492,8 @@ target_link_libraries(dish_core
Qt6::Concurrent
PkgConfig::SODIUM
PkgConfig::SDL2
+ # Controller audio (see the pkg_check_modules comment above).
+ PkgConfig::OPUS
Threads::Threads
PRIVATE
dish_warnings
diff --git a/README.md b/README.md
index 30dead7..21c924a 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ it in place. What the check sends is spelled out in [`PRIVACY.md`](PRIVACY.md).
- GCC 12+ or Clang 15+, CMake 3.21+, Ninja
- Qt 6.7+ (Core, Gui, Network, DBus, Svg, Quick, Qml, QuickControls2; Linguist
tools for the translation catalogues)
-- libsodium, SDL2, OpenSSL (libcrypto), Catch2 v3
+- libsodium, SDL2, Opus, OpenSSL (libcrypto), Catch2 v3
- Optional: `rsvg-convert`, which renders the rest of the launcher-icon ladder
from the SVG. Without it the build says so and installs only the scalable and
512x512 icons, which is enough for a working menu entry.
@@ -114,7 +114,7 @@ On Debian and Ubuntu:
sudo apt install build-essential cmake ninja-build pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \
qt6-tools-dev qt6-l10n-tools \
- libsodium-dev libsdl2-dev libssl-dev libdbus-1-dev catch2 \
+ libsodium-dev libsdl2-dev libssl-dev libopus-dev libdbus-1-dev catch2 \
librsvg2-bin
```
diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md
index 44738cc..0988578 100644
--- a/THIRD_PARTY.md
+++ b/THIRD_PARTY.md
@@ -5,10 +5,10 @@ third-party component that is linked into `dish`, embedded in it as a resource,
or used only to build and test it. It also states what someone redistributing
the binary has to do.
-Nothing is bundled. Qt, SDL2 and libsodium are dynamically linked against the
-system copies your distribution provides, so a redistributor of a package built
-from this tree is redistributing those libraries under their own distribution's
-terms, not through this repository.
+Nothing is bundled. Qt, SDL2, libsodium and Opus are dynamically linked against
+the system copies your distribution provides, so a redistributor of a package
+built from this tree is redistributing those libraries under their own
+distribution's terms, not through this repository.
The app has an in-app version of this list at Settings, Licenses, rendered from
[`assets/licenses/licenses.json`](assets/licenses/licenses.json). That manifest
@@ -25,6 +25,7 @@ today.
| [Qt 6](#2-qt-6) | CMake requires >= 6.7 | `LGPL-3.0-only` | Dynamically linked against the system Qt. Nothing is bundled. | Notice, license text, relink freedom. See section 2. |
| [SDL2](#sdl2) | >= 2.0.18 | `Zlib` | Dynamically linked against the system SDL2. | Keep the notice, do not claim authorship |
| [libsodium](#libsodium) | >= 1.0.18 | `ISC` | Dynamically linked against the system libsodium. | Keep the copyright and permission notice |
+| [Opus](#opus) | >= 1.3 | `BSD-3-Clause` | Dynamically linked against the system libopus. | Keep the copyright notice and disclaimer |
| [Inter](#4-inter) | 4.001 | `OFL-1.1` | Four `.ttf` faces embedded in `dish` as Qt resources under `:/fonts/`. | Ship the license text with every copy. See section 4. |
| [Catch2](#5-catch2) | 3.x | `BSL-1.0` | Test binary only. Not linked into `dish`. | None for redistributors of the app |
| [ENet (cgutman fork)](#9-enet) | commit `4cde9cc` | `MIT` | Vendored C sources under `third_party/enet/`, compiled into `dish`. | Ship the copyright + permission notice |
@@ -130,9 +131,9 @@ obligations on redistribution.
## 3. Linked system libraries
-SDL2 and libsodium are found through `pkg-config` and dynamically linked against
-whatever the build host provides. Neither is vendored into this tree and neither
-is bundled with the binary.
+SDL2, libsodium and Opus are found through `pkg-config` and dynamically linked
+against whatever the build host provides. None of them is vendored into this
+tree and none is bundled with the binary.
### SDL2
@@ -190,6 +191,50 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
```
+### Opus
+
+Opus audio codec (libopus). SPDX `BSD-3-Clause`. Upstream:
+.
+
+Used for the controller-audio streams only: encoding a claimed pad's
+microphone capture and decoding host audio for the pad's own speaker
+(`src/source/audio/OpusAudioCodec.*`).
+
+```
+Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic,
+ Jean-Marc Valin, Timothy B. Terriberry,
+ CSIRO, Gregory Maxwell, Mark Borgerding,
+ Erik de Castro Lopo, Mozilla, Amazon
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+- Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+- Neither the name of Internet Society, IETF or IETF Trust, nor the
+names of specific contributors, may be used to endorse or promote
+products derived from this software without specific prior written
+permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
## 4. Inter
@@ -354,8 +399,8 @@ Apollo. Wolf's documentation and MIT source were sufficient.
[`assets/licenses/licenses.json`](assets/licenses/licenses.json) is the manifest
the in-app Licenses screen renders, parsed by `src/UI/licenses/LicenseManifest.*`.
It is hand-authored, not generated, so it can drift. It currently lists Qt 6,
-SDL2, libsodium, Catch2 and Inter, which is the same set as this file, with the
-same licenses. One difference is worth knowing about:
+SDL2, libsodium, Opus, Catch2 and Inter, which is the same set as this file,
+with the same licenses. One difference is worth knowing about:
- The manifest lists Catch2, which is test-only and is not in the shipped
binary. Showing it to a user is harmless but inaccurate.
diff --git a/assets/licenses/licenses.json b/assets/licenses/licenses.json
index 1238c1d..f8d878f 100644
--- a/assets/licenses/licenses.json
+++ b/assets/licenses/licenses.json
@@ -40,6 +40,19 @@
}
]
},
+ {
+ "group": "org.opus-codec",
+ "artifact": "opus",
+ "version": "1.3+",
+ "name": "Opus",
+ "url": "https://opus-codec.org/",
+ "licenses": [
+ {
+ "name": "BSD 3-Clause License",
+ "url": "https://opensource.org/license/bsd-3-clause"
+ }
+ ]
+ },
{
"group": "org.catchorg",
"artifact": "Catch2",
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ba45351..f6bafc2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -317,7 +317,7 @@ The threads in the app:
| SDL input | [`SDLGamepadBridge`](../src/Input/SDLGamepadBridge.h) | The SDL event pump, `GamepadInputProcessor::publish`, and the `sendto` that follows it |
| USB direct | `UsbGamepadManager` per claimed device | Raw-HID URB reads, feeding the same publish path |
| Heartbeat | [`SatelliteClient`](../src/Network/SatelliteClient.h) | Per-session keepalive sends and ping arming |
-| Receive | `SatelliteClient` | Ack decode, RTT sampling, the feedback callbacks (rumble, lightbar, trigger effects, player LEDs), close-notify |
+| Receive | `SatelliteClient` | Ack decode, RTT sampling, the feedback callbacks (rumble, lightbar, trigger effects, player LEDs, speaker audio, mic LED), close-notify |
**The input hot path is intentionally not routed through the kernel.** The
input, decode, encode, `sendto` path is plain C++ with no queue, no Qt event
@@ -353,20 +353,75 @@ an actuator the satellite will never drive.
questions from the same inputs, and both `ConnectionHub`'s capability functions
and `AppModel::actuate*` go through it. The two paths carry different amounts:
-| Path | Rumble | Lightbar | Adaptive triggers | Player LEDs |
-|---|---|---|---|---|
-| Standard (SDL) | yes | yes | **no** | **no** |
-| Direct (raw HID) | yes | yes | yes | yes |
-
-SDL has a rumble call and an LED call and nothing else, so the last two columns
-are structurally out of reach there however good the pad is. The Direct path
-reaches them because the claim writes OUT reports as well as reading IN ones;
-the bytes are built by [`UsbOutputReports`](../src/core/input/UsbOutputReports.h),
-which is pure and host-tested, and the gateway adds only the framing the
-platform itself demands (on hidraw, none: the builder's length is the transfer
-length). A Direct claim that has gone away carries nothing —
-there is deliberately no fallback to Standard, because a pad on the Direct path
-is not open on the SDL path at the same time.
+| Path | Rumble | Lightbar | Adaptive triggers | Player LEDs | Mic-mute lamp |
+|---|---|---|---|---|---|
+| Standard (SDL) | yes | yes | **no** | **no** | **no** |
+| Direct (raw HID) | yes | yes | yes | yes | yes* |
+
+SDL has a rumble call and an LED call and nothing else, so the last three
+columns are structurally out of reach there however good the pad is. The Direct
+path reaches them because the claim writes OUT reports as well as reading IN
+ones; the bytes are built by
+[`UsbOutputReports`](../src/core/input/UsbOutputReports.h), which is pure and
+host-tested, and the gateway adds only the framing the platform itself demands
+(on hidraw, none: the builder's length is the transfer length). A Direct claim
+that has gone away carries nothing — there is deliberately no fallback to
+Standard, because a pad on the Direct path is not open on the SDL path at the
+same time.
+
+\* `MSG_MIC_LED` (0x0014) resolves through the same router into the DS5
+mute-lamp builder, whose state lives in the per-claim `FeedbackState` shadow:
+every DS5 report starts from a fresh memset and the firmware applies whatever
+the valid flags claim, so each builder re-asserts the last lamp or an unrelated
+colour change would be a lamp-off write. The lamp is also driven locally the
+moment mute toggles (the button must never feel dead waiting on a host
+round-trip); a later host write repaints it — last writer wins on the PAD,
+while the app UI always shows the local mute truth (`MicMuteStore`).
+
+### Controller audio: routes, verdict, engines
+
+The controller-audio caps (`mic`/`speaker`) go through the same "advertised iff
+it lands" rule but are keyed on the pad's AUDIO routes
+(`slotCarriesMicCapture` / `slotCarriesSpeakerPlayout`) rather than the HID
+path: the streams ride the pad's own USB-audio endpoints, a separate USB
+interface the OS keeps while this client claims only HID. The routes come from
+[`PadAudioMatcher`](../src/core/audio/PadAudioMatcher.h), which matches the
+claimed pad's USB product string (iProduct, read from sysfs at enumeration —
+the same string pipewire and pulse embed in the pad's audio device names)
+against the endpoint names SDL enumerates, and resolves EVERY ambiguity to "no
+route" (two DualSenses share a string; so does a DS4 — a wrong route is worse
+than none). `AppModel::resolveAudioRoutes` re-runs it on claim changes and on
+SDL's audio hotplug events (pumped by the bridge's event loop, forwarded as
+`audioDevicesChanged`), and a changed route re-binds the affected slots so
+their descriptors re-fold and re-PUT.
+
+The host's live verdict is a third layer: `WifiConnectionManager::probeHostAudio`
+reads `GET /api/server/capabilities` after every session PUT and folds it via
+[`HostAudioVerdict`](../src/core/reducer/HostAudioVerdict.h) into per-session
+connection state — conservative "no audio" until a probe answers, reset with
+the session.
+
+The engines execute the pure rules in
+[`AudioEnginePolicy`](../src/core/audio/AudioEnginePolicy.h) and nothing else:
+`MicCaptureEngine` opens one capture device per eligible slot (streaming AND
+toggled on AND route matched AND host verdict AND not muted), windows the
+callback stream to exact 20 ms frames, Opus-encodes and sends `MSG_MIC_AUDIO`
+with a caller-owned seq that advances on failed encodes too;
+`SpeakerPlayoutEngine` holds one playback device per eligible slot, fed on the
+receive thread through the reorder window and the Opus decoder's FEC/PLC, with
+a two-frame start cushion that is rebuilt as silence when a suppressed-silence
+stretch drains the queue. Both talk SDL only through
+[`AudioDeviceGateway`](../src/source/audio/AudioDeviceGateway.h);
+`SdlAudioGateway` owns `SDL_INIT_AUDIO`'s lifecycle — deliberately NOT the SDL
+bridge, whose gamepad subsystems stop and start without taking a live stream
+down. THE PRIVACY INVARIANT: muted or ineligible means the capture device is
+CLOSED and zero `sendMicAudio` calls happen — not silence sent in their place —
+enforced structurally by the reconcile and per-window by the session's enabled
+flag, and pinned by the engine tests. The DualSense's own mute button is a
+decoder-owned latch (`MicMuteLatch`) folded into `wButtons` as `kXusbMicMute`
+on the read thread, mirrored up on the edge, and stripped from every Moonlight
+button word by `moonmap::toMoonlightButtons`, whose explicit per-bit map has no
+row for it (a pin test keeps it that way).
**Not reachable on this platform, with the reason:** the Xbox One / Series pad's
impulse-trigger motors. xpad binds an Xbox-class pad as evdev-only and publishes
diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD
index 6a5a1ff..d29ab25 100644
--- a/packaging/aur/PKGBUILD
+++ b/packaging/aur/PKGBUILD
@@ -24,7 +24,7 @@ arch=('x86_64')
install='dish-bin.install'
url="https://github.com/TinkerNorth/dish-linux"
license=('LGPL-3.0-or-later')
-# The AppImage bundles Qt, SDL2 and libsodium; what remains is the AppImage
+# The AppImage bundles Qt, SDL2, libsodium and libopus; what remains is the AppImage
# runtime itself and the desktop plumbing the entry/icon rely on.
depends=(
'fuse2'
diff --git a/packaging/flatpak/com.tinkernorth.Dish.yml b/packaging/flatpak/com.tinkernorth.Dish.yml
index b9606df..9927dbc 100644
--- a/packaging/flatpak/com.tinkernorth.Dish.yml
+++ b/packaging/flatpak/com.tinkernorth.Dish.yml
@@ -31,6 +31,13 @@ finish-args:
# encrypted UDP session all need the host network.
- --share=network
+ # Controller audio: a claimed pad's own mic and speaker endpoints are
+ # ordinary audio devices, reached through the session audio server. The
+ # pulseaudio socket is the one grant that covers both stacks — on a PipeWire
+ # desktop it is served by pipewire-pulse, so no pipewire-specific grant is
+ # needed.
+ - --socket=pulseaudio
+
# SDL input needs /dev/input/event*; USB-direct needs O_RDWR on /dev/hidraw*,
# which no narrower grant provides — there is no hidraw portal, and
# org.freedesktop.portal.Usb covers raw USB devices, not hidraw character
@@ -59,6 +66,10 @@ finish-args:
modules:
# Neither is in org.kde.Platform. Checksums verified against the release
# tarballs; re-verify on every bump with `curl -sL | sha256sum`.
+ # (libopus needs no module: the freedesktop base runtime ships it and the
+ # Sdk carries its headers, so the build's REQUIRED opus.pc check binds to
+ # the runtime copy — and fails the build loudly if a runtime bump ever
+ # drops it.)
- name: libsodium
buildsystem: autotools
config-opts:
@@ -82,10 +93,13 @@ modules:
- -DSDL_STATIC=OFF
- -DSDL_SHARED=ON
- -DSDL_TEST=OFF
- # Dish uses SDL for gamepads only; the render and audio backends are dead
- # weight in the sandbox.
+ # Dish uses SDL for gamepads and for controller audio (the pad's own mic
+ # and speaker endpoints); only the render backend is dead weight in the
+ # sandbox. The audio backends auto-detect against the Sdk: pulse (served
+ # by pipewire-pulse on PipeWire desktops) is the one the pulseaudio
+ # socket grant reaches.
- -DSDL_RENDER=OFF
- - -DSDL_AUDIO=OFF
+ - -DSDL_AUDIO=ON
- -DSDL_HIDAPI=ON
- -DSDL_JOYSTICK=ON
- -DSDL_HAPTIC=ON
diff --git a/packaging/flatpak/flathub/com.tinkernorth.Dish.yml b/packaging/flatpak/flathub/com.tinkernorth.Dish.yml
index a1630ba..5ce8420 100644
--- a/packaging/flatpak/flathub/com.tinkernorth.Dish.yml
+++ b/packaging/flatpak/flathub/com.tinkernorth.Dish.yml
@@ -24,6 +24,13 @@ finish-args:
# encrypted UDP session all need the host network.
- --share=network
+ # Controller audio: a claimed pad's own mic and speaker endpoints are
+ # ordinary audio devices, reached through the session audio server. The
+ # pulseaudio socket is the one grant that covers both stacks — on a PipeWire
+ # desktop it is served by pipewire-pulse, so no pipewire-specific grant is
+ # needed.
+ - --socket=pulseaudio
+
# SDL input needs /dev/input/event*; USB-direct needs O_RDWR on /dev/hidraw*,
# which no narrower grant provides — there is no hidraw portal, and
# org.freedesktop.portal.Usb covers raw USB devices, not hidraw character
@@ -52,6 +59,10 @@ finish-args:
modules:
# Neither is in org.kde.Platform. Checksums verified against the release
# tarballs; re-verify on every bump with `curl -sL | sha256sum`.
+ # (libopus needs no module: the freedesktop base runtime ships it and the
+ # Sdk carries its headers, so the build's REQUIRED opus.pc check binds to
+ # the runtime copy — and fails the build loudly if a runtime bump ever
+ # drops it.)
- name: libsodium
buildsystem: autotools
config-opts:
@@ -75,10 +86,13 @@ modules:
- -DSDL_STATIC=OFF
- -DSDL_SHARED=ON
- -DSDL_TEST=OFF
- # Dish uses SDL for gamepads only; the render and audio backends are dead
- # weight in the sandbox.
+ # Dish uses SDL for gamepads and for controller audio (the pad's own mic
+ # and speaker endpoints); only the render backend is dead weight in the
+ # sandbox. The audio backends auto-detect against the Sdk: pulse (served
+ # by pipewire-pulse on PipeWire desktops) is the one the pulseaudio
+ # socket grant reaches.
- -DSDL_RENDER=OFF
- - -DSDL_AUDIO=OFF
+ - -DSDL_AUDIO=ON
- -DSDL_HIDAPI=ON
- -DSDL_JOYSTICK=ON
- -DSDL_HAPTIC=ON
diff --git a/src/AppModel.cpp b/src/AppModel.cpp
index e48284e..86cdf7d 100644
--- a/src/AppModel.cpp
+++ b/src/AppModel.cpp
@@ -88,6 +88,12 @@ AppModel::AppModel(std::unique_ptr inhibitor, QObject* pa
QObject::connect(hub_, &net::ConnectionHub::changed, this, &AppModel::onHubChanged);
QObject::connect(bridge_, &input::SDLGamepadBridge::devicesChanged, this,
&AppModel::onBridgeDevicesChanged);
+ // An audio endpoint arriving or leaving re-runs the pad-to-endpoint
+ // matcher: a composite pad's audio function enumerates a beat after its
+ // HID interface, and an unplug must pull the caps back before the host
+ // keeps streaming into nothing.
+ QObject::connect(bridge_, &input::SDLGamepadBridge::audioDevicesChanged, this,
+ &AppModel::resolveAudioRoutes);
// A direct relay: the bridge already QueuedConnection-hops to this thread.
QObject::connect(bridge_, &input::SDLGamepadBridge::rawJoystickInput, this,
&AppModel::rawJoystickInput);
@@ -271,6 +277,19 @@ AppModel::AppModel(std::unique_ptr inhibitor, QObject* pa
return reducer::slotCarriesFeedback(feedbackInputs(slotId), reducer::FeedbackKind::Rumble);
});
+ // CAP_MIC / CAP_SPEAKER: the audio route (a Wave-2 seam that answers false
+ // for every slot today) gated by the per-binding toggle, same shape as
+ // CAP_MOTION above. The mic gate matters doubly — CAP_MIC is also what
+ // invites the MIC_LED return path, and a lamp for a microphone the user
+ // switched off would report a stream that cannot exist.
+ hub_->setMicCapabilityFn([this](const QString& slotId) {
+ return slotCarriesMicSource(slotId) && micEnabledStore_.isEnabled(slotId.toStdString());
+ });
+ hub_->setSpeakerCapabilityFn([this](const QString& slotId) {
+ return slotCarriesSpeakerSink(slotId) &&
+ speakerEnabledStore_.isEnabled(slotId.toStdString());
+ });
+
// The user's Emulate override wins over the SDL hardware classification;
// resolveControllerType applies that ladder.
hub_->setControllerTypeFn(
@@ -444,6 +463,23 @@ void AppModel::installRumbleHandlers() {
if (deviceId.isEmpty()) { return; }
actuatePlayerLeds(deviceId, pm.ledMask);
});
+ // MSG_SPEAKER_AUDIO: straight into the playout engine on the receive
+ // thread — reorder window, Opus decode, the pad's own endpoint. The
+ // message borrows the receive buffer and the engine consumes it before
+ // returning, which is the SpeakerAudioMessage contract. Arrives only
+ // for a slot whose descriptor claimed CAP_SPEAKER; a frame for a voice
+ // the engine does not hold is dropped inside deliver().
+ conn->setSpeakerAudioHandler(
+ [this, id](const net::SatelliteClient::SpeakerAudioMessage& sm) {
+ speakerEngine_.deliver(id.toStdString(), sm.controllerIndex, sm.seq, sm.opus,
+ sm.opusLen);
+ });
+ // MSG_MIC_LED: the mute lamp, routed like every other feedback kind.
+ conn->setMicLedHandler([this, id](const net::SatelliteClient::MicLedMessage& mm) {
+ const QString deviceId = boundSlotForConnection(id);
+ if (deviceId.isEmpty()) { return; }
+ actuateMicLed(deviceId, mm.state);
+ });
}
}
@@ -821,6 +857,10 @@ void AppModel::pollUsbDirect() {
}
if (changed) { rebuild(); }
}
+ // Claims move on this poll, and a claimed pad is what makes an endpoint
+ // matchable at all. Cheap when nothing moved: the resolve compares before
+ // it publishes.
+ resolveAudioRoutes();
}
void AppModel::onUsbDirectChanged() {
@@ -828,6 +868,7 @@ void AppModel::onUsbDirectChanged() {
// runs effects with its own lock released, and rebuild() only takes a fresh
// snapshot of controllers().
rebuild();
+ resolveAudioRoutes();
// A Direct->Standard pick parks the controller in AwaitingFramework, but on
// the SDL twin never left bridge_->devices(), so
@@ -1003,6 +1044,13 @@ void AppModel::rebuild() {
const auto bindings = hub_->bindings();
for (auto& s : next) {
+ // The mute control shows exactly where the descriptor claims a mic:
+ // the same fold the hub's capability fn makes (route AND toggle), so
+ // the card and the wire cannot disagree about whether a microphone
+ // exists to mute. The muted flag is the LOCAL truth, host lamp writes
+ // notwithstanding.
+ s.micArmed = slotCarriesMicSource(s.id) && micEnabledStore_.isEnabled(s.id.toStdString());
+ s.micMuted = micMuteStore_.isMuted(s.id.toStdString());
const auto cid = bindings.value(s.id);
// A Moonlight binding is a binding: the card, the accounting and the
// apply readback all key on boundConnectionId, so a pad driving a
@@ -1089,6 +1137,21 @@ void AppModel::rebuild() {
}
streamingSlotCount_.set(nextStreaming);
+ // Mute is a live control over a present pad: a departed slot's entry is
+ // dropped so a replugged pad (which reuses its model-keyed id) comes back
+ // live, the way the hardware itself does.
+ {
+ std::set presentIds;
+ for (const auto& s : state_.slotList) { presentIds.insert(s.id.toStdString()); }
+ micMuteStore_.retainOnly(presentIds);
+ }
+
+ // Every input the audio eligibility rules read funnels through this
+ // function (bindings, session states, toggles via re-bind, the probe
+ // verdict via poolChanged, mute via setSlotMicMuted), so the engines
+ // converge here and nowhere else.
+ reconcileAudioEngines();
+
emit stateChanged();
// Rides the same rebuild the Live transition triggered; the reducer's edge
@@ -1172,10 +1235,34 @@ reducer::SlotFeedbackInputs AppModel::feedbackInputs(const QString& slotId) cons
const auto vp = reducer::parseSyntheticSlotId(slotId.toStdString());
in.directClaimLive = vp.has_value() && usbManager_ != nullptr &&
usbManager_->isDirectClaimed(vp->first, vp->second);
+ if (vp.has_value()) {
+ const auto parser = input::usbparse::parserForDevice(vp->first, vp->second);
+ in.padMicLed = input::usbout::parserHasMicMuteLed(parser);
+ }
+ // The matcher's live answer: whether THIS pad's own endpoints were
+ // confidently named on this machine. The single fold both the
+ // descriptor caps and the engines read.
+ const auto route = audioRouteForSlot(slotId);
+ in.padMicRoute = route.microphone;
+ in.padSpeakerRoute = route.speaker;
}
return in;
}
+bool AppModel::slotCarriesMicSource(const QString& slotId) const {
+ return reducer::slotCarriesMicCapture(feedbackInputs(slotId));
+}
+
+bool AppModel::slotCarriesSpeakerSink(const QString& slotId) const {
+ return reducer::slotCarriesSpeakerPlayout(feedbackInputs(slotId));
+}
+
+reducer::HostAudioVerdict AppModel::hostControllerAudioFor(const QString& hostId) const {
+ const auto* conn = wifi_->get(hostId);
+ if (conn == nullptr) { return {}; } // conservative: no probe, no audio
+ return {conn->hostMicAvailable(), conn->hostSpeakerAvailable()};
+}
+
void AppModel::actuateRumble(const QString& slotId, std::uint16_t strong, std::uint16_t weak,
std::uint16_t durationMs) {
const auto in = feedbackInputs(slotId);
@@ -1244,6 +1331,152 @@ void AppModel::actuatePlayerLeds(const QString& slotId, std::uint8_t ledMask) {
}
}
+void AppModel::actuateMicLed(const QString& slotId, std::uint8_t state) {
+ const auto in = feedbackInputs(slotId);
+ if (reducer::resolveFeedbackTarget(in, reducer::FeedbackKind::MicLed) !=
+ reducer::FeedbackTarget::DirectUsb) {
+ return;
+ }
+ const auto vp = reducer::parseSyntheticSlotId(slotId.toStdString());
+ if (vp.has_value() && usbManager_ != nullptr) {
+ usbManager_->applyMicMuteLed(vp->first, vp->second, state);
+ }
+}
+
+audio::PadAudioRoute AppModel::audioRouteForSlot(const QString& slotId) const {
+ const auto vp = reducer::parseSyntheticSlotId(slotId.toStdString());
+ if (!vp.has_value()) { return {}; }
+ std::lock_guard lock(audioRoutesMtx_);
+ const auto it = padAudioRoutes_.find(audio::padAudioKey(vp->first, vp->second));
+ if (it == padAudioRoutes_.end()) { return {}; }
+ return it->second;
+}
+
+void AppModel::resolveAudioRoutes() {
+ if (usbManager_ == nullptr) { return; }
+ std::vector pads;
+ for (const auto& [key, c] : usbManager_->controllers()) {
+ // Claimed pads only: the physical-pad audio path is the Direct claim's
+ // (the SDL twin never carries the iProduct string to match on).
+ if (c.phase != reducer::UsbPhase::Direct || !c.syntheticId.has_value()) { continue; }
+ const auto parser = input::usbparse::parserForDevice(c.vendorId, c.productId);
+ pads.push_back(audio::AudioPadCandidate{c.vendorId, c.productId, c.name,
+ input::usbparse::parserHasUsbAudio(parser)});
+ }
+ auto routes = audio::resolvePadAudioRoutes(pads, audioGateway_.captureDeviceNames(),
+ audioGateway_.playbackDeviceNames());
+ {
+ std::lock_guard lock(audioRoutesMtx_);
+ if (routes == padAudioRoutes_) { return; }
+ padAudioRoutes_ = std::move(routes);
+ }
+ // The caps fold reads the routes, so every bound synthetic slot's
+ // descriptor has to re-fold and re-PUT — this is the "an endpoint
+ // appeared/vanished re-declares the slot" edge, and re-binding is the
+ // machinery that already exists for it (attachSlot detects the change and
+ // converges via the per-controller PUT).
+ const auto bindings = hub_->bindings();
+ for (auto it = bindings.cbegin(); it != bindings.cend(); ++it) {
+ if (reducer::parseSyntheticSlotId(it.key().toStdString()).has_value()) {
+ republishSlotCaps(it.key());
+ }
+ }
+ rebuild();
+}
+
+void AppModel::republishSlotCaps(const QString& slotId) {
+ const auto bound = hub_->boundConnection(slotId);
+ if (!bound.has_value()) { return; }
+ hub_->bind(slotId, bound->id);
+}
+
+void AppModel::setSlotMicMuted(const QString& slotId, bool muted) {
+ if (slotId.isEmpty()) { return; }
+ micMuteStore_.setMuted(slotId.toStdString(), muted);
+ const auto vp = reducer::parseSyntheticSlotId(slotId.toStdString());
+ if (vp.has_value() && usbManager_ != nullptr) {
+ // Keep the wire latch in step so kXusbMicMute holds the same state the
+ // app shows; a no-op when the pad's own button was the writer.
+ usbManager_->setPadMicMuted(vp->first, vp->second, muted);
+ }
+ // The lamp answers immediately and locally — a mute that waits on a host
+ // round-trip feels dead. A host MSG_MIC_LED landing later repaints it
+ // (last writer wins on the PAD; the UI keeps showing the local truth).
+ actuateMicLed(slotId, muted ? proto::kMicLedStateOn : proto::kMicLedStateOff);
+ // Restamps the slot card and re-runs the engine reconcile: muted closes
+ // the capture device, unmuted reopens it.
+ rebuild();
+}
+
+void AppModel::toggleSlotMicMute(const QString& slotId) {
+ if (slotId.isEmpty()) { return; }
+ setSlotMicMuted(slotId, !micMuteStore_.isMuted(slotId.toStdString()));
+}
+
+void AppModel::onPadMicMuteChanged(int vendorId, int productId, bool muted) {
+ // The synthetic slot id IS the model key's string form.
+ const QString slotId =
+ QString::fromStdString(std::to_string((vendorId << 16) | (productId & 0xFFFF)));
+ // The latch already moved (the decoder owns it); this mirrors the store,
+ // drives the lamp and reconciles the engines through the same single door
+ // the UI click uses.
+ setSlotMicMuted(slotId, muted);
+}
+
+void AppModel::reconcileAudioEngines() {
+ std::vector micTargets;
+ std::vector speakerVoices;
+
+ for (const auto& s : state_.slotList) {
+ if (!s.usbDirect || !s.boundConnectionId.has_value()) { continue; }
+ auto* conn = wifi_->get(*s.boundConnectionId);
+ if (conn == nullptr) { continue; }
+ const auto route = audioRouteForSlot(s.id);
+ const std::string slotId = s.id.toStdString();
+ // Faltering counts as streaming: it is a session riding out missed
+ // acks, and tearing audio down two seconds before the input stream
+ // would flap on every blip.
+ const bool streaming = conn->state() == net::SessionState::Live ||
+ conn->state() == net::SessionState::Faltering;
+
+ audio::AudioSlotFacts mic;
+ mic.streaming = streaming;
+ mic.toggleOn = micEnabledStore_.isEnabled(slotId);
+ mic.routeMatched = route.microphone;
+ mic.hostCarries = conn->hostMicAvailable();
+ mic.muted = micMuteStore_.isMuted(slotId);
+ if (audio::micCaptureEligible(mic)) {
+ source::audio::MicCaptureTarget target;
+ target.slotId = slotId;
+ target.captureDeviceName = route.captureDeviceName;
+ if (auto sender = hub_->micAudioSenderForSlot(s.id)) {
+ target.send = std::move(sender);
+ micTargets.push_back(std::move(target));
+ }
+ }
+
+ audio::AudioSlotFacts speaker;
+ speaker.streaming = streaming;
+ speaker.toggleOn = speakerEnabledStore_.isEnabled(slotId);
+ speaker.routeMatched = route.speaker;
+ speaker.hostCarries = conn->hostSpeakerAvailable();
+ if (audio::speakerPlayoutEligible(speaker)) {
+ const auto descriptor = conn->descriptorFor(s.id);
+ if (descriptor.has_value()) {
+ source::audio::SpeakerVoiceTarget voice;
+ voice.connectionId = s.boundConnectionId->toStdString();
+ voice.controllerIndex = descriptor->ctrlIdx;
+ voice.slotId = slotId;
+ voice.playbackDeviceName = route.playbackDeviceName;
+ speakerVoices.push_back(std::move(voice));
+ }
+ }
+ }
+
+ micEngine_.reconcile(micTargets);
+ speakerEngine_.reconcile(speakerVoices);
+}
+
void AppModel::applyBindingPresence() {
if (bindingPresenceInFlight_) { return; }
const auto bindings = hub_->bindings();
diff --git a/src/AppModel.h b/src/AppModel.h
index dda91cc..9595c1c 100644
--- a/src/AppModel.h
+++ b/src/AppModel.h
@@ -23,12 +23,18 @@
#include "composer/TrayController.h"
#include "composer/WakeStateComposer.h"
#include "composer/WakeStateController.h"
+#include "repository/AudioPreferenceRepository.h"
#include "repository/DeadzoneRepository.h"
#include "repository/MotionPreferenceRepository.h"
#include "core/model/Protocol.h"
#include "core/reducer/BindingPresence.h"
#include "core/reducer/FeedbackRouting.h"
+#include "core/reducer/HostAudioVerdict.h"
#include "core/reducer/PollRateSampler.h"
+#include "core/audio/PadAudioMatcher.h"
+#include "source/audio/MicCaptureEngine.h"
+#include "source/audio/SdlAudioGateway.h"
+#include "source/audio/SpeakerPlayoutEngine.h"
#include "source/input/ControllerActivitySource.h"
#include "source/inputrate/InputRateStore.h"
#include "source/http/SatelliteCatalogRepository.h"
@@ -38,6 +44,8 @@
#include "source/store/CrashReportingStore.h"
#include "source/store/JoystickRemapStore.h"
#include "source/store/KeepAwakePreferenceStore.h"
+#include "source/store/AudioEnabledStore.h"
+#include "source/store/MicMuteStore.h"
#include "source/store/MotionEnabledStore.h"
#include "source/store/OnboardingPreferenceStore.h"
#include "source/store/TouchpadModeStore.h"
@@ -123,7 +131,22 @@ class AppModel : public QObject {
repository::DeadzoneRepository* deadzoneRepository() { return &deadzoneRepo_; }
source::MotionEnabledStore* motionEnabledStore() { return &motionEnabledStore_; }
+ // The controller-audio toggles, persisted per binding slot like motion.
+ // Mic defaults OFF (privacy), speaker ON; the stores own those defaults.
+ source::MicEnabledStore* micEnabledStore() { return &micEnabledStore_; }
+ source::SpeakerEnabledStore* speakerEnabledStore() { return &speakerEnabledStore_; }
+ // Live per-slot mute, deliberately unpersisted (see MicMuteStore.h).
+ source::MicMuteStore* micMuteStore() { return &micMuteStore_; }
source::TouchpadModeStore* touchpadModeStore() { return &touchpadModeStore_; }
+
+ // Both mute controls land here: the slot card's click and (via the
+ // observer's edge upcall) the DualSense's own button. Writes the store,
+ // syncs the claimed pad's wire latch, drives the pad's mute lamp locally
+ // at once — the button must never feel dead waiting on a host round-trip;
+ // a later MSG_MIC_LED repaints the lamp, last writer wins THERE, while the
+ // UI keeps showing this local truth — and re-runs the engine reconcile.
+ void setSlotMicMuted(const QString& slotId, bool muted);
+ void toggleSlotMicMute(const QString& slotId);
source::JoystickRemapStore* joystickRemapStore() { return &joystickRemapStore_; }
// The stored override if any, else the default layout.
@@ -184,6 +207,19 @@ class AppModel : public QObject {
QHash catalogHostFeatures(const QString& hostId) const;
+ // The pad layer of the controller-audio fold: does this slot have a usable
+ // audio route on this machine? One owner with the descriptor caps — both
+ // read reducer::slotCarriesMicCapture over feedbackInputs(), so the
+ // capability table and the wire can never disagree. False for every slot
+ // until Wave 2 lands the pad-to-audio-device matching.
+ bool slotCarriesMicSource(const QString& slotId) const;
+ bool slotCarriesSpeakerSink(const QString& slotId) const;
+
+ // The host layer for the mic/speaker rows ONLY: the per-session probe's
+ // verdict off the connection, conservative {false,false} for an unknown or
+ // never-probed host. Every other feature keeps its catalog-fed host layer.
+ reducer::HostAudioVerdict hostControllerAudioFor(const QString& hostId) const;
+
// Holds the last good catalog as stale across a refresh, so the picker can
// show a spinner or an error cause over the last-known types. Main thread.
const source::CatalogState& catalogState() const { return catalogState_; }
@@ -326,6 +362,37 @@ class AppModel : public QObject {
const std::array& left,
const std::array& right);
void actuatePlayerLeds(const QString& slotId, std::uint8_t ledMask);
+ // MSG_MIC_LED, routed through FeedbackRouting like every other feedback
+ // kind and landed via the DS5 mute-lamp builder. Also driven locally on a
+ // mute change (setSlotMicMuted); the host's writes and the local ones
+ // share the FeedbackState shadow, so whichever wrote last owns the lamp.
+ void actuateMicLed(const QString& slotId, std::uint8_t state);
+
+ // ── Controller audio (Wave 2 engines) ───────────────────────────────────
+
+ // Re-run the pad-to-endpoint matcher over the claimed pads and the live
+ // audio device lists; on a change, re-publish the affected bound slots'
+ // descriptors (the caps fold reads the routes) and rebuild.
+ void resolveAudioRoutes();
+
+ // Converge the capture and playout engines on what should run right now:
+ // the pure eligibility rules (core/audio/AudioEnginePolicy.h) over each
+ // slot's binding, toggles, route, host verdict and mute. Runs at the end
+ // of every rebuild(), which every relevant change funnels into.
+ void reconcileAudioEngines();
+
+ // Re-attach a bound slot so its descriptor re-folds and re-PUTs (the hub's
+ // capability fns re-run on bind). No-op for an unbound slot.
+ void republishSlotCaps(const QString& slotId);
+
+ // The matcher's answer for a synthetic slot's pad, NONE for everything
+ // else. Takes audioRoutesMtx_ — callable from the receive threads via
+ // feedbackInputs().
+ audio::PadAudioRoute audioRouteForSlot(const QString& slotId) const;
+
+ // The pad's own mute button moved (queued to the main thread by the
+ // observer; the edge itself is seen on the gateway read thread).
+ void onPadMicMuteChanged(int vendorId, int productId, bool muted);
// The slot bound to a connection, or empty. Reads the hub's binding table,
// which is what makes it callable from a receive thread.
@@ -413,6 +480,27 @@ class AppModel : public QObject {
repository::DeadzoneRepository deadzoneRepo_;
repository::MotionPreferenceRepository motionPrefRepo_;
source::MotionEnabledStore motionEnabledStore_;
+ // Same repo-before-store ordering rule. One repository per direction so the
+ // two toggle lists never share a settings blob.
+ repository::AudioPreferenceRepository micPrefRepo_{QStringLiteral("mic_preferences")};
+ repository::AudioPreferenceRepository speakerPrefRepo_{QStringLiteral("speaker_preferences")};
+ source::MicEnabledStore micEnabledStore_{&micPrefRepo_};
+ source::SpeakerEnabledStore speakerEnabledStore_{&speakerPrefRepo_};
+ source::MicMuteStore micMuteStore_;
+
+ // Declaration order: the gateway must outlive the engines that borrow it
+ // (members destroy in reverse order, so it is declared first). The gateway
+ // owns SDL_INIT_AUDIO — NOT the SDL bridge, whose gamepad subsystems
+ // stop/start independently.
+ source::audio::SdlAudioGateway audioGateway_;
+ source::audio::MicCaptureEngine micEngine_{&audioGateway_};
+ source::audio::SpeakerPlayoutEngine speakerEngine_{&audioGateway_};
+
+ // The matcher's current answer, whole-table per resolve. Guarded because
+ // feedbackInputs() reads it from the receive threads while resolve writes
+ // it on the main thread.
+ mutable std::mutex audioRoutesMtx_;
+ std::map padAudioRoutes_;
// Absent = the ds4 pair-time default.
repository::TouchpadModeRepository touchpadModeRepo_;
source::TouchpadModeStore touchpadModeStore_{&touchpadModeRepo_};
@@ -452,6 +540,19 @@ class AppModel : public QObject {
owner_->onUsbNotice(c, n);
}
+ // Fires on the GATEWAY READ THREAD (the one observer call that does);
+ // queued across so the store, the lamp and the engines are touched
+ // only on the main thread.
+ void padMicMuteChanged(int vendorId, int productId, bool muted) override {
+ AppModel* owner = owner_;
+ QMetaObject::invokeMethod(
+ owner,
+ [owner, vendorId, productId, muted] {
+ owner->onPadMicMuteChanged(vendorId, productId, muted);
+ },
+ Qt::QueuedConnection);
+ }
+
private:
AppModel* owner_;
};
diff --git a/src/Input/SDLGamepadBridge.cpp b/src/Input/SDLGamepadBridge.cpp
index 0094f8d..b0c3822 100644
--- a/src/Input/SDLGamepadBridge.cpp
+++ b/src/Input/SDLGamepadBridge.cpp
@@ -251,6 +251,14 @@ void SDLGamepadBridge::runLoop() {
case SDL_CONTROLLERBUTTONUP:
rebuildState(ev.cdevice.which);
break;
+ case SDL_AUDIODEVICEADDED:
+ case SDL_AUDIODEVICEREMOVED:
+ // Delivered here because this loop is the process's one SDL event
+ // pump; the audio subsystem itself is SdlAudioGateway's (see the
+ // signal's comment). A pad's own endpoints appear a beat after its
+ // HID interface, so this edge is what re-runs the route matcher.
+ QMetaObject::invokeMethod(this, "audioDevicesChanged", Qt::QueuedConnection);
+ break;
case SDL_JOYDEVICEADDED: {
// Unlike every other event here, this `which` is a device INDEX, not
// an instance id — it matches SDL_JoystickOpen's argument.
diff --git a/src/Input/SDLGamepadBridge.h b/src/Input/SDLGamepadBridge.h
index 30a75b0..27814de 100644
--- a/src/Input/SDLGamepadBridge.h
+++ b/src/Input/SDLGamepadBridge.h
@@ -126,6 +126,13 @@ class SDLGamepadBridge : public QObject {
signals:
void devicesChanged();
+ // An audio ENDPOINT appeared or vanished (SDL_AUDIODEVICEADDED/REMOVED).
+ // The bridge owns the one SDL event pump, so the events surface here, but
+ // SDL_INIT_AUDIO's lifecycle belongs to SdlAudioGateway — this signal only
+ // tells the pad-to-endpoint route resolver to re-run. Queued to the GUI
+ // thread; no payload, since the resolver re-enumerates wholesale anyway.
+ void audioDevicesChanged();
+
// A raw joystick input observed while capture is enabled. `deviceId` is the
// "sdl:" id; `kind` is 0=axis / 1=button / 2=hat; `index` is the raw
// source index; `value` is the axis int16 / 1 for a button press / the
diff --git a/src/Models/Models.cpp b/src/Models/Models.cpp
index 122bd19..8124819 100644
--- a/src/Models/Models.cpp
+++ b/src/Models/Models.cpp
@@ -38,6 +38,8 @@ std::uint16_t capsWordFromJson(const QJsonObject& caps) {
if (boolOr(caps, "lightbar", false)) { word |= proto::kCapLightbar; }
if (boolOr(caps, "triggerEffects", false)) { word |= proto::kCapTriggerEffects; }
if (boolOr(caps, "playerLeds", false)) { word |= proto::kCapPlayerLeds; }
+ if (boolOr(caps, "mic", false)) { word |= proto::kCapMic; }
+ if (boolOr(caps, "speaker", false)) { word |= proto::kCapSpeaker; }
return word;
}
@@ -204,6 +206,27 @@ CapabilitiesDto CapabilitiesDto::fromJson(const QJsonObject& obj) {
if (auto ec = optString(backend, "errorCode"); !ec.isEmpty()) { c.backendErrorCode = ec; }
const auto motion = obj.value(QLatin1String("motion")).toObject();
c.motionAvailable = boolOr(motion, "available", false);
+ for (const auto& v : obj.value(QLatin1String("backends")).toArray()) {
+ if (!v.isObject()) { continue; }
+ const auto bo = v.toObject();
+ CapabilitiesBackendDto b;
+ b.id = optString(bo, "id");
+ b.supported = boolOr(bo, "supported", false);
+ b.available = boolOr(bo, "available", false);
+ b.audio = boolOr(bo, "audio", false);
+ c.backends.append(b);
+ }
+ // Presence, not truthiness: an absent block is UNKNOWN and falls back to
+ // the per-backend audio flag (reducer/HostAudioVerdict.h), while a present
+ // block with false fields is a host that switched audio off.
+ const auto controllerAudio = obj.value(QLatin1String("controllerAudio"));
+ if (controllerAudio.isObject()) {
+ const auto ao = controllerAudio.toObject();
+ c.hasControllerAudioBlock = true;
+ c.controllerAudioEnabled = boolOr(ao, "enabled", false);
+ c.controllerAudioMic = boolOr(ao, "mic", false);
+ c.controllerAudioSpeaker = boolOr(ao, "speaker", false);
+ }
const auto host = obj.value(QLatin1String("host"));
if (host.isObject()) {
const auto ho = host.toObject();
@@ -289,6 +312,8 @@ QJsonObject ControllerDescriptor::toJson() const {
{"lightbar", (caps & proto::kCapLightbar) != 0},
{"triggerEffects", (caps & proto::kCapTriggerEffects) != 0},
{"playerLeds", (caps & proto::kCapPlayerLeds) != 0},
+ {"mic", (caps & proto::kCapMic) != 0},
+ {"speaker", (caps & proto::kCapSpeaker) != 0},
}},
{"touchpadMode",
QString::fromUtf8(proto::touchpadModeName(touchpadMode).data(),
diff --git a/src/Models/Models.h b/src/Models/Models.h
index f4b1280..a942689 100644
--- a/src/Models/Models.h
+++ b/src/Models/Models.h
@@ -232,6 +232,19 @@ struct HostCapabilityDto {
std::optional available;
};
+// One entry of the additive `backends[]` array: the host's full backend option
+// list, most-preferred first. Read for the runtime-switched `audio` flag; the
+// singular flattened backend fields below stay what every other caller uses.
+struct CapabilitiesBackendDto {
+ QString id;
+ bool supported = false;
+ bool available = false;
+ // Whether this backend will materialize a pad with real audio endpoints
+ // right now: its own ability AND the host's controllerAudio setting. Absent
+ // on satellites predating controller audio, which reads as false.
+ bool audio = false;
+};
+
// GET /api/server/capabilities: live backend health. Unauthenticated, so it is
// the only pre-pairing signal that the receiver's driver stack is broken.
struct CapabilitiesDto {
@@ -253,6 +266,19 @@ struct CapabilitiesDto {
HostCapabilityDto hostMouseControl;
HostCapabilityDto hostKeyboardControl;
HostCapabilityDto hostRumble;
+ // Additive; empty on satellites that predate the array.
+ QList backends;
+ // The top-level `controllerAudio` block: the host's audio switch split by
+ // direction. `hasControllerAudioBlock` distinguishes ABSENT (unknown — an
+ // older satellite may still carry audio, reported per-backend) from a
+ // present block whose fields are false; reducer/HostAudioVerdict.h owns the
+ // fold, so no other caller should read these four directly. A field missing
+ // from a PRESENT block reads false, the opt-out the rest of this document
+ // takes.
+ bool hasControllerAudioBlock = false;
+ bool controllerAudioEnabled = false;
+ bool controllerAudioMic = false;
+ bool controllerAudioSpeaker = false;
int httpStatus = 0;
bool reachable = false;
@@ -449,6 +475,16 @@ struct ControllerSlot {
// "Layout guessed" warn chip, which belongs on the Direct option card
// because the risk exists only there. Always false for a Bluetooth pad.
bool verifiedModel = false;
+
+ // Controller audio. `micArmed` is "this slot's descriptor claims a
+ // microphone" (route matched AND the per-binding toggle on) — the
+ // condition for showing a mute control at all, because a mute over no
+ // microphone is dead chrome. `micMuted` is the LOCAL mute truth
+ // (MicMuteStore), shown unconditionally where armed: the pad's own lamp
+ // may later be repainted by the host, but the app never reports a state it
+ // did not decide.
+ bool micArmed = false;
+ bool micMuted = false;
};
struct RememberedWifi {
diff --git a/src/Network/ConnectionHub.cpp b/src/Network/ConnectionHub.cpp
index e8a9d41..19b55db 100644
--- a/src/Network/ConnectionHub.cpp
+++ b/src/Network/ConnectionHub.cpp
@@ -144,6 +144,16 @@ ConnectionHub::TouchpadSender ConnectionHub::touchpadSenderForSlot(const QString
};
}
+ConnectionHub::MicAudioSender ConnectionHub::micAudioSenderForSlot(const QString& slotId) const {
+ const auto cid = bindings_.value(slotId);
+ if (cid.isEmpty()) { return {}; }
+ auto* conn = wifi_->get(cid);
+ if (conn == nullptr) { return {}; }
+ return [conn](std::uint16_t seq, const std::uint8_t* opus, std::size_t opusLen) {
+ return conn->sendMicAudio(seq, opus, opusLen);
+ };
+}
+
void ConnectionHub::bind(const QString& slotId, const QString& connectionId) {
QHash current = bindings_;
QString priorSlot;
@@ -166,12 +176,14 @@ void ConnectionHub::bind(const QString& slotId, const QString& connectionId) {
const bool hasTriggerEffects =
triggerEffectsCapabilityFn_ && triggerEffectsCapabilityFn_(slotId);
const bool hasPlayerLeds = playerLedsCapabilityFn_ && playerLedsCapabilityFn_(slotId);
+ const bool hasMic = micCapabilityFn_ && micCapabilityFn_(slotId);
+ const bool hasSpeaker = speakerCapabilityFn_ && speakerCapabilityFn_(slotId);
const int controllerType = controllerTypeFn_ ? controllerTypeFn_(slotId) : 0;
const std::uint8_t touchpadMode =
touchpadModeFn_ ? touchpadModeFn_(slotId) : proto::kTouchpadModeOff;
if (auto* c = wifi_->get(connectionId)) {
c->attachSlot(slotId, controllerType, hasLightbar, hasMotion, hasRumble, touchpadMode,
- hasTriggerEffects, hasPlayerLeds);
+ hasTriggerEffects, hasPlayerLeds, hasMic, hasSpeaker);
}
}
diff --git a/src/Network/ConnectionHub.h b/src/Network/ConnectionHub.h
index 1200dcb..b95902c 100644
--- a/src/Network/ConnectionHub.h
+++ b/src/Network/ConnectionHub.h
@@ -50,6 +50,12 @@ class ConnectionHub : public QObject {
BatterySender batterySenderForSlot(const QString& slotId) const;
TouchpadSender touchpadSenderForSlot(const QString& slotId) const;
+ // seq, opus bytes, length -> sent. For the mic capture engine, which calls
+ // it from the audio thread (same closure contract as ReportSender from the
+ // SDL input thread).
+ using MicAudioSender = std::function;
+ MicAudioSender micAudioSenderForSlot(const QString& slotId) const;
+
// The seams below let bind() stamp per-device hardware facts onto the
// REST descriptor. AppModel installs them off the SDL bridge's device
// classification; each is unset in tests and before the bridge exists, and
@@ -79,6 +85,16 @@ class ConnectionHub : public QObject {
playerLedsCapabilityFn_ = std::move(fn);
}
+ // Controller audio. Unset reads as "no", same as the other actuators, so
+ // the satellite neither expects MIC_AUDIO nor sends SPEAKER_AUDIO for a
+ // slot this build cannot route. AppModel folds the audio route (a Wave-2
+ // seam, false today) with the per-binding user toggle.
+ using MicCapabilityFn = std::function;
+ void setMicCapabilityFn(MicCapabilityFn fn) { micCapabilityFn_ = std::move(fn); }
+
+ using SpeakerCapabilityFn = std::function;
+ void setSpeakerCapabilityFn(SpeakerCapabilityFn fn) { speakerCapabilityFn_ = std::move(fn); }
+
// A proto CONTROLLER_TYPE_*, which is how a DualSense registers as a virtual
// DS4 rather than an Xbox pad. Unset means CONTROLLER_TYPE_XBOX.
using ControllerTypeFn = std::function;
@@ -110,6 +126,8 @@ class ConnectionHub : public QObject {
RumbleCapabilityFn rumbleCapabilityFn_;
TriggerEffectsCapabilityFn triggerEffectsCapabilityFn_;
PlayerLedsCapabilityFn playerLedsCapabilityFn_;
+ MicCapabilityFn micCapabilityFn_;
+ SpeakerCapabilityFn speakerCapabilityFn_;
ControllerTypeFn controllerTypeFn_;
TouchpadModeFn touchpadModeFn_;
};
diff --git a/src/Network/SatelliteClient.cpp b/src/Network/SatelliteClient.cpp
index 9f1f356..032d8da 100644
--- a/src/Network/SatelliteClient.cpp
+++ b/src/Network/SatelliteClient.cpp
@@ -6,6 +6,8 @@
#include "Util/Endian.h"
#include "core/wire/SessionCrypto.h"
+#include
+
#include
#include
@@ -268,9 +270,43 @@ void SatelliteClient::sendTouchpad(int controllerIndex, bool finger0Active, std:
sendEncrypted(kMsgTouchpad, payload.data(), payload.size());
}
+std::array SatelliteClient::encodeAudioFrameHeader(std::uint8_t controllerIndex,
+ std::uint16_t seq) {
+ return {controllerIndex, static_cast(seq >> 8),
+ static_cast(seq & 0xFFU)};
+}
+
+bool SatelliteClient::sendMicAudio(int controllerIndex, std::uint16_t seq, const std::uint8_t* opus,
+ std::size_t opusLen) {
+ // An empty packet is malformed by contract, and the satellite would drop
+ // it anyway; the upper bound keeps the assembled payload under the
+ // datagram ceiling so the refusal happens before the copy, not after.
+ if (opus == nullptr || opusLen == 0 || opusLen > proto::kAudioWireMaxOpusBytes) {
+ return false;
+ }
+ std::vector payload(static_cast(proto::kAudioWireHeaderBytes) +
+ opusLen);
+ const auto header = encodeAudioFrameHeader(static_cast(controllerIndex), seq);
+ std::memcpy(payload.data(), header.data(), header.size());
+ std::memcpy(payload.data() + header.size(), opus, opusLen);
+ return sendEncrypted(kMsgMicAudio, payload.data(), payload.size());
+}
+
bool SatelliteClient::sendEncrypted(std::uint16_t msgType, const std::uint8_t* payload,
std::size_t len) {
if (sock_ < 0) { return false; }
+ // Datagram ceiling. Nothing legitimate approaches it — everything but
+ // audio sits under 30 bytes, and a real 20 ms Opus packet is ~80-240 — so
+ // an oversize payload is a caller bug: refuse it here rather than emit a
+ // fragmented datagram the satellite would truncate and fail the AEAD on.
+ // Logged once per client, not per frame (see oversizeSendLogged_).
+ if (len > proto::kUdpMaxInnerPayloadBytes) {
+ if (!oversizeSendLogged_.exchange(true, std::memory_order_relaxed)) {
+ qWarning("SatelliteClient: refusing oversize payload (%zu > %zu bytes, msgType 0x%04X)",
+ len, proto::kUdpMaxInnerPayloadBytes, msgType);
+ }
+ return false;
+ }
// Inner: msgType(BE16) + payloadLen(BE16) + payload.
const std::size_t innerLen = 4 + len;
std::vector inner(innerLen);
@@ -369,7 +405,11 @@ void SatelliteClient::stopReceiveLoop() {
}
void SatelliteClient::receiveLoop() {
- std::uint8_t buf[256];
+ // A full datagram, not the 256 bytes of the pre-audio protocol: recvfrom
+ // TRUNCATES a UDP datagram to the buffer and a truncated SPEAKER_AUDIO
+ // ciphertext then fails the AEAD, so every full-size audio frame would be
+ // silently dropped here.
+ std::uint8_t buf[proto::kUdpDatagramMaxBytes];
while (ackRunning_.load(std::memory_order_relaxed)) {
if (sock_ < 0) { break; }
sockaddr_in from{};
@@ -481,6 +521,26 @@ void SatelliteClient::processIncoming(const std::uint8_t* buf, std::size_t n) {
handler = playerLedsHandler_;
}
if (handler) { handler(*pm); }
+ } else if (msgType == kMsgSpeakerAudio) {
+ // The message borrows the receive buffer: the handler decodes or
+ // copies before returning, which is the SpeakerAudioMessage contract.
+ const auto sm = parseSpeakerAudioMessage(body, bodyLen);
+ if (!sm) { return; }
+ SpeakerAudioHandler handler;
+ {
+ std::lock_guard lock(speakerAudioHandlerMtx_);
+ handler = speakerAudioHandler_;
+ }
+ if (handler) { handler(*sm); }
+ } else if (msgType == kMsgMicLed) {
+ const auto mm = parseMicLedMessage(body, bodyLen);
+ if (!mm) { return; }
+ MicLedHandler handler;
+ {
+ std::lock_guard lock(micLedHandlerMtx_);
+ handler = micLedHandler_;
+ }
+ if (handler) { handler(*mm); }
} else if (msgType == kMsgSessionClose) {
if (bodyLen < 1) { return; }
const std::uint8_t reason = body[0];
@@ -516,6 +576,16 @@ void SatelliteClient::setPlayerLedsHandler(PlayerLedsHandler handler) {
playerLedsHandler_ = std::move(handler);
}
+void SatelliteClient::setSpeakerAudioHandler(SpeakerAudioHandler handler) {
+ std::lock_guard lock(speakerAudioHandlerMtx_);
+ speakerAudioHandler_ = std::move(handler);
+}
+
+void SatelliteClient::setMicLedHandler(MicLedHandler handler) {
+ std::lock_guard lock(micLedHandlerMtx_);
+ micLedHandler_ = std::move(handler);
+}
+
void SatelliteClient::setHeartbeatAckHandler(HeartbeatAckHandler handler) {
std::lock_guard lock(ackHandlerMtx_);
ackHandler_ = std::move(handler);
@@ -575,6 +645,36 @@ SatelliteClient::parsePlayerLedsMessage(const std::uint8_t* payload, std::size_t
return pm;
}
+std::optional
+SatelliteClient::parseSpeakerAudioMessage(const std::uint8_t* payload, std::size_t len) {
+ // Floor, not exact: everything after the 3-byte header IS the Opus packet
+ // (Opus packets are self-delimiting), and a header with no Opus byte behind
+ // it is malformed — a silence frame is a 1-byte DTX packet, never an empty
+ // one.
+ if (payload == nullptr || len < kAudioWireMinPayloadLen) { return std::nullopt; }
+ SpeakerAudioMessage sm;
+ sm.controllerIndex = payload[0];
+ sm.seq = readU16Be(payload + 1);
+ sm.opus = payload + proto::kAudioWireHeaderBytes;
+ sm.opusLen = len - static_cast(proto::kAudioWireHeaderBytes);
+ return sm;
+}
+
+std::optional
+SatelliteClient::parseMicLedMessage(const std::uint8_t* payload, std::size_t len) {
+ // Exact length, unlike the floor rules above: the message is a coalesced
+ // last-value-wins state, so a longer frame is not a newer server appending
+ // fields but a frame this client does not understand at all.
+ if (payload == nullptr || len != kMicLedPayloadLen) { return std::nullopt; }
+ // An unknown state byte is dropped rather than clamped: rendering "some
+ // lamp mode" the game never asked for would be a guess at firmware.
+ if (payload[1] >= proto::kMicLedStateCount) { return std::nullopt; }
+ MicLedMessage mm;
+ mm.controllerIndex = payload[0];
+ mm.state = payload[1];
+ return mm;
+}
+
std::optional
SatelliteClient::parseRumbleMessage(const std::uint8_t* payload, std::size_t len) {
if (payload == nullptr || len < kRumblePayloadLen) { return std::nullopt; }
diff --git a/src/Network/SatelliteClient.h b/src/Network/SatelliteClient.h
index 7bd9f5e..b52c2d1 100644
--- a/src/Network/SatelliteClient.h
+++ b/src/Network/SatelliteClient.h
@@ -48,6 +48,9 @@ class SatelliteClient {
static constexpr std::uint16_t kMsgSessionClose = proto::kMsgSessionClose;
static constexpr std::uint16_t kMsgTriggerEffects = proto::kMsgTriggerEffects;
static constexpr std::uint16_t kMsgPlayerLeds = proto::kMsgPlayerLeds;
+ static constexpr std::uint16_t kMsgMicAudio = proto::kMsgMicAudio;
+ static constexpr std::uint16_t kMsgSpeakerAudio = proto::kMsgSpeakerAudio;
+ static constexpr std::uint16_t kMsgMicLed = proto::kMsgMicLed;
// Carried in the REST descriptor's caps object.
static constexpr std::uint16_t kCapAnalogTriggers = proto::kCapAnalogTriggers;
@@ -56,6 +59,8 @@ class SatelliteClient {
static constexpr std::uint16_t kCapLightbar = proto::kCapLightbar;
static constexpr std::uint16_t kCapTriggerEffects = proto::kCapTriggerEffects;
static constexpr std::uint16_t kCapPlayerLeds = proto::kCapPlayerLeds;
+ static constexpr std::uint16_t kCapMic = proto::kCapMic;
+ static constexpr std::uint16_t kCapSpeaker = proto::kCapSpeaker;
// Wire values, mirroring satellite/src/core/types.h.
static constexpr std::uint8_t kBatteryLevelUnknown = 0xFF;
@@ -128,6 +133,18 @@ class SatelliteClient {
return static_cast(base | (hasPlayerLeds ? kCapPlayerLeds : 0));
}
+ // Controller audio. Same actuator/source rule: kCapMic promises the client
+ // sources 0x0012 (and lands the 0x0014 lamp, which rides it), kCapSpeaker
+ // that it plays 0x0013. Independent directions — the fold sets each only
+ // where the matching audio route exists (core/reducer/FeedbackRouting.h).
+ static std::uint16_t withMicCapability(std::uint16_t base, bool hasMic) {
+ return static_cast(base | (hasMic ? kCapMic : 0));
+ }
+
+ static std::uint16_t withSpeakerCapability(std::uint16_t base, bool hasSpeaker) {
+ return static_cast(base | (hasSpeaker ? kCapSpeaker : 0));
+ }
+
// Axes are the satellite's right-handed frame (+X right, +Y up, +Z toward
// player); SDL already applies the manufacturer rotation for HIDAPI pads, so
// samples arrive in that frame. Scale: gyro LSB = 2000/32767 deg/s, accel LSB
@@ -146,6 +163,22 @@ class SatelliteClient {
std::uint8_t finger1Id, std::int16_t finger1X, std::int16_t finger1Y,
bool buttonPressed, std::uint32_t eventTimeMs);
+ // MSG_MIC_AUDIO: ctrlIdx(1) + seq(u16 BE) + exactly one 20 ms Opus packet.
+ // `seq` wraps and exists only for the receiver's gap detection and
+ // late-drop; the caller advances it once per encoded frame. An empty packet
+ // is malformed by contract (a DTX silence frame is a 1-byte packet, not an
+ // empty one) and is refused here, as is a packet the datagram ceiling
+ // cannot carry. Returns false when nothing was sent.
+ bool sendMicAudio(int controllerIndex, std::uint16_t seq, const std::uint8_t* opus,
+ std::size_t opusLen);
+
+ // The 3-byte header both audio messages carry ahead of their Opus bytes,
+ // big-endian unlike the LE up-stream payloads: it mirrors the satellite's
+ // own encodeAudioFrameHeader (core/types.h). Explicit shifts keep it
+ // host-byte-order-independent.
+ static std::array encodeAudioFrameHeader(std::uint8_t controllerIndex,
+ std::uint16_t seq);
+
// MSG_MOTION inner payload, after the 4-byte type+length header: ctrlIdx +
// 6×i16 + u32, little-endian to match the satellite's decodeMotionReport.
static std::array
@@ -207,6 +240,42 @@ class SatelliteClient {
static std::optional parsePlayerLedsMessage(const std::uint8_t* payload,
std::size_t len);
+ struct SpeakerAudioMessage {
+ int controllerIndex = 0;
+ std::uint16_t seq = 0;
+ // One Opus packet, borrowed from the receive buffer: valid only for the
+ // duration of the handler call, so a consumer that queues must copy.
+ // Opus packets are self-delimiting, so the length IS the packet.
+ const std::uint8_t* opus = nullptr;
+ std::size_t opusLen = 0;
+ };
+
+ using SpeakerAudioHandler = std::function;
+ void setSpeakerAudioHandler(SpeakerAudioHandler handler);
+
+ // Header already stripped. Floor kAudioWireMinPayloadLen (header + at least
+ // one Opus byte); everything past the header is the packet.
+ static constexpr std::size_t kAudioWireMinPayloadLen =
+ static_cast(proto::kAudioWireMinPayloadBytes);
+ static std::optional parseSpeakerAudioMessage(const std::uint8_t* payload,
+ std::size_t len);
+
+ struct MicLedMessage {
+ int controllerIndex = 0;
+ std::uint8_t state = 0; // proto::kMicLedState*
+ };
+
+ using MicLedHandler = std::function;
+ void setMicLedHandler(MicLedHandler handler);
+
+ // Header already stripped. Exactly 2 bytes — ctrlIdx(1) + state(1) — and
+ // the state must be a known kMicLedState*: an unknown lamp mode can only
+ // come from a host speaking something we do not, so it is dropped rather
+ // than guessed at (the same rule as the satellite's own decoder).
+ static constexpr std::size_t kMicLedPayloadLen = 2;
+ static std::optional parseMicLedMessage(const std::uint8_t* payload,
+ std::size_t len);
+
struct RumbleMessage {
int controllerIndex = 0;
std::uint16_t strongMagnitude = 0;
@@ -302,7 +371,9 @@ class SatelliteClient {
friend class SatelliteClientTestAccess;
// False only on a real failure: a buffer-full drop reports success, since
- // UDP absorbs it.
+ // UDP absorbs it. Also false when nothing went on the wire at all — no
+ // socket, counter exhausted, the AEAD refused, or the payload is over the
+ // datagram ceiling (the one-time logged refusal; see the definition).
bool sendEncrypted(std::uint16_t msgType, const std::uint8_t* payload, std::size_t len);
void heartbeatLoop();
void receiveLoop();
@@ -355,6 +426,14 @@ class SatelliteClient {
TriggerEffectsHandler triggerEffectsHandler_;
std::mutex playerLedsHandlerMtx_;
PlayerLedsHandler playerLedsHandler_;
+ std::mutex speakerAudioHandlerMtx_;
+ SpeakerAudioHandler speakerAudioHandler_;
+ std::mutex micLedHandlerMtx_;
+ MicLedHandler micLedHandler_;
+ // One log line per client for an oversize send, not one per frame: at 50
+ // audio frames a second a misconfigured caller would otherwise turn one
+ // mistake into a log flood.
+ std::atomic oversizeSendLogged_{false};
// Read on the input thread (frame selection) and written on the owner
// thread (a re-PUT), so it is atomic rather than guarded by materialMtx_:
diff --git a/src/Network/WifiConnection.cpp b/src/Network/WifiConnection.cpp
index 01d75f6..fc178a5 100644
--- a/src/Network/WifiConnection.cpp
+++ b/src/Network/WifiConnection.cpp
@@ -63,6 +63,8 @@ models::ControllerDescriptor WifiConnection::descriptorOf(const SlotBinding& b)
caps = SatelliteClient::withLightbarCapability(caps, b.hasLightbar);
caps = SatelliteClient::withTriggerEffectsCapability(caps, b.hasTriggerEffects);
caps = SatelliteClient::withPlayerLedsCapability(caps, b.hasPlayerLeds);
+ caps = SatelliteClient::withMicCapability(caps, b.hasMic);
+ caps = SatelliteClient::withSpeakerCapability(caps, b.hasSpeaker);
d.caps = caps;
d.touchpadMode = b.touchpadMode;
return d;
@@ -103,6 +105,10 @@ void WifiConnection::teardownClient() {
latencyOneWayMs_ = 0.0;
latencySamples_ = 0;
rekeyRequested_ = false;
+ // Session state, so the next session starts from the conservative "no
+ // audio" answer and waits for its own probe.
+ hostMic_ = false;
+ hostSpeaker_ = false;
// A dropped session leaves no virtual pads applied, so streams must gate off
// until the next PUT re-applies them.
for (auto& [slotId, b] : slots_) { b.registered = false; }
@@ -130,6 +136,8 @@ void WifiConnection::markConnected(const std::shared_ptr& clien
if (lightbarHandler_) { client->setLightbarHandler(lightbarHandler_); }
if (triggerEffectsHandler_) { client->setTriggerEffectsHandler(triggerEffectsHandler_); }
if (playerLedsHandler_) { client->setPlayerLedsHandler(playerLedsHandler_); }
+ if (speakerAudioHandler_) { client->setSpeakerAudioHandler(speakerAudioHandler_); }
+ if (micLedHandler_) { client->setMicLedHandler(micLedHandler_); }
// Rumble and lightbar may fire on the receive thread because they only hand
// off to the SDL bridge's own queue. Close-notify and the ack reconcile drive
// the session FSM and REST, so they are polled by the main-thread alive timer
@@ -216,7 +224,8 @@ void WifiConnection::markStale() {
void WifiConnection::attachSlot(const QString& slotId, int controllerType, bool hasLightbar,
bool hasMotion, bool hasRumble, std::uint8_t touchpadMode,
- bool hasTriggerEffects, bool hasPlayerLeds) {
+ bool hasTriggerEffects, bool hasPlayerLeds, bool hasMic,
+ bool hasSpeaker) {
auto it = slots_.find(slotId);
if (it == slots_.end()) {
SlotBinding b;
@@ -227,6 +236,8 @@ void WifiConnection::attachSlot(const QString& slotId, int controllerType, bool
b.hasRumble = hasRumble;
b.hasTriggerEffects = hasTriggerEffects;
b.hasPlayerLeds = hasPlayerLeds;
+ b.hasMic = hasMic;
+ b.hasSpeaker = hasSpeaker;
b.touchpadMode = touchpadMode;
b.registered = false;
slots_.emplace(slotId, b);
@@ -238,13 +249,16 @@ void WifiConnection::attachSlot(const QString& slotId, int controllerType, bool
it->second.hasMotion != hasMotion || it->second.hasRumble != hasRumble ||
it->second.touchpadMode != touchpadMode ||
it->second.hasTriggerEffects != hasTriggerEffects ||
- it->second.hasPlayerLeds != hasPlayerLeds;
+ it->second.hasPlayerLeds != hasPlayerLeds || it->second.hasMic != hasMic ||
+ it->second.hasSpeaker != hasSpeaker;
it->second.controllerType = controllerType;
it->second.hasLightbar = hasLightbar;
it->second.hasMotion = hasMotion;
it->second.hasRumble = hasRumble;
it->second.hasTriggerEffects = hasTriggerEffects;
it->second.hasPlayerLeds = hasPlayerLeds;
+ it->second.hasMic = hasMic;
+ it->second.hasSpeaker = hasSpeaker;
it->second.touchpadMode = touchpadMode;
if (changed && state_ == SessionState::Live) { emit slotChanged(slotId); }
}
@@ -396,6 +410,17 @@ void WifiConnection::sendTouchpad(bool finger0Active, std::uint8_t finger0Id, st
}
}
+bool WifiConnection::sendMicAudio(std::uint16_t seq, const std::uint8_t* opus,
+ std::size_t opusLen) {
+ if (!boundSlotId_.has_value()) { return false; }
+ const auto it = slots_.find(*boundSlotId_);
+ if (it == slots_.end() || !it->second.registered) { return false; }
+ if (auto c = clientRef_.get()) {
+ return c->sendMicAudio(it->second.controllerIndex, seq, opus, opusLen);
+ }
+ return false;
+}
+
void WifiConnection::setRumbleHandler(RumbleHandler handler) {
rumbleHandler_ = std::move(handler);
if (auto c = clientRef_.get()) { c->setRumbleHandler(rumbleHandler_); }
@@ -416,4 +441,23 @@ void WifiConnection::setPlayerLedsHandler(PlayerLedsHandler handler) {
if (auto c = clientRef_.get()) { c->setPlayerLedsHandler(playerLedsHandler_); }
}
+void WifiConnection::setSpeakerAudioHandler(SpeakerAudioHandler handler) {
+ speakerAudioHandler_ = std::move(handler);
+ if (auto c = clientRef_.get()) { c->setSpeakerAudioHandler(speakerAudioHandler_); }
+}
+
+void WifiConnection::setMicLedHandler(MicLedHandler handler) {
+ micLedHandler_ = std::move(handler);
+ if (auto c = clientRef_.get()) { c->setMicLedHandler(micLedHandler_); }
+}
+
+void WifiConnection::setHostControllerAudio(bool mic, bool speaker) {
+ if (hostMic_ == mic && hostSpeaker_ == speaker) { return; }
+ hostMic_ = mic;
+ hostSpeaker_ = speaker;
+ // The capability table's host layer reads it, so a landed probe must
+ // re-render the rows.
+ emit changed();
+}
+
} // namespace dish::net
diff --git a/src/Network/WifiConnection.h b/src/Network/WifiConnection.h
index b18fd0d..242f6e5 100644
--- a/src/Network/WifiConnection.h
+++ b/src/Network/WifiConnection.h
@@ -109,6 +109,12 @@ class WifiConnection : public QObject {
// live. The satellite sends 0x0010 / 0x0011 to nobody else.
bool hasTriggerEffects = false;
bool hasPlayerLeds = false;
+ // Controller audio (protocol 2): the pad has a usable audio route on
+ // this machine AND the user left the direction on. False everywhere
+ // until Wave 2 lands the route matching, so no descriptor advertises
+ // an audio cap yet.
+ bool hasMic = false;
+ bool hasSpeaker = false;
bool registered = false;
};
@@ -141,7 +147,8 @@ class WifiConnection : public QObject {
// controller PUT; while idle it rides the next session PUT.
void attachSlot(const QString& slotId, int controllerType, bool hasLightbar, bool hasMotion,
bool hasRumble, std::uint8_t touchpadMode = proto::kTouchpadModeOff,
- bool hasTriggerEffects = false, bool hasPlayerLeds = false);
+ bool hasTriggerEffects = false, bool hasPlayerLeds = false, bool hasMic = false,
+ bool hasSpeaker = false);
void detachSlot();
void detachSlot(const QString& slotId);
@@ -169,6 +176,11 @@ class WifiConnection : public QObject {
std::int16_t finger0Y, bool finger1Active, std::uint8_t finger1Id,
std::int16_t finger1X, std::int16_t finger1Y, bool buttonPressed,
std::uint32_t eventTimeMs);
+ // MSG_MIC_AUDIO for the bound slot; the capture engine calls it from the
+ // audio thread, the same cross-thread contract as sendReport from the SDL
+ // input thread. Gated on `registered` like every stream — the server drops
+ // audio for an unapplied slot anyway. Returns false when nothing was sent.
+ bool sendMicAudio(std::uint16_t seq, const std::uint8_t* opus, std::size_t opusLen);
// Held here rather than on the per-session SatelliteClient so they survive a
// reconnect; markConnected re-installs them.
@@ -184,6 +196,25 @@ class WifiConnection : public QObject {
void setTriggerEffectsHandler(TriggerEffectsHandler handler);
using PlayerLedsHandler = std::function;
void setPlayerLedsHandler(PlayerLedsHandler handler);
+ // Controller audio (protocol 2). SpeakerAudioMessage borrows the receive
+ // buffer, so a handler that queues must copy before returning.
+ using SpeakerAudioHandler = std::function;
+ void setSpeakerAudioHandler(SpeakerAudioHandler handler);
+ using MicLedHandler = std::function;
+ void setMicLedHandler(MicLedHandler handler);
+
+ // ── The host's controller-audio verdict, per session ────────────────────
+ //
+ // Probed from GET /api/server/capabilities after every session PUT (the
+ // manager owns the probe), because it is LIVE state: the host can flip its
+ // audio switches between sessions, and a reconnect is exactly when they may
+ // have moved. Conservative by construction — false until a probe says yes,
+ // and reset to false with the session, so a stale yes never outlives the
+ // host that gave it. Read by the capability model's host layer for the
+ // mic/speaker rows only; every other feature keeps its catalog-fed answer.
+ void setHostControllerAudio(bool mic, bool speaker);
+ bool hostMicAvailable() const { return hostMic_; }
+ bool hostSpeakerAvailable() const { return hostSpeaker_; }
signals:
void changed();
@@ -235,6 +266,12 @@ class WifiConnection : public QObject {
LightbarHandler lightbarHandler_;
TriggerEffectsHandler triggerEffectsHandler_;
PlayerLedsHandler playerLedsHandler_;
+ SpeakerAudioHandler speakerAudioHandler_;
+ MicLedHandler micLedHandler_;
+
+ // Main-thread only, like the session state around it.
+ bool hostMic_ = false;
+ bool hostSpeaker_ = false;
models::ControllerDescriptor descriptorOf(const SlotBinding& b) const;
int lowestFreeIndex() const;
diff --git a/src/Network/WifiConnectionManager.cpp b/src/Network/WifiConnectionManager.cpp
index 0084eda..3db6ec9 100644
--- a/src/Network/WifiConnectionManager.cpp
+++ b/src/Network/WifiConnectionManager.cpp
@@ -12,6 +12,7 @@
#include "Util/Hex.h"
#include "core/reducer/Backoff.h"
#include "core/reducer/CloseNotify.h"
+#include "core/reducer/HostAudioVerdict.h"
#include "core/reducer/ProtocolNegotiation.h"
#include "core/reducer/Reconcile.h"
#include "core/reducer/RestOutcome.h"
@@ -752,6 +753,7 @@ void WifiConnectionManager::openSession(WifiConnection* conn,
if (auto* c = connections_.value(id, nullptr)) { rekey(c, server); }
});
conn->applyResults(resp.controllers);
+ probeHostAudio(id, server);
const auto converge = reducer::lateSlotConverge(
sentDescriptors, descriptorsToDesired(conn->desiredDescriptors()));
for (std::uint8_t ctrlIdx : converge.removes) { deleteSlot(id, ctrlIdx); }
@@ -868,6 +870,24 @@ void WifiConnectionManager::rekey(WifiConnection* conn, const models::Discovered
client->setConnectionParams(token, sessionKey, negotiated.settledVersion);
// Otherwise the next enriched ack would read as drift.
c->adoptEpoch(resp.epoch);
+ // The satellite could have been upgraded or re-switched under the
+ // live session, same reason the protocol version re-settles above.
+ probeHostAudio(id, c->server());
+ });
+}
+
+void WifiConnectionManager::probeHostAudio(const QString& id,
+ const models::DiscoveredServer& server) {
+ // Unauthenticated read; a failure keeps the conservative "no audio"
+ // default rather than surfacing anything, because the absence of a verdict
+ // and a verdict of no are deliberately the same state.
+ http_->getCapabilities(
+ server.ip, server.httpPort, [this, id](const models::CapabilitiesDto& caps) {
+ auto* c = connections_.value(id, nullptr);
+ if (c == nullptr || c->state() != SessionState::Live) { return; }
+ if (!caps.reachable || caps.httpStatus < 200 || caps.httpStatus > 299) { return; }
+ const auto verdict = reducer::resolveHostControllerAudio(caps);
+ c->setHostControllerAudio(verdict.mic, verdict.speaker);
});
}
diff --git a/src/Network/WifiConnectionManager.h b/src/Network/WifiConnectionManager.h
index 8d6d980..db03777 100644
--- a/src/Network/WifiConnectionManager.h
+++ b/src/Network/WifiConnectionManager.h
@@ -127,6 +127,14 @@ class WifiConnectionManager : public QObject {
// Re-PUT for a fresh token/salt/key on the SAME socket, so there is no state
// blip visible to the UI.
void rekey(WifiConnection* conn, const models::DiscoveredServer& server);
+ // Reads GET /api/server/capabilities for the host's controller-audio
+ // verdict and folds it into the connection (reducer/HostAudioVerdict.h).
+ // Fired after EVERY successful session PUT — connect, reconnect-after-death
+ // and the proactive re-key alike — because the verdict is live host state
+ // and a fresh session is exactly when it may have moved. Probing only from
+ // a UI surface was dish-android's mistake: an auto-reconnected session then
+ // streams with the verdict stuck at the conservative default.
+ void probeHostAudio(const QString& id, const models::DiscoveredServer& server);
void syncSlot(const QString& id, const QString& slotId);
void deleteSlot(const QString& id, int ctrlIdx);
void handleServerClose(WifiConnection* conn, const models::DiscoveredServer& server,
diff --git a/src/core/audio/AudioCodec.h b/src/core/audio/AudioCodec.h
new file mode 100644
index 0000000..5d357ed
--- /dev/null
+++ b/src/core/audio/AudioCodec.h
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (C) 2026 Dish contributors.
+//
+// The codec seam for controller audio. Core owns the SHAPE of the two streams
+// (formats and framing live in core/model/Protocol.h, mirroring satellite
+// core/types.h); the library that actually codes them lives in
+// source/audio/OpusAudioCodec.*, because src/core takes no third-party
+// dependency — the same arrangement, for the same reason, as satellite's
+// core/audio/audio_codec.h over adapters/audio/opus_codec.*.
+//
+// This client encodes mic and decodes speaker; the satellite does the reverse.
+// The interfaces cover both halves anyway, because that is what lets a test
+// close the loop on either stream instead of asserting against a second
+// implementation of the same constants.
+
+#pragma once
+
+#include
+#include
+
+namespace dish::audio {
+
+// One controller's inbound stream. Stateful — decoders carry filter state and
+// a concealment history across frames — so one instance per controller, never
+// shared, and destroyed with the pad it belongs to.
+//
+// Every entry returns FRAMES (samples per channel) written, 0 on failure, and
+// writes frames * channels interleaved int16 samples. `maxFrames` is capacity
+// for decode(); for the two concealment entries it must be at least one whole
+// proto::kAudioFrameSamples window, because the codec has to be told exactly
+// how much audio is missing.
+class IAudioDecoder {
+ public:
+ virtual ~IAudioDecoder() = default;
+ virtual std::size_t decode(const std::uint8_t* opus, std::size_t opusLen, std::int16_t* pcm,
+ std::size_t maxFrames) = 0;
+
+ // No packet at all: synthesize one frame from the decoder's own history.
+ virtual std::size_t conceal(std::int16_t* pcm, std::size_t maxFrames) = 0;
+
+ // Recover the frame BEFORE `opus` from the in-band FEC copy that packet
+ // carries. Degrades to plain concealment when it turns out to carry none,
+ // so a caller never has to ask first (nor could it: whether a packet holds
+ // FEC data is an encoder-side decision made per packet).
+ virtual std::size_t decodeFec(const std::uint8_t* opus, std::size_t opusLen, std::int16_t* pcm,
+ std::size_t maxFrames) = 0;
+};
+
+// One controller's outbound stream. `frames` is per channel and must be exactly
+// one proto::kAudioFrameSamples window: the wire carries one 20 ms packet per
+// message and the windowing is the caller's job. Returns bytes written, 0 on
+// failure.
+class IAudioEncoder {
+ public:
+ virtual ~IAudioEncoder() = default;
+ virtual std::size_t encode(const std::int16_t* pcm, std::size_t frames, std::uint8_t* out,
+ std::size_t maxOut) = 0;
+};
+
+} // namespace dish::audio
diff --git a/src/core/audio/AudioEnginePolicy.h b/src/core/audio/AudioEnginePolicy.h
new file mode 100644
index 0000000..bd2d1f7
--- /dev/null
+++ b/src/core/audio/AudioEnginePolicy.h
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (C) 2026 Dish contributors.
+//
+// The controller-audio engine rules, in one place and with nothing else in
+// them: when a microphone may be open, when a speaker voice may be held, how
+// arbitrary capture chunks become exact 20 ms windows, and when a drained
+// playout queue is re-cushioned. The engines in source/audio execute these;
+// keeping the decisions here is what makes them provable without a device.
+//
+// The sibling of dish-android's MicCapturePolicy / SpeakerPlayoutPolicy /
+// SpeakerCushionPolicy, folded into one header because this client's inputs
+// are already flat bools (no permission broker, no foreground-service arming).
+
+#pragma once
+
+#include "core/model/Protocol.h"
+
+#include
+#include
+#include
+#include
+
+namespace dish::audio {
+
+// Everything the eligibility rules know about one slot, flattened out of the
+// capability model, the stores and the connection so the rules stay pure.
+// `toggleOn` and `routeMatched` are the same pair the descriptor's audio cap
+// was folded from, so a slot that captures or plays is always one the host was
+// told about; `hostCarries` is the live probe verdict, which the caps
+// deliberately do not include; `streaming` is a bound slot on a live session.
+struct AudioSlotFacts {
+ bool streaming = false;
+ bool toggleOn = false;
+ bool routeMatched = false;
+ bool hostCarries = false;
+ bool muted = false;
+};
+
+// THE PRIVACY INVARIANT's decision half: any one fact going false closes the
+// capture device — closes, not silences — and mute is one of them. The engine
+// enforces it by never being handed an ineligible target, and the tests pin
+// that an ineligible slot means zero sendMicAudio calls, not quiet ones.
+inline constexpr bool micCaptureEligible(const AudioSlotFacts& f) {
+ return f.streaming && f.toggleOn && f.routeMatched && f.hostCarries && !f.muted;
+}
+
+// Mute deliberately does NOT gate playout: the directions are not symmetric.
+// This stream is the user's own PC sending to the user's own pad, and muting
+// the microphone should not also silence the game.
+inline constexpr bool speakerPlayoutEligible(const AudioSlotFacts& f) {
+ return f.streaming && f.toggleOn && f.routeMatched && f.hostCarries;
+}
+
+// Playback starts once this many whole frames are queued, not on the first: a
+// device started empty plays one window and then underruns, and every underrun
+// is an audible click. Two windows is the same 40 ms the reorder window
+// upstream already costs, so this adds no latency the stream did not have.
+inline constexpr int kPlayoutStartThresholdFrames = 2;
+
+// The device-side buffer request, in frames: room for a scheduling hiccup
+// without letting the queue grow into latency. The start threshold decides the
+// steady-state latency, not this.
+inline constexpr int kPlayoutBufferFrames = 4;
+
+inline constexpr std::size_t kPlayoutFrameBytes =
+ static_cast(proto::kAudioFrameSamples) *
+ static_cast(proto::kAudioSpeakerChannels) * sizeof(std::int16_t);
+
+// How many frames of SILENCE to slip in front of the next window when the
+// queue has fully drained mid-stream. The satellite sends nothing for a
+// digitally silent window, so a live stream goes quiet for seconds at a time
+// and the queue empties; resuming into an empty queue is the underrun the
+// start threshold exists to prevent. Silence rather than a re-prime, because
+// withholding windows until the threshold refills would strand a sound shorter
+// than the cushion; writing silence delays the resumed audio by the same 40 ms
+// and can never swallow it.
+inline constexpr int playoutRefillFrames(bool started, std::size_t queuedBytes) {
+ return (started && queuedBytes == 0) ? kPlayoutStartThresholdFrames : 0;
+}
+
+// Reassembles a capture stream into exact wire windows. SDL delivers whatever
+// chunk the platform's period produced, and the wire takes exactly one 20 ms
+// window per message — a partial window is never sent, because the far end
+// cannot place one in its timeline. Carries the remainder between chunks.
+class FrameWindower {
+ public:
+ explicit FrameWindower(int frameSamples = proto::kAudioFrameSamples)
+ : frame_(static_cast(frameSamples)) {
+ buffer_.reserve(frame_);
+ }
+
+ // Feed one chunk; `emitWindow` fires once per COMPLETE window, with a
+ // pointer valid only for the duration of the call. (Not named `emit`: Qt
+ // defines that as a macro and this header reaches Qt translation units.)
+ void feed(const std::int16_t* samples, std::size_t count,
+ const std::function& emitWindow) {
+ if (samples == nullptr || !emitWindow) { return; }
+ std::size_t i = 0;
+ // Fast path: with nothing carried over, whole windows go straight from
+ // the caller's buffer with no copy.
+ if (buffer_.empty()) {
+ while (count - i >= frame_) {
+ emitWindow(samples + i, frame_);
+ i += frame_;
+ }
+ }
+ while (i < count) {
+ const std::size_t take =
+ frame_ - buffer_.size() < count - i ? frame_ - buffer_.size() : count - i;
+ buffer_.insert(buffer_.end(), samples + i, samples + i + take);
+ i += take;
+ if (buffer_.size() == frame_) {
+ emitWindow(buffer_.data(), frame_);
+ buffer_.clear();
+ }
+ }
+ }
+
+ // A fresh stream must not inherit the tail of the previous one.
+ void reset() { buffer_.clear(); }
+
+ std::size_t pending() const { return buffer_.size(); }
+
+ private:
+ std::size_t frame_;
+ std::vector buffer_;
+};
+
+} // namespace dish::audio
diff --git a/src/core/audio/AudioJitter.h b/src/core/audio/AudioJitter.h
new file mode 100644
index 0000000..d767d84
--- /dev/null
+++ b/src/core/audio/AudioJitter.h
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (C) 2026 Dish contributors.
+//
+// Reorder window for one direction of one controller's audio stream
+// (kMsgSpeakerAudio inbound here, kMsgMicAudio on the satellite): wrapping u16
+// sequence numbers in, in-order packets and explicit gap signals out.
+//
+// The streams are lossy by contract (satellite docs/contract.md: no acks, no
+// retransmits), so `seq` buys exactly two things and no more: it says which
+// frames never arrived, so the decoder can conceal them, and it says which
+// arrived too late to matter, so they can be dropped instead of spliced in
+// behind audio already played.
+//
+// This is the THIRD deliberate mirror of satellite src/core/audio/audio_jitter.h
+// (dish-android cpp/audio_jitter.h is the second), rule for rule and name for
+// name: both ends of one stream have to agree on what counts as lost, what
+// counts as late, and how long a hole is worth concealing, and the only way to
+// keep that true is to keep the files the same shape. Edit the three together.
+// The constant spellings are the mirrors' own, not this repo's k-prefix, so the
+// files diff clean against each other.
+//
+// Dependency-free: std only, no Protocol.h, no Qt. The ordering rules are the
+// sort of thing that should be provable on their own.
+
+#pragma once
+
+#include
+#include
+#include
+
+namespace dish::audio {
+
+// Two frames = 40 ms of tolerance. A frame arriving this far ahead of the one
+// we are waiting for is proof that the missing one is lost rather than merely
+// out of order, which is what turns a hole into a concealment decision. Wider
+// would heal more reordering at the cost of that much added ear latency on
+// every gap, and a LAN reorders by one frame or not at all.
+inline constexpr int AUDIO_JITTER_WINDOW_FRAMES = 2;
+
+// Longest run of consecutive missing frames concealed before the window gives
+// up and resynchronises to the newest packet in hand. Concealment is a 20 ms
+// guess extrapolated from the last good frame; stretched across a half-second
+// dropout it becomes synthetic noise AND holds the stream that far behind live.
+// Past this, silence is the honest rendering of silence.
+inline constexpr int AUDIO_JITTER_MAX_CONCEAL_FRAMES = 2;
+
+// Refuse a packet longer than one datagram could ever have carried. Nothing in
+// the pipeline hands us one (the dispatch layer caps the whole inner message
+// well below this), so it is purely a bound on what the window can be made to
+// allocate.
+inline constexpr int AUDIO_JITTER_MAX_PACKET_BYTES = 1500;
+
+// Worst case for one push: every packet the window can hold drains, and each
+// drain may be preceded by a full concealment run.
+inline constexpr int AUDIO_JITTER_MAX_EVENTS_PER_PUSH =
+ AUDIO_JITTER_WINDOW_FRAMES * (AUDIO_JITTER_MAX_CONCEAL_FRAMES + 1);
+
+class AudioJitterWindow {
+ public:
+ // One thing the decoder should do, in stream order.
+ struct Event {
+ enum class Kind : std::uint8_t {
+ Packet, // decode `data` normally
+ Gap, // frame `seq` never arrived; conceal it
+ };
+ Kind kind = Kind::Packet;
+ std::uint16_t seq = 0;
+ // Packet only. Valid until the next push() or reset() (it either
+ // aliases the caller's own buffer or the window's storage), which is
+ // all a caller draining the result in place needs.
+ const std::uint8_t* data = nullptr;
+ std::size_t len = 0;
+ // Gap only: the packet for seq + 1, when the window already holds it.
+ // Opus puts a redundant low-rate copy of frame N inside packet N + 1,
+ // so this pointer is the difference between recovering the lost frame
+ // and guessing at it. Null means nothing carries it: conceal blind.
+ const std::uint8_t* fecCarrier = nullptr;
+ std::size_t fecCarrierLen = 0;
+ };
+
+ // What the pushed packet itself was worth. Orthogonal to the events: a
+ // late packet can still be the push that flushes earlier frames out.
+ enum class Accept : std::uint8_t {
+ Ok, // taken: emitted now or held for reordering
+ Late, // its slot was already emitted or concealed; too late to use
+ Duplicate, // a packet with this seq is already waiting
+ Rejected, // empty, or longer than AUDIO_JITTER_MAX_PACKET_BYTES
+ };
+
+ struct Result {
+ Accept accept = Accept::Ok;
+ int count = 0;
+ Event events[AUDIO_JITTER_MAX_EVENTS_PER_PUSH];
+ };
+
+ // Offer one packet. `data` must outlive the caller's use of the result.
+ Result push(std::uint16_t seq, const std::uint8_t* data, std::size_t len) {
+ Result r;
+ if (data == nullptr || len == 0 ||
+ len > static_cast(AUDIO_JITTER_MAX_PACKET_BYTES)) {
+ r.accept = Accept::Rejected;
+ return r;
+ }
+
+ // The first packet defines where the stream starts; there is no such
+ // thing as a late or missing frame before it.
+ if (!primed_) {
+ primed_ = true;
+ next_ = seq;
+ consecutiveGaps_ = 0;
+ }
+
+ const int delta = deltaFromNext(seq);
+ if (delta < 0) {
+ r.accept = Accept::Late;
+ return r;
+ }
+
+ // The common case by far: the frame we were waiting for, with nothing
+ // held behind it. Emitted straight from the caller's buffer, so a clean
+ // stream never copies a packet or touches the heap.
+ if (delta == 0 && usedSlots() == 0) {
+ emitPacket(r, seq, data, len);
+ next_ = static_cast(next_ + 1);
+ consecutiveGaps_ = 0;
+ return r;
+ }
+
+ if (find(seq) != nullptr) {
+ r.accept = Accept::Duplicate;
+ return r;
+ }
+ Slot* slot = freeSlot();
+ if (slot == nullptr) {
+ // Unreachable: see the slots_ invariant. Kept because "the window
+ // silently ate a frame" is a far better failure than a write past
+ // the array if that invariant is ever widened.
+ r.accept = Accept::Rejected;
+ return r;
+ }
+ slot->used = true;
+ slot->seq = seq;
+ slot->bytes.assign(data, data + len);
+
+ drain(r);
+ return r;
+ }
+
+ // Fresh pad, fresh stream. Keeps the slots' allocations so a replug does
+ // not re-enter the allocator on its first frame.
+ void reset() {
+ primed_ = false;
+ next_ = 0;
+ consecutiveGaps_ = 0;
+ for (Slot& s : slots_) { s.used = false; }
+ }
+
+ bool primed() const { return primed_; }
+ // The seq the window is waiting for. Meaningless until primed.
+ std::uint16_t nextSeq() const { return next_; }
+ int buffered() const { return usedSlots(); }
+
+ private:
+ struct Slot {
+ bool used = false;
+ std::uint16_t seq = 0;
+ std::vector bytes;
+ };
+
+ // Sized by the invariant, not by guesswork: when drain() returns, every
+ // held packet is less than AUDIO_JITTER_WINDOW_FRAMES ahead of next_ (a
+ // packet further ahead forces a gap and drains the rest), and a delta of 0
+ // would have been emitted, so at most WINDOW - 1 survive a push. push()
+ // inserts one before draining, hence WINDOW slots exactly.
+ Slot slots_[AUDIO_JITTER_WINDOW_FRAMES];
+ bool primed_ = false;
+ std::uint16_t next_ = 0;
+ // Carried across pushes: a concealment run is emitted one frame per
+ // arriving packet, so the cap only means anything if it is remembered.
+ int consecutiveGaps_ = 0;
+
+ // Wrapping distance from next_, signed. The int16_t cast is the whole wrap
+ // story: 0x0000 is one after 0xFFFF, and a frame from before the wrap comes
+ // out negative rather than 65535 frames ahead.
+ int deltaFromNext(std::uint16_t seq) const {
+ return static_cast(static_cast(seq - next_));
+ }
+
+ int usedSlots() const {
+ int n = 0;
+ for (const Slot& s : slots_) {
+ if (s.used) { n++; }
+ }
+ return n;
+ }
+
+ Slot* find(std::uint16_t seq) {
+ for (Slot& s : slots_) {
+ if (s.used && s.seq == seq) { return &s; }
+ }
+ return nullptr;
+ }
+
+ Slot* freeSlot() {
+ for (Slot& s : slots_) {
+ if (!s.used) { return &s; }
+ }
+ return nullptr;
+ }
+
+ // Furthest-ahead held packet, or -1 when nothing is held.
+ int maxAhead() const {
+ int best = -1;
+ for (const Slot& s : slots_) {
+ if (!s.used) { continue; }
+ const int d = static_cast(static_cast(s.seq - next_));
+ if (d > best) { best = d; }
+ }
+ return best;
+ }
+
+ // Nearest held packet. The resync target: after giving up on a long
+ // dropout, this is the oldest audio still worth playing.
+ const Slot* oldestHeld() const {
+ const Slot* best = nullptr;
+ int bestDelta = 0;
+ for (const Slot& s : slots_) {
+ if (!s.used) { continue; }
+ const int d = static_cast(static_cast(s.seq - next_));
+ if (best == nullptr || d < bestDelta) {
+ best = &s;
+ bestDelta = d;
+ }
+ }
+ return best;
+ }
+
+ static void emitPacket(Result& r, std::uint16_t seq, const std::uint8_t* data,
+ std::size_t len) {
+ Event& e = r.events[r.count++];
+ e.kind = Event::Kind::Packet;
+ e.seq = seq;
+ e.data = data;
+ e.len = len;
+ }
+
+ void drain(Result& r) {
+ while (r.count < AUDIO_JITTER_MAX_EVENTS_PER_PUSH) {
+ Slot* due = find(next_);
+ if (due != nullptr) {
+ // Released, not cleared: the event points into these bytes and
+ // the slot cannot be reused before the next push().
+ emitPacket(r, due->seq, due->bytes.data(), due->bytes.size());
+ due->used = false;
+ next_ = static_cast(next_ + 1);
+ consecutiveGaps_ = 0;
+ continue;
+ }
+
+ const int ahead = maxAhead();
+ // Nothing held, or nothing far enough ahead to prove a loss: the
+ // frame may still be one hop behind. Wait for the next packet.
+ if (ahead < AUDIO_JITTER_WINDOW_FRAMES) { break; }
+
+ if (consecutiveGaps_ >= AUDIO_JITTER_MAX_CONCEAL_FRAMES) {
+ const Slot* resume = oldestHeld();
+ if (resume == nullptr) { break; }
+ next_ = resume->seq;
+ consecutiveGaps_ = 0;
+ continue;
+ }
+
+ const Slot* carrier = find(static_cast(next_ + 1));
+ Event& e = r.events[r.count++];
+ e.kind = Event::Kind::Gap;
+ e.seq = next_;
+ if (carrier != nullptr) {
+ e.fecCarrier = carrier->bytes.data();
+ e.fecCarrierLen = carrier->bytes.size();
+ }
+ next_ = static_cast(next_ + 1);
+ consecutiveGaps_++;
+ }
+ }
+};
+
+} // namespace dish::audio
diff --git a/src/core/audio/PadAudioMatcher.h b/src/core/audio/PadAudioMatcher.h
new file mode 100644
index 0000000..5dd5f5f
--- /dev/null
+++ b/src/core/audio/PadAudioMatcher.h
@@ -0,0 +1,190 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (C) 2026 Dish contributors.
+//
+// Matches a Direct-claimed pad to its own audio endpoints, conservatively. The
+// Windows sibling of dish-android's PadAudioMatcher, stance for stance.
+//
+// A DualSense (or DualShock 4 v2) is a composite USB device: the HID interface
+// this client claims plus a USB Audio Class function the OS keeps. That audio
+// function's endpoints surface as ordinary audio devices, which SDL enumerates
+// by NAME — and nothing in that name is a vendor:product. The one fact both
+// sides genuinely share is the pad's iProduct string ("Wireless Controller"),
+// which the hidraw enumeration reads on the HID side and the audio stack
+// embeds in the device name on the audio side. The exact wrapping differs by
+// platform — Windows/WASAPI decorates it ("Speakers (Wireless Controller)"),
+// pipewire/alsa node descriptions carry the iProduct itself or append a
+// profile suffix ("Wireless Controller Analog Stereo") — which is why the
+// match is CONTAINMENT, not equality: an endpoint belongs to a pad when the
+// endpoint's name contains the pad's product string.
+//
+// Because it is only a name, every ambiguity resolves to "no route", never to
+// a guess:
+//
+// - The pad's family must carry a USB audio function at all
+// (usbparse::parserHasUsbAudio). A name alone would let an unrelated USB
+// audio dongle lend its endpoints to a pad that has none.
+// - The product string must identify exactly one claimed pad. Two DualSenses
+// (or a DualSense next to a DualShock 4, which shares the string) are
+// indistinguishable here, and routing a slot to the wrong pad's speaker is
+// worse than not routing it.
+// - An endpoint whose name contains more than one pad's string belongs to
+// nobody, and a pad whose string matches more than one endpoint in a
+// direction gets no route in that direction, for the same reason.
+//
+// A pad that resolves to nothing simply advertises neither cap, which is the
+// honest answer for a pad whose audio function this machine cannot confidently
+// name. Qt-free so dish-linux can reuse it as-is (pipewire/alsa names embed
+// the product string the same way).
+
+#pragma once
+
+#include
+#include
+#include