Skip to content

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

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

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

Conversation

@emir-hasanbegovic

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

Copy link
Copy Markdown
Contributor

Controller audio over the Satellite protocol: the phone (virtual pad) or a plugged DualSense / DualShock 4 v2 supplies the emulated pad's microphone, and plays the emulated pad's speaker/headset audio. Extends the unreleased protocol 2 in place (no version bump). Server counterpart: TinkerNorth/satellite PR #88.

Scope: the controller's OWN audio endpoints, never host game audio. The Moonlight path is untouched (that protocol has no controller-audio or mic channel).

What this adds

  • Wire: MSG_MIC_AUDIO 0x0012 (up), MSG_SPEAKER_AUDIO 0x0013 (down), MSG_MIC_LED 0x0014 (down); caps mic 0x0040 / speaker 0x0080; datagram ceiling 1500 (inner payload 1472); Opus 48 kHz 20 ms both ways (mic mono VOIP 32 kbps with in-band FEC, speaker stereo 96 kbps), a 2-frame reorder window with PLC, no acks or retransmits.
  • Capability model and UI: Feature.MIC/SPEAKER through every layer (virtual pad unconditional; physical DS5/DS4v2 gated on the pad's USB audio function being routable; host layer gated on the satellite's per-backend audio verdict), binding-screen toggles (mic defaults off), setup rows, destination card pills, six locales.
  • Mic pipeline: AudioRecord VOICE_COMMUNICATION 48 kHz mono, URGENT_AUDIO capture thread, RECORD_AUDIO + FOREGROUND_SERVICE_MICROPHONE with the microphone FGS type held only while a mic-enabled binding streams. Privacy invariant, enforced twice and proven by tests: muted, toggled off, permission absent, or not streaming means zero mic packets on the wire, and mute closes the recorder (no green dot).
  • Mute end to end: on-screen mute button on the DualSense skin and the physical DS5 mute button drive one per-slot mute state, ride wButtons 0x0800, and render the mute lamp (host MSG_MIC_LED overrides, pulse breathes, physical DS5 lamp written with the power-save mic-amp mute).
  • Speaker playback: per-slot AudioTrack fed by a non-blocking native dispatch, routed to a physical pad's own headset/speaker via preferred-device when the OS exposes the pad's audio function.
  • Store paperwork updated truthfully: PRIVACY.md, Data Safety, permissions justification, content rating.

Pre-existing protocol-2 bugs found and fixed along the way

  • Descriptor caps were hardcoded, so CAP_LIGHTBAR / CAP_TRIGGER_EFFECTS / CAP_PLAYER_LEDS never actually reached the wire; the composer's resolved caps now do.
  • The host capabilities document was only ever read from the binding screen, so an auto-reconnected session streamed with the controller-audio verdict unknown (off); a per-session probe now runs when a link goes Live.

Tests: 2389 JVM (+240 over main), 306 native (+55), 10 instrumented on-device integration tests including the full mic eligibility matrix and real-codec wire round trips. All CI green including the emulator job.


Following the satellite's controller-audio changes

Three changes that pair with satellite #88, plus one regression that pairing created.

DTX on the mic encoder

The mic stream paid full price for a quiet room. A live microphone never goes digitally silent — room tone, breath and the preamp floor are all real samples — so nothing downstream can collapse it and the only thing that can is the encoder's own VAD. OPUS_SET_DTX(1) turns it on.

This is the encoder that actually ships: the satellite's IAudioCodecFactory exposes only makeMicDecoder and makeSpeakerEncoder, so its mic encoder is test-only and the bandwidth was always going to be saved here. docs/contract.md had already mandated it (| Silence | Opus DTX on (encoder-side VAD) |); the client was simply non-conformant.

The speaker encoder deliberately does not get it — that gate cuts anything ~26–30 dB below the recent peak, which on game audio replaces a reverb tail with comfort noise. The satellite suppresses exact digital silence on that stream instead, which cannot touch anything a listener could hear.

Muting is unaffected and remains the stricter thing: it stops delivery outright, which DTX cannot do.

Verified nothing chokes on the 1-byte packets DTX emits — the JNI send path refuses only a zero-length encode, seq still advances per packet, header + 1 is exactly AUDIO_WIRE_MIN_PAYLOAD_BYTES, and the reorder window accepts one. FakeSatellite already re-implemented the host's guard for this case.

Corrects a wrong comment while here, in the same terms as the host: the expected-loss hint, not the application, picks the mode. It forces SILK in, so both streams are Hybrid fullband and both really do carry in-band FEC. The old text said VOIP was what bought FEC and implied the speaker had none; anyone acting on that by dropping the hint would have reached CELT and silently deleted working FEC from both streams.

The playout cushion, after the host learned to go quiet

The satellite now sends nothing at all for a digitally silent speaker window. The reorder window handles that perfectly — it has no clock, so a stall is not representable in its state, and a resumption at a contiguous seq takes the fast path as if no time had passed. Confirmed by reading rather than assuming: no <chrono>, no timeout, no watchdog, and the dispatch thread uses wait, not wait_for.

But the AudioTrack under it drains while the quiet lasts, and that part was not handled. The two-window start threshold exists because "a track started empty plays the first window and then underruns, and every underrun is an audible click" — and it was latched. playing was set once and the cushion never rebuilt. Fine while the stream was continuous, which it was until the host stopped sending silence; now the first quiet stretch destroys it and the stream runs at nearly zero occupancy afterwards.

Refilled with silence rather than a pause-and-re-prime: withholding windows until the threshold is met again would strand any sound shorter than the cushion, leaving a lone 20 ms blip unplayed until the next one arrived — worse than the click. The trigger is AudioTrack's own underrun counter, which keeps wrapping frame arithmetic out of the one path this file warns is audible when wrong. The decision is a pure function beside SpeakerPlayoutPolicy, because the JVM suite stubs android.media and nothing else about the track is reachable from a unit test.

The host's per-direction verdict

HostFeatureSet carried one boolean that fanned out to both MIC and SPEAKER, which cannot express what the host now reports. Split in two. Everything downstream follows unchanged — capture and playout already read the features off SlotCapabilities.live, and the host layer is where that is decided.

The new DTO field is nullable, like motion and unlike host, because an absent block is unknown rather than off: a satellite predating it still carries audio and still reports it on the per-backend audio flag, so reading a missing block as two falses would mute every older host. Absent falls back to that flag for both directions; present wins outright.

The advertised wire caps are deliberately left alone. SatelliteConnectionManager projects wireCaps through distinctUntilChanged precisely so host and runtime changes that do not move the descriptor raise no wire traffic, and the contract lets the host flip these two under a live stream — feeding them in would turn every host-side toggle into a mid-stream descriptor re-declare on every bound slot. Nothing leaks by leaving it: the phone stops sourcing either way.

Verification

2408 JVM tests on both flavors (2389 before), 310 native (306 before), ktlint / detekt / clang-format 22.1.4 all clean.

Not verified without hardware: whether post-stall clicking was audible on a real device in the first place, and whether the refill removes it. The logic is unit-tested; the audibility is not. It needs a device: play, let the host go quiet for ~5 s, resume, and watch dumpsys media.audio_flinger underrun counts.

Two things worth knowing, neither fixed here

A pre-existing replug bug that this change makes worse. On a controller replug within a live session the host resets speakerSeq to 0 — deliberately, since a carried-over sequence number "would make the new stream look like a 40 ms reorder of the old one." The client never resets its matching reorder window, so every frame comes back Accept::Late and is silently dropped until the host's counter climbs back past where the client left off. That used to self-heal at 50 frames/second because silence still advanced seq; now only real audio does, so a mostly-quiet game recovers far more slowly. Fixing it needs a JNI reset entry point the client does not have today.

Mid-stream host toggles do not reach the client. The capabilities document is probed once per session handle and otherwise only re-read from the binding screen. The host may now flip mic/speaker under a live stream, and nothing polls or pushes, so a change lands only at the next reconnect or configure-screen visit.

The client half of controller audio starts where every capability does:
the model that decides whether an emulated pad may claim a microphone and
a speaker at all, and the surfaces that tell the user about it. Nothing
captures or plays yet; the later waves fill the pipelines behind these
caps.

Two new features, MIC (SEND, slug "mic") and SPEAKER (RECEIVE, slug
"speaker"), with descriptor bits CAP_MIC 0x0040 and CAP_SPEAKER 0x0080
mirrored off satellite core/types.h and named booleans in the descriptor
JSON. They are independent directions: a client can play a pad's audio
without sourcing its microphone, and either can be advertised alone.

Where they come from, layer by layer:

- Input. The virtual pad claims both unconditionally, because the phone
  IS the actuator: its own microphone and speaker stand in for the
  emulated pad's endpoints, so neither needs a hardware probe. A
  Direct-claimed physical pad claims only what Android actually routes:
  we take the HID interface and leave the pad's USB-audio function with
  the OS, so the model tables are the wrong source and the new
  PadAudioRoutes table is the right one. Its resolver lands with the
  playback wave; until then it reports no routes and physical pads
  advertise neither surface, which is the honest answer for a pad whose
  audio function the OS never enumerated.

- Type. The catalog's mic/speaker slugs flow through the existing
  slug-driven mapper. The bundled offline fallback carries them on the
  two Sony types only, since those are the identities a host can
  materialize with audio endpoints. The legacy v1 translator explicitly
  does not: v1 is a fixed historical shape, and a satellite still
  serving it predates controller audio.

- Host. This is the one runtime-switched fact, so unlike the other
  per-type surfaces the host layer really does gate it. The per-backend
  `audio` flag on GET /api/server/capabilities folds the host's
  controllerAudio setting into the backend's own ability, and only an
  AVAILABLE backend reporting it opens the gate. It is opt-in: an
  unprobed satellite (or one predating the field) offers no audio, so we
  never cost a user a permission prompt for a host that cannot land it.
  Because no other document carries `audio`, the probe merges it in on
  its own and a later catalog write carries it forward.

- Transport. Satellite only. The Moonlight control protocol has no
  controller-audio message and no microphone channel, so its profile is
  untouched.

The two toggles ride the binding screen next to motion and rumble, with
the same per-slot persistence, and gate the wire caps: a slot that will
not capture must not have the host accept mic frames or send it a mute
lamp, and one that will not play must not be sent audio at all. Mic
defaults OFF (it is opt-in, and it needs a runtime grant), speaker
defaults ON like rumble. When RECORD_AUDIO is missing the mic row says
so and offers the ask rather than being a switch that silently does
nothing; MicPermissionGate publishes the grant and the request seam is
one function in the activity for the capture wave to fill.

The rest is description: Microphone and Controller sound rows on the
setup capability tables (relevance-filtered like Battery and Light bar),
mic/speaker chips on the destination cards and review flows, capability
pills on the dashboard card, two new 24dp stroke icons matching the
existing feature set, and strings in all six languages.
The client's half of the two audio streams, from the socket up to a JNI
surface the capture and playback waves can call. Nothing captures or
plays yet; what lands here is the transport, the codec, and the ordering
rules both ends of a stream have to agree on.

Descriptor caps first, because it turned out none of this could have
reached the wire. SatelliteConnection built every descriptor as a
hardcoded base (analog triggers + rumble) OR'd with a motion bit, while
CapabilityResolver.wireCaps had been computing the whole word since
protocol 2 and the manager was already watching it for change detection.
So CAP_LIGHTBAR, CAP_TRIGGER_EFFECTS and CAP_PLAYER_LEDS were resolved
and then dropped on the floor, and CAP_MIC/CAP_SPEAKER would have joined
them. The connection now takes a per-slot caps provider and uses it at
all three sites; the composer projects it through wireCapsFor. The
"Android has no controller-LED API" comment that justified the old base
goes with it: it stopped being true when the feedback return paths
landed.

The wire, mirroring satellite core/types.h byte for byte: MSG_MIC_AUDIO
0x0012 up, MSG_SPEAKER_AUDIO 0x0013 down, MSG_MIC_LED 0x0014 down, the
two stream messages sharing one ctrlIdx + big-endian seq header ahead of
exactly one 20 ms Opus packet. The datagram ceilings move with them: a
whole Opus packet does not fit the old 256-byte inner payload or the old
128-byte receive buffer, so both are now derived from the contract's
1500-byte MTU rather than from what today's senders happen to emit, with
static asserts tying the buffers to that arithmetic.

Opus comes from a pinned release tarball through FetchContent, the same
shape as libsodium, and costs ~400 KB per ABI in a release-grade build.
The wrapper pins the formats the wire fixes (mic mono VOIP 32 kbps with
in-band FEC, speaker stereo 48 kHz) and refuses a window that is not
exactly 20 ms, because a mis-framed packet is one the far end cannot
place in its timeline.

audio_jitter.h is a deliberate copy of satellite's reorder window rather
than a reimplementation of it: both ends decide what counts as lost,
what counts as late, and how long a hole is worth concealing, and a
divergence there is audible rather than testable. Its suite mirrors
satellite's case for case for the same reason.

Speaker frames get their own queue and thread instead of riding the
input bridge or upcalling straight from receiveAck like the other return
paths: an audio frame must never evict a gamepad report, and the sink at
the far end is an AudioTrack whose write blocks. So the receive thread
copies the packet and returns, and reorder, decode and concealment all
happen off the socket. Mic capture encodes on the caller's own thread,
which is where the capture wave wants the work anyway.

wButtons 0x0800 is exposed on both sides as the mic-mute bit with
nothing setting it yet. The sweep in GamepadButtonLayoutsTest is the
interesting half: every other bit of the XINPUT-shaped word is reachable
from a real button and this one is reachable from none, which is what
makes it free to spend.

One gate fix rides along. CMake 4.x names the scratch file its
post-build gtest discovery writes after a hash of an argument it never
passes, so every target in the host test build shared one file; with
seven targets building in parallel that failed outright, and before that
it had been quietly listing one target's tests under another (the run
that reported 315 tests for 263 real ones). Discovery moves to PRE_TEST,
which is serial by construction and generator-independent.
The wave that makes a microphone real, and the one that owes the user a
promise about it: while muted, or switched off, or ungranted, or not
streaming, ZERO MSG_MIC_AUDIO packets leave the device. Not silence on the
wire, no packets, and the enforcement is arranged so that is provable
rather than asserted.

Four facts decide it, and any one of them going false has to stop the
microphone: the slot is bound to a live satellite, the whole capability
path carries a microphone and the user switched it on, RECORD_AUDIO is
granted, and the slot is not muted. They move independently at runtime, so
they are combined into one plan by MicCaptureComposer and reduced by one
pure rule in MicCapturePolicy, rather than being checked as four scattered
guards that each have a window where they disagree. Its suite walks all
sixteen rows of the matrix; exactly one of them captures.

MicEngine turns that plan into an AudioRecord: VOICE_COMMUNICATION for the
free echo cancellation a phone pointed at its own speaker badly needs, 48
kHz mono, one 20 ms window at a time on a dedicated URGENT_AUDIO thread,
straight into the native Opus encoder the previous wave landed. The gate is
applied twice on purpose. The eligible target set is published before
anything starts and cleared before anything stops, so the ineligible state
is the one in effect during the transition; and it is re-read for every
window AFTER that window was recorded, so a mute landing mid-window drops
the window instead of shipping it. That is what bounds mute latency to one
frame, and it is what the integration test measures against a real session.

One recorder, fanned out to every eligible slot, because the phone has one
microphone and two emulated pads asking for it are asking for the same
sound. Per-route capture is the playback wave's problem, when a pad's own
headset becomes a preferred device. A device that refuses
VOICE_COMMUNICATION falls back to plain MIC; there is deliberately no
sample-rate fallback and no resampler, since 48 kHz mono is the platform's
native capture format and inventing a converter for a case that does not
arise would put untested arithmetic in the one path where a bug is audible
on somebody else's PC.

Mute has two controls and one state. The on-screen DualSense grows the mute
button the real one has, under the PS button, as a local-only bit (1 shl
12, following the touchpad click at 1 shl 11) whose PRESS toggles the
state; the wire's WBUTTON_MIC_MUTE carries the state itself, held on every
frame including resends, so the host reads the pad as muted rather than
seeing blips. A Direct-claimed DualSense's own button does the same thing
from the other end: the report decoder owns that latch, because the pad's
input report is built and sent entirely on the USB reader thread and a
latch behind a JNI call would put the JVM in that path. The resulting state
comes back up through MicMuteBridge into the same per-slot holder, so the
capture engine and the lamp read one place whichever button was pressed.
The mute lamp lights locally the moment you press, and a host MSG_MIC_LED
overrides it afterwards, last writer wins, which is what the hardware does.

The foreground service gains the microphone type, and only while a
mic-enabled binding is streaming. It follows arming and not delivery on
purpose: a while-in-use type can only start from the foreground, so
dropping it on every mute toggle would be a coin flip on getting it back,
and zero packets while muted is enforced where it belongs. A denied type
falls back to the one the session can always hold rather than taking the
session down.

The store paperwork changes with the code, because it has to. PRIVACY.md
loses the "we do not request microphone" line and gains the exact
conditions capture runs under; the Data Safety form declares audio as
collected, transmitted off-device to the user's own paired PC, processed
ephemerally, optional and user-initiated; the permissions justification
explains both RECORD_AUDIO and the service type; and the content rating
stops claiming no user-to-user communication, since a voice that reaches
the PC reaches whatever is running on it.
The last client wave, and the one that makes the return direction real: what
a game plays through the emulated pad's speaker now comes out of something,
what a pad's own endpoints can carry is decided by what Android actually
routes, and the mute lamp reaches the hardware that has one.

Playback. Frames arrive from the native reorder thread already in stream
order, already whole, and already concealed where a packet never came, so
the only decision left per frame is which output it belongs to. That is why
the plan is keyed by (session handle, controller index): the delivery path is
one hash lookup on a volatile snapshot, no lock, no allocation, no walk of
the connection map, on a thread every stream shares. Resolving that pair to a
slot is the same job FeedbackRouter does for the lamp, done once per plan
instead of once per frame.

The write is non-blocking rather than handed to a per-slot writer thread. One
native dispatch thread carries every stream, so a sink that waits does not
stall its own pad's audio, it stalls everybody's; AudioTrack's
WRITE_NON_BLOCKING returns a short count instead, and a short count is a live
stream running ahead of playback, which dropping the newest window is exactly
the cure for. The dropped samples are counted rather than swallowed. A writer
thread would have added a queue, a copy and a thread per slot to reach the
same place. Playback starts after two windows are buffered, not on the first,
because a track started empty plays one window and then underruns, and every
underrun is a click; that is the same 40 ms the reorder window upstream
already costs, so it adds no latency the stream did not have. The session's
lock is not for throughput but for lifetime: writes come from the dispatch
thread and closes from the collector, and releasing an AudioTrack out from
under a write in progress is a use-after-free.

Routing. There is no public API that puts a vendor:product on an
AudioDeviceInfo, so the pad-to-endpoint table matches on the one field both
sides genuinely share, which is the USB device's own iProduct string, and
every ambiguity resolves to no route rather than to a guess: the pad must
carry a USB Audio Class interface of its own, the name must identify exactly
one attached device, and at most one endpoint per direction. Two DualSenses,
or a DualSense beside a DualShock 4, both answer to "Wireless Controller" and
are therefore unmatchable, because routing a slot to the wrong pad's speaker
is worse than routing it nowhere. The resolver listens on the audio side, not
the USB side: the table is a statement about endpoints, and a pad whose audio
function the OS never enumerated has no route however long its cable has been
in. Publishing re-runs the capability composition, so caps appear and vanish
with the endpoint.

Capture grows a route split to match. An AudioRecord's preferred device is
fixed when it is built, so the phone's microphone and a pad's headset cannot
share one: the engine now holds one recorder per distinct endpoint, with the
delivering set grouped the same way, and the route table rides its upstream
because a pad's endpoint appearing moves a slot between recorders without
changing the plan at all. The privacy invariant is unchanged and enforced in
the same two places, now per recorder.

The mute lamp on a Direct-claimed DualSense. Report 0x02 carries the lamp at
byte 9 and power_save at byte 10 in this file's convention (report id at
out[0], so hid-playstation's stripped 8 and 9 plus one, the shift the player
LEDs already carry). The power-save mic-mute bit rides with it because the
lamp and the microphone amplifier are one thing on that pad: a lit mute lamp
over a live microphone is the failure this whole feature exists to prevent,
and pulse counts as lit for the same reason. Every other 0x02 report we build
re-asserts the lamp from a shadow in FeedbackState, since each builder starts
from a fresh memset and the firmware applies whatever the valid flags claim,
so a colour change that flagged the lamp field and left it zeroed would turn
the lamp off as a side effect. Nothing changes for a host that never drives
it. On the on-screen pad, pulse breathes the accent ring the adaptive-trigger
effects already use rather than introducing a second treatment, and it is the
only lamp state that costs frames.
Two instrumented tests failed on the CI emulator while the rest passed. Both
were real, and only one of them was a test problem.

The host's controller-audio verdict never arrived. `audio` rides the
per-backend entries of GET /api/server/capabilities and nothing else carries
it, and the only caller of the repository that reads it was
ConfigureBindingsViewModel. So a session that reached Live without anyone
opening the binding screen never learned the verdict, and unknown is opt-OUT
by design: the host layer withheld MIC and SPEAKER, the caps never went live,
and the playback engine correctly opened nothing. No timeout would have
helped, because it never converged. That is a user-facing bug and not a
harness gap: startup auto-reconnect binds and streams without visiting that
screen, so a returning user would have had controller audio silently off
until they happened to open it once.

HostCapabilitiesProbe reads the document when a link goes Live, on the same
trigger and in the same shape as CatalogPrewarmer, watching the raw
connection states for the same reason. It dedupes on the session HANDLE
rather than on a Live transition, because a transition is the wrong question
twice over: a heartbeat blip is one session recovering and reads as a fresh
Live, while a drop and reconnect the collector only ever sees as a blip is a
genuinely new session and reads as none. The handle tells them apart. Its
watcher is cancelled when the connection leaves the map, so a forgotten host
leaves no collector behind.

The microphone one was a test that could not see what it was asserting. It
took the engine's word that it had stopped, joined a thread on a fixed
timeout, cleared the fake's received list and slept. Neither step is a
barrier: a capture body told to stop is still inside a blocking read, and
delivery is UDP with the fake reading on a thread of its own, so a datagram
sent BEFORE the stop can arrive after the clear. On a loaded emulator it did.

So the engine now says when it has actually stopped rather than only when it
was told to. `quiescent` is true when no capture body is executing, which is
the property the privacy claim is about; MicCaptureState.Idle lands with the
plan and is a different question. The test waits for that, then drains the
wire with a marker window it sends itself: loopback delivery is ordered, so
once the marker lands every earlier datagram has landed and is cleared with
it, and anything arriving afterwards can only come from a capture still
running. The mute test gets the same quiescence wait before it asserts the
recorder was released, and the speaker test now waits for the capability
model to carry SPEAKER before it waits for the engine, so a future failure
names which half broke.
@emir-hasanbegovic
emir-hasanbegovic marked this pull request as ready for review September 1, 2026 14:32
The mic stream paid full price for a quiet room. A live microphone never goes
digitally silent -- room tone, breath and the preamp floor are all real
samples -- so nothing downstream can collapse it and the only thing that can
is the encoder's own VAD. OPUS_SET_DTX(1) turns it on: measured on libopus
1.6.1, 123 of 250 frames gated at -50 dBFS after speech, 30.0 -> 16.4 kbps,
and digital silence from a closed capture path drops 8.4 -> 1.1 kbps.

The speaker encoder deliberately does not get it. That gate cuts anything
~26-30 dB below the recent peak, which on game audio replaces a reverb tail
or quiet ambience with comfort noise at -2.3 dB SNR. The satellite suppresses
exact digital silence on that stream instead, which cannot touch anything a
listener could hear. This client only decodes speaker anyway; the setting is
here so the two ends stay one file's worth of the same decision.

Muting is unaffected and remains the stricter thing: it stops delivery
outright, which DTX cannot do and must not be confused with.

Correcting the comment while here, in the same terms as satellite's: the
expected-loss hint, 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. The old text said VOIP was what bought FEC and implied the
speaker had none; anyone acting on that by dropping the hint would have
reached CELT and silently deleted working FEC from both streams.

Verified nothing chokes on the 1-byte packets DTX emits: the JNI send path
refuses only a zero-length encode, the seq still advances per packet (a DTX
packet is sent, unlike the satellite's speaker suppression which sends
nothing), header+1 meets AUDIO_WIRE_MIN_PAYLOAD_BYTES, and the reorder window
accepts one. 310 native tests pass, 306 before.
The satellite now sends nothing at all for a digitally silent speaker window.
The reorder window handles that perfectly -- it has no clock, so a stall is
not even representable in its state, and a resumption at a contiguous seq
takes the fast path as if no time had passed -- but the AudioTrack under it
drains while the quiet lasts, and that part was not handled.

The two-window start threshold exists because a track started empty plays one
window and then underruns, and every underrun is an audible click. It was
latched: playing was set once and the cushion was never rebuilt. That was
fine while the stream was continuous, which it was until the host learned to
suppress silence. Now the first quiet stretch destroys the cushion and the
stream runs at nearly zero occupancy afterwards, so every scheduling hiccup on
the shared dispatch thread is the exact click the threshold was written to
prevent.

Refill with silence rather than pausing to re-prime. Withholding windows until
the threshold is met again would strand any sound shorter than the cushion: a
lone 20 ms blip would sit unplayed until the next one arrived, which is worse
than the click. Writing two windows of zeros delays the resumed audio by the
same 40 ms the first start pays and can never swallow it.

The trigger is AudioTrack's own underrun counter, not a clock and not a
frame-position subtraction, which keeps wrapping arithmetic out of the one
path this file warns is audible when it is wrong. The decision itself is a
pure function next to SpeakerPlayoutPolicy so it can be tested without a
device; the JVM suite stubs android.media, so nothing else about the track is
reachable from a unit test.

Also corrects the libopus attribution in the DTX comments: the 1.6.1 figures
are satellite's, and this repo pins 1.5.2, where the suite re-proves the
collapse holds rather than inheriting the number.
The satellite split its one controllerAudio switch into a microphone and a
speaker switch, and publishes both in a new top-level controllerAudio block on
GET /api/server/capabilities. This reads it.

HostFeatureSet carried one boolean that fanned out to both MIC and SPEAKER,
which cannot express what the host now reports. Split into controllerMic and
controllerSpeaker: a single verdict would have a speaker-only host offering a
microphone whose frames it drops on the floor, after charging the user a
permission prompt for it. Everything downstream follows without changing --
capture and playout already read Feature.MIC/SPEAKER off SlotCapabilities.live,
and the host layer is where that is decided.

The DTO field is NULLABLE, like motion and unlike host, because an absent block
is UNKNOWN rather than off. A satellite that predates it still carries audio and
still reports it on the per-backend audio flag, so reading a missing block as
two falses would mute every older host. Absent falls back to that flag for both
directions; present wins outright, since it is the only place the directions are
reported apart.

Left the advertised wire caps alone. SatelliteConnectionManager projects
wireCaps through distinctUntilChanged specifically so host and runtime changes
that do not move the descriptor raise no wire traffic, and the contract lets the
host flip these two under a live stream -- feeding them in would turn every
host-side toggle into a mid-stream descriptor re-declare on every bound slot,
which is the churn that projection exists to prevent. Nothing leaks by leaving
it: the phone stops sourcing either way, because the plans read .live.

2408 JVM tests both flavors, 2397 before; 310 native; ktlint and detekt clean.
The user's report was "the mute pill did nothing; the mic would only
stay on". Two real bugs, both fixed here:

1. The pill's visual state was VirtualPadFeedbackStore.micLedState,
   which was last-writer-wins between the local toggle and the host's
   MSG_MIC_LED. Any host software driving the emulated DS5's lamp
   repainted the pill right after every local toggle, so the button
   looked dead even while MicMuteStore toggled and the recorder really
   closed. Worse than cosmetic: the wire's WBUTTON_MIC_MUTE carries the
   mute STATE as a held bit, so host software that treats it as a held
   BUTTON toggles its own mute per press edge and drifts out of phase
   with the client, after which the lamp can assert the exact opposite
   of local truth indefinitely.

   Fix: decouple the two visual channels. The pill's face (glyph +
   ground) now renders the LOCAL MicMuteStore state unconditionally, via
   a new micMuted property fed from the overlay's existing store
   collector: slashed glyph over a warning wash while muted, so no host
   message can make a muted microphone look live. The host lamp keeps
   only the accent ring (and pulse breath) as secondary "what the host
   thinks" info. setLocalMicMute is retired; micLedState now has exactly
   one writer, MSG_MIC_LED.

2. GamepadLayout clamps the pill up on short screens until it overlaps
   the home button's pickup halo (smallBtnRadius x 1.5), and the
   recognizer hit-tested home BEFORE the pill, so presses inside the
   pill became BTN_HOME presses. The pill is now hit-tested before the
   three centre circles: a drawn rect beats an invisible forgiveness
   zone, and home loses only halo, never its own disc.

The rest of the press-to-recorder chain (edge detection, composer
keying, MicEngine's per-window mute re-read) was audited and is sound.

The pressed/muted/idle ground choice is a pure function (micMutePillFace)
so it is pinned without a view, alongside a clamped-geometry regression
test for the hit order and rewritten store/router suites for the
single-writer lamp.
The mute pill only exists on the gamepad overlay, but the microphone
keeps capturing wherever the user navigates, and a mic the user cannot
see or silence from the current screen is a mic they cannot account for.

Following the capture pipeline's shape (pure rule -> fold -> store ->
UI):

- MicIndicatorState (HIDDEN / LIVE / MUTED) with MicIndicatorPolicy, a
  pure rule over MicCapturePlan. It reads only the plan, because the
  composer already folds MicMuteStore into `delivering`; reading the
  store again would race that fold. toggleAll is the one control:
  all-or-nothing over the armed slot set, so the single device-wide
  state its surfaces show converges instead of ping-ponging per slot.
- MicIndicatorCoordinator, the shared read/write point: maps the plan
  to the indicator and applies a toggle-all order to MicMuteStore, so
  the chip and the notification action mean exactly the same thing.
- A floating mic chip (overlay_mic_chip) on every screen while state is
  not HIDDEN: red live mic glyph reading "mic is hot", grey slashed
  glyph reading muted, distinct labels and content descriptions, one
  tap toggles everything. Installed once in attachGamepadHost, the same
  scaffolding that binds the low-power chrome, and gated by the
  StreamingScreenCoverageTest include check so a new screen cannot
  forget it. The gamepad overlay suppresses the binding: its pad
  already renders the mute pill from the same store, and a floating
  target over a full-screen control surface would steal pad touches.
- The streaming notification carries the mic state as subtext and a
  Mute mic / Unmute mic action while a mic is armed, so the control
  works from the shade outside the app. The action rides a new
  ACTION_TOGGLE_MIC through a pure command mapping, where an unknown
  action stays the re-assert no-op rather than ever toggling a mic.

Strings in all six locales; chip paint, indicator matrix, toggle-all
convergence and notification mappings pinned in dedicated suites.
@emir-hasanbegovic

Copy link
Copy Markdown
Contributor Author

Two follow-up commits from live testing feedback:

  • c732d0e fixes the virtual pad's mute button feeling dead. Two real bugs: the pill's visual state was painted from the last-writer-wins lamp cell, so any host driving the emulated DS5's mute LED repainted it right after a local toggle (the mute itself always worked: recorder closed, zero packets); and on short screens the layout clamp slid the pill into the PS button's pickup halo, which was hit-tested first. The pill's face now renders the local MicMuteStore state unconditionally (slashed glyph + amber wash when muted), the host lamp keeps only the accent ring, and the pill wins the hit-test over the centre-circle halos.
  • aabe4a4 makes mic state visible and controllable everywhere: a floating chip on every screen while a mic is armed (red "Mic live" / muted variant, tap toggles mute for all armed slots), and the streaming notification now carries the mic state plus a Mute/Unmute action so the shade works outside the app. The gamepad overlay suppresses the chip since the pad renders its own mute pill from the same store.

Net +26 tests; 2433 JVM + 310 native green, lint/ktlint/detekt/format/translation gates clean.

The accept loop registers each connection handler in the shared thread
list, so a connection landing while close() walks that list for joins
throws ConcurrentModificationException and fails whichever test closed
the fake (seen on tofu_rejectsADifferentCertOnTheSameIdentity in CI).
Register and snapshot under one lock; a thread registered after the
snapshot only ever sees closed sockets, so skipping its join keeps the
existing best-effort shutdown semantics.
emir-hasanbegovic added a commit to TinkerNorth/dish-linux that referenced this pull request Sep 2, 2026
Ports controller audio (protocol 2 extension; satellite
TinkerNorth/satellite#88, dish-android TinkerNorth/dish-android#178,
dish-windows TinkerNorth/dish-windows#66) to the Linux client. Physical
Direct-claimed pads only; no virtual controller exists here.

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Emir Hasanbegovic <1190336+emir-hasanbegovic@users.noreply.github.com>
@emir-hasanbegovic
emir-hasanbegovic merged commit f623e15 into main Sep 2, 2026
9 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the feat/controller-audio branch September 2, 2026 19:00
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