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 +#include +#include + +namespace dish::audio { + +// One Direct-claimed pad, flattened out of the claim table so the rule stays +// pure. `productName` is the HID product string the gateway enumerated. +struct AudioPadCandidate { + int vendorId = 0; + int productId = 0; + std::string productName; + // Whether the FAMILY carries a USB Audio Class function (DualSense, DS4). + // The gate that keeps a lookalike name from lending endpoints to a pad + // that has none. + bool hasAudioFunction = false; +}; + +// Which of a pad's own endpoints this machine can name, per direction, and the +// exact SDL device names to open them by. A flag is set ONLY alongside its +// name: a claim the resolver cannot point at is a claim it must not make. +struct PadAudioRoute { + bool microphone = false; + bool speaker = false; + std::string captureDeviceName; + std::string playbackDeviceName; + + bool operator==(const PadAudioRoute& o) const { + return microphone == o.microphone && speaker == o.speaker && + captureDeviceName == o.captureDeviceName && + playbackDeviceName == o.playbackDeviceName; + } + bool operator!=(const PadAudioRoute& o) const { return !(*this == o); } +}; + +// Same vendor:product packing the USB path keys everything on. +inline int padAudioKey(int vendorId, int productId) { + return (vendorId << 16) | (productId & 0xFFFF); +} + +namespace detail { + +inline std::string trimmed(const std::string& s) { + std::size_t b = 0; + std::size_t e = s.size(); + while (b < e && std::isspace(static_cast(s[b])) != 0) { b++; } + while (e > b && std::isspace(static_cast(s[e - 1])) != 0) { e--; } + return s.substr(b, e - b); +} + +inline char lowerAscii(char c) { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +// ASCII-case-insensitive containment. Friendly names are localized around the +// product string but the string itself comes from the same descriptor on both +// sides; folding case costs nothing and absorbs a stack that rewrites it. +inline bool containsInsensitive(const std::string& haystack, const std::string& needle) { + if (needle.empty() || needle.size() > haystack.size()) { return false; } + for (std::size_t i = 0; i + needle.size() <= haystack.size(); i++) { + std::size_t j = 0; + while (j < needle.size() && lowerAscii(haystack[i + j]) == lowerAscii(needle[j])) { j++; } + if (j == needle.size()) { return true; } + } + return false; +} + +// For one direction: pad name -> the single endpoint that is unambiguously +// that pad's, or nothing. `padNames` are already trimmed and unique. +inline std::map +matchDirection(const std::vector& padNames, + const std::vector& endpointNames) { + // An endpoint that names more than one pad belongs to nobody: "Wireless + // Controller" inside a name would also match a hypothetical pad whose + // whole name is a substring of another's. + std::map> byPad; + for (const auto& endpoint : endpointNames) { + const std::string name = trimmed(endpoint); + if (name.empty()) { continue; } + const std::string* owner = nullptr; + bool ambiguous = false; + for (const auto& pad : padNames) { + if (!containsInsensitive(name, pad)) { continue; } + if (owner != nullptr) { + ambiguous = true; + break; + } + owner = &pad; + } + if (owner == nullptr || ambiguous) { continue; } + // The ORIGINAL name, not the trimmed one: it is the open key. + byPad[*owner].push_back(endpoint); + } + std::map out; + for (auto& [pad, endpoints] : byPad) { + // Two endpoints answering to one pad in one direction (two identical + // dongles enumerate identical friendly names) cannot be told apart. + if (endpoints.size() == 1) { out[pad] = endpoints.front(); } + } + return out; +} + +} // namespace detail + +// The whole rule. Returns routes keyed by padAudioKey; a pad absent from the +// result has no route (advertise nothing). Directions resolve independently: +// a missing capture endpoint does not cost the pad its speaker. +inline std::map +resolvePadAudioRoutes(const std::vector& pads, + const std::vector& captureNames, + const std::vector& playbackNames) { + // Group candidates by trimmed product string; a name shared by two claimed + // pads disqualifies both. + std::map> byName; + for (const auto& pad : pads) { + if (!pad.hasAudioFunction) { continue; } + const std::string name = detail::trimmed(pad.productName); + if (name.empty()) { continue; } + byName[name].push_back(&pad); + } + std::vector padNames; + padNames.reserve(byName.size()); + for (const auto& [name, candidates] : byName) { + if (candidates.size() == 1) { padNames.push_back(name); } + } + + const auto sources = detail::matchDirection(padNames, captureNames); + const auto sinks = detail::matchDirection(padNames, playbackNames); + + std::map out; + for (const auto& name : padNames) { + const AudioPadCandidate* pad = byName[name].front(); + PadAudioRoute route; + if (const auto it = sources.find(name); it != sources.end()) { + route.microphone = true; + route.captureDeviceName = it->second; + } + if (const auto it = sinks.find(name); it != sinks.end()) { + route.speaker = true; + route.playbackDeviceName = it->second; + } + if (!route.microphone && !route.speaker) { continue; } + out[padAudioKey(pad->vendorId, pad->productId)] = route; + } + return out; +} + +} // namespace dish::audio diff --git a/src/core/catalog/BundledCatalog.h b/src/core/catalog/BundledCatalog.h index ee41dd1..dec1e54 100644 --- a/src/core/catalog/BundledCatalog.h +++ b/src/core/catalog/BundledCatalog.h @@ -31,6 +31,12 @@ inline const QString kFeatureRumble = QStringLiteral("rumble"); inline const QString kFeatureMotion = QStringLiteral("motion"); inline const QString kFeatureLightbar = QStringLiteral("lightbar"); inline const QString kFeatureTouchpad = QStringLiteral("touchpad"); +// Controller audio (protocol 2). Deliberately NOT in knownFeatureSlugs(): +// that list is the protocol-1 caps-gate vocabulary (reducer::allowedCapsForType), +// and the audio caps follow the trigger-effects precedent of passing through it +// untouched. The capability solver's type layer reads these two directly. +inline const QString kFeatureMic = QStringLiteral("mic"); +inline const QString kFeatureSpeaker = QStringLiteral("speaker"); // The `known` whitelist reducer::isFeatureOffered gates on, owned here so every // caller passes the same vocabulary instead of re-listing it. @@ -39,13 +45,25 @@ inline QStringList knownFeatureSlugs() { kFeatureTouchpad}; } +// The whitelist for the solver's audio type-layer reads, so the same +// isFeatureOffered gate serves them without widening the protocol-1 list. +inline QStringList audioFeatureSlugs() { return {kFeatureMic, kFeatureSpeaker}; } + // Order is fixed (triggers, rumble, extras) so the list is ==-comparable in tests // and downstream snapshots. +// +// Audio rides the two Sony types only: they are the pads that carry real +// speaker and microphone endpoints, so they are the only identities a host can +// materialize with any. Offering them here cannot outrun the host, which gates +// audio on its own runtime controllerAudio switch, and a satellite old enough +// to serve no catalog reports no switch at all (mirrors dish-android's +// BundledCatalog). inline std::optional typeFeatureSlugs(const QString& slug) { QStringList base{kFeatureAnalogTriggers, kFeatureRumble}; if (slug == kSlugXbox360) { return base; } if (slug == kSlugDs4 || slug == kSlugDualSense) { - return base + QStringList{kFeatureMotion, kFeatureTouchpad, kFeatureLightbar}; + return base + QStringList{kFeatureMotion, kFeatureTouchpad, kFeatureLightbar, kFeatureMic, + kFeatureSpeaker}; } if (slug == kSlugSwitchPro) { return base + QStringList{kFeatureMotion}; } return std::nullopt; diff --git a/src/core/input/GamepadButtonLayouts.h b/src/core/input/GamepadButtonLayouts.h index 7e06e60..c37b499 100644 --- a/src/core/input/GamepadButtonLayouts.h +++ b/src/core/input/GamepadButtonLayouts.h @@ -23,6 +23,16 @@ inline constexpr int kXusbRightThumb = 0x0080; inline constexpr int kXusbLeftShoulder = 0x0100; inline constexpr int kXusbRightShoulder = 0x0200; inline constexpr int kXusbGuide = 0x0400; +// Not an XINPUT bit: 0x0800 is the one value the XINPUT-shaped word leaves +// unassigned, and protocol 2 spends it on the DualSense mic-mute button +// (satellite core/types.h WBUTTON_MIC_MUTE, docs/contract.md, Controller +// audio). It carries mute STATE, not an edge, held on every frame while muted, +// and only the DualSense identity consumes it host-side; every other emulated +// pad ignores it. xusbToHid deliberately drops it (no HID gamepad descriptor +// has a mute button, and Moonlight must never see it) and hidToXusb never +// produces it, so it can only be set by an explicit fold. Wave 2 of the +// controller-audio port is what sets it on live reports. +inline constexpr int kXusbMicMute = 0x0800; inline constexpr int kXusbA = 0x1000; inline constexpr int kXusbB = 0x2000; inline constexpr int kXusbX = 0x4000; diff --git a/src/core/input/UsbOutputReports.h b/src/core/input/UsbOutputReports.h index e218055..4913114 100644 --- a/src/core/input/UsbOutputReports.h +++ b/src/core/input/UsbOutputReports.h @@ -62,9 +62,20 @@ inline bool parserHasPlayerLeds(HidParser p) { inline bool parserHasTriggerEffects(HidParser p) { return p == HidParser::DualSense; } +// The DualSense mic-mute lamp (and the mic amplifier it fronts). +inline bool parserHasMicMuteLed(HidParser p) { return p == HidParser::DualSense; } + +// The lamp's states, which are also MSG_MIC_LED's own values. Restated rather +// than taken from the wire header for the same reason as the trigger block +// length above: these are hardware fields the protocol happens to carry. +inline constexpr std::uint8_t kMicMuteLedOff = 0; +inline constexpr std::uint8_t kMicMuteLedOn = 1; +inline constexpr std::uint8_t kMicMuteLedPulse = 2; + // Merged state the writer owns across calls. The DualSense lightbar needs a -// one-time handoff before a host colour is visible at all, which is a property -// of the connection rather than of any one write. +// one-time handoff before a host colour is visible at all, and the DS5 mute +// lamp has to survive every LATER write (see reassertDs5MicMuteLed) — both +// properties of the connection rather than of any one write. // // There is deliberately no trigger-motor state here, unlike dish-android: the // only family with impulse triggers is the Xbox One/Series GIP pad, and neither @@ -72,6 +83,15 @@ inline bool parserHasTriggerEffects(HidParser p) { return p == HidParser::DualSe // it; Windows XInput hides it from raw HID). struct FeedbackState { bool ds5LightbarSetupSent = false; + // Last lamp state asked for, and whether anything ever asked. Every DS5 + // report we build starts from a fresh memset, and the firmware applies + // whatever the valid flags claim, so the lamp is re-asserted from here on + // all of them: a report that flags a field the firmware then finds zeroed + // is exactly how a lamp gets stomped by an unrelated colour change. Unset + // means nothing ever drove the lamp, and a pad we were never asked about + // must keep whatever it had. + std::uint8_t ds5MicMuteLed = kMicMuteLedOff; + bool ds5MicMuteLedSet = false; }; namespace detail { @@ -114,12 +134,43 @@ inline void zero(std::uint8_t* out, std::size_t n) { std::memset(out, 0, n); } // The high byte of a 16-bit magnitude: DS4 and DualSense motors are 8-bit. inline std::uint8_t hi8(std::uint16_t v) { return static_cast(v >> 8); } +// DualSense output report 0x02 mic-mute fields, in this file's convention: the +// report id lives at out[0], so these are hid-playstation's RID-stripped 8 and +// 9 plus one — the same shift the player-LED byte carries (out[44] against +// stripped 43). Byte-for-byte with dish-android's usb_parsers.cpp. +inline constexpr std::size_t kDs5MicMuteLedByte = 9; +inline constexpr std::size_t kDs5PowerSaveByte = 10; +inline constexpr std::uint8_t kDs5ValidFlag1MicMuteLed = 0x01; +inline constexpr std::uint8_t kDs5ValidFlag1PowerSave = 0x02; +inline constexpr std::uint8_t kDs5PowerSaveMicMute = 0x10; + +// Stamp the shadowed lamp onto a DualSense 0x02 report that was built for +// something else. Every builder here memsets a fresh report, and the firmware +// applies whatever the valid flags claim, so without this a colour or +// player-LED write would be a lamp write too: flags set, field zero. A pad +// whose lamp was never driven is left alone, so nothing changes for hosts that +// do not use it. +// +// The power-save bit rides along because the lamp and the microphone amplifier +// are one thing on this pad: a lit mute lamp over a live microphone is the one +// failure this whole feature exists to prevent. Pulse counts as lit for the +// same reason; it is the rarer state and the honest reading of a lamp the user +// can see. +inline void reassertDs5MicMuteLed(const FeedbackState& st, std::uint8_t* out) { + if (!st.ds5MicMuteLedSet) { return; } + out[2] |= kDs5ValidFlag1MicMuteLed | kDs5ValidFlag1PowerSave; + out[kDs5MicMuteLedByte] = st.ds5MicMuteLed; + out[kDs5PowerSaveByte] = st.ds5MicMuteLed == kMicMuteLedOff ? 0x00 : kDs5PowerSaveMicMute; +} + } // namespace detail // Rumble. `seq` is a rolling counter the Switch Pro requires in the low nibble -// of every packet it accepts; the PlayStation families ignore it. -inline std::size_t buildRumbleReport(HidParser p, std::uint16_t strong, std::uint16_t weak, - std::uint8_t seq, std::uint8_t* out, std::size_t outCap) { +// of every packet it accepts; the PlayStation families ignore it. `st` is read +// for the DS5 lamp re-assert only. +inline std::size_t buildRumbleReport(HidParser p, const FeedbackState& st, std::uint16_t strong, + std::uint16_t weak, std::uint8_t seq, std::uint8_t* out, + std::size_t outCap) { switch (p) { case HidParser::DualShock4: if (outCap < 32) { return 0; } @@ -136,6 +187,7 @@ inline std::size_t buildRumbleReport(HidParser p, std::uint16_t strong, std::uin out[1] = 0x01; // valid_flag0 COMPATIBLE_VIBRATION out[3] = detail::hi8(weak); out[4] = detail::hi8(strong); + detail::reassertDs5MicMuteLed(st, out); return 63; case HidParser::SwitchProUsb: if (outCap < 10) { return 0; } @@ -185,6 +237,7 @@ inline std::size_t buildLightbarReport(HidParser p, FeedbackState& st, std::uint out[45] = r; out[46] = g; out[47] = b; + detail::reassertDs5MicMuteLed(st, out); return 63; case HidParser::SwitchProUsb: case HidParser::SteamController: @@ -198,8 +251,8 @@ inline std::size_t buildLightbarReport(HidParser p, FeedbackState& st, std::uint // Player-indicator LEDs. `ledMask` bit 0 is the leftmost LED, as on the wire; // each family is masked to the LEDs it physically has so a stray high bit // cannot set a reserved firmware flag. -inline std::size_t buildPlayerLedsReport(HidParser p, std::uint8_t ledMask, std::uint8_t seq, - std::uint8_t* out, std::size_t outCap) { +inline std::size_t buildPlayerLedsReport(HidParser p, const FeedbackState& st, std::uint8_t ledMask, + std::uint8_t seq, std::uint8_t* out, std::size_t outCap) { switch (p) { case HidParser::DualSense: if (outCap < 63) { return 0; } @@ -207,6 +260,7 @@ inline std::size_t buildPlayerLedsReport(HidParser p, std::uint8_t ledMask, std: out[0] = 0x02; out[2] = 0x10; // valid_flag1 PLAYER_INDICATOR_CONTROL_ENABLE out[44] = static_cast(ledMask & 0x1FU); + detail::reassertDs5MicMuteLed(st, out); return 63; case HidParser::SwitchProUsb: // Subcommand 0x30 (set player lights) rides the 0x01 rumble+subcommand @@ -235,7 +289,7 @@ inline std::size_t buildPlayerLedsReport(HidParser p, std::uint8_t ledMask, std: // // Note the wire order (left, right) is NOT the report order: the DualSense puts // the right trigger's block first. -inline std::size_t buildTriggerEffectsReport(HidParser p, +inline std::size_t buildTriggerEffectsReport(HidParser p, const FeedbackState& st, const std::uint8_t left[kTriggerEffectBlockBytes], const std::uint8_t right[kTriggerEffectBlockBytes], std::uint8_t* out, std::size_t outCap) { @@ -246,6 +300,27 @@ inline std::size_t buildTriggerEffectsReport(HidParser p, out[1] = 0x04 | 0x08; // valid_flag0: right + left trigger-effect blocks std::memcpy(out + 11, right, kTriggerEffectBlockBytes); std::memcpy(out + 22, left, kTriggerEffectBlockBytes); + detail::reassertDs5MicMuteLed(st, out); + return 63; +} + +// The mic-mute lamp (and its amplifier). `state` is MSG_MIC_LED's own value +// (0 off / 1 on / 2 pulse); anything else is refused rather than clamped — +// the dispatch already dropped unknown states, and refusing again keeps the +// builder honest for any other caller, since a lamp state we cannot name is +// one this pad should not be shown. Records the state in `st` so every later +// DS5 report keeps re-asserting it; the report body itself IS the re-assert, +// so one code path writes the lamp whichever builder asked for it. +inline std::size_t buildMicMuteLedReport(HidParser p, FeedbackState& st, std::uint8_t state, + std::uint8_t* out, std::size_t outCap) { + if (p != HidParser::DualSense) { return 0; } + if (state > kMicMuteLedPulse) { return 0; } + if (outCap < 63) { return 0; } + st.ds5MicMuteLed = state; + st.ds5MicMuteLedSet = true; + detail::zero(out, 63); + out[0] = 0x02; + detail::reassertDs5MicMuteLed(st, out); return 63; } diff --git a/src/core/input/UsbReportParsers.h b/src/core/input/UsbReportParsers.h index 304ef80..38eb588 100644 --- a/src/core/input/UsbReportParsers.h +++ b/src/core/input/UsbReportParsers.h @@ -18,6 +18,7 @@ #include "core/input/GamepadButtonLayouts.h" +#include #include #include @@ -84,6 +85,20 @@ struct StickAutoRangeState { std::int16_t steamStickY = 0; }; +// The DualSense mic-mute button is momentary, but the wire's kXusbMicMute +// carries the mute STATE it toggles (contract, Controller audio), so the latch +// that state lives in is ours to keep — for as long as the pad is claimed, +// which is the same lifetime the pad's own firmware gives its mute setting. +// Its own struct rather than a StickAutoRangeState field because two threads +// touch it: the read loop flips it on the button edge, and the app writes it +// when the user toggles mute in the UI, so the slot-card control and the pad's +// button stay one state. Relaxed atomics — last writer wins is exactly the +// semantic a human-scale toggle wants, and the read loop must not take a lock. +struct MicMuteLatch { + std::atomic held{false}; + std::atomic muted{false}; +}; + // Normalised to the XUSB axis/trigger scale. struct ParsedReport { std::uint16_t wButtons = 0; // XUSB button bits (GamepadButtonLayouts kXusb*) @@ -299,8 +314,12 @@ inline bool decodeDualShock4(const std::uint8_t* buf, std::size_t len, ParsedRep // buf[6]=R2 analog; buf[8] face/hat (low nibble hat, 0x10 Square/X 0x20 Cross/A // 0x40 Circle/B 0x80 Triangle/Y); buf[9]: 0x01 L1 0x02 R1 0x10 Create(Back) 0x20 // Options(Start) 0x40 L3 0x80 R3. +// +// `mute` is the cross-report mic-mute latch; null decodes everything except the +// mute state, which is the honest answer for a caller that kept no per-device +// memory (dish-android's decoder makes the same offer). inline bool decodeDualSense(const std::uint8_t* buf, std::size_t len, ParsedReport& s, - StickAutoRangeState& /*unused*/) { + StickAutoRangeState& /*unused*/, MicMuteLatch* mute = nullptr) { if (len < 11) { return false; } if (buf[0] != 0x01) { return false; } @@ -325,6 +344,19 @@ inline bool decodeDualSense(const std::uint8_t* buf, std::size_t len, ParsedRepo if ((buf[9] & 0x80) != 0) { b |= layout::kXusbRightThumb; } // PS button. if (len > 10 && (buf[10] & 0x01) != 0) { b |= layout::kXusbGuide; } + // Mic-mute lives beside the PS button and the touchpad click in button + // byte 10 (0x04 next to 0x01 and 0x02). The press is an edge, the wire bit + // is a state: flip the latch on the way down only, so holding the button + // does not chatter the mute on and off at report rate. + if (mute != nullptr) { + const bool muteDown = (buf[10] & 0x04) != 0; + if (muteDown && !mute->held.load(std::memory_order_relaxed)) { + mute->muted.store(!mute->muted.load(std::memory_order_relaxed), + std::memory_order_relaxed); + } + mute->held.store(muteDown, std::memory_order_relaxed); + if (mute->muted.load(std::memory_order_relaxed)) { b |= layout::kXusbMicMute; } + } b = setDpadFromHat(b, static_cast(buf[8] & 0x0F)); s.wButtons = b; @@ -671,14 +703,16 @@ inline WirelessEvent checkWirelessEvent(HidParser p, const std::uint8_t* buf, st } // False leaves `out` untouched: the family is None, or the report was too short -// or carried the wrong id. +// or carried the wrong id. `micMute` reaches only the family with the button; +// null (the default) decodes without a mute state. inline bool decodeReport(HidParser parser, const std::uint8_t* buf, std::size_t len, - ParsedReport& out, StickAutoRangeState& sticks) { + ParsedReport& out, StickAutoRangeState& sticks, + MicMuteLatch* micMute = nullptr) { switch (parser) { case HidParser::DualShock4: return decodeDualShock4(buf, len, out, sticks); case HidParser::DualSense: - return decodeDualSense(buf, len, out, sticks); + return decodeDualSense(buf, len, out, sticks, micMute); case HidParser::SwitchProUsb: return decodeSwitchProUsb(buf, len, out, sticks); case HidParser::GenericHid: @@ -767,4 +801,14 @@ inline bool parserHasRumble(HidParser parser) { parser == HidParser::SwitchProUsb; } +// Whether the FAMILY is a composite whose USB device carries a USB Audio Class +// function next to the HID interface (the DualSense, and the DualShock 4 whose +// v2 revision streams audio over USB). The audio function stays with the OS — +// this path claims only HID — so this is a candidacy fact for the audio-route +// matcher, not a promise: whether the endpoints actually enumerated is the +// matcher's question (a v1 DS4 simply never shows them). +inline bool parserHasUsbAudio(HidParser parser) { + return parser == HidParser::DualShock4 || parser == HidParser::DualSense; +} + } // namespace dish::input::usbparse diff --git a/src/core/model/Protocol.h b/src/core/model/Protocol.h index 6200014..c42ca46 100644 --- a/src/core/model/Protocol.h +++ b/src/core/model/Protocol.h @@ -7,6 +7,7 @@ #pragma once +#include #include #include #include @@ -45,6 +46,17 @@ inline constexpr std::uint16_t kMsgSessionClose = 0x000F; // s→c reason(1) // a client that never advertises never has to handle them. inline constexpr std::uint16_t kMsgTriggerEffects = 0x0010; // s→c ctrlIdx + left(11) + right(11) inline constexpr std::uint16_t kMsgPlayerLeds = 0x0011; // s→c ctrlIdx + ledMask(1) +// Controller audio: the EMULATED pad's own endpoints, never the host's game +// audio. Both audio messages carry ctrlIdx(1) + seq(u16 BE) + exactly one 20 ms +// Opus packet; `seq` wraps and buys only gap detection and late-drop inside the +// receiver's 2-frame reorder window (core/audio/AudioJitter.h) — no acks, no +// retransmits, Opus in-band FEC + PLC conceal loss. +inline constexpr std::uint16_t kMsgMicAudio = 0x0012; // c→s the pad's headset mic, mono +inline constexpr std::uint16_t kMsgSpeakerAudio = 0x0013; // s→c the pad's speaker out, stereo +// Mic-mute lamp (s→c): ctrlIdx(1) + state(1), kMicLedState*. Coalesced +// last-value-wins server-side like the lightbar. Gated on kCapMic rather than a +// cap of its own: a mute lamp without a microphone behind it is dead metal. +inline constexpr std::uint16_t kMsgMicLed = 0x0014; // After the 4-byte inner type+len header: backendAvailable(1) + // totalActiveControllers(1) + epoch(u16 BE) + activeBitmap(u16 BE). @@ -88,6 +100,53 @@ inline constexpr int kTriggerEffectsPayloadBytes = 2 * kTriggerEffectBlockBytes; // bit 0 = leftmost LED (DualSense uses bits 0-4, Switch Pro bits 0-3). inline constexpr int kPlayerLedsPayloadBytes = 1; +// ── Datagram ceilings ─────────────────────────────────────────────────────── +// One Ethernet MTU in either direction, so a full audio frame crosses a LAN +// without fragmenting (a fragmented Opus packet would be an all-or-nothing loss +// anyway). What the crypto framing leaves behind is the ceiling every sender +// must stay under; the satellite pins the same number and truncates a longer +// read, which then fails the AEAD. +inline constexpr std::size_t kUdpDatagramMaxBytes = 1500; +inline constexpr std::size_t kUdpOuterHeaderBytes = 8; // token(4) + counter(4 BE) +inline constexpr std::size_t kUdpInnerHeaderBytes = 4; // msgType(2 BE) + msgLen(2 BE) +inline constexpr std::size_t kUdpAeadTagBytes = 16; // ChaCha20-Poly1305-IETF +inline constexpr std::size_t kUdpMaxInnerPayloadBytes = + kUdpDatagramMaxBytes - kUdpOuterHeaderBytes - kUdpInnerHeaderBytes - kUdpAeadTagBytes; + +// ── Controller audio wire format (kMsgMicAudio / kMsgSpeakerAudio) ────────── +// Fixed on the wire, never negotiated: Opus resamples to 48 kHz internally +// regardless, so pinning the rate costs nothing and spares both ends a +// resampler; 20 ms is also the emulated pad's USB-audio service interval, so no +// side re-windows. Mirrors satellite core/types.h AUDIO_*. +inline constexpr int kAudioSampleRateHz = 48000; +inline constexpr int kAudioFrameMs = 20; +inline constexpr int kAudioFrameSamples = kAudioSampleRateHz / 1000 * kAudioFrameMs; // per channel +// Mic is the pad's headset microphone (mono); speaker is channels 1/2 of the +// DualSense 4-channel OUT stream, its speaker and headset jack (stereo). +inline constexpr int kAudioMicChannels = 1; +inline constexpr int kAudioSpeakerChannels = 2; + +// Both audio messages: ctrlIdx(1) + seq(u16 BE) ahead of the Opus bytes. +inline constexpr int kAudioWireHeaderBytes = 3; +// Smallest frame worth dispatching: the header plus at least one Opus byte. A +// 1-byte Opus packet is legal (a valid DTX silence frame, and the mic encoder +// really does emit those); a header with nothing behind it is malformed. +inline constexpr int kAudioWireMinPayloadBytes = kAudioWireHeaderBytes + 1; +// Largest Opus packet one datagram can carry. Two orders of magnitude above a +// real 20 ms packet (~80 bytes mic, ~240 speaker); a bound, not a target. +inline constexpr std::size_t kAudioWireMaxOpusBytes = + kUdpMaxInnerPayloadBytes - static_cast(kAudioWireHeaderBytes); + +// kMsgMicLed state byte. Pulse is the DualSense's own breathing pattern, which +// the pad renders itself; the satellite only forwards which mode the game asked +// for. Anything else is malformed and dropped rather than guessed at. +inline constexpr std::uint8_t kMicLedStateOff = 0; +inline constexpr std::uint8_t kMicLedStateOn = 1; +inline constexpr std::uint8_t kMicLedStatePulse = 2; +inline constexpr std::uint8_t kMicLedStateCount = 3; +// After the 1-byte ctrlIdx: state(1). +inline constexpr int kMicLedPayloadBytes = 1; + // ── MSG_SESSION_CLOSE reason byte ─────────────────────────────────────────── inline constexpr std::uint8_t kCloseReasonShutdown = 0; // server going down inline constexpr std::uint8_t kCloseReasonKicked = 1; // admin kick (transient) @@ -103,6 +162,12 @@ inline constexpr std::uint16_t kCapLightbar = 0x0008; // sends 0x0010 / 0x0011 only to a session that claimed it can land them. inline constexpr std::uint16_t kCapTriggerEffects = 0x0010; inline constexpr std::uint16_t kCapPlayerLeds = 0x0020; +// Controller audio. Same house rule: a cap advertises the CLIENT's own +// actuator/source — kCapMic says this client sources kMsgMicAudio (and takes +// the kMsgMicLed return path, which rides it), kCapSpeaker that it accepts +// kMsgSpeakerAudio. Independent directions; neither implies the other. +inline constexpr std::uint16_t kCapMic = 0x0040; +inline constexpr std::uint16_t kCapSpeaker = 0x0080; // ── Controller types (catalog ids / descriptor `type`) ────────────────────── inline constexpr std::uint8_t kControllerTypeXbox = 0; diff --git a/src/core/moonlight/MoonlightButtonMap.h b/src/core/moonlight/MoonlightButtonMap.h index 8149710..a5d07d0 100644 --- a/src/core/moonlight/MoonlightButtonMap.h +++ b/src/core/moonlight/MoonlightButtonMap.h @@ -36,6 +36,13 @@ inline constexpr std::uint16_t kY = 0x8000; // Translates the internal button word into Moonlight's effective button flags. // The two vocabularies mostly line up (both descend from XInput) but Home/Guide // and the stick clicks sit at different bits, so the map is explicit. +// +// The explicitness is load-bearing the other way too: 0x0800 has no XINPUT +// assignment and protocol 2 spends it on the DualSense mic-mute STATE +// (input::layout::kXusbMicMute), a Satellite-only signal a GameStream host +// would misread. A Direct-claimed DualSense's decoder folds that bit into +// every report while muted; this map has no row for it, so it can never leak +// to a Moonlight host — a pin test holds that door shut. inline std::uint32_t toMoonlightButtons(std::uint16_t buttons) { std::uint32_t out = 0; const auto set = [&](std::uint16_t in, std::uint32_t flag) { diff --git a/src/core/reducer/CapabilitySolver.h b/src/core/reducer/CapabilitySolver.h index 3c247a5..cc17a93 100644 --- a/src/core/reducer/CapabilitySolver.h +++ b/src/core/reducer/CapabilitySolver.h @@ -44,7 +44,9 @@ enum class CapFeature : std::uint8_t { Rumble, Lightbar, TriggerEffects, - PlayerLeds + PlayerLeds, + Mic, + Speaker }; struct CapabilityInputs { @@ -59,6 +61,12 @@ struct CapabilityInputs { // that an unknown pad claiming them would be the surprising answer. bool padTriggerEffects = false; bool padPlayerLeds = false; + // Controller audio: does the pad have a usable audio route on this machine + // (core/reducer/FeedbackRouting.h answers it). Default false for the same + // reason as the two above — and false everywhere until Wave 2 lands the + // route matching, so no binding shows audio as carried yet. + bool padMic = false; + bool padSpeaker = false; bool linkDirect = false; // usb && directCapable && the draft wants Direct bool linkUsb = false; // false means Bluetooth transport @@ -67,14 +75,28 @@ struct CapabilityInputs { bool typeResolved = false; // false means the type layer refuses nothing bool typeMotion = false, typeTouchpad = false, typeRumble = false, typeLightbar = false; bool typeTriggerEffects = false, typePlayerLeds = false; + // From the catalog's mic/speaker feature slugs: true only for the types + // whose materializer can build a pad with audio endpoints (the two Sony + // types via composite personas). + bool typeMic = false, typeSpeaker = false; bool hostResolved = false; // false means Pending bool hostIsBluetooth = false; bool hostMouseControl = false; bool hostRumble = false; + // The controller-audio probe's per-direction verdict (reducer/ + // HostAudioVerdict.h), NOT the catalog: the catalog says what the host + // could build, this says what it will carry right now, and it can flip + // between sessions. Unlike the two layers above, an unprobed host reads + // FALSE rather than Pending — audio is opt-in by design, so the honest + // answer before a probe is "not carried". + bool hostMic = false, hostSpeaker = false; // User gates drive Off, never Unavailable. bool userMotionOn = true, userRumbleOn = true; + // The audio defaults mirror the stores': mic off until asked (privacy), + // speaker on. + bool userMicOn = false, userSpeakerOn = true; int userTouchpadMode = 0; // 0=off 1=pad 2=mouse }; @@ -114,6 +136,10 @@ inline bool inputCarries(const CapabilityInputs& in, CapFeature f) { return in.padTriggerEffects; case CapFeature::PlayerLeds: return in.padPlayerLeds; + case CapFeature::Mic: + return in.padMic; + case CapFeature::Speaker: + return in.padSpeaker; } return false; } @@ -127,7 +153,10 @@ inline bool linkCarries(const CapabilityInputs& in, CapFeature f) { // Standard (SDL) forwards motion, touch, rumble and the lightbar wherever // the pad's driver exposes them; the input layer's probe constrains it. The // adaptive triggers and the player LEDs have no SDL call at all, so they - // are the one thing the Standard path structurally cannot carry. + // are the one thing the Standard path structurally cannot carry. Audio is + // NOT with them: the streams ride the pad's own USB-audio endpoints, a + // separate interface reachable from either HID path, so the link layer + // never refuses it — the input layer's route facts are what constrain it. return f != CapFeature::TriggerEffects && f != CapFeature::PlayerLeds; } @@ -153,6 +182,10 @@ inline bool typeCarries(const CapabilityInputs& in, CapFeature f) { return in.typeTriggerEffects; case CapFeature::PlayerLeds: return in.typePlayerLeds; + case CapFeature::Mic: + return in.typeMic; + case CapFeature::Speaker: + return in.typeSpeaker; } return false; } @@ -161,7 +194,7 @@ inline bool hostCarries(const CapabilityInputs& in, CapFeature f) { if (!in.hostResolved) { return true; } // same rule as the type layer if (in.hostIsBluetooth) { // The system gamepad layer has no motion, touch, lightbar, trigger - // effect or player-LED channel. + // effect, player-LED or controller-audio channel. return f == CapFeature::Gamepad || f == CapFeature::Triggers || f == CapFeature::Rumble; } switch (f) { @@ -177,6 +210,13 @@ inline bool hostCarries(const CapabilityInputs& in, CapFeature f) { return in.hostMouseControl; case CapFeature::Rumble: return in.hostRumble; + // The probe's live verdict, conservative-false until it lands: an + // unprobed host is Unavailable-at-host here rather than Pending, because + // no other layer can promise the host will carry the stream. + case CapFeature::Mic: + return in.hostMic; + case CapFeature::Speaker: + return in.hostSpeaker; } return false; } @@ -193,6 +233,10 @@ inline bool userEnabled(const CapabilityInputs& in, CapFeature f) { return in.userTouchpadMode == 1; case CapFeature::Mouse: return in.userTouchpadMode == 2; + case CapFeature::Mic: + return in.userMicOn; + case CapFeature::Speaker: + return in.userSpeakerOn; default: return true; } @@ -210,7 +254,8 @@ inline std::vector solveCapabilities(const CapabilityInputs& in) static constexpr CapFeature kOrder[] = { CapFeature::Gamepad, CapFeature::Triggers, CapFeature::Motion, CapFeature::Touchpad, CapFeature::Mouse, CapFeature::Rumble, - CapFeature::Lightbar, CapFeature::TriggerEffects, CapFeature::PlayerLeds}; + CapFeature::Lightbar, CapFeature::TriggerEffects, CapFeature::PlayerLeds, + CapFeature::Mic, CapFeature::Speaker}; const bool pending = capabilitiesPending(in); std::vector rows; diff --git a/src/core/reducer/FeedbackRouting.h b/src/core/reducer/FeedbackRouting.h index 5598406..3e23b0a 100644 --- a/src/core/reducer/FeedbackRouting.h +++ b/src/core/reducer/FeedbackRouting.h @@ -32,6 +32,10 @@ enum class FeedbackKind : std::uint8_t { Lightbar, TriggerEffects, PlayerLeds, + // The mic-mute lamp (kMsgMicLed). A feedback kind like the others even + // though its gate is CAP_MIC rather than a cap of its own: the routing + // question — where does a delivered state land — is the same question. + MicLed, }; enum class FeedbackTarget : std::uint8_t { @@ -56,6 +60,20 @@ struct SlotFeedbackInputs { bool padLightbar = false; bool padTriggerEffects = false; bool padPlayerLeds = false; + // The model has a mic-mute lamp (DualSense only). False until the Wave-2 + // USB output-report builder exists to drive it — a lamp the report builder + // cannot address is a lamp this client does not have. + bool padMicLed = false; + + // Controller-audio routes: does the claimed pad have a usable audio path on + // THIS machine (its own USB-audio endpoints matched to a Windows audio + // device)? Deliberately independent of the HID path above — the pad's audio + // function is a separate USB interface, reachable whether input rides + // Standard or Direct. Both stay false until Wave 2 lands the device + // enumeration and matching, so nothing advertises an audio cap yet; the + // fold below is already the single owner Wave 2 flips. + bool padMicRoute = false; + bool padSpeakerRoute = false; }; namespace detail { @@ -70,11 +88,15 @@ inline bool padHas(const SlotFeedbackInputs& in, FeedbackKind kind) { return in.padTriggerEffects; case FeedbackKind::PlayerLeds: return in.padPlayerLeds; + case FeedbackKind::MicLed: + return in.padMicLed; } return false; } -// What the SDL layer can drive, whatever the pad has. +// What the SDL layer can drive, whatever the pad has. The mic-mute lamp is +// with the triggers and player LEDs: SDL has no call for it, so only a Direct +// claim's OUT report path can ever land one. inline bool standardPathCarries(FeedbackKind kind) { return kind == FeedbackKind::Rumble || kind == FeedbackKind::Lightbar; } @@ -97,4 +119,13 @@ inline bool slotCarriesFeedback(const SlotFeedbackInputs& in, FeedbackKind kind) return resolveFeedbackTarget(in, kind) != FeedbackTarget::None; } +// Whether the descriptor may claim the controller-audio caps. The same rule as +// the feedback kinds — a cap is a promise something lands — but keyed on the +// audio routes rather than the HID path, because the streams ride the pad's own +// audio endpoints (see SlotFeedbackInputs::padMicRoute). CAP_MIC also gates the +// kMsgMicLed return path server-side, so the lamp needs no claim of its own. +inline bool slotCarriesMicCapture(const SlotFeedbackInputs& in) { return in.padMicRoute; } + +inline bool slotCarriesSpeakerPlayout(const SlotFeedbackInputs& in) { return in.padSpeakerRoute; } + } // namespace dish::reducer diff --git a/src/core/reducer/HostAudioVerdict.h b/src/core/reducer/HostAudioVerdict.h new file mode 100644 index 0000000..cb253cd --- /dev/null +++ b/src/core/reducer/HostAudioVerdict.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The host's controller-audio verdict, folded from GET /api/server/capabilities. +// That one document is the ONLY carrier: not the catalog (cached on server +// version + locale, so a switch flipped on the PC must not move it) and not the +// session PUT. Three layers answer three different questions — a descriptor cap +// says what this CLIENT can do, the catalog's type slugs what the host COULD +// build, and this verdict what the host will actually carry RIGHT NOW. It can +// flip under a live session, so callers keep it as per-connection state with a +// conservative default: no audio until a probe says yes. + +#pragma once + +#include "Models/Models.h" + +namespace dish::reducer { + +// The facts the fold reads, lifted out of the DTO so the rule is testable as a +// truth table rather than through JSON. +struct HostAudioFacts { + // The top-level `controllerAudio` block. ABSENT is unknown, not off: a + // satellite predating the block may still carry audio and reports it on the + // per-backend `audio` flag, so reading absence as two falses would mute + // every host older than the block. + bool blockPresent = false; + bool enabled = false; + bool mic = false; + bool speaker = false; + // The fallback: any backends[] entry that is available AND carries audio. + // A satellite predating BOTH mechanisms sends no backends array either, + // which reads as off — and is the truth there. + bool anyBackendAudio = false; +}; + +struct HostAudioVerdict { + bool mic = false; + bool speaker = false; +}; + +// A PRESENT block wins outright, since it is the only place the two directions +// are reported apart. `enabled` is re-ANDed rather than trusted: the host does +// fold it into both directions, but a stale direction switch left true under a +// disabled master would otherwise advertise an endpoint that will never be +// plugged. An absent block falls back to the per-backend flag, both directions +// at once, because that flag cannot tell them apart. +inline HostAudioVerdict resolveHostControllerAudio(const HostAudioFacts& f) { + if (f.blockPresent) { return {f.enabled && f.mic, f.enabled && f.speaker}; } + return {f.anyBackendAudio, f.anyBackendAudio}; +} + +// The DTO-to-facts bridge, kept next to the rule so no caller re-reads the +// document's fields with its own ideas about absence. +inline HostAudioFacts hostAudioFactsFrom(const models::CapabilitiesDto& caps) { + HostAudioFacts f; + f.blockPresent = caps.hasControllerAudioBlock; + f.enabled = caps.controllerAudioEnabled; + f.mic = caps.controllerAudioMic; + f.speaker = caps.controllerAudioSpeaker; + for (const auto& b : caps.backends) { + if (b.available && b.audio) { + f.anyBackendAudio = true; + break; + } + } + return f; +} + +inline HostAudioVerdict resolveHostControllerAudio(const models::CapabilitiesDto& caps) { + return resolveHostControllerAudio(hostAudioFactsFrom(caps)); +} + +} // namespace dish::reducer diff --git a/src/qml/AppViewModel.cpp b/src/qml/AppViewModel.cpp index 5534048..3379c2b 100644 --- a/src/qml/AppViewModel.cpp +++ b/src/qml/AppViewModel.cpp @@ -31,6 +31,7 @@ #include "source/usb/UsbGamepadManager.h" #include "qml/AppSettingsMaps.h" #include "repository/DeadzoneRepository.h" +#include "source/store/AudioEnabledStore.h" #include "source/store/CrashReportingStore.h" #include "source/store/MotionEnabledStore.h" #include "source/store/OnboardingPreferenceStore.h" @@ -165,6 +166,10 @@ QString capFeatureToken(reducer::CapFeature f) { return QStringLiteral("triggerEffects"); case reducer::CapFeature::PlayerLeds: return QStringLiteral("playerLeds"); + case reducer::CapFeature::Mic: + return QStringLiteral("mic"); + case reducer::CapFeature::Speaker: + return QStringLiteral("speaker"); } return {}; } @@ -1247,7 +1252,8 @@ bool AppViewModel::catalogResolvedFor(const QString& hostId) const { QVariantList AppViewModel::capabilityForCandidate(const QString& slotId, int type, const QString& hostKind, const QString& hostId, const QString& desiredPath, bool motionOn, - bool rumbleOn, int touchpadMode) const { + bool rumbleOn, int touchpadMode, bool micOn, + bool speakerOn) const { reducer::CapabilityInputs in; // An unknown slot leaves the input layer at its defaults rather than @@ -1257,6 +1263,10 @@ QVariantList AppViewModel::capabilityForCandidate(const QString& slotId, int typ in.padTouchpad = slot->capabilities.hasTouchpad; in.padLightbar = slot->capabilities.hasLightbar; in.padRumble = slot->capabilities.hasRumble; + // The audio-route seam, one owner with the descriptor caps. Answers + // false for everything until Wave 2 lands the route matching. + in.padMic = model_->slotCarriesMicSource(slotId); + in.padSpeaker = model_->slotCarriesSpeakerSink(slotId); // A Bluetooth pad has no USB path to claim, so Direct is unreachable // rather than merely unselected. in.linkUsb = !slot->bluetooth; @@ -1283,8 +1293,12 @@ QVariantList AppViewModel::capabilityForCandidate(const QString& slotId, int typ in.typeTouchpad = (ceiling & moonproto::kCapTouchpad) != 0; in.typeRumble = (ceiling & moonproto::kCapRumble) != 0; in.typeLightbar = (ceiling & moonproto::kCapRgbLed) != 0; + // typeMic/typeSpeaker stay false: GameStream has no controller-audio + // channel, so the type layer is the honest refusal for a Moonlight host. in.userMotionOn = motionOn; in.userRumbleOn = rumbleOn; + in.userMicOn = micOn; + in.userSpeakerOn = speakerOn; in.userTouchpadMode = touchpadMode; QVariantList moonRows; @@ -1316,6 +1330,16 @@ QVariantList AppViewModel::capabilityForCandidate(const QString& slotId, int typ // advertises no block at all still carries it. in.hostRumble = rumble == hostFeatures.constEnd() ? true : rumble->supported; } + if (!hostIsBluetooth) { + // The mic/speaker host layer alone reads the live probe rather than the + // catalog: only /api/server/capabilities carries the host's audio + // switches, and they can flip between sessions. Deliberately outside + // the hostResolved gate above — the verdict is conservative false with + // no Pending state, so an unprobed host refuses rather than waits. + const auto audio = model_->hostControllerAudioFor(hostId); + in.hostMic = audio.mic; + in.hostSpeaker = audio.speaker; + } // Unresolved leaves typeResolved false, which refuses nothing. if (const auto typeDto = model_->catalogTypeFor(hostId, type)) { @@ -1325,10 +1349,18 @@ QVariantList AppViewModel::capabilityForCandidate(const QString& slotId, int typ in.typeTouchpad = reducer::typeOffersTouchpadDs4(*typeDto); in.typeRumble = reducer::isFeatureOffered(*typeDto, catalog::kFeatureRumble, known); in.typeLightbar = reducer::isFeatureOffered(*typeDto, catalog::kFeatureLightbar, known); + // The audio slugs ride their own whitelist: they are protocol-2 + // vocabulary and deliberately not in knownFeatureSlugs(), which the + // protocol-1 caps gate owns (see BundledCatalog.h). + const auto audioSlugs = catalog::audioFeatureSlugs(); + in.typeMic = reducer::isFeatureOffered(*typeDto, catalog::kFeatureMic, audioSlugs); + in.typeSpeaker = reducer::isFeatureOffered(*typeDto, catalog::kFeatureSpeaker, audioSlugs); } in.userMotionOn = motionOn; in.userRumbleOn = rumbleOn; + in.userMicOn = micOn; + in.userSpeakerOn = speakerOn; in.userTouchpadMode = touchpadMode; QVariantList out; @@ -1454,6 +1486,31 @@ bool AppViewModel::rumbleEnabledFor(const QString& /*slotId*/) const { return tr void AppViewModel::setRumbleEnabled(const QString& /*slotId*/, bool /*on*/) {} +bool AppViewModel::micEnabledFor(const QString& slotId) const { + if (slotId.isEmpty()) { return source::MicEnabledStore::kDefaultEnabled; } + return model_->micEnabledStore()->isEnabled(slotId.toStdString()); +} + +void AppViewModel::setMicEnabled(const QString& slotId, bool on) { + if (slotId.isEmpty()) { return; } + model_->micEnabledStore()->setEnabled(slotId.toStdString(), on); +} + +bool AppViewModel::speakerEnabledFor(const QString& slotId) const { + if (slotId.isEmpty()) { return source::SpeakerEnabledStore::kDefaultEnabled; } + return model_->speakerEnabledStore()->isEnabled(slotId.toStdString()); +} + +void AppViewModel::setSpeakerEnabled(const QString& slotId, bool on) { + if (slotId.isEmpty()) { return; } + model_->speakerEnabledStore()->setEnabled(slotId.toStdString(), on); +} + +void AppViewModel::toggleSlotMicMute(const QString& slotId) { + if (slotId.isEmpty()) { return; } + model_->toggleSlotMicMute(slotId); +} + QString AppViewModel::discoverySourceFor(const QString& serverId) const { for (const auto& s : model_->wifi()->discoveredServers()) { if (s.id() == serverId) { return models::discoverySourceLabel(s.source); } @@ -1470,7 +1527,7 @@ QString AppViewModel::applyDestinationState() const { return applyStepToken(appl void AppViewModel::applyBinding(const QString& slotId, const QString& connectionId, int type, const QString& desiredPath, bool motionOn, bool rumbleOn, - int touchpadMode) { + int touchpadMode, bool micOn, bool speakerOn) { if (applyInFlight()) { return; } // one run at a time; the overlay is modal // Re-resolve before anything is written: an earlier path switch can have @@ -1491,6 +1548,8 @@ void AppViewModel::applyBinding(const QString& slotId, const QString& connection applyMotionOn_ = motionOn; applyRumbleOn_ = rumbleOn; applyTouchpadMode_ = touchpadMode; + applyMicOn_ = micOn; + applySpeakerOn_ = speakerOn; // Only a claimable wired pad has a path at all, and only a genuine change is // worth a 20 s budget. @@ -1531,6 +1590,8 @@ void AppViewModel::beginApplyBind() { : applyTouchpadMode_ == 1 ? QStringLiteral("pad") : QStringLiteral("off")); setRumbleEnabled(applySlotId_, applyRumbleOn_); + setMicEnabled(applySlotId_, applyMicOn_); + setSpeakerEnabled(applySlotId_, applySpeakerOn_); if (applyIsMoonlight_) { // The host remembers the last pick so the NEXT binding on it starts // where this one did. The binding still owns the type it sends; this is diff --git a/src/qml/AppViewModel.h b/src/qml/AppViewModel.h index 6393b1c..529ea1f 100644 --- a/src/qml/AppViewModel.h +++ b/src/qml/AppViewModel.h @@ -456,7 +456,8 @@ class AppViewModel : public QObject { Q_INVOKABLE QVariantList capabilityForCandidate(const QString& slotId, int type, const QString& hostKind, const QString& hostId, const QString& desiredPath, bool motionOn, - bool rumbleOn, int touchpadMode) const; + bool rumbleOn, int touchpadMode, bool micOn, + bool speakerOn) const; // { feature, supported } rows for the type picker's preview pills. Empty // while the catalog is unresolved: a guessed pill is worse than none. @@ -490,13 +491,23 @@ class AppViewModel : public QObject { Q_INVOKABLE bool motionEnabledFor(const QString& slotId) const; Q_INVOKABLE bool rumbleEnabledFor(const QString& slotId) const; Q_INVOKABLE void setRumbleEnabled(const QString& slotId, bool on); + // Controller audio, persisted per slot like motion. Mic defaults OFF + // (privacy: capture is opt-in per binding), speaker ON. + Q_INVOKABLE bool micEnabledFor(const QString& slotId) const; + Q_INVOKABLE void setMicEnabled(const QString& slotId, bool on); + Q_INVOKABLE bool speakerEnabledFor(const QString& slotId) const; + Q_INVOKABLE void setSpeakerEnabled(const QString& slotId, bool on); + // The live mute control (both surfaces' click lands here; the pad's own + // button reaches the same state through the report decoder). State reads + // ride the slot model's micArmed/micMuted roles, so there is no getter. + Q_INVOKABLE void toggleSlotMicMute(const QString& slotId); // ── Apply ──────────────────────────────────────────────────────────────── // The one write the binding surfaces make. Terminates in exactly one // applyFinished, and is ignored while a run is already in flight. Q_INVOKABLE void applyBinding(const QString& slotId, const QString& connectionId, int type, const QString& desiredPath, bool motionOn, bool rumbleOn, - int touchpadMode); + int touchpadMode, bool micOn, bool speakerOn); // Accepted only while the Connection step is active. Q_INVOKABLE void cancelApply(); @@ -664,6 +675,10 @@ class AppViewModel : public QObject { bool applyMotionOn_ = true; bool applyRumbleOn_ = true; int applyTouchpadMode_ = 0; + // The store defaults, restated so an apply that never touched audio writes + // the same answer the stores would have given. + bool applyMicOn_ = false; + bool applySpeakerOn_ = true; // The 20 s path budget, the 8 s bind budget, and the 250 ms elapsed tick. QTimer* applyPathTimer_ = nullptr; QTimer* applyBindTimer_ = nullptr; diff --git a/src/qml/SlotListModel.cpp b/src/qml/SlotListModel.cpp index a93cbe4..1feab4e 100644 --- a/src/qml/SlotListModel.cpp +++ b/src/qml/SlotListModel.cpp @@ -112,6 +112,10 @@ QVariant SlotListModel::data(const QModelIndex& index, int role) const { return s.capabilities.hasRumble; case VerifiedModelRole: return s.verifiedModel; + case MicArmedRole: + return s.micArmed; + case MicMutedRole: + return s.micMuted; case BatteryLevelRole: return static_cast(s.capabilities.batteryLevel); case BatteryStatusRole: @@ -252,6 +256,8 @@ QHash SlotListModel::roleNames() const { {HasTouchpadRole, "hasTouchpad"}, {HasRumbleRole, "hasRumble"}, {VerifiedModelRole, "verifiedModel"}, + {MicArmedRole, "micArmed"}, + {MicMutedRole, "micMuted"}, }; } diff --git a/src/qml/SlotListModel.h b/src/qml/SlotListModel.h index 3cc1c2d..6ffdb11 100644 --- a/src/qml/SlotListModel.h +++ b/src/qml/SlotListModel.h @@ -81,6 +81,11 @@ class SlotListModel : public QAbstractListModel { HasTouchpadRole, HasRumbleRole, VerifiedModelRole, + + // Controller audio: whether the slot's descriptor claims a mic (the + // mute control's visibility) and the LOCAL mute truth. + MicArmedRole, + MicMutedRole, }; Q_ENUM(Roles) diff --git a/src/qml/pages/ConfigureBindingPage.qml b/src/qml/pages/ConfigureBindingPage.qml index 45857d7..254b53f 100644 --- a/src/qml/pages/ConfigureBindingPage.qml +++ b/src/qml/pages/ConfigureBindingPage.qml @@ -169,6 +169,8 @@ Kit.Page { || draft.desiredPath !== page.openedWith.desiredPath || draft.motionOn !== page.openedWith.motionOn || draft.rumbleOn !== page.openedWith.rumbleOn + || draft.micOn !== page.openedWith.micOn + || draft.speakerOn !== page.openedWith.speakerOn || draft.touchpadMode !== page.openedWith.touchpadMode) function snapshot() { @@ -178,6 +180,8 @@ Kit.Page { "desiredPath": draft.desiredPath, "motionOn": draft.motionOn, "rumbleOn": draft.rumbleOn, + "micOn": draft.micOn, + "speakerOn": draft.speakerOn, "touchpadMode": draft.touchpadMode }; } @@ -217,6 +221,8 @@ Kit.Page { } draft.motionOn = App.motionEnabledFor(page.slotId); draft.rumbleOn = App.rumbleEnabledFor(page.slotId); + draft.micOn = App.micEnabledFor(page.slotId); + draft.speakerOn = App.speakerEnabledFor(page.slotId); draft.sanitize(); page.snapshot(); @@ -430,9 +436,15 @@ Kit.Page { readonly property bool motionTunable: page.carries("motion") readonly property bool rumbleTunable: page.carries("rumble") readonly property bool touchpadTunable: page.carries("touchpad") || page.carries("mouse") + // Carried where the whole path does: a Direct-claimed Sony pad whose own + // endpoints this machine confidently named, on an emulated type with audio, + // against a host whose probe said yes. + readonly property bool micTunable: page.carries("mic") + readonly property bool speakerTunable: page.carries("speaker") readonly property bool nothingTunable: draft.hasDestination && draft.hasType && !page.motionTunable && !page.rumbleTunable - && !page.touchpadTunable + && !page.touchpadTunable && !page.micTunable + && !page.speakerTunable readonly property var touchpadOptions: [qsTr("Off"), qsTr("Pad"), qsTr("Mouse")] @@ -587,7 +599,8 @@ Kit.Page { page.applyRequested = true; applyOverlay.open(); App.applyBinding(page.slotId, draft.hostId, draft.type, draft.desiredPath, - draft.motionOn, draft.rumbleOn, draft.touchpadMode); + draft.motionOn, draft.rumbleOn, draft.touchpadMode, + draft.micOn, draft.speakerOn); } function failureText(reasonToken) { @@ -669,6 +682,8 @@ Kit.Page { required property bool hasLightbar required property bool hasRumble required property bool verifiedModel + required property bool micArmed + required property bool micMuted required property int gamepadHz required property bool gamepadHzLive @@ -1188,7 +1203,8 @@ Kit.Page { Kit.Card { visible: draft.hasDestination && (page.motionTunable || page.touchpadTunable - || page.rumbleTunable || page.nothingTunable) + || page.rumbleTunable || page.micTunable + || page.speakerTunable || page.nothingTunable) Layout.fillWidth: true contentItem: ColumnLayout { @@ -1246,6 +1262,54 @@ Kit.Page { onToggled: checked => draft.setRumble(checked) } + Rectangle { + visible: page.rumbleTunable && page.micTunable + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.outlineSubtle + } + + Kit.LabeledSwitch { + visible: page.micTunable + Layout.fillWidth: true + label: qsTr("Microphone") + description: qsTr("The pad’s mic carries your voice to the host. Off sends nothing.") + checked: draft.micOn + onToggled: checked => draft.setMic(checked) + } + + // The LIVE mute, distinct from the durable + // toggle above it: same state the slot card + // and the pad's own mute button drive, so + // every surface answers alike. Shown only + // while the current binding actually arms a + // microphone. + Kit.DishButton { + visible: page.padRow !== null && page.padRow.micArmed + text: page.padRow !== null && page.padRow.micMuted + ? qsTr("Mic muted") : qsTr("Mic live") + variant: page.padRow !== null && page.padRow.micMuted + ? Kit.DishButton.Primary : Kit.DishButton.Outline + size: Kit.DishButton.Small + onClicked: App.toggleSlotMicMute(page.slotId) + } + + Rectangle { + visible: page.micTunable && page.speakerTunable + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.outlineSubtle + } + + Kit.LabeledSwitch { + visible: page.speakerTunable + Layout.fillWidth: true + label: qsTr("Controller sound") + description: qsTr("Audio from the host plays on the pad’s speaker or headset.") + checked: draft.speakerOn + onToggled: checked => draft.setSpeaker(checked) + } + Label { visible: page.nothingTunable Layout.fillWidth: true @@ -1259,8 +1323,8 @@ Kit.Page { } // ══ WHAT CARRIES ════════════════════════════════════════════════ - // Seven rows at most: it never needs a scroller of its own, and the - // page's single scroll region owns all overflow. + // Eleven rows at most: it never needs a scroller of its own, and + // the page's single scroll region owns all overflow. Kit.Card { Layout.alignment: Qt.AlignTop Layout.fillWidth: page.stacked diff --git a/src/qml/pages/ControllersPage.qml b/src/qml/pages/ControllersPage.qml index cc40d72..08f90ba 100644 --- a/src/qml/pages/ControllersPage.qml +++ b/src/qml/pages/ControllersPage.qml @@ -111,6 +111,8 @@ Kit.Page { required property bool pathSupported required property bool claimInProgress required property string directFailure + required property bool micArmed + required property bool micMuted readonly property string pathNote: pathSupported ? page.pathNoteText(pathPhase, directFailure) : "" @@ -236,6 +238,24 @@ Kit.Page { : Kit.CapabilityChip.Neutral } + // The mic state, LOCAL truth, shown only where the + // slot's descriptor actually claims a microphone — a + // mute over no mic is dead chrome. A button, not a + // chip: mute is a live control, and the pad's own mute + // button lands on the same state so the two can never + // disagree. + Kit.DishButton { + visible: card.micArmed + text: card.micMuted ? qsTr("Mic muted") : qsTr("Mic live") + variant: card.micMuted ? Kit.DishButton.Primary + : Kit.DishButton.Outline + size: Kit.DishButton.Small + Accessible.name: card.micMuted + ? qsTr("Microphone muted, click to unmute") + : qsTr("Microphone live, click to mute") + onClicked: App.toggleSlotMicMute(card.slotId) + } + Kit.LiveStat { visible: card.motionHzShown live: card.live diff --git a/src/qml/shared/BindingDraft.qml b/src/qml/shared/BindingDraft.qml index 774d972..e5410a0 100644 --- a/src/qml/shared/BindingDraft.qml +++ b/src/qml/shared/BindingDraft.qml @@ -28,6 +28,9 @@ QtObject { property string desiredPath: "standard" // "standard" | "direct" — never "auto" property bool motionOn: true property bool rumbleOn: true + // The stores' defaults, restated: mic OFF until asked (privacy), speaker ON. + property bool micOn: false + property bool speakerOn: true property int touchpadMode: 0 // 0 off · 1 pad · 2 mouse // The app this binding will start, or the one it will join. PER SESSION, @@ -68,7 +71,8 @@ QtObject { return []; return App.capabilityForCandidate(draft.slotId, candidateType, draft.hostKind, draft.hostId, draft.desiredPath, draft.motionOn, - draft.rumbleOn, draft.touchpadMode); + draft.rumbleOn, draft.touchpadMode, draft.micOn, + draft.speakerOn); } function capabilityRows() { @@ -131,6 +135,10 @@ QtObject { return qsTr("Adaptive triggers"); case "playerLeds": return qsTr("Player LEDs"); + case "mic": + return qsTr("Microphone"); + case "speaker": + return qsTr("Controller sound"); } return feature; } @@ -156,6 +164,10 @@ QtObject { return qsTr("adaptive triggers"); case "playerLeds": return qsTr("player LEDs"); + case "mic": + return qsTr("microphone"); + case "speaker": + return qsTr("speaker"); } return feature; } @@ -284,6 +296,16 @@ QtObject { draft.sanitize(); } + function setMic(on) { + draft.micOn = on; + draft.sanitize(); + } + + function setSpeaker(on) { + draft.speakerOn = on; + draft.sanitize(); + } + function setTouchpad(mode) { draft.touchpadMode = mode; draft.sanitize(); diff --git a/src/qml/wizard/WizardFeelPage.qml b/src/qml/wizard/WizardFeelPage.qml index 8c7988f..4fccaf4 100644 --- a/src/qml/wizard/WizardFeelPage.qml +++ b/src/qml/wizard/WizardFeelPage.qml @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later // Copyright (C) 2026 Dish contributors. // -// Wizard page 4 — Binding · feel. All four rows render always; an unavailable one +// Wizard page 4 — Binding · feel. All six rows render always; an unavailable one // names the layer that refused it, and only the CONTROL dims — the reason is the // part the user came for. Reasons come from BindingDraft.whyFor(), which // Configure binding reads too, so one pad never gets two explanations. @@ -35,7 +35,7 @@ ColumnLayout { // Nothing to fetch: the solver reads what pages 1-3 already resolved. } - // ── The four extras ───────────────────────────────────────────────────── + // ── The six extras ────────────────────────────────────────────────────── // `gated` marks the ones this binding actually owns a switch for. The light // bar is forwarded per-app, not per-binding, so its control reports rather // than sets. @@ -67,6 +67,20 @@ ColumnLayout { "body": qsTr("The game drives the pad’s light bar. Lightbar forwarding is a global setting."), "segmented": false, "gated": false + }, + { + "feature": "mic", + "label": qsTr("Microphone"), + "body": qsTr("The pad’s mic carries your voice to the host. Off sends nothing."), + "segmented": false, + "gated": true + }, + { + "feature": "speaker", + "label": qsTr("Controller sound"), + "body": qsTr("Audio from the host plays on the pad’s speaker or headset."), + "segmented": false, + "gated": true } ] @@ -110,6 +124,10 @@ ColumnLayout { return page.draft.rumbleOn; if (feature === "motion") return page.draft.motionOn; + if (feature === "mic") + return page.draft.micOn; + if (feature === "speaker") + return page.draft.speakerOn; return App.lightbarFollowGame; } @@ -118,6 +136,10 @@ ColumnLayout { page.draft.setRumble(on); else if (feature === "motion") page.draft.setMotion(on); + else if (feature === "mic") + page.draft.setMic(on); + else if (feature === "speaker") + page.draft.setSpeaker(on); } spacing: Tokens.s6 diff --git a/src/qml/wizard/WizardInputPage.qml b/src/qml/wizard/WizardInputPage.qml index f6e37f6..367f1db 100644 --- a/src/qml/wizard/WizardInputPage.qml +++ b/src/qml/wizard/WizardInputPage.qml @@ -80,6 +80,11 @@ ColumnLayout { // Motion is a persisted per-pad preference (Dead zones writes it too); // seeding from the default would silently re-enable a gyro turned off. page.draft.setMotion(App.motionEnabledFor(slotId)); + // Same rule for the audio pair: a re-run of the wizard over a pad whose + // mic the user enabled must not silently reset it — and the other way + // round, which for a microphone is the reset that matters. + page.draft.setMic(App.micEnabledFor(slotId)); + page.draft.setSpeaker(App.speakerEnabledFor(slotId)); page.padAdopted({ "name": name, "transport": bluetooth ? qsTr("Bluetooth") : qsTr("USB"), diff --git a/src/qml/wizard/WizardReviewPage.qml b/src/qml/wizard/WizardReviewPage.qml index 6a3944d..c00a363 100644 --- a/src/qml/wizard/WizardReviewPage.qml +++ b/src/qml/wizard/WizardReviewPage.qml @@ -31,7 +31,7 @@ ColumnLayout { function primaryActivated() { App.applyBinding(page.draft.slotId, page.draft.hostId, page.draft.type, page.draft.desiredPath, page.draft.motionOn, page.draft.rumbleOn, - page.draft.touchpadMode); + page.draft.touchpadMode, page.draft.micOn, page.draft.speakerOn); return false; } diff --git a/src/repository/AudioPreferenceRepository.cpp b/src/repository/AudioPreferenceRepository.cpp new file mode 100644 index 0000000..56b5f8f --- /dev/null +++ b/src/repository/AudioPreferenceRepository.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "repository/AudioPreferenceRepository.h" + +#include +#include +#include + +#include + +namespace dish::repository { + +namespace { + +// Each element is {k:, slot:, on:}. The storage +// key is kept independent of the value's own slotId so put(key, value)/get(key) +// round-trip verbatim even when they differ — the Map faithfulness +// RepositoryContract pins. Field names match MotionPreferenceRepository's so a +// future merge of the toggle repos has one shape to migrate. +constexpr const char* kFieldKey = "k"; +constexpr const char* kFieldSlot = "slot"; +constexpr const char* kFieldOn = "on"; + +struct Row { + QString key; + AudioPreference value; +}; + +std::vector readRows(const QSettings& settings, const QString& listKey) { + const auto raw = settings.value(listKey).toByteArray(); + if (raw.isEmpty()) { return {}; } + QJsonParseError err{}; + const auto doc = QJsonDocument::fromJson(raw, &err); + // Corrupt or non-array blob -> empty; garbled prefs must not crash startup. + if (err.error != QJsonParseError::NoError || !doc.isArray()) { return {}; } + + std::vector out; + for (const auto& v : doc.array()) { + if (!v.isObject()) { continue; } + const auto obj = v.toObject(); + const QString slot = obj.value(QLatin1String(kFieldSlot)).toString(); + const QString key = obj.contains(QLatin1String(kFieldKey)) + ? obj.value(QLatin1String(kFieldKey)).toString() + : slot; + if (key.isEmpty()) { continue; } + out.push_back(Row{key, AudioPreference{slot, obj.value(QLatin1String(kFieldOn)).toBool()}}); + } + return out; +} + +void writeRows(QSettings& settings, const QString& listKey, const std::vector& rows) { + QJsonArray arr; + for (const auto& r : rows) { + arr.append(QJsonObject{ + {kFieldKey, r.key}, {kFieldSlot, r.value.slotId}, {kFieldOn, r.value.enabled}}); + } + settings.setValue(listKey, QJsonDocument(arr).toJson(QJsonDocument::Compact)); +} + +} // namespace + +AudioPreferenceRepository::AudioPreferenceRepository(QString listKey, + std::shared_ptr settings) + : listKey_(std::move(listKey)), + settings_(settings + ? std::move(settings) + : std::make_shared(QStringLiteral("Dish"), QStringLiteral("Dish"))) { +} + +std::optional AudioPreferenceRepository::get(const QString& slotId) const { + std::lock_guard lock(mutex_); + for (const auto& r : readRows(*settings_, listKey_)) { + if (r.key == slotId) { return r.value; } + } + return std::nullopt; // honest "never written" +} + +std::vector AudioPreferenceRepository::all() const { + std::lock_guard lock(mutex_); + std::vector out; + for (const auto& r : readRows(*settings_, listKey_)) { out.push_back(r.value); } + return out; +} + +void AudioPreferenceRepository::put(const QString& slotId, const AudioPreference& value) { + std::lock_guard lock(mutex_); + auto rows = readRows(*settings_, listKey_); + // Replace in place so the list never grows on a repeat put for one key. + rows.erase( + std::remove_if(rows.begin(), rows.end(), [&](const Row& r) { return r.key == slotId; }), + rows.end()); + rows.push_back(Row{slotId, value}); + writeRows(*settings_, listKey_, rows); +} + +void AudioPreferenceRepository::remove(const QString& slotId) { + std::lock_guard lock(mutex_); + auto rows = readRows(*settings_, listKey_); + rows.erase( + std::remove_if(rows.begin(), rows.end(), [&](const Row& r) { return r.key == slotId; }), + rows.end()); + writeRows(*settings_, listKey_, rows); +} + +void AudioPreferenceRepository::clear() { + std::lock_guard lock(mutex_); + settings_->remove(listKey_); +} + +} // namespace dish::repository diff --git a/src/repository/AudioPreferenceRepository.h b/src/repository/AudioPreferenceRepository.h new file mode 100644 index 0000000..441bb91 --- /dev/null +++ b/src/repository/AudioPreferenceRepository.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// AudioPreferenceRepository — durable per-slot controller-audio toggles, stored +// as ONE JSON array under a caller-named key ("mic_preferences" / +// "speaker_preferences", so the two directions never share a blob). The shape +// and both invariants are MotionPreferenceRepository's: +// * get() on a slot NEVER written returns std::nullopt, not a default +// boolean. The store layer above turns absence into the default, so the +// repo has to stay honest about "undecided" vs "explicitly enabled". +// * Corrupt JSON falls back to an EMPTY list — losing toggles beats bricking +// startup on a garbled blob. +// One parameterized class rather than two copies, because the mic and speaker +// rows differ ONLY in their settings key; the per-direction defaults live in +// the stores (source/store/MicEnabledStore.h, SpeakerEnabledStore.h). + +#pragma once + +#include "architecture/Repository.h" + +#include +#include + +#include +#include +#include + +namespace dish::repository { + +struct AudioPreference { + QString slotId; + bool enabled = false; + + bool operator==(const AudioPreference& o) const { + return slotId == o.slotId && enabled == o.enabled; + } + bool operator!=(const AudioPreference& o) const { return !(*this == o); } +}; + +class AudioPreferenceRepository : public arch::KeyedRepository { + public: + // `listKey` is the QSettings key the whole list lives under; it must be + // unique per direction. nullptr settings -> the default HKCU store; tests + // inject their own. + explicit AudioPreferenceRepository(QString listKey, + std::shared_ptr settings = nullptr); + + QString keyOf(const AudioPreference& value) const override { return value.slotId; } + + std::optional get(const QString& slotId) const override; + std::vector all() const override; + void put(const QString& slotId, const AudioPreference& value) override; + void remove(const QString& slotId) override; + void clear() override; + + // Un-hide the KeyedRepository value-overloads the declarations above shadow. + using arch::KeyedRepository::put; + using arch::KeyedRepository::removeValue; + + private: + QString listKey_; + std::shared_ptr settings_; + mutable std::mutex mutex_; +}; + +} // namespace dish::repository diff --git a/src/source/audio/AudioDeviceGateway.h b/src/source/audio/AudioDeviceGateway.h new file mode 100644 index 0000000..4d8c9d4 --- /dev/null +++ b/src/source/audio/AudioDeviceGateway.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The IO boundary the controller-audio engines talk to, so the capture/playout +// policy and plumbing are testable against a fake with no SDL and no sound +// card — the same seam convention as UsbDeviceGateway over the raw-HID stack. +// Production swaps in SdlAudioGateway. +// +// IO only, no domain state: which device to open, when, and what to do with +// the samples are the engines' decisions. + +#pragma once + +#include +#include +#include +#include +#include + +namespace dish::source::audio { + +// Invalid/failed device handle. Real handles are positive. +inline constexpr int kNoAudioDevice = 0; + +class AudioDeviceGateway { + public: + virtual ~AudioDeviceGateway() = default; + + // The device names present right now, per direction. Names are the open + // keys for the two open calls below, verbatim. Empty when the audio + // subsystem is unavailable (headless CI), which reads as "no routes" and + // keeps everything downstream conservatively off. + virtual std::vector captureDeviceNames() = 0; + virtual std::vector playbackDeviceNames() = 0; + + // Open one capture device at the wire's mic format (48 kHz mono S16). + // `onSamples` fires on the gateway's audio thread with however many mono + // samples the platform's period produced — the caller windows them + // (core/audio/AudioEnginePolicy.h FrameWindower). Returns kNoAudioDevice + // when the device refused. The device starts capturing immediately. + virtual int openCapture(const std::string& deviceName, + std::function onSamples) = 0; + + // Stop and close a capture handle. MUST NOT return while a callback is + // still executing — the privacy invariant leans on "closed means no more + // samples", so this is a join, not a request. Idempotent for unknown + // handles. + virtual void closeCapture(int handle) = 0; + + // Open one playback device at the wire's speaker format (48 kHz stereo + // S16), in queued mode and PAUSED: nothing plays until resumePlayback, so + // the engine can build its start cushion first. + virtual int openPlayback(const std::string& deviceName) = 0; + + // Queue interleaved stereo samples (sampleCount counts individual int16 + // values, not frames). False on an unknown handle or a failed queue. + virtual bool queuePlayback(int handle, const std::int16_t* samples, + std::size_t sampleCount) = 0; + + // Bytes currently queued and unplayed; 0 for an unknown handle. + virtual std::size_t queuedPlaybackBytes(int handle) = 0; + + // Start (unpause) a playback handle. Idempotent. + virtual void resumePlayback(int handle) = 0; + + virtual void closePlayback(int handle) = 0; +}; + +} // namespace dish::source::audio diff --git a/src/source/audio/MicCaptureEngine.cpp b/src/source/audio/MicCaptureEngine.cpp new file mode 100644 index 0000000..a1d9a40 --- /dev/null +++ b/src/source/audio/MicCaptureEngine.cpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/audio/MicCaptureEngine.h" + +#include "core/model/Protocol.h" +#include "source/audio/OpusAudioCodec.h" + +#include + +namespace dish::source::audio { + +MicCaptureEngine::MicCaptureEngine(AudioDeviceGateway* gateway, EncoderFactory encoderFactory) + : gateway_(gateway), encoderFactory_(std::move(encoderFactory)) { + if (!encoderFactory_) { + encoderFactory_ = [] { + return std::unique_ptr( + dish::audio::OpusStreamEncoder::create(dish::audio::Stream::Mic)); + }; + } +} + +MicCaptureEngine::~MicCaptureEngine() { + try { + reconcile({}); + } catch (...) { // NOLINT(bugprone-empty-catch) + // A teardown IO failure has nowhere to go; the devices are closing + // with the process either way, and a throwing destructor terminates. + } +} + +void MicCaptureEngine::reconcile(const std::vector& targets) { + if (gateway_ == nullptr) { return; } + + // Close first — narrowing before widening, so a slot that moved endpoints + // is never captured from two devices at once, and a slot that fell out of + // eligibility (a mute among the causes) is closed before anything new + // starts. + for (auto it = entries_.begin(); it != entries_.end();) { + const MicCaptureTarget* wanted = nullptr; + for (const auto& t : targets) { + if (t.slotId == it->first) { + wanted = &t; + break; + } + } + if (wanted == nullptr || wanted->captureDeviceName != it->second.deviceName) { + close(it->second); + it = entries_.erase(it); + } else { + ++it; + } + } + + for (const auto& t : targets) { + if (t.slotId.empty() || t.captureDeviceName.empty() || !t.send) { continue; } + if (entries_.find(t.slotId) != entries_.end()) { continue; } + auto session = std::make_shared(); + session->encoder = encoderFactory_(); + if (session->encoder == nullptr) { continue; } // no codec, no capture + session->send = t.send; + + // The whole per-window pipeline lives in this callback, on the + // gateway's audio thread: window -> re-check enabled -> encode -> + // seq++ -> send. The enabled re-check inside the window emit (not just + // per chunk) is the half of the privacy invariant that bounds a mute + // landing mid-close to one frame. + auto onSamples = [session](const std::int16_t* samples, std::size_t count) { + if (!session->enabled.load(std::memory_order_relaxed)) { return; } + session->windower.feed( + samples, count, [&session](const std::int16_t* pcm, std::size_t frames) { + if (!session->enabled.load(std::memory_order_relaxed)) { return; } + std::uint8_t packet[proto::kAudioWireMaxOpusBytes]; + const std::size_t bytes = + session->encoder->encode(pcm, frames, packet, sizeof(packet)); + // The window happened whether or not it encoded, so the + // seq slot is spent either way; a failed encode becomes + // the receiver's concealment, not a shortened stream. + const std::uint16_t seq = session->seq++; + if (bytes == 0) { return; } + session->send(seq, packet, bytes); + }); + }; + + Entry entry; + entry.deviceName = t.captureDeviceName; + entry.session = session; + entry.handle = gateway_->openCapture(t.captureDeviceName, onSamples); + if (entry.handle == kNoAudioDevice) { continue; } + entries_.emplace(t.slotId, std::move(entry)); + } +} + +bool MicCaptureEngine::capturingFor(const std::string& slotId) const { + return entries_.find(slotId) != entries_.end(); +} + +void MicCaptureEngine::close(Entry& entry) { + // Disable BEFORE the close: the close joins the callback, but the flag is + // what drops a window already captured when the decision landed. + if (entry.session != nullptr) { + entry.session->enabled.store(false, std::memory_order_relaxed); + } + if (entry.handle != kNoAudioDevice) { gateway_->closeCapture(entry.handle); } +} + +} // namespace dish::source::audio diff --git a/src/source/audio/MicCaptureEngine.h b/src/source/audio/MicCaptureEngine.h new file mode 100644 index 0000000..996d657 --- /dev/null +++ b/src/source/audio/MicCaptureEngine.h @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The microphone capture pipeline: one open capture device per eligible slot +// (the pad's OWN headset mic endpoint — the matcher never routes two slots to +// one endpoint, so per-slot IS per-device), windowed to exact 20 ms frames, +// Opus-encoded and handed to the slot's MSG_MIC_AUDIO sender. +// +// THE PRIVACY INVARIANT. Muted, toggled off, unrouted, unstreaming or +// unwelcome at the host means ZERO MSG_MIC_AUDIO packets AND a CLOSED capture +// device — never silence sent in their place. It is enforced twice: +// +// - structurally: eligibility (core/audio/AudioEnginePolicy.h) decides the +// target list BEFORE it reaches reconcile(), and a slot absent from the +// list has its device closed — closeCapture is a join, so after reconcile +// returns no callback is executing; +// - per window: the session's `enabled` flag is cleared FIRST, before the +// close is even requested, and the audio-thread callback re-checks it per +// delivery, so a window captured while the close is in flight is dropped +// rather than shipped. That bounds mute latency to one 20 ms frame. +// +// `seq` is caller-owned per stream and advances once per captured window, +// INCLUDING windows whose encode failed: that 20 ms really happened, so the +// receiver conceals it rather than playing the stream short and drifting +// against the pad's clock. (A DTX silence frame is not a failure — it is a +// 1-byte packet and it is sent.) + +#pragma once + +#include "core/audio/AudioCodec.h" +#include "core/audio/AudioEnginePolicy.h" +#include "source/audio/AudioDeviceGateway.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace dish::source::audio { + +// One eligible slot: where to capture from and how to send. The sender runs on +// the gateway's audio thread and must be thread-safe (WifiConnection's send +// path is, by the same contract as the SDL input thread's). +struct MicCaptureTarget { + std::string slotId; + std::string captureDeviceName; + std::function send; +}; + +class MicCaptureEngine { + public: + using EncoderFactory = std::function()>; + + // The default factory builds the wire's mic encoder (Opus VOIP mono, DTX); + // tests inject a fake to make encode failures and byte-exact sends + // observable. + explicit MicCaptureEngine(AudioDeviceGateway* gateway, EncoderFactory encoderFactory = {}); + ~MicCaptureEngine(); + + MicCaptureEngine(const MicCaptureEngine&) = delete; + MicCaptureEngine& operator=(const MicCaptureEngine&) = delete; + MicCaptureEngine(MicCaptureEngine&&) = delete; + MicCaptureEngine& operator=(MicCaptureEngine&&) = delete; + + // Converge on exactly `targets`: open what is missing, close what is not + // wanted (or whose device moved), leave the rest running. Main thread. + // An open the device refuses is dropped silently; the next reconcile — + // routes and claims re-trigger it — is the retry. + void reconcile(const std::vector& targets); + + // Whether a capture device is open for the slot right now (UI/tests). + bool capturingFor(const std::string& slotId) const; + + private: + // Shared with the audio-thread callback via shared_ptr, so a close racing + // a callback can never leave the callback on freed state. + struct Session { + std::atomic enabled{true}; + dish::audio::FrameWindower windower; + std::unique_ptr encoder; + std::uint16_t seq = 0; // audio-thread only after start + std::function send; + }; + struct Entry { + int handle = kNoAudioDevice; + std::string deviceName; + std::shared_ptr session; + }; + + void close(Entry& entry); + + AudioDeviceGateway* gateway_; + EncoderFactory encoderFactory_; + std::map entries_; // by slotId; main thread only +}; + +} // namespace dish::source::audio diff --git a/src/source/audio/OpusAudioCodec.cpp b/src/source/audio/OpusAudioCodec.cpp new file mode 100644 index 0000000..a69bc4a --- /dev/null +++ b/src/source/audio/OpusAudioCodec.cpp @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "OpusAudioCodec.h" + +#include "core/model/Protocol.h" + +// Resolved as : every packaging of libopus we build against puts the +// headers in /include/opus and puts THAT directory on the include path +// (the Opus::opus target's install interface under vcpkg, opus.pc's Cflags on +// msys2 / Debian / Homebrew), so the unqualified spelling is the portable one. +#include + +#include + +namespace dish::audio { +namespace { + +// The contract's formats, in one place. Both streams are 48 kHz, 20 ms, VBR +// with in-band FEC requested; what differs is the application and the bitrate. +// Mirrored from satellite adapters/audio/opus_codec.cpp — the measurements +// quoted below are its, on the same libopus generation. +// +// Mic: 32 kbps mono under OPUS_APPLICATION_VOIP puts Opus in SILK mode, which +// is the only mode that HAS in-band FEC — the redundant low-rate copy of the +// previous frame that lets a receiver recover a single lost packet instead of +// guessing at it. The expected-loss hint is what makes the encoder actually +// spend bits on that copy; without it the flag alone does nothing. +// +// Speaker: 96 kbps stereo under OPUS_APPLICATION_AUDIO, because this carries +// game and chat audio a player listens to rather than speech a codec can model. +// The loss hint below, not the application, is what picks the mode: it forces +// SILK in, so BOTH streams encode as Hybrid fullband and both really do carry +// in-band FEC (measured on libopus 1.6.1: 8.4 dB recovery via decode_fec vs +// -1.3 dB for blind PLC on the speaker stream). Dropping the hint to zero would +// hand the speaker to CELT and silently delete that FEC. +constexpr int kOpusMicBitrateBps = 32000; +constexpr int kOpusSpeakerBitrateBps = 96000; +constexpr int kOpusExpectedPacketLossPct = 10; + +int channelsFor(Stream stream) { + return stream == Stream::Mic ? proto::kAudioMicChannels : proto::kAudioSpeakerChannels; +} + +} // namespace + +void OpusEncoderDeleter::operator()(::OpusEncoder* enc) const noexcept { + if (enc != nullptr) { opus_encoder_destroy(enc); } +} + +void OpusDecoderDeleter::operator()(::OpusDecoder* dec) const noexcept { + if (dec != nullptr) { opus_decoder_destroy(dec); } +} + +// ── decoder ───────────────────────────────────────────────────────────────── + +std::unique_ptr OpusStreamDecoder::create(Stream stream) { + const int channels = channelsFor(stream); + int err = OPUS_OK; + std::unique_ptr<::OpusDecoder, OpusDecoderDeleter> dec( + opus_decoder_create(proto::kAudioSampleRateHz, channels, &err)); + if (!dec || err != OPUS_OK) { return nullptr; } + // The decoder carries no format negotiation: a stream's parameters travel + // inside each Opus packet, so there is nothing else to set here. + return std::unique_ptr(new OpusStreamDecoder(std::move(dec), channels)); +} + +std::size_t OpusStreamDecoder::decode(const std::uint8_t* opus, std::size_t opusLen, + std::int16_t* pcm, std::size_t maxFrames) { + if (opus == nullptr || opusLen == 0 || pcm == nullptr || maxFrames == 0) { return 0; } + if (opusLen > static_cast(INT32_MAX) || + maxFrames > static_cast(INT32_MAX)) { + return 0; + } + const int n = opus_decode(dec_.get(), opus, static_cast(opusLen), pcm, + static_cast(maxFrames), /*decode_fec=*/0); + return n > 0 ? static_cast(n) : 0; +} + +std::size_t OpusStreamDecoder::conceal(std::int16_t* pcm, std::size_t maxFrames) { + // A null packet is how libopus is asked for concealment, and unlike a real + // decode the frame size is an instruction rather than a capacity: it must + // be the duration of what is missing, which on this wire is always one + // 20 ms window. + if (pcm == nullptr || maxFrames < static_cast(proto::kAudioFrameSamples)) { + return 0; + } + const int n = + opus_decode(dec_.get(), nullptr, 0, pcm, proto::kAudioFrameSamples, /*decode_fec=*/0); + return n > 0 ? static_cast(n) : 0; +} + +std::size_t OpusStreamDecoder::decodeFec(const std::uint8_t* opus, std::size_t opusLen, + std::int16_t* pcm, std::size_t maxFrames) { + if (opus == nullptr || opusLen == 0) { return conceal(pcm, maxFrames); } + if (pcm == nullptr || maxFrames < static_cast(proto::kAudioFrameSamples)) { + return 0; + } + if (opusLen > static_cast(INT32_MAX)) { return 0; } + // decode_fec asks for the frame BEFORE this packet, so the frame size is + // again the missing duration, not this packet's. libopus falls back to + // concealment by itself when the packet carries no FEC data, which is why + // callers can take this path unconditionally on a gap. + const int n = opus_decode(dec_.get(), opus, static_cast(opusLen), pcm, + proto::kAudioFrameSamples, /*decode_fec=*/1); + return n > 0 ? static_cast(n) : 0; +} + +// ── encoder ───────────────────────────────────────────────────────────────── + +std::unique_ptr OpusStreamEncoder::create(Stream stream) { + const int channels = channelsFor(stream); + const int application = stream == Stream::Mic ? OPUS_APPLICATION_VOIP : OPUS_APPLICATION_AUDIO; + const int bitrate = stream == Stream::Mic ? kOpusMicBitrateBps : kOpusSpeakerBitrateBps; + + int err = OPUS_OK; + std::unique_ptr<::OpusEncoder, OpusEncoderDeleter> enc( + opus_encoder_create(proto::kAudioSampleRateHz, channels, application, &err)); + if (!enc || err != OPUS_OK) { return nullptr; } + + // Every ctl is checked: an encoder silently running at the wrong bitrate or + // without FEC would degrade a live call in a way no test would catch later. + if (opus_encoder_ctl(enc.get(), OPUS_SET_BITRATE(bitrate)) != OPUS_OK) { return nullptr; } + if (opus_encoder_ctl(enc.get(), OPUS_SET_VBR(1)) != OPUS_OK) { return nullptr; } + if (opus_encoder_ctl(enc.get(), OPUS_SET_INBAND_FEC(1)) != OPUS_OK) { return nullptr; } + if (opus_encoder_ctl(enc.get(), OPUS_SET_PACKET_LOSS_PERC(kOpusExpectedPacketLossPct)) != + OPUS_OK) { + return nullptr; + } + // Mic only. A live microphone never goes digitally silent, so DTX is the + // only thing that can collapse a quiet room (satellite measured 123 of 250 + // frames gated at -50 dBFS after speech, 30.0 -> 16.4 kbps). The speaker + // stream declines it on purpose: its gate cuts anything ~26-30 dB below the + // recent peak, which on game audio means a reverb tail or quiet ambience is + // replaced by comfort noise at -2.3 dB SNR. That direction relies on the + // satellite's exact-silence suppression instead, which cannot touch audible + // content. + if (stream == Stream::Mic) { + if (opus_encoder_ctl(enc.get(), OPUS_SET_DTX(1)) != OPUS_OK) { return nullptr; } + } + return std::unique_ptr(new OpusStreamEncoder(std::move(enc), channels)); +} + +std::size_t OpusStreamEncoder::encode(const std::int16_t* pcm, std::size_t frames, + std::uint8_t* out, std::size_t maxOut) { + // One wire message is exactly one 20 ms packet, so a caller handing a + // different window has mis-framed rather than merely mis-sized: refuse it + // instead of emitting a packet the other end cannot place in its timeline. + if (pcm == nullptr || out == nullptr) { return 0; } + if (frames != static_cast(proto::kAudioFrameSamples)) { return 0; } + if (maxOut == 0 || maxOut > static_cast(INT32_MAX)) { return 0; } + const int n = opus_encode(enc_.get(), pcm, proto::kAudioFrameSamples, out, + static_cast(maxOut)); + // A 1-byte result is a legal silence packet, not a failure, and the wire + // format is sized to carry one (proto::kAudioWireMinPayloadBytes). + return n > 0 ? static_cast(n) : 0; +} + +} // namespace dish::audio diff --git a/src/source/audio/OpusAudioCodec.h b/src/source/audio/OpusAudioCodec.h new file mode 100644 index 0000000..8289ea2 --- /dev/null +++ b/src/source/audio/OpusAudioCodec.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// libopus behind the core codec seam (core/audio/AudioCodec.h). Every knob the +// two controller-audio streams need is pinned in OpusAudioCodec.cpp; nothing +// about the format is negotiated at runtime, so this header is only about +// lifetime. Lives in source/ rather than core/ because it links a third-party +// library, the same layering as the sodium- and SDL-facing edges. +// +// Both directions of both streams are defined here even though this client only +// encodes mic and decodes speaker. The other two halves are what the satellite +// does, and having all four in one file is what lets a test close the loop on +// either stream instead of asserting against a second implementation of the +// same constants (satellite's opus_codec.h says the same thing from the other +// side). + +#pragma once + +#include "core/audio/AudioCodec.h" + +#include +#include +#include + +// libopus's handle types, forward-declared rather than #include : this +// header is included by tests and (in Wave 2) the audio engines, which have no +// reason to carry the codec's include path, and opus.h's own `typedef struct +// OpusEncoder OpusEncoder` agrees with these declarations. +struct OpusEncoder; +struct OpusDecoder; + +namespace dish::audio { + +// Which of the two wire streams an instance is pinned to. The distinction is +// not just channel count: the mic runs Opus's VOIP application at a bitrate +// where in-band FEC exists, the speaker runs the AUDIO application at a bitrate +// where fidelity matters more (OpusAudioCodec.cpp carries the numbers and why). +enum class Stream : std::uint8_t { Mic, Speaker }; + +// Declared here, defined in OpusAudioCodec.cpp, so the unique_ptrs below work +// against the incomplete handle types above. +struct OpusEncoderDeleter { + void operator()(::OpusEncoder* enc) const noexcept; +}; +struct OpusDecoderDeleter { + void operator()(::OpusDecoder* dec) const noexcept; +}; + +class OpusStreamDecoder : public IAudioDecoder { + public: + // Null when libopus refuses to allocate. Callers treat that as "no codec" + // rather than fatal: a controller without audio is still a controller. + static std::unique_ptr create(Stream stream); + + std::size_t decode(const std::uint8_t* opus, std::size_t opusLen, std::int16_t* pcm, + std::size_t maxFrames) override; + std::size_t conceal(std::int16_t* pcm, std::size_t maxFrames) override; + std::size_t decodeFec(const std::uint8_t* opus, std::size_t opusLen, std::int16_t* pcm, + std::size_t maxFrames) override; + + int channels() const { return channels_; } + + private: + OpusStreamDecoder(std::unique_ptr<::OpusDecoder, OpusDecoderDeleter> dec, int channels) + : dec_(std::move(dec)), channels_(channels) {} + + std::unique_ptr<::OpusDecoder, OpusDecoderDeleter> dec_; + int channels_ = 1; +}; + +class OpusStreamEncoder : public IAudioEncoder { + public: + // Null when libopus refuses to allocate; see OpusStreamDecoder::create. + static std::unique_ptr create(Stream stream); + + std::size_t encode(const std::int16_t* pcm, std::size_t frames, std::uint8_t* out, + std::size_t maxOut) override; + + int channels() const { return channels_; } + + private: + OpusStreamEncoder(std::unique_ptr<::OpusEncoder, OpusEncoderDeleter> enc, int channels) + : enc_(std::move(enc)), channels_(channels) {} + + std::unique_ptr<::OpusEncoder, OpusEncoderDeleter> enc_; + int channels_ = 2; +}; + +} // namespace dish::audio diff --git a/src/source/audio/SdlAudioGateway.cpp b/src/source/audio/SdlAudioGateway.cpp new file mode 100644 index 0000000..ad35b5b --- /dev/null +++ b/src/source/audio/SdlAudioGateway.cpp @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/audio/SdlAudioGateway.h" + +#include "core/model/Protocol.h" + +#include + +#include + +#include + +namespace dish::source::audio { + +namespace { + +// One SDL period, in per-channel sample frames. The wire's own window, so on +// the capture side the common case is one callback per wire frame and the +// windower's fast path applies; the playout queue smooths whatever the device +// actually granted. +constexpr int kPeriodSamples = dish::proto::kAudioFrameSamples; + +} // namespace + +// SDL's capture callback, on the device's audio thread. The format was pinned +// with allowed_changes = 0, so the stream is whole int16 mono samples. +void SdlAudioGateway::captureCallback(void* userdata, std::uint8_t* stream, int len) { + auto* holder = static_cast(userdata); + if (holder == nullptr || stream == nullptr || len <= 0) { return; } + holder->onSamples(reinterpret_cast(stream), + static_cast(len) / sizeof(std::int16_t)); +} + +SdlAudioGateway::SdlAudioGateway() { + // Ref-counted per subsystem, so this coexists with the bridge's + // GAMECONTROLLER|JOYSTICK init on its own thread. Failure is tolerated: + // every entry point below degrades to "nothing there". + audioReady_ = SDL_InitSubSystem(SDL_INIT_AUDIO) == 0; + if (!audioReady_) { + qWarning("SdlAudioGateway: SDL audio unavailable (%s); controller audio stays off", + SDL_GetError()); + } +} + +SdlAudioGateway::~SdlAudioGateway() { + { + std::lock_guard lock(mtx_); + for (const auto& [handle, dev] : captureDevices_) { SDL_CloseAudioDevice(dev); } + for (const auto& [handle, dev] : playbackDevices_) { SDL_CloseAudioDevice(dev); } + captureDevices_.clear(); + captureHolders_.clear(); + playbackDevices_.clear(); + } + if (audioReady_) { SDL_QuitSubSystem(SDL_INIT_AUDIO); } +} + +std::vector SdlAudioGateway::captureDeviceNames() { + if (!audioReady_) { return {}; } + std::vector out; + const int n = SDL_GetNumAudioDevices(SDL_TRUE); + for (int i = 0; i < n; i++) { + const char* name = SDL_GetAudioDeviceName(i, SDL_TRUE); + if (name != nullptr) { out.emplace_back(name); } + } + return out; +} + +std::vector SdlAudioGateway::playbackDeviceNames() { + if (!audioReady_) { return {}; } + std::vector out; + const int n = SDL_GetNumAudioDevices(SDL_FALSE); + for (int i = 0; i < n; i++) { + const char* name = SDL_GetAudioDeviceName(i, SDL_FALSE); + if (name != nullptr) { out.emplace_back(name); } + } + return out; +} + +int SdlAudioGateway::openCapture(const std::string& deviceName, + std::function onSamples) { + if (!audioReady_ || deviceName.empty() || !onSamples) { return kNoAudioDevice; } + auto holder = std::make_unique(); + holder->onSamples = std::move(onSamples); + + SDL_AudioSpec want{}; + want.freq = dish::proto::kAudioSampleRateHz; + want.format = AUDIO_S16SYS; + want.channels = static_cast(dish::proto::kAudioMicChannels); + want.samples = static_cast(kPeriodSamples); + want.callback = &SdlAudioGateway::captureCallback; + want.userdata = holder.get(); + SDL_AudioSpec have{}; + // allowed_changes = 0: SDL converts whatever the endpoint really runs to + // the wire format, so the callback only ever sees 48 kHz mono S16 and no + // resampler lives in this repo. + const SDL_AudioDeviceID dev = + SDL_OpenAudioDevice(deviceName.c_str(), SDL_TRUE, &want, &have, 0); + if (dev == 0) { + qWarning("SdlAudioGateway: capture open failed for '%s': %s", deviceName.c_str(), + SDL_GetError()); + return kNoAudioDevice; + } + int handle = kNoAudioDevice; + { + std::lock_guard lock(mtx_); + handle = nextHandle_++; + captureDevices_[handle] = dev; + captureHolders_[handle] = std::move(holder); + } + SDL_PauseAudioDevice(dev, 0); + return handle; +} + +void SdlAudioGateway::closeCapture(int handle) { + std::uint32_t dev = 0; + std::unique_ptr holder; + { + std::lock_guard lock(mtx_); + const auto it = captureDevices_.find(handle); + if (it == captureDevices_.end()) { return; } + dev = it->second; + captureDevices_.erase(it); + const auto hit = captureHolders_.find(handle); + if (hit != captureHolders_.end()) { + holder = std::move(hit->second); + captureHolders_.erase(hit); + } + } + // SDL_CloseAudioDevice waits out a callback in flight, which is the join + // the interface promises: after this returns, no more samples. The holder + // is destroyed after, so the callback never sees a dead function. + SDL_CloseAudioDevice(dev); +} + +int SdlAudioGateway::openPlayback(const std::string& deviceName) { + if (!audioReady_ || deviceName.empty()) { return kNoAudioDevice; } + SDL_AudioSpec want{}; + want.freq = dish::proto::kAudioSampleRateHz; + want.format = AUDIO_S16SYS; + want.channels = static_cast(dish::proto::kAudioSpeakerChannels); + want.samples = static_cast(kPeriodSamples); + want.callback = nullptr; // queued mode: the engine pushes whole frames + SDL_AudioSpec have{}; + const SDL_AudioDeviceID dev = + SDL_OpenAudioDevice(deviceName.c_str(), SDL_FALSE, &want, &have, 0); + if (dev == 0) { + qWarning("SdlAudioGateway: playback open failed for '%s': %s", deviceName.c_str(), + SDL_GetError()); + return kNoAudioDevice; + } + // Opened paused (SDL's default), per the interface: the engine resumes once + // the start cushion is queued. + int handle = kNoAudioDevice; + { + std::lock_guard lock(mtx_); + handle = nextHandle_++; + playbackDevices_[handle] = dev; + } + return handle; +} + +bool SdlAudioGateway::queuePlayback(int handle, const std::int16_t* samples, + std::size_t sampleCount) { + if (samples == nullptr || sampleCount == 0) { return false; } + std::uint32_t dev = 0; + { + std::lock_guard lock(mtx_); + const auto it = playbackDevices_.find(handle); + if (it == playbackDevices_.end()) { return false; } + dev = it->second; + } + return SDL_QueueAudio(dev, samples, static_cast(sampleCount * sizeof(std::int16_t))) == + 0; +} + +std::size_t SdlAudioGateway::queuedPlaybackBytes(int handle) { + std::uint32_t dev = 0; + { + std::lock_guard lock(mtx_); + const auto it = playbackDevices_.find(handle); + if (it == playbackDevices_.end()) { return 0; } + dev = it->second; + } + return SDL_GetQueuedAudioSize(dev); +} + +void SdlAudioGateway::resumePlayback(int handle) { + std::uint32_t dev = 0; + { + std::lock_guard lock(mtx_); + const auto it = playbackDevices_.find(handle); + if (it == playbackDevices_.end()) { return; } + dev = it->second; + } + SDL_PauseAudioDevice(dev, 0); +} + +void SdlAudioGateway::closePlayback(int handle) { + std::uint32_t dev = 0; + { + std::lock_guard lock(mtx_); + const auto it = playbackDevices_.find(handle); + if (it == playbackDevices_.end()) { return; } + dev = it->second; + playbackDevices_.erase(it); + } + SDL_CloseAudioDevice(dev); +} + +} // namespace dish::source::audio diff --git a/src/source/audio/SdlAudioGateway.h b/src/source/audio/SdlAudioGateway.h new file mode 100644 index 0000000..fbadba5 --- /dev/null +++ b/src/source/audio/SdlAudioGateway.h @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// SdlAudioGateway — the SDL2 (pipewire/pulse/alsa, whichever backend SDL +// picks) implementation of AudioDeviceGateway, and the OWNER of +// SDL_INIT_AUDIO's lifecycle. +// +// Deliberately not SDLGamepadBridge: SDL subsystems are ref-counted +// independently, and the bridge quits GAMECONTROLLER|JOYSTICK on stop() — a +// gamepad re-init (settings change, teardown ordering) must not take a live +// microphone or speaker stream down with it. The bridge still PUMPS the audio +// hotplug events, because it owns the one SDL event loop, and forwards them as +// a signal; this class never touches the event queue. +// +// The audio subsystem is initialized eagerly in the constructor so hotplug +// events flow from app start, and a failed init (headless CI, no audio stack) +// degrades to empty enumerations and refused opens — everything downstream +// then reads "no routes" and stays off, which is the conservative posture the +// whole feature takes. + +#pragma once + +#include "source/audio/AudioDeviceGateway.h" + +#include +#include +#include +#include + +namespace dish::source::audio { + +class SdlAudioGateway : public AudioDeviceGateway { + public: + SdlAudioGateway(); + ~SdlAudioGateway() override; + + SdlAudioGateway(const SdlAudioGateway&) = delete; + SdlAudioGateway& operator=(const SdlAudioGateway&) = delete; + SdlAudioGateway(SdlAudioGateway&&) = delete; + SdlAudioGateway& operator=(SdlAudioGateway&&) = delete; + + std::vector captureDeviceNames() override; + std::vector playbackDeviceNames() override; + int openCapture(const std::string& deviceName, + std::function onSamples) override; + void closeCapture(int handle) override; + int openPlayback(const std::string& deviceName) override; + bool queuePlayback(int handle, const std::int16_t* samples, std::size_t sampleCount) override; + std::size_t queuedPlaybackBytes(int handle) override; + void resumePlayback(int handle) override; + void closePlayback(int handle) override; + + private: + // The capture callback's home, heap-held so SDL's userdata pointer stays + // valid for the device's whole life whatever the map does. + struct CaptureHolder { + std::function onSamples; + }; + + // SDL's C callback shape (SDLCALL is plain cdecl here); a static member so + // it can name the private holder type. + static void captureCallback(void* userdata, std::uint8_t* stream, int len); + + bool audioReady_ = false; + + // handle -> SDL device id (+ holder for captures). Guarded by mtx_; the + // capture callback itself never touches the maps (its holder is reached + // through SDL's userdata), so the audio thread and these stay apart. + std::mutex mtx_; + int nextHandle_ = 1; + std::map captureDevices_; + std::map> captureHolders_; + std::map playbackDevices_; +}; + +} // namespace dish::source::audio diff --git a/src/source/audio/SpeakerPlayoutEngine.cpp b/src/source/audio/SpeakerPlayoutEngine.cpp new file mode 100644 index 0000000..5931fe2 --- /dev/null +++ b/src/source/audio/SpeakerPlayoutEngine.cpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/audio/SpeakerPlayoutEngine.h" + +#include "core/audio/AudioEnginePolicy.h" +#include "core/model/Protocol.h" +#include "source/audio/OpusAudioCodec.h" + +#include +#include + +namespace dish::source::audio { + +namespace { + +// One decoded 20 ms stereo window, interleaved. +constexpr std::size_t kWindowSamples = static_cast(proto::kAudioFrameSamples) * + static_cast(proto::kAudioSpeakerChannels); + +} // namespace + +SpeakerPlayoutEngine::SpeakerPlayoutEngine(AudioDeviceGateway* gateway, + DecoderFactory decoderFactory) + : gateway_(gateway), decoderFactory_(std::move(decoderFactory)) { + if (!decoderFactory_) { + decoderFactory_ = [] { + return std::unique_ptr( + dish::audio::OpusStreamDecoder::create(dish::audio::Stream::Speaker)); + }; + } +} + +SpeakerPlayoutEngine::~SpeakerPlayoutEngine() { + try { + reconcile({}); + } catch (...) { // NOLINT(bugprone-empty-catch) + // Same rule as the capture engine: nowhere to report a teardown + // failure, and the handles die with the process anyway. + } +} + +void SpeakerPlayoutEngine::reconcile(const std::vector& targets) { + if (gateway_ == nullptr) { return; } + std::lock_guard lock(mtx_); + + for (auto it = voices_.begin(); it != voices_.end();) { + const SpeakerVoiceTarget* wanted = nullptr; + for (const auto& t : targets) { + if (t.connectionId == it->first.first && t.controllerIndex == it->first.second) { + wanted = &t; + break; + } + } + if (wanted == nullptr || wanted->playbackDeviceName != it->second->deviceName || + wanted->slotId != it->second->slotId) { + closeLocked(*it->second); + it = voices_.erase(it); + } else { + ++it; + } + } + + for (const auto& t : targets) { + if (t.connectionId.empty() || t.playbackDeviceName.empty()) { continue; } + const VoiceKey key{t.connectionId, t.controllerIndex}; + if (voices_.find(key) != voices_.end()) { continue; } + auto voice = std::make_unique(); + voice->slotId = t.slotId; + voice->deviceName = t.playbackDeviceName; + voice->decoder = decoderFactory_(); + if (voice->decoder == nullptr) { continue; } // no codec, no voice + voice->handle = gateway_->openPlayback(t.playbackDeviceName); + if (voice->handle == kNoAudioDevice) { continue; } + voices_.emplace(key, std::move(voice)); + } +} + +void SpeakerPlayoutEngine::deliver(const std::string& connectionId, int controllerIndex, + std::uint16_t seq, const std::uint8_t* opus, + std::size_t opusLen) { + if (opus == nullptr || opusLen == 0) { return; } + std::lock_guard lock(mtx_); + const auto it = voices_.find(VoiceKey{connectionId, controllerIndex}); + if (it == voices_.end()) { return; } + Voice& voice = *it->second; + + const auto result = voice.window.push(seq, opus, opusLen); + std::array pcm{}; + for (int i = 0; i < result.count; i++) { + const auto& event = result.events[i]; + std::size_t frames = 0; + if (event.kind == dish::audio::AudioJitterWindow::Event::Kind::Packet) { + frames = voice.decoder->decode(event.data, event.len, pcm.data(), + static_cast(proto::kAudioFrameSamples)); + } else { + // The gap path takes FEC unconditionally: whether the carrier + // packet really holds a redundant copy is an encoder decision this + // end cannot see, and the decoder degrades to plain concealment on + // its own — a null carrier included. + frames = voice.decoder->decodeFec(event.fecCarrier, event.fecCarrierLen, pcm.data(), + static_cast(proto::kAudioFrameSamples)); + } + if (frames == 0) { continue; } + + // A queue that drained mid-stream (a suppressed-silence stretch) gets + // its cushion back as silence ahead of the resumed audio. + const int refill = dish::audio::playoutRefillFrames( + voice.started, gateway_->queuedPlaybackBytes(voice.handle)); + if (refill > 0) { + const std::array silence{}; + for (int f = 0; f < refill; f++) { + gateway_->queuePlayback(voice.handle, silence.data(), silence.size()); + } + } + + gateway_->queuePlayback(voice.handle, pcm.data(), + frames * static_cast(proto::kAudioSpeakerChannels)); + if (!voice.started && + gateway_->queuedPlaybackBytes(voice.handle) >= + static_cast(dish::audio::kPlayoutStartThresholdFrames) * + dish::audio::kPlayoutFrameBytes) { + gateway_->resumePlayback(voice.handle); + voice.started = true; + } + } +} + +bool SpeakerPlayoutEngine::playingFor(const std::string& slotId) const { + std::lock_guard lock(mtx_); + for (const auto& [key, voice] : voices_) { + if (voice->slotId == slotId) { return true; } + } + return false; +} + +void SpeakerPlayoutEngine::closeLocked(Voice& voice) { + if (voice.handle != kNoAudioDevice) { gateway_->closePlayback(voice.handle); } +} + +} // namespace dish::source::audio diff --git a/src/source/audio/SpeakerPlayoutEngine.h b/src/source/audio/SpeakerPlayoutEngine.h new file mode 100644 index 0000000..53ae64d --- /dev/null +++ b/src/source/audio/SpeakerPlayoutEngine.h @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The speaker playout pipeline: one open playback device per eligible slot +// (the pad's OWN speaker/headset endpoint), fed from the receive thread — +// reorder window (core/audio/AudioJitter.h) in, Opus decode with FEC/PLC on +// its gap events, whole 20 ms windows queued to the device. +// +// Playback starts only once the start cushion is queued (two frames, the same +// 40 ms the reorder window already costs), and a queue that drains mid-stream +// — the satellite suppresses digitally silent windows WITHOUT advancing seq, +// so multi-second holes are normal — is re-cushioned with silence rather than +// re-primed, so a short sound after a quiet stretch is delayed 40 ms instead +// of stranded (core/audio/AudioEnginePolicy.h owns both rules). +// +// A voice is addressed the way the frames arrive: (connectionId, controller +// index), resolved to a slot once per reconcile rather than once per frame — +// 50 frames a second per stream land on the receive thread, and that thread +// must not spend them walking binding tables. + +#pragma once + +#include "core/audio/AudioCodec.h" +#include "core/audio/AudioJitter.h" +#include "source/audio/AudioDeviceGateway.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dish::source::audio { + +// One eligible slot's voice: where its frames come from and where they play. +struct SpeakerVoiceTarget { + std::string connectionId; + int controllerIndex = 0; + std::string slotId; + std::string playbackDeviceName; +}; + +class SpeakerPlayoutEngine { + public: + using DecoderFactory = std::function()>; + + // The default factory builds the wire's speaker decoder (Opus stereo); + // tests inject a fake to observe the FEC/PLC dispatch and the queue math. + explicit SpeakerPlayoutEngine(AudioDeviceGateway* gateway, DecoderFactory decoderFactory = {}); + ~SpeakerPlayoutEngine(); + + SpeakerPlayoutEngine(const SpeakerPlayoutEngine&) = delete; + SpeakerPlayoutEngine& operator=(const SpeakerPlayoutEngine&) = delete; + SpeakerPlayoutEngine(SpeakerPlayoutEngine&&) = delete; + SpeakerPlayoutEngine& operator=(SpeakerPlayoutEngine&&) = delete; + + // Converge on exactly `targets` (main thread). A failed device open drops + // the voice; the next reconcile is the retry. + void reconcile(const std::vector& targets); + + // One MSG_SPEAKER_AUDIO frame, on the connection's receive thread. The + // opus bytes are borrowed (the SatelliteClient message contract); they are + // consumed before returning. A frame for a voice that is not held — the + // caps said so, or the plan moved — is dropped, which is also what a host + // that ignored the caps deserves. + void deliver(const std::string& connectionId, int controllerIndex, std::uint16_t seq, + const std::uint8_t* opus, std::size_t opusLen); + + // Whether a playback device is open for the slot right now (UI/tests). + bool playingFor(const std::string& slotId) const; + + private: + struct Voice { + int handle = kNoAudioDevice; + std::string slotId; + std::string deviceName; + dish::audio::AudioJitterWindow window; + std::unique_ptr decoder; + bool started = false; + }; + using VoiceKey = std::pair; + + void closeLocked(Voice& voice); + + AudioDeviceGateway* gateway_; + DecoderFactory decoderFactory_; + // One mutex over the voice table: reconcile (main) against deliver + // (receive threads). Held across a voice's decode+queue so a close cannot + // free the decoder under a frame; both sides are bounded and 50 frames/s + // per voice is nothing next to it. + mutable std::mutex mtx_; + std::map> voices_; +}; + +} // namespace dish::source::audio diff --git a/src/source/store/AudioEnabledStore.cpp b/src/source/store/AudioEnabledStore.cpp new file mode 100644 index 0000000..28057a8 --- /dev/null +++ b/src/source/store/AudioEnabledStore.cpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/store/AudioEnabledStore.h" + +namespace dish::source { + +AudioEnabledMap AudioEnabledStore::hydrate(repository::AudioPreferenceRepository* repo) { + AudioEnabledMap out; + if (repo == nullptr) { return out; } + for (const auto& pref : repo->all()) { out[pref.slotId.toStdString()] = pref.enabled; } + return out; +} + +AudioEnabledStore::AudioEnabledStore(repository::AudioPreferenceRepository* repo, + bool defaultEnabled) + : arch::StateSource(hydrate(repo)), repo_(repo), + defaultEnabled_(defaultEnabled) {} + +bool AudioEnabledStore::isEnabled(const std::string& slotId) const { + const auto& snapshot = state().value(); + const auto it = snapshot.find(slotId); + if (it == snapshot.end()) { return defaultEnabled_; } + return it->second; +} + +void AudioEnabledStore::setEnabled(const std::string& slotId, bool enabled) { + if (repo_ != nullptr) { + repo_->put(repository::AudioPreference{QString::fromStdString(slotId), enabled}); + } + setState([&](const AudioEnabledMap& current) { + AudioEnabledMap next = current; + next[slotId] = enabled; + return next; + }); +} + +void AudioEnabledStore::forget(const std::string& slotId) { + if (repo_ != nullptr) { repo_->remove(QString::fromStdString(slotId)); } + setState([&](const AudioEnabledMap& current) { + if (current.find(slotId) == current.end()) { return current; } + AudioEnabledMap next = current; + next.erase(slotId); + return next; + }); +} + +} // namespace dish::source diff --git a/src/source/store/AudioEnabledStore.h b/src/source/store/AudioEnabledStore.h new file mode 100644 index 0000000..580196b --- /dev/null +++ b/src/source/store/AudioEnabledStore.h @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// AudioEnabledStore — a StateSource over one per-slot controller-audio toggle: +// slotId -> bool, bridging a durable AudioPreferenceRepository with a reactive +// in-memory Observable. The exact behaviour contract of MotionEnabledStore +// (hydrate on construction, setEnabled persists AND republishes, forget +// cascades to both, isEnabled collapses absence to the default while the raw +// map keeps "absent" distinct from "explicitly set"). +// +// One class for both directions because they differ only in their default; the +// two thin subclasses below pin those defaults where callers can name them. +// Mirrors dish-android's source/store/{Mic,Speaker}EnabledStore pair. + +#pragma once + +#include "architecture/StateSource.h" +#include "repository/AudioPreferenceRepository.h" + +#include +#include + +namespace dish::source { + +// slotId -> enabled. std::map gives a deterministic, ==-comparable value so the +// Observable's distinct-until-changed suppresses no-op re-emits. +using AudioEnabledMap = std::map; + +class AudioEnabledStore : public arch::StateSource { + public: + // `repo` is borrowed, not owned — it outlives the store (both live on the + // AppModel). `defaultEnabled` is the answer for a slot the user has never + // touched. + AudioEnabledStore(repository::AudioPreferenceRepository* repo, bool defaultEnabled); + + bool isEnabled(const std::string& slotId) const; + void setEnabled(const std::string& slotId, bool enabled); + // Drop the slot from both the repo and the live state (cascade forget). A + // no-op (no emit) on the state side if it was absent. + void forget(const std::string& slotId); + + bool defaultEnabled() const { return defaultEnabled_; } + + private: + static AudioEnabledMap hydrate(repository::AudioPreferenceRepository* repo); + + repository::AudioPreferenceRepository* repo_; + bool defaultEnabled_; +}; + +// The microphone toggle. DEFAULT OFF: capturing a microphone is opt-in per +// binding, never something a fresh install starts doing on its own — the +// privacy stance is the point, not a convenience default. +class MicEnabledStore final : public AudioEnabledStore { + public: + static constexpr bool kDefaultEnabled = false; + explicit MicEnabledStore(repository::AudioPreferenceRepository* repo) + : AudioEnabledStore(repo, kDefaultEnabled) {} +}; + +// The controller-speaker toggle. DEFAULT ON: playback carries nothing of the +// user's, so the useful default is the working one. +class SpeakerEnabledStore final : public AudioEnabledStore { + public: + static constexpr bool kDefaultEnabled = true; + explicit SpeakerEnabledStore(repository::AudioPreferenceRepository* repo) + : AudioEnabledStore(repo, kDefaultEnabled) {} +}; + +} // namespace dish::source diff --git a/src/source/store/MicMuteStore.cpp b/src/source/store/MicMuteStore.cpp new file mode 100644 index 0000000..b35f435 --- /dev/null +++ b/src/source/store/MicMuteStore.cpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/store/MicMuteStore.h" + +namespace dish::source { + +bool MicMuteStore::isMuted(const std::string& slotId) const { + const auto& snapshot = state().value(); + const auto it = snapshot.find(slotId); + if (it == snapshot.end()) { return kDefaultMuted; } + return it->second; +} + +void MicMuteStore::setMuted(const std::string& slotId, bool muted) { + setState([&](const MicMuteMap& current) { + const auto it = current.find(slotId); + if (it != current.end() && it->second == muted) { return current; } + MicMuteMap next = current; + next[slotId] = muted; + return next; + }); +} + +bool MicMuteStore::toggle(const std::string& slotId) { + const bool next = !isMuted(slotId); + setMuted(slotId, next); + return next; +} + +void MicMuteStore::retainOnly(const std::set& present) { + setState([&](const MicMuteMap& current) { + MicMuteMap next; + for (const auto& [slotId, muted] : current) { + if (present.find(slotId) != present.end()) { next.emplace(slotId, muted); } + } + return next; + }); +} + +} // namespace dish::source diff --git a/src/source/store/MicMuteStore.h b/src/source/store/MicMuteStore.h new file mode 100644 index 0000000..22665de --- /dev/null +++ b/src/source/store/MicMuteStore.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Per-slot mic mute, the client-side half of the privacy invariant: while a +// slot is muted no MSG_MIC_AUDIO packet leaves this machine at all. Muting +// closes the capture delivery outright rather than sending silence, so +// "muted" is not a promise about the contents of a stream, it is the absence +// of one. +// +// One holder for every slot, because both mute controls mean the same thing +// and a user should not have to remember which one they used: the slot card's +// mute control writes here, and so does a Direct-claimed DualSense's own mute +// button (through the report decoder's latch, which folds the state onto the +// wire, and the manager's edge upcall, which mirrors it up). +// +// Deliberately NOT persisted, unlike the mic toggle beside it (mirrors +// dish-android's MicMuteStore). Mute is a live control over a session the way +// it is on the hardware, where it clears when the pad powers down; the +// durable "do not capture" answer is MicEnabledStore, which is off by default +// and survives restarts. A mute that outlived the process would also be +// invisible until the next session armed a microphone — every mute surface +// only shows while one is armed — which is the worst possible place to hide a +// microphone that looks broken. retainOnly() clears departed slots for the +// same reason: a replugged pad reuses its model-keyed slot id, and it must +// come back live like the hardware does. + +#pragma once + +#include "architecture/StateSource.h" + +#include +#include +#include + +namespace dish::source { + +using MicMuteMap = std::map; + +class MicMuteStore : public arch::StateSource { + public: + // Absent means unmuted: nothing has asked this slot to stop capturing. + static constexpr bool kDefaultMuted = false; + + MicMuteStore() : arch::StateSource(MicMuteMap{}) {} + + bool isMuted(const std::string& slotId) const; + void setMuted(const std::string& slotId, bool muted); + + // What both mute controls do: the state is a toggle, the control is a + // press. Returns the new state. + bool toggle(const std::string& slotId); + + // Drop every slot not in `present`, so a departed pad's mute does not + // resurrect on replug. + void retainOnly(const std::set& present); +}; + +} // namespace dish::source diff --git a/src/source/usb/HidrawGateway.cpp b/src/source/usb/HidrawGateway.cpp index 30fc17a..ed96d55 100644 --- a/src/source/usb/HidrawGateway.cpp +++ b/src/source/usb/HidrawGateway.cpp @@ -217,8 +217,17 @@ std::optional probe(const std::string& hidrawName) { ProbedNode probed; probed.node = "/dev/" + hidrawName; - // HID_NAME is the same hid->name string HIDIOCGRAWNAME copies out. - probed.name = detail::ueventValue(uevent, "HID_NAME"); + // The USB device's `product` attribute (iProduct) first: the audio stack + // derives its card and node names from that same descriptor string, and + // the pad-to-endpoint matcher (core/audio/PadAudioMatcher.h) keys on + // exactly that shared fact — it is also what Windows and android + // enumerate, so the pad shows one name across clients. HID_NAME would not + // do for either job: the kernel builds it as " ", + // and the longer string is contained in no endpoint name. It stays as the + // fallback (the same hid->name string HIDIOCGRAWNAME copies out) for a + // device with no readable product string. + probed.name = readSysfsLine(sysDir + "/../../product"); + if (probed.name.empty()) { probed.name = detail::ueventValue(uevent, "HID_NAME"); } probed.vendorId = static_cast(ids->vendorId); probed.productId = static_cast(ids->productId); // Admission is decided per parser at the call sites, so probe keeps the @@ -425,14 +434,17 @@ void HidrawGateway::readLoop(Claimed* c) { } input::usbparse::ParsedReport parsed{}; - const bool decoded = - c->layout.valid - ? input::usbhid::decodeFromLayout(data, len, parsed, c->layout) - : input::usbparse::decodeReport(c->parser, data, len, parsed, c->sticks); + const bool decoded = c->layout.valid + ? input::usbhid::decodeFromLayout(data, len, parsed, c->layout) + : input::usbparse::decodeReport(c->parser, data, len, parsed, + c->sticks, &c->micMute); if (!decoded) { continue; } UsbReport report{}; report.wButtons = parsed.wButtons; + // The latch, not the button: what the wire's kXusbMicMute bit carries, + // mirrored up so the driver can edge-detect it. + report.micMuted = c->micMute.muted.load(std::memory_order_relaxed); report.lt = parsed.lt; report.rt = parsed.rt; report.lx = parsed.lx; @@ -491,6 +503,17 @@ std::int64_t HidrawGateway::completionCount(int syntheticId) const { return it->second->completions.load(); } +bool HidrawGateway::setPadMicMuted(int syntheticId, bool muted) { + std::lock_guard lock(mtx_); + const auto it = claimed_.find(syntheticId); + if (it == claimed_.end()) { return false; } + if (it->second->parser != input::usbparse::HidParser::DualSense) { return false; } + // The latch alone, never `held`: a UI toggle is not a button press, and + // touching the edge tracker could swallow or double a real press racing in. + it->second->micMute.muted.store(muted, std::memory_order_relaxed); + return true; +} + bool HidrawGateway::writeOutputReport(int syntheticId, const std::uint8_t* data, std::size_t len) { if (data == nullptr || len == 0) { return false; } Claimed* c = nullptr; diff --git a/src/source/usb/HidrawGateway.h b/src/source/usb/HidrawGateway.h index 3fbbfb7..5e82ece 100644 --- a/src/source/usb/HidrawGateway.h +++ b/src/source/usb/HidrawGateway.h @@ -195,6 +195,7 @@ class HidrawGateway : public UsbDeviceGateway { bool isKnownFastLaneModel(int vendorId, int productId) const override; std::int64_t completionCount(int syntheticId) const override; bool writeOutputReport(int syntheticId, const std::uint8_t* data, std::size_t len) override; + bool setPadMicMuted(int syntheticId, bool muted) override; private: struct Claimed { @@ -212,6 +213,11 @@ class HidrawGateway : public UsbDeviceGateway { input::usbparse::StickAutoRangeState sticks; input::usbhid::HidLayout layout; int featureReportLen = 0; + // The DualSense mic-mute latch. Atomics, because it is the one piece + // of decode state a second thread touches: the read loop flips it on + // the button edge, setPadMicMuted overwrites it when the user toggles + // mute in the UI, and both must land on the wire's next report. + input::usbparse::MicMuteLatch micMute; // Serialises writers against each other. The reader is untouched: a // hidraw fd carries independent read and write paths, so an OUT report // never waits on the blocking read. diff --git a/src/source/usb/UsbDeviceGateway.h b/src/source/usb/UsbDeviceGateway.h index e120dfd..e5c72c3 100644 --- a/src/source/usb/UsbDeviceGateway.h +++ b/src/source/usb/UsbDeviceGateway.h @@ -93,6 +93,12 @@ struct UsbReport { std::int16_t finger1X = 0; std::int16_t finger1Y = 0; bool touchpadButton = false; + + // The decoder's mic-mute LATCH state (DualSense only; false elsewhere). + // Already folded into wButtons as kXusbMicMute by the decoder — this field + // exists so the driver can mirror the state up as an edge without + // re-reading button bits. + bool micMuted = false; }; class UsbDeviceGateway { @@ -134,6 +140,16 @@ class UsbDeviceGateway { // Callable from any thread and from inside a report callback: feedback // arrives on the network receive thread while the read loop is mid-transfer. virtual bool writeOutputReport(int syntheticId, const std::uint8_t* data, std::size_t len) = 0; + + // Overwrite the claimed device's mic-mute latch (the state the DualSense + // decoder folds onto the wire), so the app's mute control and the pad's + // own button stay one state. False for an unknown id or a family with no + // latch. Default no-op so fakes that never touch audio need no override. + virtual bool setPadMicMuted(int syntheticId, bool muted) { + (void)syntheticId; + (void)muted; + return false; + } }; } // namespace dish::source::usb diff --git a/src/source/usb/UsbGamepadManager.cpp b/src/source/usb/UsbGamepadManager.cpp index 7018e65..9f8df15 100644 --- a/src/source/usb/UsbGamepadManager.cpp +++ b/src/source/usb/UsbGamepadManager.cpp @@ -10,6 +10,8 @@ #include "Input/GamepadInputProcessor.h" +#include +#include #include #include #include @@ -201,13 +203,16 @@ bool UsbGamepadManager::applyRumble(int vendorId, int productId, std::uint16_t s const auto target = directTarget(vendorId, productId); if (!target.has_value() || gateway_ == nullptr) { return false; } std::array buf{}; - std::uint8_t seq = 0; + std::size_t n = 0; { + // The state is read for the DS5 mute-lamp re-assert, so the lookup and + // the build are one critical section like the lightbar's. std::lock_guard lock(feedbackMtx_); - seq = nextSeqLocked(vpKey(vendorId, productId)); + const std::uint8_t seq = nextSeqLocked(vpKey(vendorId, productId)); + const auto& st = feedback_[vpKey(vendorId, productId)]; + n = input::usbout::buildRumbleReport(target->parser, st, strongMagnitude, weakMagnitude, + seq, buf.data(), buf.size()); } - const std::size_t n = input::usbout::buildRumbleReport( - target->parser, strongMagnitude, weakMagnitude, seq, buf.data(), buf.size()); if (n == 0) { return false; } return gateway_->writeOutputReport(target->syntheticId, buf.data(), n); } @@ -234,13 +239,14 @@ bool UsbGamepadManager::applyPlayerLeds(int vendorId, int productId, std::uint8_ const auto target = directTarget(vendorId, productId); if (!target.has_value() || gateway_ == nullptr) { return false; } std::array buf{}; - std::uint8_t seq = 0; + std::size_t n = 0; { std::lock_guard lock(feedbackMtx_); - seq = nextSeqLocked(vpKey(vendorId, productId)); + const std::uint8_t seq = nextSeqLocked(vpKey(vendorId, productId)); + const auto& st = feedback_[vpKey(vendorId, productId)]; + n = input::usbout::buildPlayerLedsReport(target->parser, st, ledMask, seq, buf.data(), + buf.size()); } - const std::size_t n = - input::usbout::buildPlayerLedsReport(target->parser, ledMask, seq, buf.data(), buf.size()); if (n == 0) { return false; } return gateway_->writeOutputReport(target->syntheticId, buf.data(), n); } @@ -251,12 +257,39 @@ bool UsbGamepadManager::applyTriggerEffects( const auto target = directTarget(vendorId, productId); if (!target.has_value() || gateway_ == nullptr) { return false; } std::array buf{}; - const std::size_t n = input::usbout::buildTriggerEffectsReport(target->parser, left, right, - buf.data(), buf.size()); + std::size_t n = 0; + { + std::lock_guard lock(feedbackMtx_); + const auto& st = feedback_[vpKey(vendorId, productId)]; + n = input::usbout::buildTriggerEffectsReport(target->parser, st, left, right, buf.data(), + buf.size()); + } + if (n == 0) { return false; } + return gateway_->writeOutputReport(target->syntheticId, buf.data(), n); +} + +bool UsbGamepadManager::applyMicMuteLed(int vendorId, int productId, std::uint8_t state) { + const auto target = directTarget(vendorId, productId); + if (!target.has_value() || gateway_ == nullptr) { return false; } + std::array buf{}; + std::size_t n = 0; + { + // The builder writes the lamp shadow, so the lookup and the build are + // one critical section like the lightbar handoff's. + std::lock_guard lock(feedbackMtx_); + auto& st = feedback_[vpKey(vendorId, productId)]; + n = input::usbout::buildMicMuteLedReport(target->parser, st, state, buf.data(), buf.size()); + } if (n == 0) { return false; } return gateway_->writeOutputReport(target->syntheticId, buf.data(), n); } +bool UsbGamepadManager::setPadMicMuted(int vendorId, int productId, bool muted) { + const auto target = directTarget(vendorId, productId); + if (!target.has_value() || gateway_ == nullptr) { return false; } + return gateway_->setPadMicMuted(target->syntheticId, muted); +} + std::map UsbGamepadManager::controllers() const { std::lock_guard lock(mtx_); return controllers_; @@ -428,7 +461,26 @@ ClaimResult UsbGamepadManager::doClaim(const UsbDeviceInfo& device) { // throwing move ctor plus a heap copy of the slot id on every claim. std::string slotId = std::to_string(vp); input::GamepadInputProcessor* processor = processor_; - const ClaimResult outcome = gateway_->claim(device, [processor, slotId](const UsbReport& r) { + // Last mute state mirrored up, per claim. Starts unmuted, which is what a + // freshly claimed pad's latch says too, so the first edge is a real press + // and not a startup echo. shared_ptr because the closure is copied into + // the gateway and the edge must be tracked in one place. + auto lastMicMuted = std::make_shared>(false); + UsbDirectObserver* observer = observer_; + const int vendorId = device.vendorId; + const int productId = device.productId; + const ClaimResult outcome = gateway_->claim(device, [processor, slotId, lastMicMuted, observer, + vendorId, productId](const UsbReport& r) { + // The decoder owns the mute latch (the wire bit is folded in on the + // read thread, with nothing in the way); the observer is told only on + // the edge, so the mirror costs one relaxed load per report. Fires on + // THIS thread — padMicMuteChanged's contract says so. + if (r.micMuted != lastMicMuted->load(std::memory_order_relaxed)) { + lastMicMuted->store(r.micMuted, std::memory_order_relaxed); + if (observer != nullptr) { + observer->padMicMuteChanged(vendorId, productId, r.micMuted); + } + } if (processor == nullptr) { return; } input::GamepadInputProcessor::DeviceState st; st.wButtons = r.wButtons; diff --git a/src/source/usb/UsbGamepadManager.h b/src/source/usb/UsbGamepadManager.h index 8583d58..8de010d 100644 --- a/src/source/usb/UsbGamepadManager.h +++ b/src/source/usb/UsbGamepadManager.h @@ -67,6 +67,12 @@ class UsbDirectObserver { virtual void syntheticAdded(int /*syntheticId*/, const std::string& /*name*/, bool /*hasGyro*/, int /*pollRateHz*/, int /*vendorId*/, int /*productId*/) {} virtual void syntheticRemoved(int /*syntheticId*/) {} + // The claimed pad's own mic-mute latch moved (the DualSense button, or a + // setPadMicMuted write echoing back). UNLIKE the other callbacks, this one + // fires on the GATEWAY READ THREAD — the edge is seen where the reports + // are — so an observer must marshal to its own thread before touching + // state (AppModel queues it). + virtual void padMicMuteChanged(int /*vendorId*/, int /*productId*/, bool /*muted*/) {} // Fired once after EVERY state-changing applyEvent. This is the single // unidirectional "the FSM state moved, rebuild the slot list from the fresh // controllers() snapshot" signal — the slot list binds to this, not to the @@ -141,6 +147,15 @@ class UsbGamepadManager { bool applyTriggerEffects(int vendorId, int productId, const std::uint8_t left[input::usbout::kTriggerEffectBlockBytes], const std::uint8_t right[input::usbout::kTriggerEffectBlockBytes]); + // The mic-mute lamp. `state` is MSG_MIC_LED's own value (0/1/2); the + // builder records it in the FeedbackState shadow so every later DS5 write + // re-asserts it instead of stomping it. + bool applyMicMuteLed(int vendorId, int productId, std::uint8_t state); + + // Overwrite the claimed pad's mic-mute latch (the UI mute control's way + // into the wire bit). False when the model is not Direct-claimed or has no + // latch. + bool setPadMicMuted(int vendorId, int productId, bool muted); // Whether a Direct claim for this model is live right now. The link layer of // the capability solve asks this before advertising an actuator, so the diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b320d46..501d7da 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,6 +174,19 @@ set(DISH_TEST_SOURCES test_app_view_model_bind_availability.cpp test_capability_solver.cpp test_feedback_routing.cpp + # Controller audio. + test_audio_jitter.cpp + test_audio_codec.cpp + test_satellite_client_audio.cpp + test_host_audio_verdict.cpp + test_audio_enabled_store.cpp + test_audio_preference_repository.cpp + test_wifi_connection_audio.cpp + test_pad_audio_matcher.cpp + test_audio_engine_policy.cpp + test_mic_capture_engine.cpp + test_speaker_playout_engine.cpp + test_mic_mute_store.cpp test_protocol_negotiation.cpp test_apply_binding_machine.cpp test_font_stacks.cpp diff --git a/tests/FakeAudioGateway.h b/tests/FakeAudioGateway.h new file mode 100644 index 0000000..f07f039 --- /dev/null +++ b/tests/FakeAudioGateway.h @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// A scriptable AudioDeviceGateway for the engine suites: capture callbacks are +// driven by the test, playback queues are byte-counted maps, and every open / +// close is recorded so the privacy claim ("closed means closed") is an +// assertion rather than a hope. + +#pragma once + +#include "source/audio/AudioDeviceGateway.h" + +#include +#include +#include +#include + +namespace dish::test { + +class FakeAudioGateway : public dish::source::audio::AudioDeviceGateway { + public: + std::vector captureNames; + std::vector playbackNames; + // Names that refuse to open. + std::vector refuse; + + struct Capture { + std::string name; + std::function onSamples; + bool open = true; + }; + struct Playback { + std::string name; + std::vector queued; // everything ever queued, in order + std::size_t queuedBytes = 0; // the "unplayed" figure the engine reads + bool open = true; + bool resumed = false; + }; + + std::map captures; + std::map playbacks; + int closes = 0; + + std::vector captureDeviceNames() override { return captureNames; } + std::vector playbackDeviceNames() override { return playbackNames; } + + int openCapture(const std::string& deviceName, + std::function onSamples) override { + for (const auto& r : refuse) { + if (r == deviceName) { return dish::source::audio::kNoAudioDevice; } + } + const int handle = nextHandle_++; + captures[handle] = Capture{deviceName, std::move(onSamples), true}; + return handle; + } + + void closeCapture(int handle) override { + const auto it = captures.find(handle); + if (it == captures.end() || !it->second.open) { return; } + it->second.open = false; + it->second.onSamples = {}; + closes++; + } + + int openPlayback(const std::string& deviceName) override { + for (const auto& r : refuse) { + if (r == deviceName) { return dish::source::audio::kNoAudioDevice; } + } + const int handle = nextHandle_++; + playbacks[handle] = Playback{deviceName, {}, 0, true, false}; + return handle; + } + + bool queuePlayback(int handle, const std::int16_t* samples, std::size_t sampleCount) override { + const auto it = playbacks.find(handle); + if (it == playbacks.end() || !it->second.open) { return false; } + it->second.queued.insert(it->second.queued.end(), samples, samples + sampleCount); + it->second.queuedBytes += sampleCount * sizeof(std::int16_t); + return true; + } + + std::size_t queuedPlaybackBytes(int handle) override { + const auto it = playbacks.find(handle); + return it != playbacks.end() ? it->second.queuedBytes : 0; + } + + void resumePlayback(int handle) override { + const auto it = playbacks.find(handle); + if (it != playbacks.end()) { it->second.resumed = true; } + } + + void closePlayback(int handle) override { + const auto it = playbacks.find(handle); + if (it == playbacks.end() || !it->second.open) { return; } + it->second.open = false; + closes++; + } + + // Test conveniences. + Capture* captureFor(const std::string& deviceName) { + for (auto& [handle, c] : captures) { + if (c.open && c.name == deviceName) { return &c; } + } + return nullptr; + } + Playback* playbackFor(const std::string& deviceName) { + for (auto& [handle, p] : playbacks) { + if (p.open && p.name == deviceName) { return &p; } + } + return nullptr; + } + int openCaptureCount() const { + int n = 0; + for (const auto& [handle, c] : captures) { + if (c.open) { n++; } + } + return n; + } + int openPlaybackCount() const { + int n = 0; + for (const auto& [handle, p] : playbacks) { + if (p.open) { n++; } + } + return n; + } + // Simulate the device consuming everything queued so far. + void drain(int handle) { playbacks.at(handle).queuedBytes = 0; } + + private: + int nextHandle_ = 1; +}; + +} // namespace dish::test diff --git a/tests/satellite_client_test_access.h b/tests/satellite_client_test_access.h index b3846b6..6758d05 100644 --- a/tests/satellite_client_test_access.h +++ b/tests/satellite_client_test_access.h @@ -18,6 +18,20 @@ class SatelliteClientTestAccess { static void seedSendCounter(SatelliteClient& client, std::uint64_t next) { client.sendCounter_.reset(next); } + + // Drives the receive dispatch with a fully framed datagram, so the wire + // tests exercise decrypt + replay-guard + dispatch without a socket. + static void processIncoming(SatelliteClient& client, const std::uint8_t* buf, std::size_t n) { + client.processIncoming(buf, n); + } + + // Direct line to the send framing, for the payload-ceiling tests: the + // public senders each guard their own payloads before this, so the generic + // ceiling would otherwise be unreachable at its exact boundary. + static bool sendEncrypted(SatelliteClient& client, std::uint16_t msgType, + const std::uint8_t* payload, std::size_t len) { + return client.sendEncrypted(msgType, payload, len); + } }; } // namespace dish::net diff --git a/tests/test_audio_codec.cpp b/tests/test_audio_codec.cpp new file mode 100644 index 0000000..0a553ce --- /dev/null +++ b/tests/test_audio_codec.cpp @@ -0,0 +1,509 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The libopus wrapper (source/audio/OpusAudioCodec.*): the two controller-audio +// stream formats, round-tripped through real encoders and decoders. +// +// The assertions are deliberately about SHAPE rather than samples. Opus is +// lossy and version-dependent, so pinning bytes would pin the library version; +// what must not drift is that a 20 ms window in comes back out as a 20 ms +// window, that a tone survives as a tone, that concealment produces audio for a +// frame that never arrived, and above all that the mic stream really carries +// in-band FEC. That last one is an encoder-setting question and exactly the +// sort of thing that silently stops being true: a stream with no FEC encodes, +// decodes and sounds perfect right up until the first packet goes missing. +// +// This client only encodes mic and decodes speaker, but both halves of both +// streams are built (OpusAudioCodec.h says why), so each stream's loop closes +// here instead of against a second implementation of the same constants. A +// mirror of dish-android's audio_codec_test.cpp, case for case. + +#include "source/audio/OpusAudioCodec.h" + +#include "core/audio/AudioJitter.h" +#include "core/model/Protocol.h" + +#include + +#include +#include +#include + +namespace proto = dish::proto; + +namespace { + +using dish::audio::OpusStreamDecoder; +using dish::audio::OpusStreamEncoder; +using dish::audio::Stream; + +constexpr std::size_t kMicFrame = static_cast(proto::kAudioFrameSamples); +constexpr std::size_t kSpeakerFrame = + static_cast(proto::kAudioFrameSamples * proto::kAudioSpeakerChannels); +// Comfortably past a 96 kbps 20 ms packet (~240 bytes) without being the wire +// ceiling, so a wildly oversized packet would still be visible as one. +constexpr std::size_t kMaxPacket = 1024; +constexpr double kPi = 3.14159265358979; + +// Speech-ish content: a 220 Hz fundamental plus two harmonics, amplitude +// modulated so successive frames differ. Steady silence would let a +// concealment path look identical to a real decode and prove nothing. +void fillMicFrame(std::vector& pcm, int frameIndex) { + pcm.resize(kMicFrame); + for (std::size_t i = 0; i < kMicFrame; i++) { + const double t = (frameIndex * static_cast(kMicFrame) + static_cast(i)) / + proto::kAudioSampleRateHz; + const double env = 0.55 + 0.45 * std::sin(2.0 * kPi * 3.0 * t); + const double s = std::sin(2.0 * kPi * 220.0 * t) + 0.5 * std::sin(2.0 * kPi * 440.0 * t) + + 0.25 * std::sin(2.0 * kPi * 880.0 * t); + pcm[i] = static_cast(env * s * 8000.0); + } +} + +// Stereo with the channels deliberately unequal, so a wrapper that collapsed or +// swapped them would show up as an energy imbalance rather than passing. +void fillSpeakerFrame(std::vector& pcm, int frameIndex) { + pcm.resize(kSpeakerFrame); + for (std::size_t i = 0; i < kMicFrame; i++) { + const double t = (frameIndex * static_cast(kMicFrame) + static_cast(i)) / + proto::kAudioSampleRateHz; + pcm[i * 2 + 0] = static_cast(std::sin(2.0 * kPi * 330.0 * t) * 9000.0); + pcm[i * 2 + 1] = static_cast(std::sin(2.0 * kPi * 660.0 * t) * 4000.0); + } +} + +// Mean square over a channel of an interleaved buffer (stride 1 for mono). +double energy(const std::int16_t* pcm, std::size_t frames, int stride, int offset) { + if (frames == 0) { return 0.0; } + double sum = 0.0; + for (std::size_t i = 0; i < frames; i++) { + const double v = + pcm[i * static_cast(stride) + static_cast(offset)]; + sum += v * v; + } + return sum / static_cast(frames); +} + +bool sameSamples(const std::vector& a, const std::vector& b) { + if (a.size() != b.size()) { return false; } + for (std::size_t i = 0; i < a.size(); i++) { + if (a[i] != b[i]) { return false; } + } + return true; +} + +// Encode a run of mic frames, drop `lost`, and decode the run twice from +// identical decoder state: once recovering the hole from the carrier packet's +// in-band FEC, once concealing it blind. Whether the two outputs differ is +// exactly "did packet lost+1 carry a redundant copy of frame lost". +bool fecBeatsPlcForFrame(int lost, double& outFecEnergy, double& outSourceEnergy) { + auto enc = OpusStreamEncoder::create(Stream::Mic); + auto decFec = OpusStreamDecoder::create(Stream::Mic); + auto decPlc = OpusStreamDecoder::create(Stream::Mic); + if (!enc || !decFec || !decPlc) { return false; } + + const int kFrames = 14; + std::vector> packets; + std::vector> sources; + for (int f = 0; f < kFrames; f++) { + std::vector src; + fillMicFrame(src, f); + std::uint8_t buf[kMaxPacket]; + const std::size_t bytes = enc->encode(src.data(), kMicFrame, buf, sizeof(buf)); + if (bytes == 0) { return false; } + packets.push_back(std::vector(buf, buf + bytes)); + sources.push_back(src); + } + + std::vector fecOut(kMicFrame, 0); + std::vector plcOut(kMicFrame, 0); + std::vector scratch(kMicFrame, 0); + for (int f = 0; f < kFrames; f++) { + const auto fi = static_cast(f); + if (f == lost) { + // Recovered from packet f+1, which is what the jitter window hands + // over as a gap's carrier. Order matters: the FEC copy is decoded + // BEFORE the carrier's own frame. + if (decFec->decodeFec(packets[fi + 1].data(), packets[fi + 1].size(), fecOut.data(), + fecOut.size()) != kMicFrame) { + return false; + } + if (decPlc->conceal(plcOut.data(), plcOut.size()) != kMicFrame) { return false; } + continue; + } + decFec->decode(packets[fi].data(), packets[fi].size(), scratch.data(), scratch.size()); + decPlc->decode(packets[fi].data(), packets[fi].size(), scratch.data(), scratch.size()); + } + + outFecEnergy = energy(fecOut.data(), kMicFrame, 1, 0); + outSourceEnergy = energy(sources[static_cast(lost)].data(), kMicFrame, 1, 0); + return !sameSamples(fecOut, plcOut); +} + +} // namespace + +TEST_CASE("wire audio constants match the contract", "[audio][codec]") { + // A cross-repo agreement, not a tuning knob: satellite pins the same + // numbers in core/types.h, and the wire never negotiates any of them. + CHECK(proto::kAudioSampleRateHz == 48000); + CHECK(proto::kAudioFrameMs == 20); + CHECK(proto::kAudioFrameSamples == 960); + CHECK(proto::kAudioMicChannels == 1); + CHECK(proto::kAudioSpeakerChannels == 2); + CHECK(proto::kAudioWireHeaderBytes == 3); + CHECK(proto::kAudioWireMinPayloadBytes == 4); + CHECK(proto::kUdpDatagramMaxBytes == 1500U); + CHECK(proto::kUdpMaxInnerPayloadBytes == 1472U); + CHECK(proto::kAudioWireMaxOpusBytes == 1469U); +} + +TEST_CASE("mic round trip preserves the frame and the signal", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Mic); + auto dec = OpusStreamDecoder::create(Stream::Mic); + REQUIRE(enc != nullptr); + REQUIRE(dec != nullptr); + CHECK(enc->channels() == proto::kAudioMicChannels); + CHECK(dec->channels() == proto::kAudioMicChannels); + + std::vector src; + std::vector out(kMicFrame * 2, 0); + std::uint8_t packet[kMaxPacket]; + double lastEnergyRatio = 0.0; + + // Several frames: Opus needs a few to leave its start-up transient, and a + // wrapper that only worked on frame 0 would be a real bug. + for (int f = 0; f < 12; f++) { + fillMicFrame(src, f); + const std::size_t bytes = enc->encode(src.data(), kMicFrame, packet, sizeof(packet)); + CHECK(bytes > 0U); + CHECK(bytes < sizeof(packet)); + CHECK(dec->decode(packet, bytes, out.data(), out.size()) == kMicFrame); + if (f >= 4) { + const double in = energy(src.data(), kMicFrame, 1, 0); + const double got = energy(out.data(), kMicFrame, 1, 0); + lastEnergyRatio = in > 0.0 ? got / in : 0.0; + // Lossy, so not equal; but a codec that dropped the signal or blew + // it up by an order of magnitude is broken, not lossy. + CHECK(lastEnergyRatio > 0.3); + CHECK(lastEnergyRatio < 3.0); + } + } + CHECK(lastEnergyRatio > 0.0); + + // ~32 kbps at 20 ms is ~80 bytes; the assertion is only that VBR is not + // running an order of magnitude off the configured rate. + fillMicFrame(src, 20); + const std::size_t bytes = enc->encode(src.data(), kMicFrame, packet, sizeof(packet)); + CHECK(bytes > 20U); + CHECK(bytes < 400U); +} + +TEST_CASE("every mic packet fits the wire ceiling with room to spare", "[audio][codec]") { + // The send path hands the encoder the whole wire ceiling as its output + // budget, so this is what keeps that generosity honest: a 20 ms mic packet + // is two orders of magnitude below the datagram limit. + auto enc = OpusStreamEncoder::create(Stream::Mic); + REQUIRE(enc != nullptr); + std::vector src; + std::vector out(proto::kAudioWireMaxOpusBytes); + for (int f = 0; f < 12; f++) { + fillMicFrame(src, f); + const std::size_t bytes = enc->encode(src.data(), kMicFrame, out.data(), out.size()); + CHECK(bytes > 0U); + CHECK(static_cast(proto::kAudioWireHeaderBytes) + bytes <= + proto::kUdpMaxInnerPayloadBytes); + } +} + +TEST_CASE("speaker stereo round-trips with the channel imbalance intact", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Speaker); + auto dec = OpusStreamDecoder::create(Stream::Speaker); + REQUIRE(enc != nullptr); + REQUIRE(dec != nullptr); + CHECK(enc->channels() == proto::kAudioSpeakerChannels); + CHECK(dec->channels() == proto::kAudioSpeakerChannels); + + std::vector src; + std::vector out(kSpeakerFrame * 2, 0); + std::uint8_t packet[kMaxPacket]; + double leftOverRight = 0.0; + for (int f = 0; f < 12; f++) { + fillSpeakerFrame(src, f); + const std::size_t bytes = enc->encode(src.data(), kMicFrame, packet, sizeof(packet)); + CHECK(bytes > 0U); + CHECK(dec->decode(packet, bytes, out.data(), out.size() / 2) == kMicFrame); + if (f >= 4) { + const double l = energy(out.data(), kMicFrame, 2, 0); + const double r = energy(out.data(), kMicFrame, 2, 1); + leftOverRight = r > 0.0 ? l / r : 0.0; + } + } + // Source left is ~5x right in power. A wrapper that downmixed to mono, or + // swapped the interleave, would land near 1.0 or well under it. + CHECK(leftOverRight > 1.5); + CHECK(leftOverRight < 12.0); +} + +TEST_CASE("encode refuses a window that is not exactly twenty milliseconds", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Mic); + REQUIRE(enc != nullptr); + + std::vector src; + fillMicFrame(src, 0); + std::uint8_t packet[kMaxPacket]; + // One wire message is one 20 ms packet: a caller handing a different window + // has mis-framed, and emitting the packet anyway would put audio on the + // wire the satellite cannot place in its timeline. + CHECK(enc->encode(src.data(), kMicFrame - 1, packet, sizeof(packet)) == 0U); + CHECK(enc->encode(src.data(), kMicFrame + 1, packet, sizeof(packet)) == 0U); + CHECK(enc->encode(src.data(), 0, packet, sizeof(packet)) == 0U); + CHECK(enc->encode(nullptr, kMicFrame, packet, sizeof(packet)) == 0U); + CHECK(enc->encode(src.data(), kMicFrame, nullptr, sizeof(packet)) == 0U); + CHECK(enc->encode(src.data(), kMicFrame, packet, 0) == 0U); + // And the good call still works afterwards: a refusal must not wedge the + // encoder. + CHECK(enc->encode(src.data(), kMicFrame, packet, sizeof(packet)) > 0U); +} + +TEST_CASE("garbage and truncated packets leave a usable decoder", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Speaker); + auto dec = OpusStreamDecoder::create(Stream::Speaker); + REQUIRE(enc != nullptr); + REQUIRE(dec != nullptr); + + std::vector out(kSpeakerFrame, 0); + const std::uint8_t garbage[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + // Not asserting failure: some byte strings ARE valid Opus. Asserting only + // that nothing reads out of bounds and the decoder survives. + (void)dec->decode(garbage, sizeof(garbage), out.data(), kMicFrame); + CHECK(dec->decode(nullptr, 4, out.data(), kMicFrame) == 0U); + CHECK(dec->decode(garbage, 0, out.data(), kMicFrame) == 0U); + CHECK(dec->decode(garbage, sizeof(garbage), nullptr, kMicFrame) == 0U); + CHECK(dec->decode(garbage, sizeof(garbage), out.data(), 0) == 0U); + + std::vector src; + fillSpeakerFrame(src, 0); + std::uint8_t packet[kMaxPacket]; + const std::size_t bytes = enc->encode(src.data(), kMicFrame, packet, sizeof(packet)); + REQUIRE(bytes > 4U); + (void)dec->decode(packet, bytes / 2, out.data(), kMicFrame); // truncated + // Whatever the malformed input did, a real packet still decodes. + CHECK(dec->decode(packet, bytes, out.data(), kMicFrame) == kMicFrame); +} + +TEST_CASE("a packet claiming more than twenty milliseconds is refused", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Speaker); + auto dec = OpusStreamDecoder::create(Stream::Speaker); + REQUIRE(enc != nullptr); + REQUIRE(dec != nullptr); + + std::vector src; + fillSpeakerFrame(src, 0); + std::uint8_t packet[kMaxPacket]; + const std::size_t bytes = enc->encode(src.data(), kMicFrame, packet, sizeof(packet)); + REQUIRE(bytes > 1U); + + // Forge a 40 ms packet out of the 20 ms one: Opus's TOC byte carries the + // frame count in its low two bits, so code 1 (two equal frames) plus a + // duplicated body is a structurally valid packet of twice the duration. + // This is exactly what a hostile host could put on the wire, and the + // dispatch decodes into a fixed kAudioFrameSamples buffer, so "refused" + // and "not written past" have to be the same thing. + std::vector twoFrames; + twoFrames.push_back(static_cast((packet[0] & 0xFC) | 0x01)); + twoFrames.insert(twoFrames.end(), packet + 1, packet + bytes); + twoFrames.insert(twoFrames.end(), packet + 1, packet + bytes); + + std::vector out(kSpeakerFrame, 0); + CHECK(dec->decode(twoFrames.data(), twoFrames.size(), out.data(), kMicFrame) == 0U); + // Given room for the whole 40 ms it decodes fine, which is what makes the + // refusal above a capacity check rather than the packet being malformed. + std::vector roomy(kSpeakerFrame * 2, 0); + CHECK(dec->decode(twoFrames.data(), twoFrames.size(), roomy.data(), kMicFrame * 2) == + kMicFrame * 2); +} + +TEST_CASE("concealment synthesizes a full frame with no packet at all", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Speaker); + auto dec = OpusStreamDecoder::create(Stream::Speaker); + REQUIRE(enc != nullptr); + REQUIRE(dec != nullptr); + + std::vector src; + std::vector out(kSpeakerFrame, 0); + std::uint8_t packet[kMaxPacket]; + for (int f = 0; f < 8; f++) { + fillSpeakerFrame(src, f); + const std::size_t bytes = enc->encode(src.data(), kMicFrame, packet, sizeof(packet)); + CHECK(dec->decode(packet, bytes, out.data(), kMicFrame) == kMicFrame); + } + + std::vector concealed(kSpeakerFrame, 0); + CHECK(dec->conceal(concealed.data(), kSpeakerFrame) == kMicFrame); + // Extrapolated from the tone that came before, so it must not be silence. + // (An all-zero frame is what a decoder that ignored the request would give.) + CHECK(energy(concealed.data(), kMicFrame, 2, 0) > 1000.0); + + // Too small a buffer is a caller error, not a request to conceal less: the + // codec has to be told exactly how much audio is missing. + CHECK(dec->conceal(concealed.data(), kMicFrame - 1) == 0U); + CHECK(dec->conceal(nullptr, kSpeakerFrame) == 0U); +} + +TEST_CASE("in-band FEC recovers most lost mic frames where PLC alone cannot", "[audio][codec]") { + // Every frame in a run, not one: whether a given packet carries LBRR is an + // encoder decision made per packet, and a mode switch can make the two + // decode paths agree for a frame on its own. A strict majority separates + // the two worlds cleanly. This is the regression that pins + // OPUS_SET_INBAND_FEC plus the expected-loss hint; nothing else would catch + // losing them, because a stream without FEC sounds perfect until a packet + // goes missing. + const int first = 4; + const int last = 11; + const int trials = last - first + 1; + int recovered = 0; + double fecEnergy = 0.0; + double sourceEnergy = 0.0; + for (int lost = first; lost <= last; lost++) { + double e = 0.0; + double s = 0.0; + if (fecBeatsPlcForFrame(lost, e, s)) { + recovered++; + fecEnergy = e; + sourceEnergy = s; + } + } + CHECK(recovered * 2 > trials); + REQUIRE(recovered > 0); + + // And a recovery is audio, not a click: energy in the same league as what + // was encoded for the frame that went missing. + CHECK(sourceEnergy > 0.0); + CHECK(fecEnergy > sourceEnergy * 0.1); + CHECK(fecEnergy < sourceEnergy * 10.0); +} + +TEST_CASE("decodeFec with no carrier conceals instead of failing", "[audio][codec]") { + auto dec = OpusStreamDecoder::create(Stream::Speaker); + REQUIRE(dec != nullptr); + + // The dispatch takes the FEC path unconditionally on a gap, because + // whether a packet carries FEC is an encoder decision it cannot see, and + // the reorder window hands over a null carrier whenever the next frame has + // not arrived either. A null carrier therefore has to mean "conceal". + std::vector out(kSpeakerFrame, 0); + CHECK(dec->decodeFec(nullptr, 0, out.data(), kSpeakerFrame) == kMicFrame); + CHECK(dec->decodeFec(nullptr, 0, out.data(), kMicFrame - 1) == 0U); +} + +// DTX is asymmetric between the two streams on purpose, and the asymmetry is +// invisible from the header: only behaviour can pin it. The mic wants it +// because a live microphone never goes digitally silent, so a VAD gate is the +// only thing that can collapse a quiet room. The speaker must not have it, +// because its gate cuts anything ~26-30 dB below the recent peak, which on game +// audio turns a reverb tail into comfort noise. +TEST_CASE("sustained mic silence collapses to DTX packets", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Mic); + REQUIRE(enc != nullptr); + + const std::vector silence(kMicFrame, 0); + std::uint8_t packet[kMaxPacket]; + + // DTX needs a run of qualifying input before it engages (200 ms when + // satellite measured it), so the steady state is what is asserted, not + // frame 1. The 20-frame lead-in is deliberately looser than that figure so + // this does not become a pin on one libopus version's ramp. + std::size_t tiny = 0; + std::size_t counted = 0; + for (int i = 0; i < 100; i++) { + const std::size_t bytes = enc->encode(silence.data(), kMicFrame, packet, sizeof(packet)); + REQUIRE(bytes > 0U); + if (i >= 20) { + counted++; + if (bytes <= 2) { tiny++; } + } + } + REQUIRE(counted > 0U); + CHECK(tiny > counted * 3 / 4); +} + +TEST_CASE("DTX packets stay legal on the wire and decode", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Mic); + auto dec = OpusStreamDecoder::create(Stream::Mic); + REQUIRE(enc != nullptr); + REQUIRE(dec != nullptr); + + const std::vector silence(kMicFrame, 0); + std::uint8_t packet[kMaxPacket]; + std::size_t bytes = 0; + for (int i = 0; i < 40; i++) { + bytes = enc->encode(silence.data(), kMicFrame, packet, sizeof(packet)); + } + REQUIRE(bytes >= 1U); + REQUIRE(bytes <= 2U); + + // A 1-byte packet is a legal Opus frame, and the wire minimum exists so it + // survives dispatch: header + at least one Opus byte. + CHECK(bytes + static_cast(proto::kAudioWireHeaderBytes) >= + static_cast(proto::kAudioWireMinPayloadBytes)); + + std::vector out(kMicFrame, 12345); + CHECK(dec->decode(packet, bytes, out.data(), + static_cast(proto::kAudioFrameSamples)) == kMicFrame); + + // And the reorder window must take it, not reject it as a runt. + dish::audio::AudioJitterWindow window; + const auto pushed = window.push(0, packet, bytes); + CHECK(pushed.accept == dish::audio::AudioJitterWindow::Accept::Ok); +} + +TEST_CASE("DTX does not gate real speech", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Mic); + REQUIRE(enc != nullptr); + + std::vector pcm; + std::uint8_t packet[kMaxPacket]; + std::size_t tiny = 0; + for (int i = 0; i < 60; i++) { + fillMicFrame(pcm, i); + const std::size_t bytes = enc->encode(pcm.data(), kMicFrame, packet, sizeof(packet)); + REQUIRE(bytes > 0U); + if (bytes <= 2) { tiny++; } + } + CHECK(tiny == 0U); +} + +TEST_CASE("declining DTX keeps speaker silence a full packet", "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Speaker); + REQUIRE(enc != nullptr); + + const std::vector silence(kSpeakerFrame, 0); + std::uint8_t packet[kMaxPacket]; + std::size_t tiny = 0; + for (int i = 0; i < 100; i++) { + const std::size_t bytes = enc->encode(silence.data(), kMicFrame, packet, sizeof(packet)); + REQUIRE(bytes > 0U); + if (bytes <= 2) { tiny++; } + } + // Not a bug being pinned: the satellite suppresses exact digital silence + // before it ever reaches its speaker encoder, so the codec never needs a + // VAD there — and this client's mirror of the encoder keeps the setting. + CHECK(tiny == 0U); +} + +TEST_CASE("a tight output buffer truncates the packet rather than overrunning it", + "[audio][codec]") { + auto enc = OpusStreamEncoder::create(Stream::Speaker); + REQUIRE(enc != nullptr); + + std::vector src; + fillSpeakerFrame(src, 3); + // libopus treats max_data_bytes as a hard ceiling it encodes down to, so a + // small buffer produces a smaller packet rather than a buffer overrun. The + // wire ceiling is generous, but this guarantee is what makes passing + // sizeof(buffer) safe at the call site. + std::uint8_t tight[64]; + const std::size_t bytes = enc->encode(src.data(), kMicFrame, tight, sizeof(tight)); + CHECK(bytes > 0U); + CHECK(bytes <= sizeof(tight)); +} diff --git a/tests/test_audio_enabled_store.cpp b/tests/test_audio_enabled_store.cpp new file mode 100644 index 0000000..3ae692c --- /dev/null +++ b/tests/test_audio_enabled_store.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// MicEnabledStore / SpeakerEnabledStore (source/store/AudioEnabledStore.h): the +// same behaviour contract as MotionEnabledStore, plus the pair's one load- +// bearing difference — the defaults. Mic OFF is the privacy stance (capture is +// opt-in per binding), speaker ON is the working default, and both are asserted +// here so neither can be "tidied" into the other. + +#include "repository/AudioPreferenceRepository.h" +#include "source/store/AudioEnabledStore.h" + +#include "QSettingsFixture.h" + +#include + +#include + +using dish::repository::AudioPreference; +using dish::repository::AudioPreferenceRepository; +using dish::source::MicEnabledStore; +using dish::source::SpeakerEnabledStore; +using dish::test::makeSharedSettings; + +namespace { + +std::unique_ptr +seededRepo(const QString& listKey, std::initializer_list prefs) { + auto repo = std::make_unique(listKey, makeSharedSettings()); + for (const auto& p : prefs) { repo->put(p); } + return repo; +} + +} // namespace + +TEST_CASE("mic defaults OFF and speaker ON for an unwritten slot", "[audio-store]") { + auto micRepo = seededRepo(QStringLiteral("mic_preferences"), {}); + auto spkRepo = seededRepo(QStringLiteral("speaker_preferences"), {}); + MicEnabledStore mic(micRepo.get()); + SpeakerEnabledStore speaker(spkRepo.get()); + + CHECK_FALSE(mic.isEnabled("never-toggled")); + CHECK(speaker.isEnabled("never-toggled")); + // The class constants the UI seeds from must agree with the behaviour. + CHECK_FALSE(MicEnabledStore::kDefaultEnabled); + CHECK(SpeakerEnabledStore::kDefaultEnabled); + CHECK_FALSE(mic.defaultEnabled()); + CHECK(speaker.defaultEnabled()); +} + +TEST_CASE("hydrates state from the repository on construction", "[audio-store]") { + auto repo = seededRepo(QStringLiteral("mic_preferences"), + {AudioPreference{"sdl:1", true}, AudioPreference{"9", false}}); + MicEnabledStore store(repo.get()); + + const auto& snapshot = store.state().value(); + REQUIRE(snapshot.size() == 2); + CHECK(snapshot.at("sdl:1") == true); + CHECK(snapshot.at("9") == false); +} + +TEST_CASE("setEnabled persists to the repository AND republishes state", "[audio-store]") { + auto repo = seededRepo(QStringLiteral("mic_preferences"), {}); + MicEnabledStore store(repo.get()); + + store.setEnabled("9", true); + + REQUIRE(repo->get("9").has_value()); + CHECK(repo->get("9")->enabled == true); + CHECK(store.state().value().at("9") == true); + CHECK(store.isEnabled("9")); +} + +TEST_CASE("an explicit off survives even where the default is on", "[audio-store]") { + auto repo = seededRepo(QStringLiteral("speaker_preferences"), {AudioPreference{"9", false}}); + SpeakerEnabledStore store(repo.get()); + CHECK_FALSE(store.isEnabled("9")); + CHECK(store.isEnabled("other")); // the default still answers for the rest +} + +TEST_CASE("forget removes the entry from state AND from the repository", "[audio-store]") { + auto repo = seededRepo(QStringLiteral("mic_preferences"), {AudioPreference{"9", true}}); + MicEnabledStore store(repo.get()); + CHECK(store.isEnabled("9")); + + store.forget("9"); + + CHECK_FALSE(repo->get("9").has_value()); + // Absent means the mic default (off) applies again — the forget must not + // leave a microphone silently enabled. + CHECK_FALSE(store.isEnabled("9")); + const auto snapshot = store.state().value(); + CHECK(snapshot.find("9") == snapshot.end()); +} + +TEST_CASE("the two directions never share state through their repos", "[audio-store]") { + // Same slot id, same QSettings file, different list keys: a mic toggle + // must not move the speaker's answer. + const auto settings = makeSharedSettings(); + AudioPreferenceRepository micRepo(QStringLiteral("mic_preferences"), settings); + AudioPreferenceRepository spkRepo(QStringLiteral("speaker_preferences"), settings); + MicEnabledStore mic(&micRepo); + SpeakerEnabledStore speaker(&spkRepo); + + mic.setEnabled("9", true); + speaker.setEnabled("9", false); + + CHECK(mic.isEnabled("9")); + CHECK_FALSE(speaker.isEnabled("9")); + CHECK(micRepo.get("9")->enabled); + CHECK_FALSE(spkRepo.get("9")->enabled); +} diff --git a/tests/test_audio_engine_policy.cpp b/tests/test_audio_engine_policy.cpp new file mode 100644 index 0000000..15fd0af --- /dev/null +++ b/tests/test_audio_engine_policy.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The engine rules (core/audio/AudioEnginePolicy.h): the mic and speaker +// eligibility truth tables, the exact-window reassembly of arbitrary capture +// chunks, and the drained-queue cushion refill. These decide when a +// microphone is OPEN, so the mic table is exhaustive: every one of the 32 +// fact combinations is pinned, not sampled. + +#include "core/audio/AudioEnginePolicy.h" + +#include + +#include +#include +#include + +using dish::audio::AudioSlotFacts; +using dish::audio::FrameWindower; +using dish::audio::micCaptureEligible; +using dish::audio::playoutRefillFrames; +using dish::audio::speakerPlayoutEligible; + +TEST_CASE("mic capture needs every fact and no mute, exhaustively", "[audio][policy]") { + for (int bits = 0; bits < 32; ++bits) { + AudioSlotFacts f; + f.streaming = (bits & 1) != 0; + f.toggleOn = (bits & 2) != 0; + f.routeMatched = (bits & 4) != 0; + f.hostCarries = (bits & 8) != 0; + f.muted = (bits & 16) != 0; + const bool expected = + f.streaming && f.toggleOn && f.routeMatched && f.hostCarries && !f.muted; + INFO("bits " << bits); + CHECK(micCaptureEligible(f) == expected); + } +} + +TEST_CASE("speaker playout ignores mute but needs everything else", "[audio][policy]") { + // The asymmetry is deliberate: mute is about the user's voice leaving the + // machine, not about the game reaching the pad. + for (int bits = 0; bits < 32; ++bits) { + AudioSlotFacts f; + f.streaming = (bits & 1) != 0; + f.toggleOn = (bits & 2) != 0; + f.routeMatched = (bits & 4) != 0; + f.hostCarries = (bits & 8) != 0; + f.muted = (bits & 16) != 0; + const bool expected = f.streaming && f.toggleOn && f.routeMatched && f.hostCarries; + INFO("bits " << bits); + CHECK(speakerPlayoutEligible(f) == expected); + } +} + +TEST_CASE("the playout constants are the contract's cushion", "[audio][policy]") { + CHECK(dish::audio::kPlayoutStartThresholdFrames == 2); + CHECK(dish::audio::kPlayoutBufferFrames == 4); + // 960 frames x 2 channels x 2 bytes. + CHECK(dish::audio::kPlayoutFrameBytes == 3840U); +} + +TEST_CASE("the cushion refills only on a drained queue after start", "[audio][policy]") { + // Before start the threshold owns the cushion; after it, only a genuinely + // empty queue earns silence, and it earns exactly the start cushion. + CHECK(playoutRefillFrames(/*started=*/false, /*queuedBytes=*/0) == 0); + CHECK(playoutRefillFrames(true, 0) == dish::audio::kPlayoutStartThresholdFrames); + CHECK(playoutRefillFrames(true, 1) == 0); + CHECK(playoutRefillFrames(true, 4096) == 0); +} + +// ── FrameWindower ──────────────────────────────────────────────────────────── + +namespace { + +// Feed `chunks` and collect every emitted window's contents. +std::vector> +collect(FrameWindower& w, const std::vector>& chunks) { + std::vector> windows; + for (const auto& chunk : chunks) { + w.feed(chunk.data(), chunk.size(), [&](const std::int16_t* pcm, std::size_t frames) { + windows.emplace_back(pcm, pcm + frames); + }); + } + return windows; +} + +std::vector ramp(std::size_t count, std::int16_t start) { + std::vector out(count); + std::iota(out.begin(), out.end(), start); + return out; +} + +} // namespace + +TEST_CASE("exact chunks pass straight through as whole windows", "[audio][windower]") { + FrameWindower w(4); + const auto windows = collect(w, {ramp(4, 0), ramp(4, 100)}); + REQUIRE(windows.size() == 2U); + CHECK(windows[0] == ramp(4, 0)); + CHECK(windows[1] == ramp(4, 100)); + CHECK(w.pending() == 0U); +} + +TEST_CASE("small chunks accumulate and never emit a partial window", "[audio][windower]") { + // A partial window must never reach the encoder: the far end cannot place + // one in its timeline. + FrameWindower w(4); + const auto windows = collect(w, {ramp(1, 0), ramp(2, 1), ramp(1, 3), ramp(3, 4)}); + REQUIRE(windows.size() == 1U); + CHECK(windows[0] == ramp(4, 0)); + CHECK(w.pending() == 3U); // the tail waits for its window's last sample +} + +TEST_CASE("an oversized chunk yields every whole window plus a carried tail", "[audio][windower]") { + FrameWindower w(4); + const auto windows = collect(w, {ramp(11, 0)}); + REQUIRE(windows.size() == 2U); + CHECK(windows[0] == ramp(4, 0)); + CHECK(windows[1] == ramp(4, 4)); + CHECK(w.pending() == 3U); + + // The carried tail joins the next chunk in order, sample-exact. + const auto more = collect(w, {ramp(1, 11)}); + REQUIRE(more.size() == 1U); + CHECK(more[0] == ramp(4, 8)); +} + +TEST_CASE("a carried tail forces the copy path for following whole windows", "[audio][windower]") { + // With a remainder in hand, a subsequent full-window-sized chunk must not + // take the zero-copy fast path or samples would reorder. + FrameWindower w(4); + const auto windows = collect(w, {ramp(2, 0), ramp(4, 2), ramp(2, 6)}); + REQUIRE(windows.size() == 2U); + CHECK(windows[0] == ramp(4, 0)); + CHECK(windows[1] == ramp(4, 4)); +} + +TEST_CASE("reset drops the carried tail", "[audio][windower]") { + // A fresh stream must not inherit the old one's remainder. + FrameWindower w(4); + collect(w, {ramp(3, 0)}); + CHECK(w.pending() == 3U); + w.reset(); + CHECK(w.pending() == 0U); + const auto windows = collect(w, {ramp(4, 50)}); + REQUIRE(windows.size() == 1U); + CHECK(windows[0] == ramp(4, 50)); +} + +TEST_CASE("null input and a missing sink are refused quietly", "[audio][windower]") { + FrameWindower w(4); + w.feed(nullptr, 8, [](const std::int16_t*, std::size_t) { FAIL("emitted from null input"); }); + const auto samples = ramp(8, 0); + w.feed(samples.data(), samples.size(), {}); + CHECK(w.pending() == 0U); +} + +TEST_CASE("the default window is the wire frame", "[audio][windower]") { + FrameWindower w; + std::size_t emitted = 0; + const auto chunk = ramp(960, 0); + w.feed(chunk.data(), chunk.size(), + [&](const std::int16_t*, std::size_t frames) { emitted = frames; }); + CHECK(emitted == 960U); +} diff --git a/tests/test_audio_jitter.cpp b/tests/test_audio_jitter.cpp new file mode 100644 index 0000000..975a5ad --- /dev/null +++ b/tests/test_audio_jitter.cpp @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// AudioJitterWindow (core/audio/AudioJitter.h): the 2-frame reorder window that +// decides, from wrapping u16 sequence numbers alone, which speaker frames to +// play, which to conceal, and which arrived too late to be worth anything. +// +// A deliberate mirror of satellite's tests/test_audio_jitter.cpp and +// dish-android's audio_jitter_test.cpp, case for case: the two 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 a divergence there would show up as +// audible glitching rather than as a failure anywhere. +// +// Dependency-free by design, so this suite links no codec: the ordering rules +// are exactly the part that should be provable without one. + +#include "core/audio/AudioJitter.h" + +#include + +#include +#include +#include + +namespace { + +using dish::audio::AudioJitterWindow; +using Kind = AudioJitterWindow::Event::Kind; +using Accept = AudioJitterWindow::Accept; + +// Packets are only ever compared by identity here, so each one is a block of a +// distinctive byte: it makes "the window handed back the packet I pushed" an +// assertion rather than a hope. +std::vector packet(std::uint8_t tag, std::size_t bytes = 24) { + return std::vector(bytes, tag); +} + +AudioJitterWindow::Result push(AudioJitterWindow& w, std::uint16_t seq, + const std::vector& p) { + return w.push(seq, p.data(), p.size()); +} + +// One-line shape of a result: "P12" for a packet, "G12" for a gap, "G12/F" for +// a gap that has an FEC carrier in hand. Comparing shapes catches ordering +// mistakes that per-field asserts miss. +std::string shape(const AudioJitterWindow::Result& r) { + std::string s; + for (int i = 0; i < r.count; i++) { + const AudioJitterWindow::Event& e = r.events[i]; + if (!s.empty()) { s += " "; } + s += (e.kind == Kind::Packet ? "P" : "G"); + s += std::to_string(e.seq); + if (e.kind == Kind::Gap && e.fecCarrier != nullptr) { s += "/F"; } + } + return s; +} + +// Every byte of the event's payload equals `tag`. +bool payloadIs(const AudioJitterWindow::Event& e, std::uint8_t tag) { + if (e.data == nullptr || e.len == 0) { return false; } + for (std::size_t i = 0; i < e.len; i++) { + if (e.data[i] != tag) { return false; } + } + return true; +} + +bool carrierIs(const AudioJitterWindow::Event& e, std::uint8_t tag) { + if (e.fecCarrier == nullptr || e.fecCarrierLen == 0) { return false; } + for (std::size_t i = 0; i < e.fecCarrierLen; i++) { + if (e.fecCarrier[i] != tag) { return false; } + } + return true; +} + +} // namespace + +TEST_CASE("in-order stream passes straight through", "[audio][jitter]") { + AudioJitterWindow w; + CHECK_FALSE(w.primed()); + + for (int i = 0; i < 8; i++) { + const auto p = packet(static_cast(0x40 + i)); + const auto r = push(w, static_cast(100 + i), p); + CHECK(r.accept == Accept::Ok); + REQUIRE(r.count == 1); + CHECK(r.events[0].kind == Kind::Packet); + CHECK(static_cast(r.events[0].seq) == 100 + i); + CHECK(payloadIs(r.events[0], static_cast(0x40 + i))); + // The clean path never copies: the event aliases the caller's buffer. + CHECK(r.events[0].data == p.data()); + CHECK(w.buffered() == 0); + } + CHECK(w.primed()); + CHECK(static_cast(w.nextSeq()) == 108); +} + +TEST_CASE("first packet defines the origin whatever its seq", "[audio][jitter]") { + AudioJitterWindow w; + const auto p = packet(0x11); + // There is no such thing as a late or missing frame before the stream + // started, so a mid-range opening seq is simply where it starts. + const auto r = push(w, 40000, p); + CHECK(r.accept == Accept::Ok); + CHECK(shape(r) == "P40000"); + CHECK(static_cast(w.nextSeq()) == 40001); +} + +TEST_CASE("one swapped pair is reordered, not concealed", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0xA0)); + push(w, 11, packet(0xA1)); + + // 13 before 12: held, nothing due yet. This is the whole reason the window + // exists, so it must not emit a gap here. + const auto p13 = packet(0xA3); + const auto held = push(w, 13, p13); + CHECK(held.accept == Accept::Ok); + CHECK(held.count == 0); + CHECK(w.buffered() == 1); + + // 12 arrives: it goes out, and 13 follows it immediately. + const auto p12 = packet(0xA2); + const auto healed = push(w, 12, p12); + CHECK(healed.accept == Accept::Ok); + REQUIRE(shape(healed) == "P12 P13"); + CHECK(payloadIs(healed.events[0], 0xA2)); + CHECK(payloadIs(healed.events[1], 0xA3)); + CHECK(w.buffered() == 0); + CHECK(static_cast(w.nextSeq()) == 14); +} + +TEST_CASE("lost frame is declared with its FEC carrier", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0xB0)); + push(w, 11, packet(0xB1)); + + // 12 is lost. 13 alone proves nothing (it could be a reorder). + const auto p13 = packet(0xB3); + CHECK(push(w, 13, p13).count == 0); + + // 14 is 2 ahead of the frame we want, which is the window's whole + // definition of "lost". The gap names 12 and carries 13, because Opus hides + // a redundant copy of 12 inside 13 and that is what makes recovery + // possible; then 13 and 14 follow in order. + const auto p14 = packet(0xB4); + const auto r = push(w, 14, p14); + CHECK(r.accept == Accept::Ok); + REQUIRE(shape(r) == "G12/F P13 P14"); + CHECK(carrierIs(r.events[0], 0xB3)); + CHECK(r.events[0].data == nullptr); // a gap has no packet of its own + CHECK(payloadIs(r.events[1], 0xB3)); + CHECK(payloadIs(r.events[2], 0xB4)); +} + +TEST_CASE("back-to-back losses conceal blind then recover by FEC", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0xC0)); + push(w, 11, packet(0xC1)); + + // 12 and 13 both lost. When 14 lands it is 2 ahead of 12, so 12 is + // declared; nothing carries it (13 never arrived), so the gap is blind. + const auto first = push(w, 14, packet(0xC4)); + REQUIRE(shape(first) == "G12"); + CHECK(first.events[0].fecCarrier == nullptr); + + // 15 lands: now 13 is 2 behind, and 14 IS in hand to carry it. + const auto second = push(w, 15, packet(0xC5)); + REQUIRE(shape(second) == "G13/F P14 P15"); + CHECK(carrierIs(second.events[0], 0xC4)); +} + +TEST_CASE("frame arriving after its slot played is dropped", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0xD0)); + push(w, 11, packet(0xD1)); + push(w, 13, packet(0xD3)); + const auto flushed = push(w, 14, packet(0xD4)); + CHECK(shape(flushed) == "G12/F P13 P14"); + + // 12 finally shows up, long after it was concealed. Playing it now would be + // an audible jump backwards. + const auto late = push(w, 12, packet(0xD2)); + CHECK(late.accept == Accept::Late); + CHECK(late.count == 0); + + // So would replaying a frame already emitted. + const auto replay = push(w, 13, packet(0xD3)); + CHECK(replay.accept == Accept::Late); + CHECK(replay.count == 0); + + // The stream carries on untouched. + CHECK(shape(push(w, 15, packet(0xD5))) == "P15"); +} + +TEST_CASE("duplicate of a held frame is dropped", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0xE0)); + CHECK(push(w, 12, packet(0xE2)).count == 0); // held, waiting on 11 + + const auto dup = push(w, 12, packet(0xE2)); + CHECK(dup.accept == Accept::Duplicate); + CHECK(dup.count == 0); + CHECK(w.buffered() == 1); + + // The real 11 still heals the hole. + CHECK(shape(push(w, 11, packet(0xE1))) == "P11 P12"); +} + +TEST_CASE("empty, null and oversize packets are refused", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0xF0)); + + const auto p = packet(0xF1); + CHECK(w.push(11, nullptr, p.size()).accept == Accept::Rejected); + CHECK(w.push(11, p.data(), 0).accept == Accept::Rejected); + + const auto huge = + packet(0xF2, static_cast(dish::audio::AUDIO_JITTER_MAX_PACKET_BYTES) + 1); + CHECK(w.push(11, huge.data(), huge.size()).accept == Accept::Rejected); + + // Exactly at the ceiling is fine; the bound is on what the window can be + // made to allocate, not on what is plausible. + const auto atCap = + packet(0xF3, static_cast(dish::audio::AUDIO_JITTER_MAX_PACKET_BYTES)); + const auto ok = w.push(11, atCap.data(), atCap.size()); + CHECK(ok.accept == Accept::Ok); + CHECK(shape(ok) == "P11"); + + // None of the refusals moved the stream on. + CHECK(static_cast(w.nextSeq()) == 12); +} + +TEST_CASE("zero is the frame after 0xFFFF, not 65535 losses", "[audio][jitter]") { + AudioJitterWindow w; + const std::uint16_t start = 0xFFFD; + for (int i = 0; i < 6; i++) { + const auto seq = static_cast(start + i); + const auto r = push(w, seq, packet(static_cast(0x50 + i))); + CHECK(r.accept == Accept::Ok); + REQUIRE(r.count == 1); + CHECK(static_cast(r.events[0].seq) == static_cast(seq)); + } + CHECK(static_cast(w.nextSeq()) == 3); +} + +TEST_CASE("reorder and gap detection keep working across the wrap", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 0xFFFD, packet(0x60)); + push(w, 0xFFFE, packet(0x61)); + + // 0x0000 before 0xFFFF: a one-frame reorder that happens to straddle the + // wrap. Signed u16 arithmetic is what keeps this from reading as a jump. + CHECK(push(w, 0x0000, packet(0x63)).count == 0); + CHECK(shape(push(w, 0xFFFF, packet(0x62))) == "P65535 P0"); + + // And a loss straddling it: 0x0001 lost, 0x0002 held, 0x0003 declares it. + CHECK(push(w, 0x0002, packet(0x65)).count == 0); + const auto gapped = push(w, 0x0003, packet(0x66)); + REQUIRE(shape(gapped) == "G1/F P2 P3"); + CHECK(carrierIs(gapped.events[0], 0x65)); +} + +TEST_CASE("long dropout is concealed only to the cap, then resyncs", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0x70)); + + // Half a second of nothing, then the stream resumes. Concealing all of it + // would inject synthetic audio AND hold the stream that far behind live. + const auto resume = push(w, 36, packet(0x71)); + int gaps = 0; + int packets = 0; + for (int i = 0; i < resume.count; i++) { + if (resume.events[i].kind == Kind::Gap) { + gaps++; + } else { + packets++; + } + } + CHECK(gaps == dish::audio::AUDIO_JITTER_MAX_CONCEAL_FRAMES); + CHECK(packets == 1); + CHECK(shape(resume) == "G11 G12 P36"); + // Resynchronised onto the new audio, not still grinding through the hole. + CHECK(static_cast(w.nextSeq()) == 37); + CHECK(w.buffered() == 0); + + // A frame from inside the skipped range is history now. + CHECK(push(w, 20, packet(0x72)).accept == Accept::Late); +} + +TEST_CASE("concealment budget refills once real audio gets through", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 10, packet(0x80)); + // Burn the budget on one dropout... + CHECK(shape(push(w, 30, packet(0x81))) == "G11 G12 P30"); + // ...then a clean frame, then another dropout: the second one gets its own + // full allowance rather than inheriting a spent counter. + CHECK(shape(push(w, 31, packet(0x82))) == "P31"); + CHECK(shape(push(w, 60, packet(0x83))) == "G32 G33 P60"); +} + +TEST_CASE("at most one frame is ever left waiting", "[audio][jitter]") { + AudioJitterWindow w; + // A deliberately hostile order: reorders, repeats, jumps forward and back. + const std::uint16_t order[] = {100, 102, 101, 105, 103, 104, 104, 110, 108, + 111, 112, 109, 113, 65535, 116, 115, 117}; + for (std::uint16_t seq : order) { + push(w, seq, packet(0x90)); + // The slots_ array is sized by this invariant; if it ever stops + // holding, the window would be writing past its storage. + CHECK(w.buffered() < dish::audio::AUDIO_JITTER_WINDOW_FRAMES); + } +} + +TEST_CASE("no arrival order overruns the result array", "[audio][jitter]") { + AudioJitterWindow w; + // Worst shape the window can produce: something held, then a jump big + // enough to burn the concealment budget and resync in one push. + push(w, 10, packet(0xA0)); + push(w, 11, packet(0xA1)); + CHECK(push(w, 13, packet(0xA3)).count == 0); // 12 held back + const auto burst = push(w, 900, packet(0xA9)); + CHECK(burst.count <= dish::audio::AUDIO_JITTER_MAX_EVENTS_PER_PUSH); + CHECK(burst.count > 0); +} + +TEST_CASE("reset forgets the old pad's stream entirely", "[audio][jitter]") { + AudioJitterWindow w; + push(w, 500, packet(0xB0)); + push(w, 502, packet(0xB2)); // left waiting + CHECK(w.buffered() == 1); + + w.reset(); + CHECK_FALSE(w.primed()); + CHECK(w.buffered() == 0); + + // A seq that would have been ancient history before the reset is now simply + // where the new stream begins: a re-PUT restarts the far end's numbering and + // must not have its first second thrown away as "late". + const auto r = push(w, 3, packet(0xB3)); + CHECK(r.accept == Accept::Ok); + CHECK(shape(r) == "P3"); +} + +TEST_CASE("window constants match the contract", "[audio][jitter]") { + // Pinned here because they are a cross-repo agreement, not a tuning knob: + // satellite's window is the same two frames with the same conceal cap, and + // a change on one side alone would desynchronise the two ends of a stream. + CHECK(dish::audio::AUDIO_JITTER_WINDOW_FRAMES == 2); + CHECK(dish::audio::AUDIO_JITTER_MAX_CONCEAL_FRAMES == 2); + CHECK(dish::audio::AUDIO_JITTER_MAX_PACKET_BYTES == 1500); + CHECK(dish::audio::AUDIO_JITTER_MAX_EVENTS_PER_PUSH == 6); +} diff --git a/tests/test_audio_preference_repository.cpp b/tests/test_audio_preference_repository.cpp new file mode 100644 index 0000000..e7d5da6 --- /dev/null +++ b/tests/test_audio_preference_repository.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// get() on an unwritten slot answers nullopt rather than a default boolean: the +// store layer above owns the per-direction defaults. One class serves both +// directions keyed apart, so isolation between the two lists is asserted here. + +#include "repository/AudioPreferenceRepository.h" + +#include "QSettingsFixture.h" +#include "RepositoryContract.h" + +#include + +#include + +using dish::repository::AudioPreference; +using dish::repository::AudioPreferenceRepository; +using dish::test::makeSharedSettings; + +TEST_CASE("AudioPreferenceRepository satisfies the repository contract", "[repository][audio]") { + dish::test::runRepositoryContract( + [] { + return std::make_unique(QStringLiteral("mic_preferences"), + makeSharedSettings()); + }, + [](int i) { return QStringLiteral("slot-%1").arg(i); }, + [](const QString& k) { return AudioPreference{k, (k.size() % 2) == 0}; }); +} + +TEST_CASE("audio toggles round-trip with durability", "[audio-pref]") { + auto store = makeSharedSettings(); + { + AudioPreferenceRepository repo(QStringLiteral("mic_preferences"), store); + repo.put(AudioPreference{"sdl:1", true}); + repo.put(AudioPreference{"9", false}); + } + // A fresh repo over the same store rules out an in-memory cache. + AudioPreferenceRepository repo2(QStringLiteral("mic_preferences"), store); + REQUIRE(repo2.get("sdl:1").has_value()); + CHECK(repo2.get("sdl:1")->enabled == true); + REQUIRE(repo2.get("9").has_value()); + CHECK(repo2.get("9")->enabled == false); +} + +TEST_CASE("get on a slot that was never written returns nullopt, not a default", "[audio-pref]") { + AudioPreferenceRepository repo(QStringLiteral("speaker_preferences"), makeSharedSettings()); + CHECK_FALSE(repo.get("never-written").has_value()); +} + +TEST_CASE("corrupt audio JSON falls back to empty, does not crash", "[audio-pref]") { + auto store = makeSharedSettings(); + store->setValue(QStringLiteral("mic_preferences"), QByteArrayLiteral("{not valid json")); + AudioPreferenceRepository repo(QStringLiteral("mic_preferences"), store); + CHECK(repo.all().empty()); + CHECK_FALSE(repo.get("anything").has_value()); +} + +TEST_CASE("the two direction keys are fully isolated", "[audio-pref]") { + auto store = makeSharedSettings(); + AudioPreferenceRepository mic(QStringLiteral("mic_preferences"), store); + AudioPreferenceRepository speaker(QStringLiteral("speaker_preferences"), store); + + mic.put(AudioPreference{"9", true}); + CHECK_FALSE(speaker.get("9").has_value()); + + speaker.put(AudioPreference{"9", false}); + speaker.clear(); + // Clearing one direction leaves the other's list intact. + REQUIRE(mic.get("9").has_value()); + CHECK(mic.get("9")->enabled == true); + CHECK_FALSE(speaker.get("9").has_value()); +} + +TEST_CASE("audio put with the same slot replaces in place, list never grows", "[audio-pref]") { + AudioPreferenceRepository repo(QStringLiteral("mic_preferences"), makeSharedSettings()); + repo.put(AudioPreference{"sdl:1", true}); + repo.put(AudioPreference{"sdl:1", false}); + repo.put(AudioPreference{"sdl:1", true}); + CHECK(repo.all().size() == 1); + CHECK(repo.get("sdl:1")->enabled == true); +} diff --git a/tests/test_capability_solver.cpp b/tests/test_capability_solver.cpp index 0be8eeb..869a465 100644 --- a/tests/test_capability_solver.cpp +++ b/tests/test_capability_solver.cpp @@ -43,12 +43,23 @@ CapabilityInputs everythingCarries() { in.padPlayerLeds = true; in.typeTriggerEffects = true; in.typePlayerLeds = true; + // Controller audio: the pad has routes (a Wave-2 fact, forced true here so + // the fold is exercised ahead of the routes existing), the type is a Sony + // composite and the probe said the host carries both directions. + in.padMic = true; + in.padSpeaker = true; + in.typeMic = true; + in.typeSpeaker = true; + in.hostMic = true; + in.hostSpeaker = true; in.hostResolved = true; in.hostIsBluetooth = false; in.hostMouseControl = true; in.hostRumble = true; in.userMotionOn = true; in.userRumbleOn = true; + in.userMicOn = true; + in.userSpeakerOn = true; in.userTouchpadMode = 1; // pad return in; } @@ -63,13 +74,14 @@ CapabilityRow rowFor(const std::vector& rows, CapFeature f) { } // namespace -TEST_CASE("capability solver: the nine features come back in the fixed render order", +TEST_CASE("capability solver: the eleven features come back in the fixed render order", "[capability][solver]") { // Declaration order is the render order both surfaces use, so a row inserted - // in the middle would silently reshuffle two UI tables. The two protocol-2 - // actuators are appended after the lightbar for that reason. + // in the middle would silently reshuffle two UI tables. The protocol-2 + // additions are appended after the lightbar for that reason, the audio pair + // last of all. const auto rows = solveCapabilities(everythingCarries()); - REQUIRE(rows.size() == 9); + REQUIRE(rows.size() == 11); REQUIRE(rows[0].feature == CapFeature::Gamepad); REQUIRE(rows[1].feature == CapFeature::Triggers); REQUIRE(rows[2].feature == CapFeature::Motion); @@ -79,6 +91,8 @@ TEST_CASE("capability solver: the nine features come back in the fixed render or REQUIRE(rows[6].feature == CapFeature::Lightbar); REQUIRE(rows[7].feature == CapFeature::TriggerEffects); REQUIRE(rows[8].feature == CapFeature::PlayerLeds); + REQUIRE(rows[9].feature == CapFeature::Mic); + REQUIRE(rows[10].feature == CapFeature::Speaker); } TEST_CASE("capability solver: everything carrying reads Available and blames nobody", @@ -236,7 +250,8 @@ TEST_CASE("capability solver: a Bluetooth host carries none of the new actuators in.linkDirect = true; in.hostIsBluetooth = true; const auto rows = solveCapabilities(in); - for (const auto f : {CapFeature::TriggerEffects, CapFeature::PlayerLeds}) { + for (const auto f : {CapFeature::TriggerEffects, CapFeature::PlayerLeds, CapFeature::Mic, + CapFeature::Speaker}) { const auto row = rowFor(rows, f); INFO("feature " << static_cast(f)); REQUIRE(row.verdict == CapVerdict::Unavailable); @@ -406,3 +421,121 @@ TEST_CASE("capability solver: gamepad and triggers always carry on a resolved ca REQUIRE(rumble.verdict == CapVerdict::Unavailable); REQUIRE(rumble.failingLayer == CapLayer::Input); } + +// ── Controller audio ───────────────────────────────────────────────────────── + +TEST_CASE("capability solver: audio carries when every layer says yes, on either path", + "[capability][solver][audio]") { + // The streams ride the pad's own USB-audio endpoints — a separate interface + // from the HID path — so unlike the trigger/LED pair the Link layer never + // refuses them and Standard vs Direct changes nothing. + auto in = everythingCarries(); + for (const bool direct : {false, true}) { + in.linkDirect = direct; + const auto rows = solveCapabilities(in); + for (const auto f : {CapFeature::Mic, CapFeature::Speaker}) { + INFO("direct " << direct << " feature " << static_cast(f)); + const auto row = rowFor(rows, f); + REQUIRE(row.verdict == CapVerdict::Available); + REQUIRE(row.linkOk); + } + } +} + +TEST_CASE("capability solver: the audio fold matrix names the first refusing layer", + "[capability][solver][audio]") { + auto in = everythingCarries(); + + SECTION("no route at the pad -> Input") { + in.padMic = false; + in.padSpeaker = false; + const auto rows = solveCapabilities(in); + for (const auto f : {CapFeature::Mic, CapFeature::Speaker}) { + const auto row = rowFor(rows, f); + REQUIRE(row.verdict == CapVerdict::Unavailable); + REQUIRE(row.failingLayer == CapLayer::Input); + } + } + SECTION("a type without audio endpoints -> Type") { + // An Xbox 360 emulation on a DualSense: the materialized pad has no + // audio function however good the physical hardware is. + in.typeMic = false; + in.typeSpeaker = false; + const auto rows = solveCapabilities(in); + for (const auto f : {CapFeature::Mic, CapFeature::Speaker}) { + const auto row = rowFor(rows, f); + REQUIRE(row.verdict == CapVerdict::Unavailable); + REQUIRE(row.failingLayer == CapLayer::Type); + } + } + SECTION("a host that switched audio off -> Host") { + in.hostMic = false; + in.hostSpeaker = false; + const auto rows = solveCapabilities(in); + for (const auto f : {CapFeature::Mic, CapFeature::Speaker}) { + const auto row = rowFor(rows, f); + REQUIRE(row.verdict == CapVerdict::Unavailable); + REQUIRE(row.failingLayer == CapLayer::Host); + } + } + SECTION("the directions are independent at every layer") { + in.hostMic = false; // speaker-only host + const auto rows = solveCapabilities(in); + REQUIRE(rowFor(rows, CapFeature::Mic).verdict == CapVerdict::Unavailable); + REQUIRE(rowFor(rows, CapFeature::Speaker).verdict == CapVerdict::Available); + } +} + +TEST_CASE("capability solver: an unprobed host refuses audio rather than waiting", + "[capability][solver][audio]") { + // hostMic/hostSpeaker have no Pending state on purpose: audio is opt-in, + // so before a probe lands the honest verdict is "not carried" — and it must + // not read as the uniform Pending the unresolved-catalog case shows. + auto in = everythingCarries(); + in.hostMic = false; + in.hostSpeaker = false; + const auto rows = solveCapabilities(in); + REQUIRE(rowFor(rows, CapFeature::Mic).verdict == CapVerdict::Unavailable); + REQUIRE(rowFor(rows, CapFeature::Speaker).verdict == CapVerdict::Unavailable); + REQUIRE(rowFor(rows, CapFeature::Motion).verdict == CapVerdict::Available); +} + +TEST_CASE("capability solver: the audio user switches read Off, never Unavailable", + "[capability][solver][audio]") { + auto in = everythingCarries(); + in.userMicOn = false; + in.userSpeakerOn = false; + const auto rows = solveCapabilities(in); + for (const auto f : {CapFeature::Mic, CapFeature::Speaker}) { + const auto row = rowFor(rows, f); + REQUIRE(row.verdict == CapVerdict::Off); + REQUIRE_FALSE(row.hasFailingLayer); + REQUIRE(row.inOk); + REQUIRE(row.hostOk); + } +} + +TEST_CASE("capability solver: default inputs advertise no audio anywhere", + "[capability][solver][audio]") { + // The Wave-1 end-to-end truth: the pad-route seam answers false for every + // slot until the audio engines land, so with everything else carrying, the + // audio rows still refuse at Input and nothing ever advertises a cap. + auto in = everythingCarries(); + in.padMic = CapabilityInputs{}.padMic; // the real default, + in.padSpeaker = CapabilityInputs{}.padSpeaker; // not a test's idea of it + const auto rows = solveCapabilities(in); + for (const auto f : {CapFeature::Mic, CapFeature::Speaker}) { + const auto row = rowFor(rows, f); + REQUIRE(row.verdict == CapVerdict::Unavailable); + REQUIRE(row.failingLayer == CapLayer::Input); + } + // And the defaults themselves are pinned: flipping them is Wave 2's move, + // made deliberately, not by a refactor. + CHECK_FALSE(CapabilityInputs{}.padMic); + CHECK_FALSE(CapabilityInputs{}.padSpeaker); + CHECK_FALSE(CapabilityInputs{}.hostMic); + CHECK_FALSE(CapabilityInputs{}.hostSpeaker); + // The user defaults mirror the stores': mic off (privacy), speaker on. + CHECK_FALSE(CapabilityInputs{}.userMicOn); + CHECK(CapabilityInputs{}.userSpeakerOn); +} diff --git a/tests/test_feedback_routing.cpp b/tests/test_feedback_routing.cpp index c6fcbe5..6b98e3a 100644 --- a/tests/test_feedback_routing.cpp +++ b/tests/test_feedback_routing.cpp @@ -17,12 +17,15 @@ using dish::reducer::FeedbackKind; using dish::reducer::FeedbackTarget; using dish::reducer::resolveFeedbackTarget; using dish::reducer::slotCarriesFeedback; +using dish::reducer::slotCarriesMicCapture; +using dish::reducer::slotCarriesSpeakerPlayout; using dish::reducer::SlotFeedbackInputs; namespace { const std::vector kAllKinds{FeedbackKind::Rumble, FeedbackKind::Lightbar, - FeedbackKind::TriggerEffects, FeedbackKind::PlayerLeds}; + FeedbackKind::TriggerEffects, FeedbackKind::PlayerLeds, + FeedbackKind::MicLed}; // A DualSense on the Standard (SDL) path: every surface in hardware. SlotFeedbackInputs standardDualSense() { @@ -32,6 +35,7 @@ SlotFeedbackInputs standardDualSense() { in.padLightbar = true; in.padTriggerEffects = true; in.padPlayerLeds = true; + in.padMicLed = true; return in; } @@ -63,6 +67,8 @@ TEST_CASE("the Standard path carries rumble and the lightbar and nothing else", CHECK(resolveFeedbackTarget(in, FeedbackKind::Lightbar) == FeedbackTarget::Standard); CHECK(resolveFeedbackTarget(in, FeedbackKind::TriggerEffects) == FeedbackTarget::None); CHECK(resolveFeedbackTarget(in, FeedbackKind::PlayerLeds) == FeedbackTarget::None); + // The mic-mute lamp is with them: SDL has no call for it either. + CHECK(resolveFeedbackTarget(in, FeedbackKind::MicLed) == FeedbackTarget::None); } TEST_CASE("a synthetic slot whose claim is gone carries nothing", "[feedback][routing]") { @@ -90,6 +96,8 @@ TEST_CASE("hardware the pad lacks is never advertised or dispatched", "[feedback CHECK(resolveFeedbackTarget(in, FeedbackKind::Lightbar) == FeedbackTarget::DirectUsb); CHECK(resolveFeedbackTarget(in, FeedbackKind::TriggerEffects) == FeedbackTarget::None); CHECK(resolveFeedbackTarget(in, FeedbackKind::PlayerLeds) == FeedbackTarget::None); + // No DS4 has a mute lamp either. + CHECK(resolveFeedbackTarget(in, FeedbackKind::MicLed) == FeedbackTarget::None); } TEST_CASE("a Switch Pro on the Direct path drives its player lights but no colour", @@ -126,8 +134,8 @@ TEST_CASE("a live claim flag on a Standard slot changes nothing", "[feedback][ro TEST_CASE("advertising and dispatching are the same answer", "[feedback][routing]") { // The invariant the whole file exists for, over every combination of the - // five inputs: slotCarriesFeedback is true exactly when a target exists. - for (int bits = 0; bits < 32; ++bits) { + // six inputs: slotCarriesFeedback is true exactly when a target exists. + for (int bits = 0; bits < 64; ++bits) { SlotFeedbackInputs in; in.usbDirect = (bits & 1) != 0; in.directClaimLive = (bits & 2) != 0; @@ -135,6 +143,7 @@ TEST_CASE("advertising and dispatching are the same answer", "[feedback][routing in.padLightbar = (bits & 8) != 0; in.padTriggerEffects = (bits & 16) != 0; in.padPlayerLeds = (bits & 16) != 0; + in.padMicLed = (bits & 32) != 0; for (const auto kind : kAllKinds) { INFO("bits " << bits << " kind " << static_cast(kind)); const bool carries = slotCarriesFeedback(in, kind); @@ -143,3 +152,64 @@ TEST_CASE("advertising and dispatching are the same answer", "[feedback][routing } } } + +// ── The mic-mute lamp and the audio routes ─────────────────────────────────── + +TEST_CASE("the mute lamp routes like the other Direct-only actuators", "[feedback][routing]") { + // A DualSense with a lamp: Direct-and-live lands it, Standard cannot (no + // SDL call), and a dead claim carries nothing — exactly the trigger/LED + // matrix, because a delivered MIC_LED is a write into the same OUT report. + SlotFeedbackInputs in = standardDualSense(); + CHECK(resolveFeedbackTarget(in, FeedbackKind::MicLed) == FeedbackTarget::None); + + in.usbDirect = true; + in.directClaimLive = true; + CHECK(resolveFeedbackTarget(in, FeedbackKind::MicLed) == FeedbackTarget::DirectUsb); + + in.directClaimLive = false; + CHECK(resolveFeedbackTarget(in, FeedbackKind::MicLed) == FeedbackTarget::None); + + in.directClaimLive = true; + in.padMicLed = false; // a family without the lamp + CHECK(resolveFeedbackTarget(in, FeedbackKind::MicLed) == FeedbackTarget::None); +} + +TEST_CASE("the audio caps follow the routes, not the HID path", "[feedback][routing][audio]") { + // Mic capture and speaker playout ride the pad's own USB-audio endpoints, + // a separate interface from the raw-HID claim, so the path bits and the + // claim state must not move the answer in either direction. + SlotFeedbackInputs in; + in.padMicRoute = true; + in.padSpeakerRoute = true; + for (const bool direct : {false, true}) { + for (const bool live : {false, true}) { + in.usbDirect = direct; + in.directClaimLive = live; + INFO("direct " << direct << " live " << live); + CHECK(slotCarriesMicCapture(in)); + CHECK(slotCarriesSpeakerPlayout(in)); + } + } + // The directions are independent. + in.padMicRoute = false; + CHECK_FALSE(slotCarriesMicCapture(in)); + CHECK(slotCarriesSpeakerPlayout(in)); +} + +TEST_CASE("the default inputs carry no audio and no lamp", "[feedback][routing][audio]") { + // The Wave-1 invariant: the routes and the lamp default false, so with + // nothing flipped a slot advertises neither audio cap and a delivered + // MIC_LED resolves to nowhere. Wave 2 flips these in AppModel's + // feedbackInputs(); this pin is what makes that an explicit move. + const SlotFeedbackInputs defaults; + CHECK_FALSE(slotCarriesMicCapture(defaults)); + CHECK_FALSE(slotCarriesSpeakerPlayout(defaults)); + CHECK(resolveFeedbackTarget(defaults, FeedbackKind::MicLed) == FeedbackTarget::None); + + SlotFeedbackInputs liveDirect; + liveDirect.usbDirect = true; + liveDirect.directClaimLive = true; + CHECK_FALSE(slotCarriesMicCapture(liveDirect)); + CHECK_FALSE(slotCarriesSpeakerPlayout(liveDirect)); + CHECK(resolveFeedbackTarget(liveDirect, FeedbackKind::MicLed) == FeedbackTarget::None); +} diff --git a/tests/test_gamepad_button_layouts.cpp b/tests/test_gamepad_button_layouts.cpp index c817cc6..e4355bf 100644 --- a/tests/test_gamepad_button_layouts.cpp +++ b/tests/test_gamepad_button_layouts.cpp @@ -96,10 +96,35 @@ TEST_CASE("xusb zero is identity", "[input][layout]") { } TEST_CASE("xusb unknown bits are dropped", "[input][layout]") { - // 0x0800 is reserved/unused in XUSB. + // 0x0800 was the one unassigned XUSB value; protocol 2 spends it on the + // DualSense mic-mute state, and it must STAY dropped here. REQUIRE(layout::hidButtonsOf(layout::xusbToHid(0x0800)) == 0); } +TEST_CASE("the mic-mute bit is the one free XUSB value and never leaks", "[input][layout]") { + // The constant is a cross-repo wire agreement (satellite WBUTTON_MIC_MUTE). + REQUIRE(layout::kXusbMicMute == 0x0800); + // It sits in the gap between GUIDE and A, colliding with nothing. + constexpr int kAssigned = + layout::kXusbDpadMask | layout::kXusbStart | layout::kXusbBack | layout::kXusbLeftThumb | + layout::kXusbRightThumb | layout::kXusbLeftShoulder | layout::kXusbRightShoulder | + layout::kXusbGuide | layout::kXusbA | layout::kXusbB | layout::kXusbX | layout::kXusbY; + REQUIRE((layout::kXusbMicMute & kAssigned) == 0); + + // xusbToHid drops it even in company: no HID gamepad descriptor has a mute + // button, and Moonlight must never see it. + const int packed = + layout::xusbToHid(layout::kXusbMicMute | layout::kXusbA | layout::kXusbGuide); + REQUIRE(layout::hidButtonsOf(packed) == (BTN_A | BTN_HOME)); + REQUIRE(layout::hidHatOf(packed) == HAT_NONE); + + // ...and hidToXusb can never produce it, from any button set or hat, so the + // bit only appears through an explicit fold (Wave 2's mute state). + for (int hat = HAT_NONE; hat <= HAT_NW; ++hat) { + REQUIRE((layout::hidToXusb(0xFFFF, hat) & layout::kXusbMicMute) == 0); + } +} + TEST_CASE("HID BTN_A maps to xusb A", "[input][layout]") { assertXusb(BTN_A, 0, 0x1000); } TEST_CASE("HID BTN_B maps to xusb B", "[input][layout]") { assertXusb(BTN_B, 0, 0x2000); } TEST_CASE("HID BTN_X maps to xusb X", "[input][layout]") { assertXusb(BTN_X, 0, 0x4000); } diff --git a/tests/test_hid_transport.cpp b/tests/test_hid_transport.cpp index 9fba190..1b85a41 100644 --- a/tests/test_hid_transport.cpp +++ b/tests/test_hid_transport.cpp @@ -131,7 +131,11 @@ TEST_CASE("hidraw uevent: an identity that cannot be read is skipped, never gues CHECK_FALSE(parseHidIds("HID_IDENT=0003:0000054C:00000CE6\n").has_value()); } -TEST_CASE("hidraw uevent: HID_NAME is what an unmodelled pad is named", "[input][hid-transport]") { +TEST_CASE("hidraw uevent: HID_NAME is the unmodelled pad's fallback name", + "[input][hid-transport]") { + // Fallback, not first choice: enumeration prefers the USB `product` + // attribute (the string the audio stack also names endpoints with) and + // reads HID_NAME only when that is absent. const std::string_view uevent = "DRIVER=playstation\n" "HID_ID=0003:0000054C:00000CE6\n" "HID_NAME=Sony Interactive Entertainment DualSense\n" diff --git a/tests/test_host_audio_verdict.cpp b/tests/test_host_audio_verdict.cpp new file mode 100644 index 0000000..1d04052 --- /dev/null +++ b/tests/test_host_audio_verdict.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The host controller-audio fold (core/reducer/HostAudioVerdict.h): a present +// `controllerAudio` block wins per direction with `enabled` re-ANDed, an absent +// one falls back to the per-backend `audio` flag, and a satellite predating +// both reads as no audio — which is the truth there. Includes the JSON edge +// cases through the CapabilitiesDto parse, because absence-versus-false is the +// whole trick of this document. + +#include "Models/Models.h" +#include "core/reducer/HostAudioVerdict.h" + +#include + +#include +#include + +using dish::models::CapabilitiesDto; +using dish::reducer::HostAudioFacts; +using dish::reducer::hostAudioFactsFrom; +using dish::reducer::resolveHostControllerAudio; + +namespace { + +CapabilitiesDto parse(const char* json) { + const auto doc = QJsonDocument::fromJson(QByteArray(json)); + REQUIRE(doc.isObject()); + return CapabilitiesDto::fromJson(doc.object()); +} + +} // namespace + +TEST_CASE("a present block answers per direction", "[audio][hostverdict]") { + HostAudioFacts f; + f.blockPresent = true; + f.enabled = true; + f.mic = true; + f.speaker = false; + const auto v = resolveHostControllerAudio(f); + CHECK(v.mic); + CHECK_FALSE(v.speaker); + + f.mic = false; + f.speaker = true; + const auto flipped = resolveHostControllerAudio(f); + CHECK_FALSE(flipped.mic); + CHECK(flipped.speaker); +} + +TEST_CASE("enabled=false overrides both directions however they read", "[audio][hostverdict]") { + // The host folds `enabled` in server-side, but a stale direction switch + // left true under a disabled master must not advertise an endpoint that + // will never be plugged: the client re-ANDs rather than trusts. + HostAudioFacts f; + f.blockPresent = true; + f.enabled = false; + f.mic = true; + f.speaker = true; + f.anyBackendAudio = true; // and the fallback must not leak past the block + const auto v = resolveHostControllerAudio(f); + CHECK_FALSE(v.mic); + CHECK_FALSE(v.speaker); +} + +TEST_CASE("a present all-false block beats a positive backend flag", "[audio][hostverdict]") { + // A block that says off IS the verdict; the per-backend flag is only the + // fallback for satellites predating the block. + HostAudioFacts f; + f.blockPresent = true; + f.anyBackendAudio = true; + const auto v = resolveHostControllerAudio(f); + CHECK_FALSE(v.mic); + CHECK_FALSE(v.speaker); +} + +TEST_CASE("an absent block falls back to the backend flag, both directions at once", + "[audio][hostverdict]") { + HostAudioFacts f; + f.blockPresent = false; + f.anyBackendAudio = true; + const auto v = resolveHostControllerAudio(f); + CHECK(v.mic); + CHECK(v.speaker); + + f.anyBackendAudio = false; + const auto off = resolveHostControllerAudio(f); + CHECK_FALSE(off.mic); + CHECK_FALSE(off.speaker); +} + +TEST_CASE("the backend fallback needs available AND audio on one entry", "[audio][hostverdict]") { + CapabilitiesDto caps; + dish::models::CapabilitiesBackendDto vigem; + vigem.id = QStringLiteral("vigem"); + vigem.available = true; + vigem.audio = false; + dish::models::CapabilitiesBackendDto maestroDown; + maestroDown.id = QStringLiteral("hidmaestro"); + maestroDown.available = false; // an unavailable backend materializes nothing + maestroDown.audio = true; + caps.backends = {vigem, maestroDown}; + CHECK_FALSE(hostAudioFactsFrom(caps).anyBackendAudio); + + dish::models::CapabilitiesBackendDto maestroUp = maestroDown; + maestroUp.available = true; + caps.backends = {vigem, maestroUp}; + CHECK(hostAudioFactsFrom(caps).anyBackendAudio); + const auto v = resolveHostControllerAudio(caps); + CHECK(v.mic); + CHECK(v.speaker); +} + +// ── Through the JSON parse ─────────────────────────────────────────────────── + +TEST_CASE("an absent controllerAudio block parses as unknown, not off", "[audio][hostverdict]") { + const auto caps = parse(R"({ + "protocolVersion": 2, + "backends": [ + {"id": "hidmaestro", "supported": true, "available": true, "audio": true} + ] + })"); + CHECK_FALSE(caps.hasControllerAudioBlock); + // The fallback carries it: an older satellite that still speaks audio. + const auto v = resolveHostControllerAudio(caps); + CHECK(v.mic); + CHECK(v.speaker); +} + +TEST_CASE("a satellite predating both mechanisms reads as no audio", "[audio][hostverdict]") { + const auto caps = parse(R"({"protocolVersion": 1, "serverVersion": "0.9.0"})"); + CHECK_FALSE(caps.hasControllerAudioBlock); + CHECK(caps.backends.isEmpty()); + const auto v = resolveHostControllerAudio(caps); + CHECK_FALSE(v.mic); + CHECK_FALSE(v.speaker); +} + +TEST_CASE("a partial block reads missing fields as false", "[audio][hostverdict]") { + // The opt-out the rest of the document takes: a host that stopped sending + // a field is a host we no longer understand, and offering a microphone it + // will not plug costs the user a prompt for nothing. + const auto caps = parse(R"({"controllerAudio": {"enabled": true}})"); + CHECK(caps.hasControllerAudioBlock); + CHECK(caps.controllerAudioEnabled); + CHECK_FALSE(caps.controllerAudioMic); + CHECK_FALSE(caps.controllerAudioSpeaker); + const auto v = resolveHostControllerAudio(caps); + CHECK_FALSE(v.mic); + CHECK_FALSE(v.speaker); +} + +TEST_CASE("the full block parses and folds per direction", "[audio][hostverdict]") { + const auto caps = parse(R"({ + "controllerAudio": {"enabled": true, "mic": true, "speaker": false}, + "backends": [ + {"id": "hidmaestro", "supported": true, "available": true, "audio": true} + ] + })"); + CHECK(caps.hasControllerAudioBlock); + const auto v = resolveHostControllerAudio(caps); + CHECK(v.mic); + CHECK_FALSE(v.speaker); // the block wins over the coarser backend flag +} + +TEST_CASE("backend entries parse audio absent as false and skip junk", "[audio][hostverdict]") { + const auto caps = parse(R"({ + "backends": [ + {"id": "vigem", "supported": true, "available": true}, + "not-an-object", + {"id": "hidmaestro", "supported": true, "available": true, "audio": true} + ] + })"); + REQUIRE(caps.backends.size() == 2); + CHECK(caps.backends[0].id == QStringLiteral("vigem")); + CHECK_FALSE(caps.backends[0].audio); + CHECK(caps.backends[1].audio); +} diff --git a/tests/test_mic_capture_engine.cpp b/tests/test_mic_capture_engine.cpp new file mode 100644 index 0000000..73966a4 --- /dev/null +++ b/tests/test_mic_capture_engine.cpp @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// MicCaptureEngine (source/audio/MicCaptureEngine.h) against the fake gateway +// and a scriptable encoder. The load-bearing claims: exact 20 ms windows in, +// one seq per window OUT INCLUDING failed encodes, and — the privacy +// invariant — a slot reconciled away has its device CLOSED and its callback +// disabled, so not one packet leaves after the decision. + +#include "source/audio/MicCaptureEngine.h" + +#include "FakeAudioGateway.h" +#include "core/model/Protocol.h" + +#include + +#include +#include +#include + +using dish::source::audio::MicCaptureEngine; +using dish::source::audio::MicCaptureTarget; +using dish::test::FakeAudioGateway; + +namespace proto = dish::proto; + +namespace { + +// Encodes every window to a fixed marker packet; window sizes and call counts +// are recorded, and `failEvery` makes the Nth encode fail to pin the +// seq-advances-anyway rule. +class FakeEncoder : public dish::audio::IAudioEncoder { + public: + struct State { + std::vector windows; + int failEvery = 0; // 0 = never fail + }; + explicit FakeEncoder(std::shared_ptr state) : state_(std::move(state)) {} + + std::size_t encode(const std::int16_t* pcm, std::size_t frames, std::uint8_t* out, + std::size_t maxOut) override { + if (pcm == nullptr || out == nullptr || maxOut < 3) { return 0; } + state_->windows.push_back(frames); + const auto n = static_cast(state_->windows.size()); + if (state_->failEvery != 0 && n % state_->failEvery == 0) { return 0; } + out[0] = 0xE0; + out[1] = static_cast(n); + out[2] = pcm[0] != 0 ? 0x01 : 0x00; + return 3; + } + + private: + std::shared_ptr state_; +}; + +struct Sent { + std::uint16_t seq; + std::vector packet; +}; + +struct Harness { + FakeAudioGateway gateway; + std::shared_ptr encoderState = std::make_shared(); + MicCaptureEngine engine{&gateway, + [this] { return std::make_unique(encoderState); }}; + std::vector sent; + + MicCaptureTarget target(const std::string& slotId, const std::string& device) { + MicCaptureTarget t; + t.slotId = slotId; + t.captureDeviceName = device; + t.send = [this](std::uint16_t seq, const std::uint8_t* opus, std::size_t len) { + sent.push_back(Sent{seq, std::vector(opus, opus + len)}); + return true; + }; + return t; + } + + void pump(const std::string& device, std::size_t samples) { + auto* capture = gateway.captureFor(device); + REQUIRE(capture != nullptr); + const std::vector pcm(samples, 7); + capture->onSamples(pcm.data(), pcm.size()); + } +}; + +constexpr std::size_t kFrame = static_cast(proto::kAudioFrameSamples); + +} // namespace + +TEST_CASE("a reconciled target opens its device and streams windowed packets", + "[audio][micengine]") { + Harness h; + h.engine.reconcile({h.target("slot", "Headset Microphone (Wireless Controller)")}); + CHECK(h.engine.capturingFor("slot")); + CHECK(h.gateway.openCaptureCount() == 1); + + // One and a half windows: exactly one packet, the tail carried. + h.pump("Headset Microphone (Wireless Controller)", kFrame + kFrame / 2); + REQUIRE(h.sent.size() == 1U); + CHECK(h.sent[0].seq == 0); + CHECK(h.sent[0].packet.size() == 3U); + REQUIRE(h.encoderState->windows.size() == 1U); + CHECK(h.encoderState->windows[0] == kFrame); // never a partial window + + // The carried half completes on the next chunk; seq advances. + h.pump("Headset Microphone (Wireless Controller)", kFrame / 2); + REQUIRE(h.sent.size() == 2U); + CHECK(h.sent[1].seq == 1); +} + +TEST_CASE("a failed encode spends its seq without sending", "[audio][micengine]") { + // That 20 ms really happened: skipping the seq would play the stream short + // at the receiver and drift it against the pad's clock; the gap is what + // the satellite's concealment is for. + Harness h; + h.encoderState->failEvery = 2; // windows 2, 4, ... fail + h.engine.reconcile({h.target("slot", "mic")}); + h.pump("mic", kFrame * 4); + REQUIRE(h.sent.size() == 2U); + CHECK(h.sent[0].seq == 0); + CHECK(h.sent[1].seq == 2); // seq 1 and 3 were spent by the failed encodes + CHECK(h.encoderState->windows.size() == 4U); +} + +TEST_CASE("reconciling a slot away closes the device and silences it", "[audio][micengine]") { + // THE PRIVACY INVARIANT, at the engine's edge. Mute, a toggle, a dead + // session and a lost route all arrive here the same way: the target is + // gone from the list, and gone must mean a closed device AND zero sends — + // even for a callback the platform had already handed samples to. + Harness h; + h.engine.reconcile({h.target("slot", "mic")}); + auto* capture = h.gateway.captureFor("mic"); + REQUIRE(capture != nullptr); + h.pump("mic", kFrame); + REQUIRE(h.sent.size() == 1U); + + // Keep the callback the fake captured, as a real audio thread would. + const auto lateCallback = capture->onSamples; + h.engine.reconcile({}); + CHECK_FALSE(h.engine.capturingFor("slot")); + CHECK(h.gateway.openCaptureCount() == 0); + CHECK(h.gateway.closes == 1); + + // A window that raced the close is dropped by the enabled flag, not sent. + const std::vector pcm(kFrame, 7); + lateCallback(pcm.data(), pcm.size()); + CHECK(h.sent.size() == 1U); + CHECK(h.encoderState->windows.size() == 1U); // it never even reached the encoder +} + +TEST_CASE("a device change reopens; an unchanged target is left running", "[audio][micengine]") { + Harness h; + h.engine.reconcile({h.target("slot", "mic-a")}); + h.pump("mic-a", kFrame / 2); // half a window in flight + h.engine.reconcile({h.target("slot", "mic-a")}); + CHECK(h.gateway.closes == 0); // unchanged: not restarted, tail preserved + h.pump("mic-a", kFrame / 2); + CHECK(h.sent.size() == 1U); // the two halves made one window + + h.engine.reconcile({h.target("slot", "mic-b")}); + CHECK(h.gateway.closes == 1); + CHECK(h.gateway.captureFor("mic-a") == nullptr); + CHECK(h.gateway.captureFor("mic-b") != nullptr); +} + +TEST_CASE("a refused device leaves no session and the next reconcile retries", + "[audio][micengine]") { + Harness h; + h.gateway.refuse = {"mic"}; + h.engine.reconcile({h.target("slot", "mic")}); + CHECK_FALSE(h.engine.capturingFor("slot")); + CHECK(h.gateway.openCaptureCount() == 0); + + h.gateway.refuse = {}; + h.engine.reconcile({h.target("slot", "mic")}); + CHECK(h.engine.capturingFor("slot")); +} + +TEST_CASE("two targets capture from their own devices independently", "[audio][micengine]") { + Harness h; + h.engine.reconcile({h.target("a", "mic-a"), h.target("b", "mic-b")}); + CHECK(h.gateway.openCaptureCount() == 2); + h.pump("mic-a", kFrame); + REQUIRE(h.sent.size() == 1U); + h.engine.reconcile({h.target("a", "mic-a")}); + CHECK(h.gateway.openCaptureCount() == 1); + CHECK(h.engine.capturingFor("a")); + CHECK_FALSE(h.engine.capturingFor("b")); +} + +TEST_CASE("destruction closes every capture", "[audio][micengine]") { + FakeAudioGateway gateway; + { + MicCaptureEngine engine{ + &gateway, + [] { return std::make_unique(std::make_shared()); }}; + MicCaptureTarget t; + t.slotId = "slot"; + t.captureDeviceName = "mic"; + t.send = [](std::uint16_t, const std::uint8_t*, std::size_t) { return true; }; + engine.reconcile({t}); + CHECK(gateway.openCaptureCount() == 1); + } + CHECK(gateway.openCaptureCount() == 0); +} diff --git a/tests/test_mic_mute_store.cpp b/tests/test_mic_mute_store.cpp new file mode 100644 index 0000000..23b9eff --- /dev/null +++ b/tests/test_mic_mute_store.cpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// MicMuteStore (source/store/MicMuteStore.h): live per-slot mute, deliberately +// session-scoped. Unlike the toggle stores next to it there is no repository +// here at all — that absence is the design (a mute that survived a restart +// would hide a silenced microphone behind a fresh-looking session), so the +// suite pins behaviour on a bare store. + +#include "source/store/MicMuteStore.h" + +#include + +using dish::source::MicMuteStore; + +TEST_CASE("a slot nobody muted is live", "[mic-mute]") { + MicMuteStore store; + CHECK_FALSE(store.isMuted("9")); + CHECK_FALSE(MicMuteStore::kDefaultMuted); + CHECK(store.state().value().empty()); +} + +TEST_CASE("setMuted holds and clears per slot", "[mic-mute]") { + MicMuteStore store; + store.setMuted("9", true); + CHECK(store.isMuted("9")); + CHECK_FALSE(store.isMuted("other")); + store.setMuted("9", false); + CHECK_FALSE(store.isMuted("9")); +} + +TEST_CASE("toggle flips and reports the new state", "[mic-mute]") { + // Both mute buttons are presses over one toggle, so the return value is + // what the caller renders without a second read. + MicMuteStore store; + CHECK(store.toggle("9")); + CHECK(store.isMuted("9")); + CHECK_FALSE(store.toggle("9")); + CHECK_FALSE(store.isMuted("9")); +} + +TEST_CASE("a redundant write does not republish", "[mic-mute]") { + MicMuteStore store; + store.setMuted("9", true); + int emits = 0; + auto sub = store.state().subscribe([&emits](const dish::source::MicMuteMap&) { emits++; }, + /*emitCurrent=*/false); + store.setMuted("9", true); // no change, no emit + CHECK(emits == 0); + store.setMuted("9", false); + CHECK(emits == 1); +} + +TEST_CASE("retainOnly drops departed slots so a replug comes back live", "[mic-mute]") { + // A physical pad's slot id is model-keyed and REUSED across replugs; the + // hardware clears its own mute at power-down, and the app must match it. + MicMuteStore store; + store.setMuted("gone", true); + store.setMuted("here", true); + store.retainOnly({"here"}); + CHECK(store.isMuted("here")); + CHECK_FALSE(store.isMuted("gone")); + const auto snapshot = store.state().value(); + CHECK(snapshot.find("gone") == snapshot.end()); +} diff --git a/tests/test_models.cpp b/tests/test_models.cpp index 1039cde..4454b4d 100644 --- a/tests/test_models.cpp +++ b/tests/test_models.cpp @@ -5,6 +5,7 @@ // they are the oracle, so do not "tidy" them. #include "Models/Models.h" +#include "core/catalog/BundledCatalog.h" #include "core/model/Protocol.h" #include @@ -434,7 +435,8 @@ TEST_CASE("the actuator caps round-trip through the caps fold", "[models][descri // heartbeat would read as drift and re-PUT. ControllerDescriptor d; d.caps = proto::kCapAnalogTriggers | proto::kCapRumble | proto::kCapMotion | - proto::kCapLightbar | proto::kCapTriggerEffects | proto::kCapPlayerLeds; + proto::kCapLightbar | proto::kCapTriggerEffects | proto::kCapPlayerLeds | + proto::kCapMic | proto::kCapSpeaker; const auto view = SessionViewControllerDto::fromJson(d.toJson()); CHECK(view.capsPresent); CHECK(view.caps == d.caps); @@ -443,7 +445,7 @@ TEST_CASE("the actuator caps round-trip through the caps fold", "[models][descri TEST_CASE("a server that omits the new caps reads them as absent, not set", "[models][descriptor]") { // Forward and backward compatibility both live here: an older satellite - // echoes a caps object without the two keys, and reading a missing key as + // echoes a caps object without the newer keys, and reading a missing key as // true would make the client believe an actuator was negotiated. const auto view = SessionViewControllerDto::fromJson( parse(R"({"ctrlIdx":0,"caps":{"rumble":true,"motion":false, @@ -451,9 +453,69 @@ TEST_CASE("a server that omits the new caps reads them as absent, not set", CHECK(view.capsPresent); CHECK((view.caps & proto::kCapTriggerEffects) == 0); CHECK((view.caps & proto::kCapPlayerLeds) == 0); + CHECK((view.caps & proto::kCapMic) == 0); + CHECK((view.caps & proto::kCapSpeaker) == 0); CHECK((view.caps & proto::kCapRumble) != 0); } +TEST_CASE("the descriptor carries the audio caps as the contract's slugs", "[models][descriptor]") { + // The satellite gates the whole controller-audio feature on exactly these + // two keys ("mic" gates MSG_MIC_AUDIO in and MSG_MIC_LED out, "speaker" + // gates MSG_SPEAKER_AUDIO), so a missing or misspelled one is audio that + // silently never flows. + ControllerDescriptor d; + d.caps = proto::kCapMic; + auto caps = d.toJson().value("caps").toObject(); + CHECK(caps.value("mic").toBool()); + CHECK_FALSE(caps.value("speaker").toBool()); + // Independent directions on the wire too. + d.caps = proto::kCapSpeaker; + caps = d.toJson().value("caps").toObject(); + CHECK_FALSE(caps.value("mic").toBool()); + CHECK(caps.value("speaker").toBool()); + // Both keys are ALWAYS present, false when unclaimed, like every other cap. + d.caps = 0; + caps = d.toJson().value("caps").toObject(); + REQUIRE(caps.contains("mic")); + REQUIRE(caps.contains("speaker")); + CHECK_FALSE(caps.value("mic").toBool()); + CHECK_FALSE(caps.value("speaker").toBool()); +} + +TEST_CASE("the audio caps parse back from the session view", "[models][descriptor]") { + const auto view = SessionViewControllerDto::fromJson( + parse(R"({"ctrlIdx":2,"caps":{"analogTriggers":true,"mic":true,"speaker":false}})")); + CHECK(view.capsPresent); + CHECK((view.caps & proto::kCapMic) != 0); + CHECK((view.caps & proto::kCapSpeaker) == 0); +} + +TEST_CASE("the bundled catalog offers audio on the two Sony types only", "[models][catalog]") { + // The type layer's offline truth: only the composite personas carry real + // audio endpoints, so xbox360 and switchpro must never gain the slugs by + // accident. Offering them for the Sony types cannot outrun the host, which + // gates audio on its own runtime switch. + namespace cat = dish::catalog; + for (const auto& slug : {cat::kSlugDs4, cat::kSlugDualSense}) { + const auto features = cat::typeFeatureSlugs(slug); + REQUIRE(features.has_value()); + CHECK(features->contains(cat::kFeatureMic)); + CHECK(features->contains(cat::kFeatureSpeaker)); + } + for (const auto& slug : {cat::kSlugXbox360, cat::kSlugSwitchPro}) { + const auto features = cat::typeFeatureSlugs(slug); + REQUIRE(features.has_value()); + CHECK_FALSE(features->contains(cat::kFeatureMic)); + CHECK_FALSE(features->contains(cat::kFeatureSpeaker)); + } + // And the audio slugs stay OUT of the protocol-1 caps-gate vocabulary: + // reducer::allowedCapsForType passes unknown bits through untouched, which + // is the trigger-effects precedent audio follows. + CHECK_FALSE(cat::knownFeatureSlugs().contains(cat::kFeatureMic)); + CHECK_FALSE(cat::knownFeatureSlugs().contains(cat::kFeatureSpeaker)); + CHECK(cat::audioFeatureSlugs() == QStringList{cat::kFeatureMic, cat::kFeatureSpeaker}); +} + TEST_CASE("controllersJson builds the WHOLE desired array", "[models][descriptor]") { ControllerDescriptor a; a.ctrlIdx = 0; diff --git a/tests/test_moonlight_button_map.cpp b/tests/test_moonlight_button_map.cpp index 565fdb0..8878743 100644 --- a/tests/test_moonlight_button_map.cpp +++ b/tests/test_moonlight_button_map.cpp @@ -4,6 +4,7 @@ // The internal-button word to Moonlight button-flag translation, pinned per // bit so a mis-mapped Home/Guide or stick click is caught at build time. +#include "core/input/GamepadButtonLayouts.h" #include "core/moonlight/MoonlightButtonMap.h" #include "core/moonlight/MoonlightProtocol.h" @@ -56,3 +57,22 @@ TEST_CASE("ABXY combination reproduces the doc example", "[moonlight][buttonmap] // A + X pressed -> 0x1000 | 0x4000 = 0x5000. CHECK(toMoonlightButtons(inbtn::kA | inbtn::kX) == 0x5000U); } + +TEST_CASE("the Satellite-only mic-mute bit never reaches a Moonlight button word", + "[moonlight][buttonmap]") { + // 0x0800 has no XINPUT assignment; protocol 2 spends it on the DualSense + // mic-mute STATE (input::layout::kXusbMicMute), and a Direct-claimed + // DualSense folds it into every report while muted. The map's explicitness + // is what strips it — these pins keep that structural fact from eroding + // into an accidental row. + CHECK(toMoonlightButtons(input::layout::kXusbMicMute) == 0U); + const std::uint16_t all = inbtn::kDpadUp | inbtn::kDpadDown | inbtn::kDpadLeft | + inbtn::kDpadRight | inbtn::kStart | inbtn::kBack | inbtn::kLeftThumb | + inbtn::kRightThumb | inbtn::kLeftShoulder | inbtn::kRightShoulder | + inbtn::kA | inbtn::kB | inbtn::kX | inbtn::kY; + // Alongside every mapped bit, the word reads the same with or without it. + CHECK(toMoonlightButtons(static_cast(all | input::layout::kXusbMicMute)) == + toMoonlightButtons(all)); + // The bit the map must keep dropping is the bit the wire spends. + CHECK(input::layout::kXusbMicMute == 0x0800); +} diff --git a/tests/test_pad_audio_matcher.cpp b/tests/test_pad_audio_matcher.cpp new file mode 100644 index 0000000..b5ebfdc --- /dev/null +++ b/tests/test_pad_audio_matcher.cpp @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The pad-to-endpoint matcher (core/audio/PadAudioMatcher.h). The one rule +// under test is CONSERVATISM: the only evidence linking an audio endpoint to a +// USB pad is a product string inside a device name, so every ambiguity must +// resolve to "no route" — routing a slot to the WRONG pad's microphone is the +// failure none of these cases may allow. The fixture names use the Windows +// wrapping ("Speakers (Wireless Controller)"); pipewire's are flatter, and the +// containment rule covers both, which is part of what these cases pin. + +#include "core/audio/PadAudioMatcher.h" + +#include + +#include +#include + +using dish::audio::AudioPadCandidate; +using dish::audio::padAudioKey; +using dish::audio::PadAudioRoute; +using dish::audio::resolvePadAudioRoutes; + +namespace { + +AudioPadCandidate dualSense(int pid = 0x0CE6, const std::string& name = "Wireless Controller") { + return AudioPadCandidate{0x054C, pid, name, /*hasAudioFunction=*/true}; +} + +const std::vector kNoDevices; + +} // namespace + +TEST_CASE("one pad, one endpoint pair: both directions route by name", "[audio][matcher]") { + const auto routes = resolvePadAudioRoutes( + {dualSense()}, {"Headset Microphone (Wireless Controller)", "Microphone (USB Webcam)"}, + {"Speakers (Wireless Controller)", "Speakers (Realtek Audio)"}); + REQUIRE(routes.size() == 1U); + const auto& route = routes.at(padAudioKey(0x054C, 0x0CE6)); + CHECK(route.microphone); + CHECK(route.speaker); + // The ORIGINAL device names, verbatim: they are the SDL open keys. + CHECK(route.captureDeviceName == "Headset Microphone (Wireless Controller)"); + CHECK(route.playbackDeviceName == "Speakers (Wireless Controller)"); +} + +TEST_CASE("the directions resolve independently", "[audio][matcher]") { + // Only a speaker endpoint enumerated (a driver that hides the capture + // side): the pad keeps its speaker and simply has no microphone. + const auto routes = + resolvePadAudioRoutes({dualSense()}, kNoDevices, {"Speakers (Wireless Controller)"}); + REQUIRE(routes.size() == 1U); + const auto& route = routes.at(padAudioKey(0x054C, 0x0CE6)); + CHECK_FALSE(route.microphone); + CHECK(route.captureDeviceName.empty()); // no flag without a name, no name without a flag + CHECK(route.speaker); +} + +TEST_CASE("no matching endpoint at all publishes nothing", "[audio][matcher]") { + const auto routes = resolvePadAudioRoutes({dualSense()}, {"Microphone (USB Webcam)"}, + {"Speakers (Realtek Audio)"}); + CHECK(routes.empty()); +} + +TEST_CASE("zero pads or zero endpoints is simply empty", "[audio][matcher]") { + CHECK(resolvePadAudioRoutes({}, {"Speakers (Wireless Controller)"}, {}).empty()); + CHECK(resolvePadAudioRoutes({dualSense()}, kNoDevices, kNoDevices).empty()); + CHECK(resolvePadAudioRoutes({}, kNoDevices, kNoDevices).empty()); +} + +TEST_CASE("two pads with the same product string disqualify each other", "[audio][matcher]") { + // Two DualSenses — or a DualSense next to a DS4, which shares the string — + // are indistinguishable by name. Neither may route, however many matching + // endpoints exist. + const auto routes = resolvePadAudioRoutes({dualSense(0x0CE6), dualSense(0x09CC)}, + {"Headset Microphone (Wireless Controller)"}, + {"Speakers (Wireless Controller)"}); + CHECK(routes.empty()); +} + +TEST_CASE("two endpoints answering one pad in one direction cancel that direction", + "[audio][matcher]") { + // Two identical dongles enumerate identical friendly names; picking either + // would be a coin toss with someone's audio. + const auto routes = resolvePadAudioRoutes( + {dualSense()}, + {"Headset Microphone (Wireless Controller)", "Headset Microphone (Wireless Controller)"}, + {"Speakers (Wireless Controller)"}); + REQUIRE(routes.size() == 1U); + const auto& route = routes.at(padAudioKey(0x054C, 0x0CE6)); + CHECK_FALSE(route.microphone); // ambiguous direction: nothing + CHECK(route.speaker); // the clean direction survives +} + +TEST_CASE("an endpoint naming two different pads belongs to nobody", "[audio][matcher]") { + // "DualSense Wireless Controller" contains BOTH pad names when one pad's + // string is a substring of the other's. That endpoint is evidence for + // neither. + const auto routes = + resolvePadAudioRoutes({dualSense(0x0CE6, "Wireless Controller"), + dualSense(0x0DF2, "DualSense Wireless Controller")}, + {"Headset Microphone (DualSense Wireless Controller)"}, kNoDevices); + // The longer-named pad still cannot claim it either: the endpoint matched + // two pad names, so it was discarded before ownership was decided. + CHECK(routes.empty()); +} + +TEST_CASE("a family without a USB audio function never routes", "[audio][matcher]") { + // A name alone must not let an unrelated audio dongle lend its endpoints + // to a pad that has none. + AudioPadCandidate switchPro{0x057E, 0x2009, "Pro Controller", /*hasAudioFunction=*/false}; + const auto routes = resolvePadAudioRoutes({switchPro}, {"Microphone (Pro Controller)"}, + {"Speakers (Pro Controller)"}); + CHECK(routes.empty()); +} + +TEST_CASE("blank and whitespace product strings are not candidates", "[audio][matcher]") { + const auto routes = + resolvePadAudioRoutes({dualSense(0x0CE6, ""), dualSense(0x0DF2, " ")}, + {"Headset Microphone (Wireless Controller)"}, kNoDevices); + CHECK(routes.empty()); +} + +TEST_CASE("matching is trimmed and ASCII-case-insensitive", "[audio][matcher]") { + const auto routes = + resolvePadAudioRoutes({dualSense(0x0CE6, " Wireless Controller ")}, + {"Headset Microphone (WIRELESS CONTROLLER)"}, kNoDevices); + REQUIRE(routes.size() == 1U); + CHECK(routes.at(padAudioKey(0x054C, 0x0CE6)).microphone); +} + +TEST_CASE("an unrelated endpoint does not disturb the match", "[audio][matcher]") { + const auto routes = resolvePadAudioRoutes( + {dualSense()}, {"Microphone (USB Webcam)", "Headset Microphone (Wireless Controller)", ""}, + {"Speakers (Realtek Audio)", "Speakers (Wireless Controller)"}); + REQUIRE(routes.size() == 1U); + const auto& route = routes.at(padAudioKey(0x054C, 0x0CE6)); + CHECK(route.microphone); + CHECK(route.speaker); +} + +TEST_CASE("two distinct pads route independently", "[audio][matcher]") { + const auto routes = resolvePadAudioRoutes( + {dualSense(0x0CE6, "Wireless Controller"), dualSense(0x0DF2, "DualSense Edge")}, + {"Headset Microphone (Wireless Controller)", "Headset Microphone (DualSense Edge)"}, + {"Speakers (DualSense Edge)"}); + REQUIRE(routes.size() == 2U); + CHECK(routes.at(padAudioKey(0x054C, 0x0CE6)).microphone); + CHECK_FALSE(routes.at(padAudioKey(0x054C, 0x0CE6)).speaker); + CHECK(routes.at(padAudioKey(0x054C, 0x0DF2)).microphone); + CHECK(routes.at(padAudioKey(0x054C, 0x0DF2)).speaker); +} diff --git a/tests/test_satellite_client_audio.cpp b/tests/test_satellite_client_audio.cpp new file mode 100644 index 0000000..a2f8002 --- /dev/null +++ b/tests/test_satellite_client_audio.cpp @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The controller-audio wire (MSG_MIC_AUDIO 0x0012 up, MSG_SPEAKER_AUDIO 0x0013 +// down, MSG_MIC_LED 0x0014 down): byte-exact framing over loopback UDP, the +// parse rules, the dispatch arms, and the two ceilings that keep an audio frame +// inside one datagram. The receive-buffer case at the bottom is the regression +// this feature forced: the pre-audio loop read into 256 bytes, and recvfrom +// TRUNCATES, so every full-size speaker frame would have failed the AEAD +// silently. + +#include "Network/SatelliteClient.h" +#include "core/input/GamepadButtonLayouts.h" +#include "core/model/Protocol.h" +#include "core/wire/SessionCrypto.h" +#include "satellite_client_test_access.h" + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using dish::net::SatelliteClient; +using dish::net::SatelliteClientTestAccess; + +namespace proto = dish::proto; + +namespace { + +std::array kToken{0x11, 0x22, 0x33, 0x44}; +constexpr std::uint32_t kTokenBe = 0x11223344; + +std::array key32(std::uint8_t fill) { + std::array k{}; + k.fill(fill); + return k; +} + +int bindLoopback(std::uint16_t& port) { + const int fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (fd < 0) { return -1; } + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = 0; + ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + ::close(fd); + return -1; + } + socklen_t len = sizeof(addr); + if (::getsockname(fd, reinterpret_cast(&addr), &len) != 0) { + ::close(fd); + return -1; + } + port = ntohs(addr.sin_port); + timeval rtv{}; + rtv.tv_sec = 0; + rtv.tv_usec = 500'000; + ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &rtv, sizeof(rtv)); + return fd; +} + +// A full datagram's worth of buffer, so the server side of a test never +// truncates what it is asserting about. +std::optional> recvDatagram(int fd, sockaddr_in* from = nullptr) { + std::uint8_t buf[proto::kUdpDatagramMaxBytes]; + sockaddr_in src{}; + socklen_t srcLen = sizeof(src); + const ssize_t n = + ::recvfrom(fd, buf, sizeof(buf), 0, reinterpret_cast(&src), &srcLen); + if (n <= 0) { return std::nullopt; } + if (from != nullptr) { *from = src; } + return std::vector(buf, buf + n); +} + +struct LoopbackClient { + int fd = -1; + std::uint16_t port = 0; + SatelliteClient client; + + LoopbackClient() { + fd = bindLoopback(port); + REQUIRE(fd >= 0); + REQUIRE(client.openSocket("127.0.0.1", port)); + client.setConnectionParams(kToken, key32(0xA5), proto::kProtocolVersion); + } + ~LoopbackClient() { + client.closeSocket(); + if (fd >= 0) { ::close(fd); } + } + LoopbackClient(const LoopbackClient&) = delete; + LoopbackClient& operator=(const LoopbackClient&) = delete; + LoopbackClient(LoopbackClient&&) = delete; + LoopbackClient& operator=(LoopbackClient&&) = delete; +}; + +// Decrypt a client->server datagram back to the inner message. REQUIREs on +// structural failure so a test never asserts against garbage. +std::vector decryptClientPacket(const std::vector& pkt) { + REQUIRE(pkt.size() >= 8 + 16); + REQUIRE(std::memcmp(pkt.data(), kToken.data(), 4) == 0); + const std::uint32_t ctr = + (static_cast(pkt[4]) << 24) | (static_cast(pkt[5]) << 16) | + (static_cast(pkt[6]) << 8) | static_cast(pkt[7]); + std::vector plain(pkt.size() - 8); + unsigned long long plainLen = 0; + REQUIRE(dish::wire::decryptPacket(key32(0xA5).data(), dish::wire::kDirClientToServer, ctr, + kTokenBe, pkt.data() + 8, pkt.size() - 8, plain.data(), + &plainLen)); + plain.resize(plainLen); + return plain; +} + +// Frame + encrypt a server->client message the way the satellite does: +// token(4) | counter(4 BE) | AEAD(inner), inner = msgType(BE) + len(BE) + body. +std::vector serverPacket(std::uint16_t msgType, const std::vector& body, + std::uint32_t counter) { + std::vector inner(4 + body.size()); + inner[0] = static_cast(msgType >> 8); + inner[1] = static_cast(msgType & 0xFF); + inner[2] = static_cast(body.size() >> 8); + inner[3] = static_cast(body.size() & 0xFF); + if (!body.empty()) { std::memcpy(inner.data() + 4, body.data(), body.size()); } + + std::vector pkt(8 + inner.size() + 16); + std::memcpy(pkt.data(), kToken.data(), 4); + pkt[4] = static_cast(counter >> 24); + pkt[5] = static_cast(counter >> 16); + pkt[6] = static_cast(counter >> 8); + pkt[7] = static_cast(counter & 0xFF); + unsigned long long ctLen = 0; + REQUIRE(dish::wire::encryptPacket(key32(0xA5).data(), dish::wire::kDirServerToClient, counter, + kTokenBe, inner.data(), inner.size(), pkt.data() + 8, + &ctLen)); + pkt.resize(8 + ctLen); + return pkt; +} + +std::vector audioBody(std::uint8_t ctrlIdx, std::uint16_t seq, + const std::vector& opus) { + std::vector body; + body.push_back(ctrlIdx); + body.push_back(static_cast(seq >> 8)); + body.push_back(static_cast(seq & 0xFF)); + body.insert(body.end(), opus.begin(), opus.end()); + return body; +} + +} // namespace + +// ── Constants ──────────────────────────────────────────────────────────────── + +TEST_CASE("the controller-audio opcodes and caps are the contract's values", "[audio][wire]") { + CHECK(SatelliteClient::kMsgMicAudio == 0x0012); + CHECK(SatelliteClient::kMsgSpeakerAudio == 0x0013); + CHECK(SatelliteClient::kMsgMicLed == 0x0014); + CHECK(SatelliteClient::kCapMic == 0x0040); + CHECK(SatelliteClient::kCapSpeaker == 0x0080); + // ...and neither collides with any cap that already existed. + constexpr std::uint16_t kPrior = + SatelliteClient::kCapAnalogTriggers | SatelliteClient::kCapRumble | + SatelliteClient::kCapMotion | SatelliteClient::kCapLightbar | + SatelliteClient::kCapTriggerEffects | SatelliteClient::kCapPlayerLeds; + CHECK((SatelliteClient::kCapMic & kPrior) == 0); + CHECK((SatelliteClient::kCapSpeaker & kPrior) == 0); + CHECK((SatelliteClient::kCapMic & SatelliteClient::kCapSpeaker) == 0); + // The mic-mute state bit satellite-side consumes; pinned so the input + // layout and this file cannot drift apart. + CHECK(dish::input::layout::kXusbMicMute == 0x0800); +} + +TEST_CASE("the audio caps helpers fold only their own bit", "[audio][wire]") { + const std::uint16_t base = SatelliteClient::kCapAnalogTriggers; + CHECK(SatelliteClient::withMicCapability(base, false) == base); + CHECK(SatelliteClient::withMicCapability(base, true) == (base | SatelliteClient::kCapMic)); + CHECK(SatelliteClient::withSpeakerCapability(base, false) == base); + CHECK(SatelliteClient::withSpeakerCapability(base, true) == + (base | SatelliteClient::kCapSpeaker)); + // Independent directions: folding both keeps both and neither implies the + // other. + const auto both = SatelliteClient::withSpeakerCapability( + SatelliteClient::withMicCapability(base, true), true); + CHECK((both & SatelliteClient::kCapMic) != 0); + CHECK((both & SatelliteClient::kCapSpeaker) != 0); + const auto micOnly = SatelliteClient::withMicCapability(base, true); + CHECK((micOnly & SatelliteClient::kCapSpeaker) == 0); +} + +// ── The 3-byte header ──────────────────────────────────────────────────────── + +TEST_CASE("the audio frame header is ctrlIdx then seq big-endian", "[audio][wire]") { + const auto h = SatelliteClient::encodeAudioFrameHeader(7, 0xABCD); + REQUIRE(h.size() == 3U); + CHECK(h[0] == 7); + CHECK(h[1] == 0xAB); // BE: high byte first, mirroring the satellite decoder + CHECK(h[2] == 0xCD); + // 0x0100 BE = 256; an LE encoder would emit 0x00 0x01 here. + const auto boundary = SatelliteClient::encodeAudioFrameHeader(0, 0x0100); + CHECK(boundary[1] == 0x01); + CHECK(boundary[2] == 0x00); +} + +// ── sendMicAudio: byte-exact over loopback ─────────────────────────────────── + +TEST_CASE("sendMicAudio frames ctrlIdx + seq BE + the verbatim Opus packet", "[audio][wire]") { + LoopbackClient lb; + const std::vector opus{0xD0, 0xD1, 0xD2, 0xD3, 0xD4}; + REQUIRE(lb.client.sendMicAudio(3, 0x0102, opus.data(), opus.size())); + + const auto pkt = recvDatagram(lb.fd); + REQUIRE(pkt.has_value()); + const auto inner = decryptClientPacket(*pkt); + REQUIRE(inner.size() == 4U + 3U + opus.size()); + CHECK(inner[0] == 0x00); + CHECK(inner[1] == 0x12); // MSG_MIC_AUDIO + CHECK(inner[2] == 0x00); + CHECK(inner[3] == 3 + opus.size()); // inner msgLen + CHECK(inner[4] == 3); // ctrlIdx + CHECK(inner[5] == 0x01); // seq BE + CHECK(inner[6] == 0x02); + CHECK(std::memcmp(inner.data() + 7, opus.data(), opus.size()) == 0); +} + +TEST_CASE("a 1-byte DTX packet rides the wire; an empty one never does", "[audio][wire]") { + LoopbackClient lb; + const std::uint8_t dtx = 0xF8; + + // Empty is malformed by contract: the satellite would drop it, so the + // client refuses to spend a datagram on it. + CHECK_FALSE(lb.client.sendMicAudio(0, 1, nullptr, 1)); + CHECK_FALSE(lb.client.sendMicAudio(0, 1, &dtx, 0)); + CHECK_FALSE(recvDatagram(lb.fd).has_value()); + + // A 1-byte packet is a legal DTX silence frame and the minimum the wire + // carries: header + one Opus byte. + REQUIRE(lb.client.sendMicAudio(0, 1, &dtx, 1)); + const auto pkt = recvDatagram(lb.fd); + REQUIRE(pkt.has_value()); + const auto inner = decryptClientPacket(*pkt); + CHECK(inner.size() == 4U + static_cast(proto::kAudioWireMinPayloadBytes)); + CHECK(inner[7] == 0xF8); +} + +TEST_CASE("seq wraps on the wire exactly as sent", "[audio][wire]") { + LoopbackClient lb; + const std::uint8_t opus = 0x01; + REQUIRE(lb.client.sendMicAudio(0, 0xFFFF, &opus, 1)); + REQUIRE(lb.client.sendMicAudio(0, 0x0000, &opus, 1)); + const auto first = recvDatagram(lb.fd); + const auto second = recvDatagram(lb.fd); + REQUIRE(first.has_value()); + REQUIRE(second.has_value()); + const auto a = decryptClientPacket(*first); + const auto b = decryptClientPacket(*second); + CHECK(a[5] == 0xFF); + CHECK(a[6] == 0xFF); + CHECK(b[5] == 0x00); + CHECK(b[6] == 0x00); +} + +// ── The datagram ceilings ──────────────────────────────────────────────────── + +TEST_CASE("the largest legal Opus packet fills the datagram exactly", "[audio][wire]") { + LoopbackClient lb; + const std::vector opus(proto::kAudioWireMaxOpusBytes, 0x5A); // 1469 + REQUIRE(lb.client.sendMicAudio(1, 42, opus.data(), opus.size())); + const auto pkt = recvDatagram(lb.fd); + REQUIRE(pkt.has_value()); + // 8 outer + 4 inner + 3 header + 1469 opus + 16 tag = exactly one MTU. + CHECK(pkt->size() == proto::kUdpDatagramMaxBytes); + const auto inner = decryptClientPacket(*pkt); + CHECK(inner.size() == 4U + proto::kUdpMaxInnerPayloadBytes); + + // One byte more is refused before it touches the socket. + const std::vector tooBig(proto::kAudioWireMaxOpusBytes + 1, 0x5A); + CHECK_FALSE(lb.client.sendMicAudio(1, 43, tooBig.data(), tooBig.size())); + CHECK_FALSE(recvDatagram(lb.fd).has_value()); +} + +TEST_CASE("the generic send framing takes 1472 payload bytes and refuses 1473", "[audio][wire]") { + LoopbackClient lb; + // At the ceiling: sent, and the datagram is exactly one MTU. + const std::vector atCap(proto::kUdpMaxInnerPayloadBytes, 0x33); + CHECK(SatelliteClientTestAccess::sendEncrypted(lb.client, 0x0012, atCap.data(), atCap.size())); + const auto sent = recvDatagram(lb.fd); + REQUIRE(sent.has_value()); + CHECK(sent->size() == proto::kUdpDatagramMaxBytes); + + // One past it: refused, logged once, nothing on the wire — a fragmented + // datagram would be truncated satellite-side and fail the AEAD anyway. + const std::vector overCap(proto::kUdpMaxInnerPayloadBytes + 1, 0x33); + CHECK_FALSE(SatelliteClientTestAccess::sendEncrypted(lb.client, 0x0012, overCap.data(), + overCap.size())); + CHECK_FALSE(recvDatagram(lb.fd).has_value()); + // The refusal must not wedge the sender. + CHECK(SatelliteClientTestAccess::sendEncrypted(lb.client, 0x0012, atCap.data(), atCap.size())); + CHECK(recvDatagram(lb.fd).has_value()); +} + +// ── parseSpeakerAudioMessage ───────────────────────────────────────────────── + +TEST_CASE("SPEAKER_AUDIO parses header + Opus bytes with a floor of four", "[audio][wire]") { + const std::vector body = audioBody(2, 0xBEEF, {0x10, 0x20, 0x30}); + const auto msg = SatelliteClient::parseSpeakerAudioMessage(body.data(), body.size()); + REQUIRE(msg.has_value()); + CHECK(msg->controllerIndex == 2); + CHECK(msg->seq == 0xBEEF); + REQUIRE(msg->opusLen == 3U); + // Borrowed, not copied: the pointer aliases the payload. + CHECK(msg->opus == body.data() + 3); + CHECK(msg->opus[0] == 0x10); + CHECK(msg->opus[2] == 0x30); +} + +TEST_CASE("a bare audio header with no Opus byte is malformed", "[audio][wire]") { + // A silence frame is a 1-byte DTX packet, never an empty one, so a 3-byte + // frame can only be a bug or an attack. + const std::vector body = audioBody(0, 7, {}); + CHECK(body.size() == 3U); + CHECK_FALSE(SatelliteClient::parseSpeakerAudioMessage(body.data(), body.size()).has_value()); + CHECK_FALSE(SatelliteClient::parseSpeakerAudioMessage(body.data(), 2).has_value()); + CHECK_FALSE(SatelliteClient::parseSpeakerAudioMessage(body.data(), 0).has_value()); + CHECK_FALSE(SatelliteClient::parseSpeakerAudioMessage(nullptr, 8).has_value()); + + // One Opus byte is the legal minimum (a DTX frame). + const std::vector dtx = audioBody(0, 7, {0xF8}); + const auto msg = SatelliteClient::parseSpeakerAudioMessage(dtx.data(), dtx.size()); + REQUIRE(msg.has_value()); + CHECK(msg->opusLen == 1U); +} + +TEST_CASE("a full-size SPEAKER_AUDIO payload parses to 1469 Opus bytes", "[audio][wire]") { + const std::vector opus(proto::kAudioWireMaxOpusBytes, 0x77); + const std::vector body = audioBody(5, 1000, opus); + CHECK(body.size() == proto::kUdpMaxInnerPayloadBytes); + const auto msg = SatelliteClient::parseSpeakerAudioMessage(body.data(), body.size()); + REQUIRE(msg.has_value()); + CHECK(msg->opusLen == proto::kAudioWireMaxOpusBytes); + CHECK(msg->seq == 1000); +} + +// ── parseMicLedMessage ─────────────────────────────────────────────────────── + +TEST_CASE("MIC_LED parses the three lamp states and drops the rest", "[audio][wire]") { + for (std::uint8_t state : + {proto::kMicLedStateOff, proto::kMicLedStateOn, proto::kMicLedStatePulse}) { + const std::uint8_t body[2] = {4, state}; + const auto msg = SatelliteClient::parseMicLedMessage(body, sizeof(body)); + REQUIRE(msg.has_value()); + CHECK(msg->controllerIndex == 4); + CHECK(msg->state == state); + } + // An unknown state is dropped, not clamped: rendering a lamp mode the game + // never asked for would be a guess at firmware. + const std::uint8_t unknown[2] = {4, proto::kMicLedStateCount}; + CHECK_FALSE(SatelliteClient::parseMicLedMessage(unknown, sizeof(unknown)).has_value()); + const std::uint8_t wild[2] = {4, 0xFF}; + CHECK_FALSE(SatelliteClient::parseMicLedMessage(wild, sizeof(wild)).has_value()); +} + +TEST_CASE("MIC_LED takes exactly two bytes, no more and no fewer", "[audio][wire]") { + const std::uint8_t body[3] = {1, 1, 0}; + CHECK(SatelliteClient::parseMicLedMessage(body, 2).has_value()); + CHECK_FALSE(SatelliteClient::parseMicLedMessage(body, 1).has_value()); + CHECK_FALSE(SatelliteClient::parseMicLedMessage(body, 3).has_value()); + CHECK_FALSE(SatelliteClient::parseMicLedMessage(body, 0).has_value()); + CHECK_FALSE(SatelliteClient::parseMicLedMessage(nullptr, 2).has_value()); +} + +// ── Dispatch through processIncoming ───────────────────────────────────────── + +TEST_CASE("a SPEAKER_AUDIO datagram dispatches to the registered handler", "[audio][wire]") { + SatelliteClient c; + c.setConnectionParams(kToken, key32(0xA5), proto::kProtocolVersion); + + int calls = 0; + std::vector gotOpus; + std::uint16_t gotSeq = 0; + int gotIdx = -1; + c.setSpeakerAudioHandler([&](const SatelliteClient::SpeakerAudioMessage& sm) { + calls++; + gotIdx = sm.controllerIndex; + gotSeq = sm.seq; + gotOpus.assign(sm.opus, sm.opus + sm.opusLen); + }); + + const std::vector opus{0xAA, 0xBB, 0xCC}; + const auto pkt = serverPacket(0x0013, audioBody(1, 513, opus), /*counter=*/1); + SatelliteClientTestAccess::processIncoming(c, pkt.data(), pkt.size()); + REQUIRE(calls == 1); + CHECK(gotIdx == 1); + CHECK(gotSeq == 513); + CHECK(gotOpus == opus); + + // A malformed frame (header only) never reaches the handler. + const auto bad = serverPacket(0x0013, audioBody(1, 514, {}), /*counter=*/2); + SatelliteClientTestAccess::processIncoming(c, bad.data(), bad.size()); + CHECK(calls == 1); +} + +TEST_CASE("a full-size 1500-byte datagram decrypts and dispatches whole", "[audio][wire]") { + SatelliteClient c; + c.setConnectionParams(kToken, key32(0xA5), proto::kProtocolVersion); + + std::size_t gotLen = 0; + std::uint8_t firstByte = 0; + std::uint8_t lastByte = 0; + c.setSpeakerAudioHandler([&](const SatelliteClient::SpeakerAudioMessage& sm) { + gotLen = sm.opusLen; + firstByte = sm.opus[0]; + lastByte = sm.opus[sm.opusLen - 1]; + }); + + std::vector opus(proto::kAudioWireMaxOpusBytes, 0x00); + opus.front() = 0x11; + opus.back() = 0x99; + const auto pkt = serverPacket(0x0013, audioBody(0, 1, opus), /*counter=*/1); + REQUIRE(pkt.size() == proto::kUdpDatagramMaxBytes); + SatelliteClientTestAccess::processIncoming(c, pkt.data(), pkt.size()); + CHECK(gotLen == proto::kAudioWireMaxOpusBytes); + CHECK(firstByte == 0x11); + CHECK(lastByte == 0x99); +} + +TEST_CASE("a MIC_LED datagram dispatches state; junk states are dropped", "[audio][wire]") { + SatelliteClient c; + c.setConnectionParams(kToken, key32(0xA5), proto::kProtocolVersion); + + int calls = 0; + std::uint8_t gotState = 0xFF; + c.setMicLedHandler([&](const SatelliteClient::MicLedMessage& mm) { + calls++; + gotState = mm.state; + }); + + const auto pulse = serverPacket(0x0014, {2, proto::kMicLedStatePulse}, /*counter=*/1); + SatelliteClientTestAccess::processIncoming(c, pulse.data(), pulse.size()); + REQUIRE(calls == 1); + CHECK(gotState == proto::kMicLedStatePulse); + + const auto junk = serverPacket(0x0014, {2, 9}, /*counter=*/2); + SatelliteClientTestAccess::processIncoming(c, junk.data(), junk.size()); + CHECK(calls == 1); // dropped, not clamped +} + +// ── The receive loop's buffer ──────────────────────────────────────────────── + +TEST_CASE("receiveLoop survives a full-size datagram end to end", "[audio][wire]") { + // The regression case: a 256-byte receive buffer truncates this datagram, + // the AEAD fails on the truncated ciphertext, and the frame vanishes with + // no error anywhere. Driving the REAL socket loop is the only thing that + // pins the buffer size. + LoopbackClient lb; + + std::atomic got{0}; + std::atomic gotLen{0}; + lb.client.setSpeakerAudioHandler([&](const SatelliteClient::SpeakerAudioMessage& sm) { + gotLen.store(sm.opusLen); + got.fetch_add(1); + }); + lb.client.startReceiveLoop(); + + // The client's socket is unbound until it sends; one heartbeat-sized send + // teaches the server side its address. + lb.client.sendBattery(0, 50, SatelliteClient::kBatteryStatusDischarging); + sockaddr_in clientAddr{}; + REQUIRE(recvDatagram(lb.fd, &clientAddr).has_value()); + + const std::vector opus(proto::kAudioWireMaxOpusBytes, 0x42); + const auto pkt = serverPacket(0x0013, audioBody(0, 77, opus), /*counter=*/1); + REQUIRE(pkt.size() == proto::kUdpDatagramMaxBytes); + ::sendto(lb.fd, pkt.data(), pkt.size(), 0, reinterpret_cast(&clientAddr), + sizeof(clientAddr)); + + // Loopback delivery is fast; the deadline only bounds a genuine failure. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (got.load() == 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + lb.client.stopReceiveLoop(); + REQUIRE(got.load() == 1); + CHECK(gotLen.load() == proto::kAudioWireMaxOpusBytes); +} diff --git a/tests/test_slot_list_model.cpp b/tests/test_slot_list_model.cpp index a37a77c..2cef3dc 100644 --- a/tests/test_slot_list_model.cpp +++ b/tests/test_slot_list_model.cpp @@ -89,8 +89,10 @@ TEST_CASE("SlotListModel: roleNames covers every Roles enumerator", "[slotmodel] SlotListModel model; const auto names = model.roleNames(); // One entry per declared role: a role added without a name drifts this count. - REQUIRE(names.size() == 40); + REQUIRE(names.size() == 42); REQUIRE(names.value(SlotListModel::HasRumbleRole) == QByteArray("hasRumble")); + REQUIRE(names.value(SlotListModel::MicArmedRole) == QByteArray("micArmed")); + REQUIRE(names.value(SlotListModel::MicMutedRole) == QByteArray("micMuted")); REQUIRE(names.value(SlotListModel::BatteryLowRole) == QByteArray("batteryLow")); REQUIRE(names.value(SlotListModel::IdRole) == QByteArray("slotId")); REQUIRE(names.value(SlotListModel::NameRole) == QByteArray("name")); diff --git a/tests/test_speaker_playout_engine.cpp b/tests/test_speaker_playout_engine.cpp new file mode 100644 index 0000000..0673682 --- /dev/null +++ b/tests/test_speaker_playout_engine.cpp @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// SpeakerPlayoutEngine (source/audio/SpeakerPlayoutEngine.h) against the fake +// gateway and a scriptable decoder. The load-bearing claims: frames route by +// (connection, controller index); playback starts only once the two-frame +// cushion is queued; a gap takes the FEC path with the reorder window's +// carrier; a drained queue is re-cushioned with silence; and a voice +// reconciled away is closed. + +#include "source/audio/SpeakerPlayoutEngine.h" + +#include "FakeAudioGateway.h" +#include "core/audio/AudioEnginePolicy.h" +#include "core/model/Protocol.h" + +#include + +#include +#include +#include + +using dish::source::audio::SpeakerPlayoutEngine; +using dish::source::audio::SpeakerVoiceTarget; +using dish::test::FakeAudioGateway; + +namespace proto = dish::proto; + +namespace { + +constexpr std::size_t kFrame = static_cast(proto::kAudioFrameSamples); +constexpr std::size_t kWindowSamples = kFrame * 2; // stereo interleaved + +// Fills every decoded window with a marker so the queue contents say which +// path produced them: packet decodes carry the packet's first byte, FEC +// recoveries carry 0x7E00 + carrier length, concealment 0x7C00. +class FakeDecoder : public dish::audio::IAudioDecoder { + public: + struct State { + int decodes = 0; + int fecs = 0; + int conceals = 0; + }; + explicit FakeDecoder(std::shared_ptr state) : state_(std::move(state)) {} + + std::size_t decode(const std::uint8_t* opus, std::size_t opusLen, std::int16_t* pcm, + std::size_t maxFrames) override { + if (opus == nullptr || opusLen == 0 || maxFrames < kFrame) { return 0; } + state_->decodes++; + fill(pcm, static_cast(opus[0])); + return kFrame; + } + std::size_t conceal(std::int16_t* pcm, std::size_t maxFrames) override { + if (maxFrames < kFrame) { return 0; } + state_->conceals++; + fill(pcm, 0x7C00); + return kFrame; + } + std::size_t decodeFec(const std::uint8_t* opus, std::size_t opusLen, std::int16_t* pcm, + std::size_t maxFrames) override { + if (opus == nullptr || opusLen == 0) { return conceal(pcm, maxFrames); } + if (maxFrames < kFrame) { return 0; } + state_->fecs++; + fill(pcm, static_cast(0x7E00 + static_cast(opusLen))); + return kFrame; + } + + private: + static void fill(std::int16_t* pcm, std::int16_t value) { + for (std::size_t i = 0; i < kWindowSamples; i++) { pcm[i] = value; } + } + std::shared_ptr state_; +}; + +struct Harness { + FakeAudioGateway gateway; + std::shared_ptr decoderState = std::make_shared(); + SpeakerPlayoutEngine engine{&gateway, + [this] { return std::make_unique(decoderState); }}; + + static SpeakerVoiceTarget voice(const std::string& conn, int idx, const std::string& slot, + const std::string& device) { + SpeakerVoiceTarget t; + t.connectionId = conn; + t.controllerIndex = idx; + t.slotId = slot; + t.playbackDeviceName = device; + return t; + } + + void frame(const std::string& conn, int idx, std::uint16_t seq, std::uint8_t marker) { + const std::uint8_t opus[4] = {marker, 1, 2, 3}; + engine.deliver(conn, idx, seq, opus, sizeof(opus)); + } +}; + +} // namespace + +TEST_CASE("frames queue on the voice's own device and start at the cushion", "[audio][playout]") { + Harness h; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "Speakers (Wireless Controller)")}); + CHECK(h.engine.playingFor("slot")); + auto* playback = h.gateway.playbackFor("Speakers (Wireless Controller)"); + REQUIRE(playback != nullptr); + + // First frame: queued, but the device stays paused — starting an empty + // device plays one window and clicks on the underrun. + h.frame("sat", 0, 0, 0x11); + CHECK(playback->queued.size() == kWindowSamples); + CHECK(playback->queued.front() == 0x11); + CHECK_FALSE(playback->resumed); + + // Second frame reaches the two-window cushion: now it starts. + h.frame("sat", 0, 1, 0x22); + CHECK(playback->queued.size() == 2 * kWindowSamples); + CHECK(playback->resumed); + CHECK(h.decoderState->decodes == 2); +} + +TEST_CASE("a frame for a voice nobody holds is dropped", "[audio][playout]") { + Harness h; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk")}); + h.frame("sat", 1, 0, 0x11); // wrong index + h.frame("other", 0, 0, 0x11); // wrong connection + CHECK(h.decoderState->decodes == 0); + CHECK(h.gateway.playbackFor("spk")->queued.empty()); +} + +TEST_CASE("a reorder heals and a loss takes the FEC path with the carrier", "[audio][playout]") { + Harness h; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk")}); + auto* playback = h.gateway.playbackFor("spk"); + + h.frame("sat", 0, 0, 0x10); + h.frame("sat", 0, 1, 0x11); + // 2 lost; 3 held (proves nothing yet), 4 declares the loss. + h.frame("sat", 0, 3, 0x13); + CHECK(h.decoderState->decodes == 2); // 3 is held, not played + h.frame("sat", 0, 4, 0x14); + // Gap for 2 (FEC off packet 3, the 4-byte carrier), then 3 and 4 in order. + CHECK(h.decoderState->fecs == 1); + CHECK(h.decoderState->decodes == 4); + REQUIRE(playback->queued.size() == 5 * kWindowSamples); + CHECK(playback->queued[2 * kWindowSamples] == static_cast(0x7E04)); + CHECK(playback->queued[3 * kWindowSamples] == 0x13); + CHECK(playback->queued[4 * kWindowSamples] == 0x14); +} + +TEST_CASE("a duplicate or late frame queues nothing", "[audio][playout]") { + Harness h; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk")}); + h.frame("sat", 0, 5, 0x15); + h.frame("sat", 0, 5, 0x15); // duplicate: its slot already played + h.frame("sat", 0, 4, 0x14); // late: behind the stream + CHECK(h.decoderState->decodes == 1); + CHECK(h.gateway.playbackFor("spk")->queued.size() == kWindowSamples); +} + +TEST_CASE("a drained queue is re-cushioned with silence before the resumed audio", + "[audio][playout]") { + // The satellite suppresses digitally silent windows without advancing seq, + // so a stream goes quiet for seconds and the queue empties. The resumed + // sound must ride behind a fresh 40 ms cushion of SILENCE — not wait for a + // second frame that may be another suppression away. + Harness h; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk")}); + h.frame("sat", 0, 0, 0x10); + h.frame("sat", 0, 1, 0x11); + auto* playback = h.gateway.playbackFor("spk"); + REQUIRE(playback->resumed); + + // The device played everything out during the quiet stretch. + for (auto& [handle, p] : h.gateway.playbacks) { h.gateway.drain(handle); } + playback->queued.clear(); + + // seq CONTINUES from 2: suppression is not loss, so no gap events fire. + h.frame("sat", 0, 2, 0x12); + CHECK(h.decoderState->fecs == 0); + CHECK(h.decoderState->conceals == 0); + const std::size_t cushion = + static_cast(dish::audio::kPlayoutStartThresholdFrames) * kWindowSamples; + REQUIRE(playback->queued.size() == cushion + kWindowSamples); + for (std::size_t i = 0; i < cushion; i++) { + if (playback->queued[i] != 0) { FAIL("cushion sample " << i << " is not silence"); } + } + CHECK(playback->queued[cushion] == 0x12); +} + +TEST_CASE("reconciling a voice away closes its device", "[audio][playout]") { + Harness h; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk")}); + CHECK(h.gateway.openPlaybackCount() == 1); + h.engine.reconcile({}); + CHECK(h.gateway.openPlaybackCount() == 0); + CHECK_FALSE(h.engine.playingFor("slot")); + // Frames after the plan moved are dropped, not decoded. + h.frame("sat", 0, 0, 0x11); + CHECK(h.decoderState->decodes == 0); +} + +TEST_CASE("a device change reopens the voice; an unchanged one is kept", "[audio][playout]") { + Harness h; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk-a")}); + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk-a")}); + CHECK(h.gateway.closes == 0); + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk-b")}); + CHECK(h.gateway.closes == 1); + CHECK(h.gateway.playbackFor("spk-a") == nullptr); + CHECK(h.gateway.playbackFor("spk-b") != nullptr); +} + +TEST_CASE("a refused playback device leaves no voice and retries next reconcile", + "[audio][playout]") { + Harness h; + h.gateway.refuse = {"spk"}; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk")}); + CHECK_FALSE(h.engine.playingFor("slot")); + h.gateway.refuse = {}; + h.engine.reconcile({Harness::voice("sat", 0, "slot", "spk")}); + CHECK(h.engine.playingFor("slot")); +} + +TEST_CASE("two voices on one connection route by controller index", "[audio][playout]") { + Harness h; + h.engine.reconcile( + {Harness::voice("sat", 0, "slot-a", "spk-a"), Harness::voice("sat", 1, "slot-b", "spk-b")}); + h.frame("sat", 1, 0, 0x21); + CHECK(h.gateway.playbackFor("spk-a")->queued.empty()); + CHECK(h.gateway.playbackFor("spk-b")->queued.size() == kWindowSamples); +} diff --git a/tests/test_udp_opcodes.cpp b/tests/test_udp_opcodes.cpp index 9106a06..1dda073 100644 --- a/tests/test_udp_opcodes.cpp +++ b/tests/test_udp_opcodes.cpp @@ -26,6 +26,19 @@ TEST_CASE("kept opcode constants match the protocol-1 values", "[udp][opcodes]") REQUIRE(SatelliteClient::kMsgSessionClose == 0x000F); } +TEST_CASE("the protocol-2 opcodes extend the range contiguously", "[udp][opcodes]") { + REQUIRE(SatelliteClient::kMsgTriggerEffects == 0x0010); + REQUIRE(SatelliteClient::kMsgPlayerLeds == 0x0011); + REQUIRE(SatelliteClient::kMsgMicAudio == 0x0012); + REQUIRE(SatelliteClient::kMsgSpeakerAudio == 0x0013); + REQUIRE(SatelliteClient::kMsgMicLed == 0x0014); + // The MIC_LED state vocabulary is satellite MIC_LED_STATE_*, verbatim. + REQUIRE(proto::kMicLedStateOff == 0); + REQUIRE(proto::kMicLedStateOn == 1); + REQUIRE(proto::kMicLedStatePulse == 2); + REQUIRE(proto::kMicLedStateCount == 3); +} + // 0x0004..0x0008 and 0x000E were the topology opcodes, now removed. A test // cannot name a member that no longer compiles, so re-introducing one instead // forces an edit to this list, which surfaces the regression in review. diff --git a/tests/test_usb_gamepad_manager_feedback.cpp b/tests/test_usb_gamepad_manager_feedback.cpp index 43d4dfc..15af633 100644 --- a/tests/test_usb_gamepad_manager_feedback.cpp +++ b/tests/test_usb_gamepad_manager_feedback.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include using namespace dish::source::usb; @@ -67,7 +69,9 @@ class RecordingGateway : public UsbDeviceGateway { std::vector enumerate() override { return devices; } ClaimResult claim(const UsbDeviceInfo& /*d*/, - std::function /*onReport*/) override { + std::function onReport) override { + // Kept so a test can play the read thread and feed reports back in. + lastOnReport = std::move(onReport); return ClaimResult::success(nextSyntheticId--); } void releaseClaim(int syntheticId) override { released.push_back(syntheticId); } @@ -77,6 +81,13 @@ class RecordingGateway : public UsbDeviceGateway { writes.push_back(Written{syntheticId, std::vector(data, data + len)}); return writeSucceeds; } + bool setPadMicMuted(int syntheticId, bool muted) override { + muteWrites.push_back({syntheticId, muted}); + return true; + } + + std::function lastOnReport; + std::vector> muteWrites; }; // A manager with one claimed pad of the given identity. Claims are driven the @@ -247,3 +258,114 @@ TEST_CASE("player LEDs are masked before they reach the gateway", "[usb][manager REQUIRE(h.gateway.writes.size() == 1U); CHECK(h.gateway.writes[0].bytes[44] == 0x1F); } + +// ---- The mic-mute lamp and the mute latch ------------------------------------ + +TEST_CASE("the mute lamp reaches the claimed pad and shadows into later writes", + "[usb][manager][feedback]") { + Harness h(kSonyVid, kDualSensePid); + const auto claimed = h.manager->controllerFor(kSonyVid, kDualSensePid); + REQUIRE(claimed.has_value()); + + REQUIRE(h.manager->applyMicMuteLed(kSonyVid, kDualSensePid, 1)); + REQUIRE(h.gateway.writes.size() == 1U); + CHECK(h.gateway.writes[0].syntheticId == *claimed->syntheticId); + REQUIRE(h.gateway.writes[0].bytes.size() == 63U); + CHECK(h.gateway.writes[0].bytes[0] == 0x02); + CHECK(h.gateway.writes[0].bytes[2] == 0x03); + CHECK(h.gateway.writes[0].bytes[9] == 1); + CHECK(h.gateway.writes[0].bytes[10] == 0x10); + + // The shadow lives in the manager's per-claim FeedbackState: a colour + // written afterwards must carry the lamp along. + REQUIRE(h.manager->applyLightbar(kSonyVid, kDualSensePid, 0x11, 0x22, 0x33)); + REQUIRE(h.gateway.writes.size() == 2U); + CHECK(h.gateway.writes[1].bytes[9] == 1); + CHECK(h.gateway.writes[1].bytes[10] == 0x10); + CHECK(h.gateway.writes[1].bytes[45] == 0x11); +} + +TEST_CASE("the lamp refuses an unclaimed model, a lamp-less family and a junk state", + "[usb][manager][feedback]") { + Harness ds4(kSonyVid, kDualShock4Pid); + CHECK_FALSE(ds4.manager->applyMicMuteLed(kSonyVid, kDualShock4Pid, 1)); // no lamp + CHECK_FALSE(ds4.manager->applyMicMuteLed(kSonyVid, kDualSensePid, 1)); // not claimed + CHECK(ds4.gateway.writes.empty()); + + Harness ds5(kSonyVid, kDualSensePid); + CHECK_FALSE(ds5.manager->applyMicMuteLed(kSonyVid, kDualSensePid, 3)); // junk state + CHECK(ds5.gateway.writes.empty()); +} + +TEST_CASE("a released claim forgets the lamp shadow", "[usb][manager][feedback]") { + // A replugged pad must not inherit a stale lamp any more than a stale + // lightbar handoff: the FeedbackState is per claim. + Harness h(kSonyVid, kDualSensePid); + REQUIRE(h.manager->applyMicMuteLed(kSonyVid, kDualSensePid, 1)); + h.manager->onUsbGone(kSonyVid, kDualSensePid); + h.gateway.devices.clear(); + h.gateway.devices.push_back(device(kSonyVid, kDualSensePid)); + h.manager->reconcile(); + REQUIRE(h.manager->isDirectClaimed(kSonyVid, kDualSensePid)); + + h.gateway.writes.clear(); + REQUIRE(h.manager->applyLightbar(kSonyVid, kDualSensePid, 1, 2, 3)); + REQUIRE(h.gateway.writes.size() == 1U); + CHECK(h.gateway.writes[0].bytes[9] == 0); // no resurrected lamp + CHECK((h.gateway.writes[0].bytes[2] & 0x03) == 0); +} + +TEST_CASE("setPadMicMuted plumbs to the claimed pad's latch and nowhere else", + "[usb][manager][feedback]") { + Harness h(kSonyVid, kDualSensePid); + const auto claimed = h.manager->controllerFor(kSonyVid, kDualSensePid); + REQUIRE(h.manager->setPadMicMuted(kSonyVid, kDualSensePid, true)); + REQUIRE(h.gateway.muteWrites.size() == 1U); + CHECK(h.gateway.muteWrites[0].first == *claimed->syntheticId); + CHECK(h.gateway.muteWrites[0].second); + CHECK_FALSE(h.manager->setPadMicMuted(kSonyVid, kDualShock4Pid, true)); // not claimed + CHECK(h.gateway.muteWrites.size() == 1U); +} + +namespace { + +class MuteObserver : public UsbDirectObserver { + public: + std::vector> edges; + void padMicMuteChanged(int vendorId, int productId, bool muted) override { + edges.emplace_back(vendorId, productId, muted); + } +}; + +} // namespace + +TEST_CASE("the pad's mute latch reaches the observer on the edge only", + "[usb][manager][feedback]") { + // The decoder owns the latch and folds the wire bit itself; the observer + // exists so the app's store and lamp can mirror it, and it must fire once + // per CHANGE, not once per report -- fifty extra upcalls a second would be + // the read thread paying for nothing. + RecordingGateway gateway; + MuteObserver observer; + gateway.devices.push_back(device(kSonyVid, kDualSensePid)); + UsbGamepadManager manager(&gateway, nullptr, nullptr, &observer); + manager.reconcile(); + REQUIRE(gateway.lastOnReport); + + UsbReport report{}; + gateway.lastOnReport(report); // unmuted, matches the initial state: no edge + CHECK(observer.edges.empty()); + + report.micMuted = true; + gateway.lastOnReport(report); + gateway.lastOnReport(report); // held state repeats: still one edge + REQUIRE(observer.edges.size() == 1U); + CHECK(std::get<0>(observer.edges[0]) == kSonyVid); + CHECK(std::get<1>(observer.edges[0]) == kDualSensePid); + CHECK(std::get<2>(observer.edges[0])); + + report.micMuted = false; + gateway.lastOnReport(report); + REQUIRE(observer.edges.size() == 2U); + CHECK_FALSE(std::get<2>(observer.edges[1])); +} diff --git a/tests/test_usb_output_reports.cpp b/tests/test_usb_output_reports.cpp index 9921a81..fc5fd55 100644 --- a/tests/test_usb_output_reports.cpp +++ b/tests/test_usb_output_reports.cpp @@ -20,13 +20,18 @@ #include using dish::input::usbout::buildLightbarReport; +using dish::input::usbout::buildMicMuteLedReport; using dish::input::usbout::buildPlayerLedsReport; using dish::input::usbout::buildRumbleReport; using dish::input::usbout::buildTriggerEffectsReport; using dish::input::usbout::FeedbackState; using dish::input::usbout::kMaxOutputReportBytes; +using dish::input::usbout::kMicMuteLedOff; +using dish::input::usbout::kMicMuteLedOn; +using dish::input::usbout::kMicMuteLedPulse; using dish::input::usbout::kTriggerEffectBlockBytes; using dish::input::usbout::parserHasLightbar; +using dish::input::usbout::parserHasMicMuteLed; using dish::input::usbout::parserHasPlayerLeds; using dish::input::usbout::parserHasTriggerEffects; using dish::input::usbparse::HidParser; @@ -74,12 +79,18 @@ TEST_CASE("only the families with the hardware carry each actuator", "[usb][outp CHECK(parserHasTriggerEffects(HidParser::DualSense)); CHECK_FALSE(parserHasTriggerEffects(HidParser::DualShock4)); CHECK_FALSE(parserHasTriggerEffects(HidParser::SwitchProUsb)); + + // So is the mic-mute lamp (the DS4 has a mute-less headset path). + CHECK(parserHasMicMuteLed(HidParser::DualSense)); + CHECK_FALSE(parserHasMicMuteLed(HidParser::DualShock4)); + CHECK_FALSE(parserHasMicMuteLed(HidParser::SwitchProUsb)); + CHECK_FALSE(parserHasMicMuteLed(HidParser::GenericHid)); } TEST_CASE("DualShock 4 rumble is report 0x05 with the motors flagged valid", "[usb][output]") { Buf buf{}; - const std::size_t n = - buildRumbleReport(HidParser::DualShock4, 0xAB00, 0xCD00, /*seq=*/0, buf.data(), buf.size()); + const std::size_t n = buildRumbleReport(HidParser::DualShock4, FeedbackState{}, 0xAB00, 0xCD00, + /*seq=*/0, buf.data(), buf.size()); REQUIRE(n == 32); CHECK(buf[0] == 0x05); // report id CHECK(buf[1] == 0x01); // valid flags: motors only, so a colour is untouched @@ -92,8 +103,8 @@ TEST_CASE("DualShock 4 rumble is report 0x05 with the motors flagged valid", "[u TEST_CASE("DualSense rumble is report 0x02 with COMPATIBLE_VIBRATION", "[usb][output]") { Buf buf{}; - const std::size_t n = - buildRumbleReport(HidParser::DualSense, 0x1200, 0x3400, /*seq=*/0, buf.data(), buf.size()); + const std::size_t n = buildRumbleReport(HidParser::DualSense, FeedbackState{}, 0x1200, 0x3400, + /*seq=*/0, buf.data(), buf.size()); REQUIRE(n == 63); CHECK(buf[0] == 0x02); CHECK(buf[1] == 0x01); // valid_flag0 @@ -106,8 +117,8 @@ TEST_CASE("only the top byte of a magnitude reaches an 8-bit motor", "[usb][outp // A caller passing 0x00FF must not produce a motor at 0xFF: the pads take // the high byte, so sub-256 magnitudes are silence, not full power. Buf buf{}; - const std::size_t n = - buildRumbleReport(HidParser::DualSense, 0x00FF, 0x00FF, 0, buf.data(), buf.size()); + const std::size_t n = buildRumbleReport(HidParser::DualSense, FeedbackState{}, 0x00FF, 0x00FF, + 0, buf.data(), buf.size()); REQUIRE(n == 63); CHECK(buf[3] == 0); CHECK(buf[4] == 0); @@ -116,8 +127,8 @@ TEST_CASE("only the top byte of a magnitude reaches an 8-bit motor", "[usb][outp TEST_CASE("Switch Pro rumble encodes both motors and advances the packet counter", "[usb][output]") { Buf buf{}; - const std::size_t n = - buildRumbleReport(HidParser::SwitchProUsb, 0xFFFF, 0, /*seq=*/0x35, buf.data(), buf.size()); + const std::size_t n = buildRumbleReport(HidParser::SwitchProUsb, FeedbackState{}, 0xFFFF, 0, + /*seq=*/0x35, buf.data(), buf.size()); REQUIRE(n == 10); CHECK(buf[0] == 0x10); // rumble-only report CHECK(buf[1] == 0x05); // seq masked to the low nibble @@ -137,7 +148,8 @@ TEST_CASE("Switch Pro amplitude rounds down between table steps", "[usb][output] // Halfway is amp 501, which sits between the 387 and 650 codes. Rounding UP // would make a gentle rumble jump a step. Buf buf{}; - REQUIRE(buildRumbleReport(HidParser::SwitchProUsb, 0x8000, 0, 0, buf.data(), buf.size()) == 10); + REQUIRE(buildRumbleReport(HidParser::SwitchProUsb, FeedbackState{}, 0x8000, 0, 0, buf.data(), + buf.size()) == 10); CHECK(buf[3] == 0x01 + 0x70); CHECK(buf[4] == 0x40 + 0x00); CHECK(buf[5] == 0x5C); @@ -145,10 +157,12 @@ TEST_CASE("Switch Pro amplitude rounds down between table steps", "[usb][output] TEST_CASE("families without motors build nothing rather than a wrong report", "[usb][output]") { Buf buf{}; - CHECK(buildRumbleReport(HidParser::SteamController, 0xFFFF, 0xFFFF, 0, buf.data(), + CHECK(buildRumbleReport(HidParser::SteamController, FeedbackState{}, 0xFFFF, 0xFFFF, 0, + buf.data(), buf.size()) == 0); + CHECK(buildRumbleReport(HidParser::GenericHid, FeedbackState{}, 0xFFFF, 0xFFFF, 0, buf.data(), + buf.size()) == 0); + CHECK(buildRumbleReport(HidParser::None, FeedbackState{}, 0xFFFF, 0xFFFF, 0, buf.data(), buf.size()) == 0); - CHECK(buildRumbleReport(HidParser::GenericHid, 0xFFFF, 0xFFFF, 0, buf.data(), buf.size()) == 0); - CHECK(buildRumbleReport(HidParser::None, 0xFFFF, 0xFFFF, 0, buf.data(), buf.size()) == 0); } TEST_CASE("a buffer too small builds nothing rather than a partial report", "[usb][output]") { @@ -156,16 +170,17 @@ TEST_CASE("a buffer too small builds nothing rather than a partial report", "[us // on the wire; every family gets its own check because each has its own size. std::array tiny{}; FeedbackState st; - CHECK(buildRumbleReport(HidParser::DualShock4, 1, 1, 0, tiny.data(), tiny.size()) == 0); - CHECK(buildRumbleReport(HidParser::DualSense, 1, 1, 0, tiny.data(), tiny.size()) == 0); - CHECK(buildRumbleReport(HidParser::SwitchProUsb, 1, 1, 0, tiny.data(), tiny.size()) == 0); + CHECK(buildRumbleReport(HidParser::DualShock4, st, 1, 1, 0, tiny.data(), tiny.size()) == 0); + CHECK(buildRumbleReport(HidParser::DualSense, st, 1, 1, 0, tiny.data(), tiny.size()) == 0); + CHECK(buildRumbleReport(HidParser::SwitchProUsb, st, 1, 1, 0, tiny.data(), tiny.size()) == 0); CHECK(buildLightbarReport(HidParser::DualShock4, st, 1, 2, 3, tiny.data(), tiny.size()) == 0); CHECK(buildLightbarReport(HidParser::DualSense, st, 1, 2, 3, tiny.data(), tiny.size()) == 0); - CHECK(buildPlayerLedsReport(HidParser::DualSense, 0x01, 0, tiny.data(), tiny.size()) == 0); - CHECK(buildPlayerLedsReport(HidParser::SwitchProUsb, 0x01, 0, tiny.data(), tiny.size()) == 0); + CHECK(buildPlayerLedsReport(HidParser::DualSense, st, 0x01, 0, tiny.data(), tiny.size()) == 0); + CHECK(buildPlayerLedsReport(HidParser::SwitchProUsb, st, 0x01, 0, tiny.data(), tiny.size()) == + 0); const std::array block{}; - CHECK(buildTriggerEffectsReport(HidParser::DualSense, block.data(), block.data(), tiny.data(), - tiny.size()) == 0); + CHECK(buildTriggerEffectsReport(HidParser::DualSense, st, block.data(), block.data(), + tiny.data(), tiny.size()) == 0); // ...and the setup flag must NOT have been consumed by a build that failed. CHECK_FALSE(st.ds5LightbarSetupSent); } @@ -237,8 +252,8 @@ TEST_CASE("player LEDs are masked to the LEDs a family actually has", "[usb][out // families, so passing 0xFF straight through would be setting flags at // random. Buf ds{}; - const std::size_t dsLen = - buildPlayerLedsReport(HidParser::DualSense, 0xFF, /*seq=*/0, ds.data(), ds.size()); + const std::size_t dsLen = buildPlayerLedsReport(HidParser::DualSense, FeedbackState{}, 0xFF, + /*seq=*/0, ds.data(), ds.size()); REQUIRE(dsLen == 63); CHECK(ds[0] == 0x02); CHECK(ds[2] == 0x10); // valid_flag1 PLAYER_INDICATOR_CONTROL_ENABLE @@ -246,8 +261,8 @@ TEST_CASE("player LEDs are masked to the LEDs a family actually has", "[usb][out checkZeroExcept(ds, dsLen, {0, 2, 44}); Buf sw{}; - const std::size_t swLen = - buildPlayerLedsReport(HidParser::SwitchProUsb, 0xFF, /*seq=*/0x21, sw.data(), sw.size()); + const std::size_t swLen = buildPlayerLedsReport(HidParser::SwitchProUsb, FeedbackState{}, 0xFF, + /*seq=*/0x21, sw.data(), sw.size()); REQUIRE(swLen == 12); CHECK(sw[0] == 0x01); // rumble + subcommand report CHECK(sw[1] == 0x01); // seq low nibble @@ -259,7 +274,8 @@ TEST_CASE("Switch player lights ride neutral rumble blocks", "[usb][output]") { // The subcommand shares its report with rumble, so the motor bytes have to // be the neutral code or setting a player number would stop a live rumble. Buf buf{}; - REQUIRE(buildPlayerLedsReport(HidParser::SwitchProUsb, 0x01, 0, buf.data(), buf.size()) == 12); + REQUIRE(buildPlayerLedsReport(HidParser::SwitchProUsb, FeedbackState{}, 0x01, 0, buf.data(), + buf.size()) == 12); CHECK(buf[2] == 0x00); CHECK(buf[3] == 0x01); CHECK(buf[4] == 0x40); @@ -281,8 +297,8 @@ TEST_CASE("trigger-effect blocks are copied verbatim, right block first", "[usb] right[i] = static_cast(0xB0 + i); } Buf buf{}; - const std::size_t n = buildTriggerEffectsReport(HidParser::DualSense, left.data(), right.data(), - buf.data(), buf.size()); + const std::size_t n = buildTriggerEffectsReport( + HidParser::DualSense, FeedbackState{}, left.data(), right.data(), buf.data(), buf.size()); REQUIRE(n == 63); CHECK(buf[0] == 0x02); CHECK(buf[1] == 0x0C); // valid_flag0: right (0x04) + left (0x08) @@ -300,12 +316,12 @@ TEST_CASE("trigger-effect blocks are copied verbatim, right block first", "[usb] TEST_CASE("trigger effects build nothing for a family without adaptive triggers", "[usb][output]") { const std::array block{}; Buf buf{}; - CHECK(buildTriggerEffectsReport(HidParser::DualShock4, block.data(), block.data(), buf.data(), - buf.size()) == 0); - CHECK(buildTriggerEffectsReport(HidParser::SwitchProUsb, block.data(), block.data(), buf.data(), - buf.size()) == 0); - CHECK(buildTriggerEffectsReport(HidParser::None, block.data(), block.data(), buf.data(), - buf.size()) == 0); + CHECK(buildTriggerEffectsReport(HidParser::DualShock4, FeedbackState{}, block.data(), + block.data(), buf.data(), buf.size()) == 0); + CHECK(buildTriggerEffectsReport(HidParser::SwitchProUsb, FeedbackState{}, block.data(), + block.data(), buf.data(), buf.size()) == 0); + CHECK(buildTriggerEffectsReport(HidParser::None, FeedbackState{}, block.data(), block.data(), + buf.data(), buf.size()) == 0); } TEST_CASE("lightbar and player LEDs build nothing for the wrong family", "[usb][output]") { @@ -313,8 +329,10 @@ TEST_CASE("lightbar and player LEDs build nothing for the wrong family", "[usb][ FeedbackState st; CHECK(buildLightbarReport(HidParser::SwitchProUsb, st, 1, 2, 3, buf.data(), buf.size()) == 0); CHECK(buildLightbarReport(HidParser::None, st, 1, 2, 3, buf.data(), buf.size()) == 0); - CHECK(buildPlayerLedsReport(HidParser::DualShock4, 1, 0, buf.data(), buf.size()) == 0); - CHECK(buildPlayerLedsReport(HidParser::GenericHid, 1, 0, buf.data(), buf.size()) == 0); + CHECK(buildPlayerLedsReport(HidParser::DualShock4, FeedbackState{}, 1, 0, buf.data(), + buf.size()) == 0); + CHECK(buildPlayerLedsReport(HidParser::GenericHid, FeedbackState{}, 1, 0, buf.data(), + buf.size()) == 0); } TEST_CASE("the scratch size fits every report a family can produce", "[usb][output]") { @@ -323,9 +341,141 @@ TEST_CASE("the scratch size fits every report a family can produce", "[usb][outp Buf buf{}; FeedbackState st; const std::array block{}; - CHECK(buildRumbleReport(HidParser::DualSense, 1, 1, 0, buf.data(), buf.size()) != 0); + CHECK(buildRumbleReport(HidParser::DualSense, st, 1, 1, 0, buf.data(), buf.size()) != 0); CHECK(buildLightbarReport(HidParser::DualSense, st, 1, 1, 1, buf.data(), buf.size()) != 0); - CHECK(buildPlayerLedsReport(HidParser::DualSense, 1, 0, buf.data(), buf.size()) != 0); - CHECK(buildTriggerEffectsReport(HidParser::DualSense, block.data(), block.data(), buf.data(), - buf.size()) != 0); + CHECK(buildPlayerLedsReport(HidParser::DualSense, st, 1, 0, buf.data(), buf.size()) != 0); + CHECK(buildTriggerEffectsReport(HidParser::DualSense, st, block.data(), block.data(), + buf.data(), buf.size()) != 0); +} + +// ---- The mic-mute lamp ------------------------------------------------------- + +TEST_CASE("the mute lamp report is 0x02 with both valid flags and the amp bit", "[usb][output]") { + // Byte-for-byte with dish-android's buildMicMuteLedReport: valid_flag1 + // gains MIC_MUTE_LED_CONTROL_ENABLE (0x01) + POWER_SAVE_CONTROL_ENABLE + // (0x02), the lamp state rides out[9], and the mic-amp power-save bit + // (0x10 in out[10]) follows the lamp -- a lit mute lamp over a live + // microphone amplifier is the one failure this feature exists to prevent. + Buf buf{}; + FeedbackState st; + const std::size_t n = + buildMicMuteLedReport(HidParser::DualSense, st, kMicMuteLedOn, buf.data(), buf.size()); + REQUIRE(n == 63); + CHECK(buf[0] == 0x02); + CHECK(buf[2] == 0x03); // 0x01 lamp + 0x02 power save + CHECK(buf[9] == kMicMuteLedOn); + CHECK(buf[10] == 0x10); + checkZeroExcept(buf, n, {0, 2, 9, 10}); + CHECK(st.ds5MicMuteLedSet); + CHECK(st.ds5MicMuteLed == kMicMuteLedOn); +} + +TEST_CASE("lamp off clears the amp bit; pulse counts as lit", "[usb][output]") { + Buf buf{}; + FeedbackState st; + REQUIRE(buildMicMuteLedReport(HidParser::DualSense, st, kMicMuteLedOff, buf.data(), + buf.size()) == 63); + CHECK(buf[9] == kMicMuteLedOff); + CHECK(buf[10] == 0x00); + + REQUIRE(buildMicMuteLedReport(HidParser::DualSense, st, kMicMuteLedPulse, buf.data(), + buf.size()) == 63); + CHECK(buf[9] == kMicMuteLedPulse); + CHECK(buf[10] == 0x10); +} + +TEST_CASE("an unknown lamp state is refused, not clamped", "[usb][output]") { + // A state past pulse can only come from a host speaking something newer; + // the dispatch already dropped it, and refusing again keeps the builder + // honest for any other caller. The shadow must NOT record the refusal. + Buf buf{}; + FeedbackState st; + CHECK(buildMicMuteLedReport(HidParser::DualSense, st, 3, buf.data(), buf.size()) == 0); + CHECK(buildMicMuteLedReport(HidParser::DualSense, st, 0xFF, buf.data(), buf.size()) == 0); + CHECK_FALSE(st.ds5MicMuteLedSet); +} + +TEST_CASE("the lamp builds nothing for the wrong family or a short buffer", "[usb][output]") { + Buf buf{}; + FeedbackState st; + CHECK(buildMicMuteLedReport(HidParser::DualShock4, st, kMicMuteLedOn, buf.data(), buf.size()) == + 0); + CHECK(buildMicMuteLedReport(HidParser::SwitchProUsb, st, kMicMuteLedOn, buf.data(), + buf.size()) == 0); + CHECK(buildMicMuteLedReport(HidParser::None, st, kMicMuteLedOn, buf.data(), buf.size()) == 0); + std::array tiny{}; + CHECK(buildMicMuteLedReport(HidParser::DualSense, st, kMicMuteLedOn, tiny.data(), + tiny.size()) == 0); + CHECK_FALSE(st.ds5MicMuteLedSet); +} + +TEST_CASE("every later DS5 write re-asserts the lamp instead of stomping it", "[usb][output]") { + // Each builder memsets a fresh 0x02 report and the firmware applies + // whatever the valid flags claim, so without the re-assert a colour change + // would be a lamp-off write. The lamp bytes must survive every sibling. + Buf buf{}; + FeedbackState st; + REQUIRE(buildMicMuteLedReport(HidParser::DualSense, st, kMicMuteLedOn, buf.data(), + buf.size()) == 63); + + Buf colour{}; + REQUIRE(buildLightbarReport(HidParser::DualSense, st, 0x44, 0x55, 0x66, colour.data(), + colour.size()) == 63); + CHECK((colour[2] & 0x03) == 0x03); // lamp + power-save flags ride along + CHECK(colour[9] == kMicMuteLedOn); + CHECK(colour[10] == 0x10); + CHECK(colour[45] == 0x44); // and the colour is intact + CHECK(colour[46] == 0x55); + CHECK(colour[47] == 0x66); + + Buf rumble{}; + REQUIRE(buildRumbleReport(HidParser::DualSense, st, 0x1200, 0x3400, 0, rumble.data(), + rumble.size()) == 63); + CHECK(rumble[9] == kMicMuteLedOn); + CHECK(rumble[10] == 0x10); + CHECK(rumble[3] == 0x34); + CHECK(rumble[4] == 0x12); + + Buf leds{}; + REQUIRE(buildPlayerLedsReport(HidParser::DualSense, st, 0x1F, 0, leds.data(), leds.size()) == + 63); + CHECK(leds[9] == kMicMuteLedOn); + CHECK(leds[44] == 0x1F); + + const std::array block{}; + Buf effects{}; + REQUIRE(buildTriggerEffectsReport(HidParser::DualSense, st, block.data(), block.data(), + effects.data(), effects.size()) == 63); + CHECK(effects[9] == kMicMuteLedOn); + CHECK(effects[10] == 0x10); +} + +TEST_CASE("the reverse order survives too: a lamp write keeps the shadowed setup", + "[usb][output]") { + // Lightbar first (consumes the one-time handoff), then the lamp: the lamp + // report must not resurrect the handoff, and a following colour must carry + // BOTH the new colour and the lamp. + Buf buf{}; + FeedbackState st; + REQUIRE(buildLightbarReport(HidParser::DualSense, st, 1, 2, 3, buf.data(), buf.size()) == 63); + REQUIRE(buildMicMuteLedReport(HidParser::DualSense, st, kMicMuteLedOn, buf.data(), + buf.size()) == 63); + CHECK(buf[39] == 0); // no handoff resurrection + CHECK(buf[42] == 0); + Buf second{}; + REQUIRE(buildLightbarReport(HidParser::DualSense, st, 7, 8, 9, second.data(), second.size()) == + 63); + CHECK(second[45] == 7); + CHECK(second[9] == kMicMuteLedOn); +} + +TEST_CASE("an undriven lamp leaves every other report untouched", "[usb][output]") { + // A pad whose lamp the host never drove must keep whatever it had: no + // flags, no zeroed field. + Buf buf{}; + FeedbackState st; + REQUIRE(buildLightbarReport(HidParser::DualSense, st, 1, 2, 3, buf.data(), buf.size()) == 63); + CHECK((buf[2] & 0x03) == 0); + CHECK(buf[9] == 0); + CHECK(buf[10] == 0); } diff --git a/tests/test_usb_report_parsers.cpp b/tests/test_usb_report_parsers.cpp index 0f708f5..9feb5fb 100644 --- a/tests/test_usb_report_parsers.cpp +++ b/tests/test_usb_report_parsers.cpp @@ -725,3 +725,87 @@ TEST_CASE("other parsers never see wireless events", "[usb-parsers][steam]") { WirelessEvent::None); CHECK(checkWirelessEvent(HidParser::None, conn.data(), conn.size()) == WirelessEvent::None); } + +// ── DualSense mic-mute button ──────────────────────────────────────────────── + +namespace { + +// A neutral DualSense 0x01 report with the mute button up or down. +std::vector ds5Report(bool muteDown) { + std::vector r(11, 0); + r[0] = 0x01; + r[1] = r[2] = r[3] = r[4] = 128; // sticks centred + r[8] = 0x08; // hat neutral + if (muteDown) { r[10] = 0x04; } + return r; +} + +std::uint16_t ds5Buttons(const std::vector& r, MicMuteLatch& mute) { + ParsedReport out{}; + StickAutoRangeState sticks; + REQUIRE(decodeReport(HidParser::DualSense, r.data(), r.size(), out, sticks, &mute)); + return out.wButtons; +} + +} // namespace + +TEST_CASE("the mute press is an edge that toggles a held state", "[usb-parsers][ds5][mute]") { + // The button is momentary; the wire bit is the STATE it toggles, flipped + // on the way down only. Holding the button must not chatter the mute on + // and off at report rate. + MicMuteLatch mute; + CHECK((ds5Buttons(ds5Report(false), mute) & layout::kXusbMicMute) == 0); + + CHECK((ds5Buttons(ds5Report(true), mute) & layout::kXusbMicMute) != 0); // press: muted + CHECK((ds5Buttons(ds5Report(true), mute) & layout::kXusbMicMute) != 0); // held: still muted + CHECK((ds5Buttons(ds5Report(false), mute) & layout::kXusbMicMute) != + 0); // released: state stays + + CHECK((ds5Buttons(ds5Report(true), mute) & layout::kXusbMicMute) == 0); // second press: live + CHECK((ds5Buttons(ds5Report(false), mute) & layout::kXusbMicMute) == 0); + CHECK_FALSE(mute.muted.load()); +} + +TEST_CASE("an externally written latch rides the next report", "[usb-parsers][ds5][mute]") { + // The UI mute control writes the latch from another thread; the decoder + // folds whatever the latch says, so the app's toggle and the pad's button + // are one state on the wire. + MicMuteLatch mute; + mute.muted.store(true); + CHECK((ds5Buttons(ds5Report(false), mute) & layout::kXusbMicMute) != 0); + // And the pad's own button still toggles it back off from there. + CHECK((ds5Buttons(ds5Report(true), mute) & layout::kXusbMicMute) == 0); +} + +TEST_CASE("a null latch decodes without a mute state", "[usb-parsers][ds5][mute]") { + // The honest answer for a caller that kept no per-device memory — and the + // proof the bit only ever comes from the latch. + ParsedReport out{}; + StickAutoRangeState sticks; + const auto r = ds5Report(true); + REQUIRE(decodeReport(HidParser::DualSense, r.data(), r.size(), out, sticks)); + CHECK((out.wButtons & layout::kXusbMicMute) == 0); +} + +TEST_CASE("the mute bit does not disturb its neighbours in byte 10", "[usb-parsers][ds5][mute]") { + // PS (0x01) and the touchpad click (0x02) share the byte. + MicMuteLatch mute; + auto r = ds5Report(true); + r[10] |= 0x01; // PS held too + ParsedReport out{}; + StickAutoRangeState sticks; + REQUIRE(decodeReport(HidParser::DualSense, r.data(), r.size(), out, sticks, &mute)); + CHECK((out.wButtons & layout::kXusbGuide) != 0); + CHECK((out.wButtons & layout::kXusbMicMute) != 0); +} + +TEST_CASE("only the Sony composites are USB-audio candidates", "[usb-parsers][audio]") { + // The matcher's candidacy gate: a family without a USB Audio Class + // function must never borrow endpoints by name alone. + CHECK(parserHasUsbAudio(HidParser::DualSense)); + CHECK(parserHasUsbAudio(HidParser::DualShock4)); + CHECK_FALSE(parserHasUsbAudio(HidParser::SwitchProUsb)); + CHECK_FALSE(parserHasUsbAudio(HidParser::SteamController)); + CHECK_FALSE(parserHasUsbAudio(HidParser::GenericHid)); + CHECK_FALSE(parserHasUsbAudio(HidParser::None)); +} diff --git a/tests/test_wifi_connection_audio.cpp b/tests/test_wifi_connection_audio.cpp new file mode 100644 index 0000000..94baa84 --- /dev/null +++ b/tests/test_wifi_connection_audio.cpp @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The connection-level half of controller audio: the descriptor's audio caps +// ride the attach facts (and default to ABSENT, which is the whole Wave-1 +// posture), and the host's probed verdict is per-session state that resets with +// the session it was probed for. + +#include "Network/WifiConnection.h" +#include "core/model/Protocol.h" + +#include + +#include + +using dish::net::WifiConnection; +namespace proto = dish::proto; + +namespace { + +dish::models::DiscoveredServer server() { + dish::models::DiscoveredServer s; + s.machineId = QStringLiteral("m1"); + s.ip = QStringLiteral("127.0.0.1"); + s.name = QStringLiteral("Sat"); + return s; +} + +} // namespace + +TEST_CASE("an attach without audio facts advertises neither audio cap", "[wifi][audio]") { + // The Wave-1 end-to-end invariant, at the wire's edge: every caller of + // attachSlot folds the audio route seam, which answers false for every + // slot, so the PUT body must carry mic:false, speaker:false everywhere. + WifiConnection conn(QStringLiteral("mid:m1"), server()); + conn.attachSlot(QStringLiteral("sdl:1"), proto::kControllerTypeDualSense, + /*hasLightbar=*/true, /*hasMotion=*/true, /*hasRumble=*/true, + proto::kTouchpadModeDs4, /*hasTriggerEffects=*/true, /*hasPlayerLeds=*/true); + const auto d = conn.descriptorFor(QStringLiteral("sdl:1")); + REQUIRE(d.has_value()); + CHECK((d->caps & proto::kCapMic) == 0); + CHECK((d->caps & proto::kCapSpeaker) == 0); + // The rest of the fold is untouched by the audio additions. + CHECK((d->caps & proto::kCapTriggerEffects) != 0); + CHECK((d->caps & proto::kCapRumble) != 0); +} + +TEST_CASE("the attach facts fold each audio cap independently", "[wifi][audio]") { + WifiConnection conn(QStringLiteral("mid:m1"), server()); + conn.attachSlot(QStringLiteral("sdl:1"), proto::kControllerTypeDualSense, + /*hasLightbar=*/false, /*hasMotion=*/false, /*hasRumble=*/false, + proto::kTouchpadModeOff, /*hasTriggerEffects=*/false, /*hasPlayerLeds=*/false, + /*hasMic=*/true, /*hasSpeaker=*/false); + auto d = conn.descriptorFor(QStringLiteral("sdl:1")); + REQUIRE(d.has_value()); + CHECK((d->caps & proto::kCapMic) != 0); + CHECK((d->caps & proto::kCapSpeaker) == 0); + + // A re-attach with the toggles moved converges the descriptor, same as + // every other capability fact. + conn.attachSlot(QStringLiteral("sdl:1"), proto::kControllerTypeDualSense, false, false, false, + proto::kTouchpadModeOff, false, false, /*hasMic=*/false, /*hasSpeaker=*/true); + d = conn.descriptorFor(QStringLiteral("sdl:1")); + REQUIRE(d.has_value()); + CHECK((d->caps & proto::kCapMic) == 0); + CHECK((d->caps & proto::kCapSpeaker) != 0); +} + +TEST_CASE("the host audio verdict defaults conservative and resets with the session", + "[wifi][audio]") { + WifiConnection conn(QStringLiteral("mid:m1"), server()); + // No probe has landed: no audio, in either direction. + CHECK_FALSE(conn.hostMicAvailable()); + CHECK_FALSE(conn.hostSpeakerAvailable()); + + conn.setHostControllerAudio(/*mic=*/true, /*speaker=*/true); + CHECK(conn.hostMicAvailable()); + CHECK(conn.hostSpeakerAvailable()); + + // The directions move independently, as the host switches them. + conn.setHostControllerAudio(/*mic=*/false, /*speaker=*/true); + CHECK_FALSE(conn.hostMicAvailable()); + CHECK(conn.hostSpeakerAvailable()); + + // A teardown is a new session next time, and the verdict was THIS + // session's: it must not survive into one the host may have re-switched. + // (markConnecting first: an Idle connection with no client short-circuits + // markDisconnected, and only a session that existed can be torn down.) + conn.markConnecting(); + conn.markDisconnected(); + CHECK_FALSE(conn.hostMicAvailable()); + CHECK_FALSE(conn.hostSpeakerAvailable()); +} diff --git a/tests/tsan.suppressions b/tests/tsan.suppressions index 1d153b3..306d086 100644 --- a/tests/tsan.suppressions +++ b/tests/tsan.suppressions @@ -13,6 +13,15 @@ called_from_lib:libQt6Core.so called_from_lib:libQt6DBus.so # The HTTP thread QNetworkAccessManager runs the manifest fetch on. called_from_lib:libQt6Network.so +# Qt 6.9 reimplemented QWaitCondition over std::condition_variable, so a Qt +# thread handoff now enters the interceptor through libstdc++'s out-of-line +# condition_variable::wait instead of a libQt6* frame, and the lines above no +# longer match: TSan then reports "unlock of an unlocked mutex" against a +# mutex living inside Qt-allocated memory it never saw locked (seen from +# QNetworkAccessManager's worker startup under test_moonlight_binding_refcount). +# Safe ONLY while no first-party code uses std::condition_variable — both of +# those waits would be silenced too. Grep src/ before keeping this line. +called_from_lib:libstdc++.so called_from_lib:libglib-2.0.so called_from_lib:libgobject-2.0.so called_from_lib:libdbus-1.so diff --git a/translations/dish_bs.ts b/translations/dish_bs.ts index 4b41ac8..5b53828 100644 --- a/translations/dish_bs.ts +++ b/translations/dish_bs.ts @@ -137,6 +137,14 @@ Player LEDs LED indikatori igrača + + Microphone + Mikrofon + + + Controller sound + Zvuk kontrolera + buttons or sticks dugmad ili palice @@ -173,6 +181,14 @@ player LEDs LED indikatore igrača + + microphone + mikrofon + + + speaker + zvučnik + Waiting on a destination. Čeka se odredište. @@ -657,6 +673,30 @@ Auto sends %1 for this controller. Automatski šalje %1 za ovaj kontroler. + + Microphone + Mikrofon + + + The pad’s mic carries your voice to the host. Off sends nothing. + Mikrofon pada prenosi vaš glas do hosta. Isključeno ne šalje ništa. + + + Mic muted + Mikrofon utišan + + + Mic live + Mikrofon aktivan + + + Controller sound + Zvuk kontrolera + + + Audio from the host plays on the pad’s speaker or headset. + Zvuk s hosta se reproducira na zvučniku ili slušalicama pada. + Handing the device over can take a few seconds. Predaja uređaja može potrajati nekoliko sekundi. @@ -1030,6 +1070,22 @@ Lightbar Svjetlosna traka + + Mic muted + Mikrofon utišan + + + Mic live + Mikrofon aktivan + + + Microphone muted, click to unmute + Mikrofon je utišan, kliknite da ga uključite + + + Microphone live, click to mute + Mikrofon je aktivan, kliknite da ga utišate + Motion %1 Hz Pokret %1 Hz @@ -3798,6 +3854,22 @@ The game drives the pad’s light bar. Lightbar forwarding is a global setting. Igra upravlja svjetlosnom trakom pada. Prosljeđivanje svjetlosne trake je globalna postavka. + + Microphone + Mikrofon + + + The pad’s mic carries your voice to the host. Off sends nothing. + Mikrofon pada prenosi vaš glas do hosta. Isključeno ne šalje ništa. + + + Controller sound + Zvuk kontrolera + + + Audio from the host plays on the pad’s speaker or headset. + Zvuk s hosta se reproducira na zvučniku ili slušalicama pada. + Off Isključeno diff --git a/translations/dish_de.ts b/translations/dish_de.ts index e523917..72269d3 100644 --- a/translations/dish_de.ts +++ b/translations/dish_de.ts @@ -137,6 +137,14 @@ Player LEDs Spieler-LEDs + + Microphone + Mikrofon + + + Controller sound + Controller-Ton + buttons or sticks Tasten oder Sticks @@ -173,6 +181,14 @@ player LEDs Spieler-LEDs + + microphone + Mikrofon + + + speaker + Lautsprecher + Waiting on a destination. Warte auf ein Ziel. @@ -655,6 +671,30 @@ Auto sends %1 for this controller. Automatisch sendet %1 für diesen Controller. + + Microphone + Mikrofon + + + The pad’s mic carries your voice to the host. Off sends nothing. + Das Mikrofon des Pads überträgt Ihre Stimme zum Host. Aus sendet nichts. + + + Mic muted + Mikrofon stumm + + + Mic live + Mikrofon aktiv + + + Controller sound + Controller-Ton + + + Audio from the host plays on the pad’s speaker or headset. + Ton vom Host läuft über den Lautsprecher oder das Headset des Pads. + Handing the device over can take a few seconds. Die Übergabe des Geräts kann ein paar Sekunden dauern. @@ -1022,6 +1062,22 @@ Lightbar Lichtleiste + + Mic muted + Mikrofon stumm + + + Mic live + Mikrofon aktiv + + + Microphone muted, click to unmute + Mikrofon stummgeschaltet, zum Aufheben klicken + + + Microphone live, click to mute + Mikrofon aktiv, zum Stummschalten klicken + Motion %1 Hz Bewegung %1 Hz @@ -3777,6 +3833,22 @@ The game drives the pad’s light bar. Lightbar forwarding is a global setting. Das Spiel steuert die Lichtleiste des Pads. Die Weiterleitung der Lichtleiste ist eine globale Einstellung. + + Microphone + Mikrofon + + + The pad’s mic carries your voice to the host. Off sends nothing. + Das Mikrofon des Pads überträgt Ihre Stimme zum Host. Aus sendet nichts. + + + Controller sound + Controller-Ton + + + Audio from the host plays on the pad’s speaker or headset. + Ton vom Host läuft über den Lautsprecher oder das Headset des Pads. + Off Aus diff --git a/translations/dish_en.ts b/translations/dish_en.ts index ae5568f..d951656 100644 --- a/translations/dish_en.ts +++ b/translations/dish_en.ts @@ -137,6 +137,14 @@ Player LEDs Player LEDs + + Microphone + Microphone + + + Controller sound + Controller sound + buttons or sticks buttons or sticks @@ -173,6 +181,14 @@ player LEDs player LEDs + + microphone + microphone + + + speaker + speaker + Waiting on a destination. Waiting on a destination. @@ -655,6 +671,30 @@ Auto sends %1 for this controller. Auto sends %1 for this controller. + + Microphone + Microphone + + + The pad’s mic carries your voice to the host. Off sends nothing. + The pad’s mic carries your voice to the host. Off sends nothing. + + + Mic muted + Mic muted + + + Mic live + Mic live + + + Controller sound + Controller sound + + + Audio from the host plays on the pad’s speaker or headset. + Audio from the host plays on the pad’s speaker or headset. + Handing the device over can take a few seconds. Handing the device over can take a few seconds. @@ -1060,6 +1100,22 @@ Lightbar Lightbar + + Mic muted + Mic muted + + + Mic live + Mic live + + + Microphone muted, click to unmute + Microphone muted, click to unmute + + + Microphone live, click to mute + Microphone live, click to mute + Motion %1 Hz Motion %1 Hz @@ -3777,6 +3833,22 @@ The game drives the pad’s light bar. Lightbar forwarding is a global setting. The game drives the pad’s light bar. Lightbar forwarding is a global setting. + + Microphone + Microphone + + + The pad’s mic carries your voice to the host. Off sends nothing. + The pad’s mic carries your voice to the host. Off sends nothing. + + + Controller sound + Controller sound + + + Audio from the host plays on the pad’s speaker or headset. + Audio from the host plays on the pad’s speaker or headset. + Off Off diff --git a/translations/dish_es.ts b/translations/dish_es.ts index 73995c3..4cb2b64 100644 --- a/translations/dish_es.ts +++ b/translations/dish_es.ts @@ -137,6 +137,14 @@ Player LEDs LED de jugador + + Microphone + Micrófono + + + Controller sound + Sonido del mando + buttons or sticks botones o sticks @@ -173,6 +181,14 @@ player LEDs LED de jugador + + microphone + micrófono + + + speaker + altavoz + Waiting on a destination. Falta elegir un destino. @@ -655,6 +671,30 @@ Auto sends %1 for this controller. Automático envía %1 para este mando. + + Microphone + Micrófono + + + The pad’s mic carries your voice to the host. Off sends nothing. + El micrófono del mando lleva tu voz al host. Apagado no envía nada. + + + Mic muted + Micrófono silenciado + + + Mic live + Micrófono activo + + + Controller sound + Sonido del mando + + + Audio from the host plays on the pad’s speaker or headset. + El audio del host suena por el altavoz o los auriculares del mando. + Handing the device over can take a few seconds. Ceder el dispositivo puede tardar unos segundos. @@ -1022,6 +1062,22 @@ Lightbar Barra de luz + + Mic muted + Micrófono silenciado + + + Mic live + Micrófono activo + + + Microphone muted, click to unmute + Micrófono silenciado, haz clic para activarlo + + + Microphone live, click to mute + Micrófono activo, haz clic para silenciarlo + Motion %1 Hz Movimiento %1 Hz @@ -3777,6 +3833,22 @@ The game drives the pad’s light bar. Lightbar forwarding is a global setting. El juego controla la barra de luz del mando. El reenvío de la barra de luz es un ajuste global. + + Microphone + Micrófono + + + The pad’s mic carries your voice to the host. Off sends nothing. + El micrófono del mando lleva tu voz al host. Apagado no envía nada. + + + Controller sound + Sonido del mando + + + Audio from the host plays on the pad’s speaker or headset. + El audio del host suena por el altavoz o los auriculares del mando. + Off Apagado diff --git a/translations/dish_fr.ts b/translations/dish_fr.ts index eb23b8e..538e3d0 100644 --- a/translations/dish_fr.ts +++ b/translations/dish_fr.ts @@ -137,6 +137,14 @@ Player LEDs LED de joueur + + Microphone + Microphone + + + Controller sound + Son de la manette + buttons or sticks boutons ou sticks @@ -173,6 +181,14 @@ player LEDs LED de joueur + + microphone + microphone + + + speaker + haut-parleur + Waiting on a destination. En attente d'une destination. @@ -655,6 +671,30 @@ Auto sends %1 for this controller. Automatique envoie %1 pour cette manette. + + Microphone + Microphone + + + The pad’s mic carries your voice to the host. Off sends nothing. + Le micro de la manette porte votre voix vers l’hôte. Désactivé, rien n’est envoyé. + + + Mic muted + Micro coupé + + + Mic live + Micro actif + + + Controller sound + Son de la manette + + + Audio from the host plays on the pad’s speaker or headset. + L’audio de l’hôte est lu sur le haut-parleur ou le casque de la manette. + Handing the device over can take a few seconds. Céder le périphérique peut prendre quelques secondes. @@ -1022,6 +1062,22 @@ Lightbar Barre lumineuse + + Mic muted + Micro coupé + + + Mic live + Micro actif + + + Microphone muted, click to unmute + Micro coupé, cliquez pour le réactiver + + + Microphone live, click to mute + Micro actif, cliquez pour le couper + Motion %1 Hz Mouvement %1 Hz @@ -3777,6 +3833,22 @@ The game drives the pad’s light bar. Lightbar forwarding is a global setting. Le jeu pilote la barre lumineuse de la manette. La transmission de la barre lumineuse est un paramètre global. + + Microphone + Microphone + + + The pad’s mic carries your voice to the host. Off sends nothing. + Le micro de la manette porte votre voix vers l’hôte. Désactivé, rien n’est envoyé. + + + Controller sound + Son de la manette + + + Audio from the host plays on the pad’s speaker or headset. + L’audio de l’hôte est lu sur le haut-parleur ou le casque de la manette. + Off Désactivé diff --git a/translations/dish_pt_BR.ts b/translations/dish_pt_BR.ts index fe97968..260a4c4 100644 --- a/translations/dish_pt_BR.ts +++ b/translations/dish_pt_BR.ts @@ -137,6 +137,14 @@ Player LEDs LEDs de jogador + + Microphone + Microfone + + + Controller sound + Som do controle + buttons or sticks botões ou analógicos @@ -173,6 +181,14 @@ player LEDs LEDs de jogador + + microphone + microfone + + + speaker + alto-falante + Waiting on a destination. Aguardando um destino. @@ -655,6 +671,30 @@ Auto sends %1 for this controller. Automático envia %1 para este controle. + + Microphone + Microfone + + + The pad’s mic carries your voice to the host. Off sends nothing. + O microfone do controle leva sua voz ao host. Desligado não envia nada. + + + Mic muted + Microfone mudo + + + Mic live + Microfone ativo + + + Controller sound + Som do controle + + + Audio from the host plays on the pad’s speaker or headset. + O áudio do host toca no alto-falante ou fone do controle. + Handing the device over can take a few seconds. Liberar o dispositivo pode levar alguns segundos. @@ -1022,6 +1062,22 @@ Lightbar Barra de luz + + Mic muted + Microfone mudo + + + Mic live + Microfone ativo + + + Microphone muted, click to unmute + Microfone mudo, clique para reativar + + + Microphone live, click to mute + Microfone ativo, clique para silenciar + Motion %1 Hz Movimento %1 Hz @@ -3777,6 +3833,22 @@ The game drives the pad’s light bar. Lightbar forwarding is a global setting. O jogo controla a barra de luz do controle. O encaminhamento da barra de luz é uma configuração global. + + Microphone + Microfone + + + The pad’s mic carries your voice to the host. Off sends nothing. + O microfone do controle leva sua voz ao host. Desligado não envia nada. + + + Controller sound + Som do controle + + + Audio from the host plays on the pad’s speaker or headset. + O áudio do host toca no alto-falante ou fone do controle. + Off Desligado