Skip to content

Controller audio: mic + speaker over the Satellite protocol - #48

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

Controller audio: mic + speaker over the Satellite protocol#48
emir-hasanbegovic merged 4 commits into
mainfrom
feat/controller-audio

Conversation

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

Ports controller audio (protocol 2 extension; satellite TinkerNorth/satellite#88, dish-android TinkerNorth/dish-android#178, dish-windows TinkerNorth/dish-windows#66) to the Linux client. Physical Direct-claimed pads only; no virtual controller exists here.

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

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

Of the 33 new source files, 29 are byte-identical to dish-windows; the four that differ are three cross-platform comment variants and the POSIX loopback test. 12 new test suites plus 10 extended ones. This box has no Linux toolchain, so beyond syntax/format/translation gates the proof is this PR's CI: the compile+ctest lane, the stricter clang-tidy, TSan over the engines, and the package job's shlibdeps.

…lamp

The client half of the protocol-2 controller-audio extension
(MSG_MIC_AUDIO 0x0012 / MSG_SPEAKER_AUDIO 0x0013 / MSG_MIC_LED 0x0014),
ported from dish-windows feat/controller-audio (389134b wave 1 + 4218a0a
wave 2) in one pass; dish-android shipped the reference client. The
shared audio cores land byte-identical with the windows copies:
AudioJitter.h is another mirror of the satellite's reorder window (same
names on purpose - edit them together), the Opus wrapper pins the same
fixed formats (mic mono VOIP 32 kbps FEC+DTX, speaker stereo AUDIO
96 kbps FEC), and the matcher, engine policy, host verdict, stores,
engines and their tests diff clean against windows except where this
platform genuinely differs. What differs, and why:

- The matcher's pad string is the USB `product` attribute (iProduct)
  read from sysfs, not HID_NAME: the kernel builds HID_NAME as
  "<manufacturer> <product>", and that longer string is contained in no
  pipewire/pulse endpoint name, while iProduct is exactly the string the
  audio stack derives its device names from - and what Windows and
  android enumerate, so a pad also shows one name across clients now.
  HID_NAME stays as the fallback for a device with no product string.

- The Moonlight strip needs no new chokepoint: buttons fold through
  moonmap::toMoonlightButtons, an explicit per-bit map with no row for
  0x0800, so the mic-mute state bit structurally cannot leak to a
  GameStream host. A pin test keeps that from eroding into an
  accidental row, and the map's comment now says the explicitness is
  load-bearing.

- libopus arrives through pkg_check_modules like sodium and SDL2, and
  dpkg-shlibdeps / rpm's soname scanner pick the runtime dependency up
  from DT_NEEDED, so the packaging Depends lists need nothing. The
  Flatpak gains --socket=pulseaudio (pipewire-pulse serves it on
  PipeWire desktops) and flips its bundled SDL to -DSDL_AUDIO=ON; the
  AppImage job does the same and adds libpulse-dev so SDL's pulse
  backend configures (SDL dlopen's the host libpulse at run time, so
  nothing new is bundled). The KDE runtime already ships libopus, so
  the Flatpak needs no opus module - and the REQUIRED opus.pc check
  fails the build loudly if a runtime bump ever drops it.

- SatelliteClient's audio arms ride the POSIX socket shape it already
  has: sendEncrypted was already bool with the MSG_DONTWAIT/EAGAIN
  soft-drop, so the port adds only the 1472-byte oversize refusal
  (logged once), the full-datagram receive buffer (recvfrom truncates,
  and a truncated SPEAKER_AUDIO ciphertext fails the AEAD silently),
  and the two parsers; the loopback test speaks sockaddr_in/close
  instead of Winsock.

The capture engine enforces the privacy invariant unchanged: muted,
toggled off, unrouted, unstreaming or unwelcome at the host means the
capture device is CLOSED and zero MSG_MIC_AUDIO packets leave - never
silent ones - and seq still advances on failed encodes. Mute stays
session-scoped (it clears when the pad leaves, like the hardware's
own); the durable off-switch is the per-binding Microphone toggle,
still OFF by default. The DualSense mute button lives as a decoder-
owned latch folded into wButtons on the read thread, the lamp answers
locally at once, and a later host MSG_MIC_LED repaints it - last
writer wins on the PAD while the UI shows the local truth.
This repo's clang-tidy runs performance-enum-size, which the Windows
client's config does not; the enum is shared code, so the base type
lands in both repos to keep the files byte-identical.
Qt 6.9 rebuilt QWaitCondition over std::condition_variable, so a Qt
thread handoff now reaches the pthread interceptor with libstdc++ as
the immediate caller and the existing libQt6* called_from_lib lines no
longer match. TSan then flags an unlock-of-unlocked mutex inside
Qt-allocated memory it never saw locked; QNetworkAccessManager's worker
startup under test_moonlight_binding_refcount trips it depending on
scheduling, which is why the same code passed and failed across
identical reruns. No first-party code uses std::condition_variable
(the file says to re-check before keeping the line).
@emir-hasanbegovic
emir-hasanbegovic marked this pull request as ready for review September 2, 2026 17:23
@emir-hasanbegovic

Copy link
Copy Markdown
Contributor Author

All lanes green: both compiler legs, ASan+UBSan, TSan, coverage, the .deb package-and-launch gate, and CodeQL. Two follow-ups landed after the initial push: the shared Stream enum gained a byte-sized base (this repo's clang-tidy runs performance-enum-size; the file stays byte-identical with dish-windows), and tests/tsan.suppressions learned that Qt 6.9 rebuilt QWaitCondition over std::condition_variable, which routed Qt's thread handoffs past the existing libQt6* suppressions and produced a scheduling-dependent unlock-of-unlocked report from QNetworkAccessManager's worker startup; the new line is constrained by a grep-verified fact recorded in the file (no first-party std::condition_variable exists). Marking ready. Live-hardware items (pipewire endpoint naming on a real DualSense, lamp/amp bytes against firmware) are listed in the PR body.

@emir-hasanbegovic
emir-hasanbegovic enabled auto-merge (squash) September 2, 2026 18:00
@emir-hasanbegovic
emir-hasanbegovic merged commit f461960 into main Sep 2, 2026
13 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the feat/controller-audio branch September 2, 2026 18:18
emir-hasanbegovic added a commit that referenced this pull request Sep 2, 2026
Completes the fleet-wide build unification (satellite's landed inside
TinkerNorth/satellite#88, dish-windows in TinkerNorth/dish-windows#67):
local builds and CI share the same entry points so they cannot drift.
This repo already had the fleet's best piece (ci_local.sh,
build-appimage.sh); this converges the rest.

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

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

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

Stacked on #48 (both edit linux-ci.yml); will be rebased onto main once
that merges.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant