Skip to content

Controller Audio Internals

How AudioPassthroughService drives the DualSense and DualShock 4 speaker: the per-device sink model, the WASAPI loopback mirror, the USB and Bluetooth transports, and the Opus and SBC encoders.

This is the developer-side companion to Controller Audio (the user guide) and issue #83.


Files

File Role
PadForge.App/Common/Input/AudioPassthroughService.cs The core. Sinks, captures, the worker and Bluetooth threads, USB and BT transports, Opus encode, remote-audio hooks, and the composite USB persona feed. internal static partial class.
PadForge.App/Common/Input/AudioPassthroughService.PersonaOwnership.cs The other half of the partial class: persona feed ownership and retirement, the retired-feed drain, and CreatePersonaMicCapture (the USB mic).
PadForge.App/Common/Input/CapturePcmFormat.cs The loopback formats a capture accepts.
PadForge.App/Common/Input/DeferredRemoteToneQueue.cs Queues a remote-driven tone sink's build off the UDP receive thread.
PadForge.App/Common/Input/MirrorDsp.cs The per-sink DSP chain (#347): CrossfeedStage, ParametricEqStage, LimiterStage behind IMirrorStage, composed by MirrorChain. Also EqBandCodec (the band list's one-attribute text form), EqResponse (the drawn curve's magnitude math), and AutoEqProfile (the Filter N: parser).
PadForge.App/Controls/EqCurveControl.cs The graphic EQ: a FrameworkElement drawn in OnRender, log-frequency curve plus one draggable handle per band.
PadForge.App/Common/Input/Ds4SbcEncoder.cs Clean-room SBC encoder for DualShock 4 Bluetooth audio.
PadForge.App/Common/Input/Ds4BtAudio.cs (#528) The DualShock 4 Bluetooth report layout: stream and arm reports, the input CRC and status byte, and microphone frame extraction. Also Ds4MicStream, which decodes the headset microphone and conceals lost frames, and Upsampler3x.
PadForge.App/Common/Input/SbcDecoder.cs (#528) SBC and mSBC decoder for the DualShock 4 headset microphone.
PadForge.App/Common/Input/AudioPassthroughService.Ds4Bluetooth.cs (#528) The per-pad DualShock 4 mode byte and microphone arming, the microphone's persona and voice lanes, and the writer that converts it to a persona microphone's own rate.
PadForge.App/Common/Input/SoundMacroService.cs Macro sound decode and playback. Feeds the per-slot sink mixers, including the Wii speaker and HD-haptic tone sinks.
PadForge.App/Common/Input/WiiSpeakerService.cs The Wii Remote speaker sink. Real 8-bit PCM at 2 kHz over raw HID. internal static class. See Wii Controllers Internals.
PadForge.App/Common/Input/HapticToneService.cs The HD-haptic tone sink for Switch and Steam controllers (#147). Reduces the macro mix to a tone and encodes per family. internal static class.
PadForge.Engine/Haptics/HapticToneReducer.cs PCM-to-tone reduction: one (dominant frequency, amplitude) pair per rumble tick.
PadForge.Engine/Haptics/HapticToneEncoder.cs Per-family wire-byte encoders for the tone requests.
PadForge.Engine/Haptics/TritonPcmEncoder.cs (#381) The Steam Controller 2026 PCM stream bytes: the 0x86 command, the 0x88 stereo packet, G.711 mu-law. See Steam Controller Haptics Internals.
PadForge.App/Common/Input/TritonPcmSupport.cs (#381) TritonPcmLowPassProvider and the eight-slot TritonPcmWriteRing.
PadForge.App/Common/Input/BluetoothLinkHelper.cs TryDisconnect(serial), the radio-drop lane the dual-link fix (#387) and Quick Charge (#372) share.
PadForge.App/Common/Input/UserEffectsDispatcher.cs Asserts the DualSense firmware speaker path and the master-volume byte on each output report, and the DualShock 4's audio mode byte, headphone level and microphone gain (#528).
PadForge.App/Common/Input/RemoteLinkOutputRouter.cs The ShipAudio (speaker PCM) and ShipHapticTone (tone pair) producer side of the Remote Link relay.
PadForge.App/Services/InputService.cs Wires the config providers (PassthroughConfigProvider, DspConfigProvider, and the output-path, buffer-length, headphone-volume and macro-demand providers), the router's SendScopedAudio, and OnRemoteFrameReceived, which hands Audio frames to FeedRemoteAudio.

Dependencies: Concentus (pure-C# Opus) and NAudio.Wasapi (loopback capture, WasapiOut, Media Foundation decode), with its NAudio.Core dependency supplying the mixers and resamplers. The EQ bands ride NAudio.Dsp.BiQuadFilter (RBJ cookbook, already in NAudio.Core). Crossfeed is a C# port of bs2b as vendored in openal-soft's core/bs2b.cpp (Boris Mikhaylov, MIT), attributed on all three surfaces.

Identity constants at the top of the service: Rate = 48000, SonyVid = 0x054C, Ds5Pids = {0x0CE6, 0x0DF2} (DualSense, DualSense Edge), Ds4Pids = {0x05C4, 0x09CC, 0x0BA0} (DualShock 4 v1, v2, and the Sony USB wireless adapter).


The sink model

One Sink exists per speaker-capable pad, held in Dictionary<Guid, Sink> _sinks keyed by device InstanceGuid under _lock. Keying on the device, not the slot, is deliberate: a slot holding two DualSense gets two independent sinks with independent Opus sequence counters. Per-device runtime state is never static, which is the rule that fixed the garbled-audio bug when two DualSense shared a counter.

Each sink carries its audio graph (MacroMixer, a 48 kHz stereo MixingSampleProvider, plus Source, the mixer-and-loopback combiner), its transport (IWavePlayer Player for USB, or IntPtr BtHandle plus a BtWritePool Tx for Bluetooth), and its per-lane encoder state. The DualSense lane holds Ds5OpusEncoder, Ds5Seq (the report sequence nibble), and Ds5PktCounter. The DualShock 4 lane holds Ds4Sbc, the pending-sample buffer, the SBC frame queue, and the resampler carry.

BtWritePool is a fixed 8-slot overlapped non-blocking HID write pool (WriteFile with FILE_FLAG_OVERLAPPED). TrySend returns immediately. On saturation the DualSense lane drops the frame and the DualShock 4 lane keeps its SBC frames queued for the next tick. An I/O error flags a hard fail. The kernel HID IRP queue is the jitter buffer, roughly 80 ms in flight across the eight slots.


Threads

Two threads, started by EnsureThreads_NoLock:

  1. WorkerThreadMain ("PadForge.AudioWorker") owns sink I/O. It waits on _workSignal or wakes every five seconds and runs ReconcileOnWorker. Sink build and teardown, loopback capture start and stop, and the CreateFile for each sink's Bluetooth write handle happen here. Two exceptions: Shutdown disposes on its caller's thread, and the read-only jack and mic readers (PadForge.JackWatch, PersonaBtMic, PadForge.PersonaUsbJack, VoiceBtMic) open their own synchronous handles on their own threads.
  2. BtThreadMain ("PadForge.BtAudio", highest priority) runs the Bluetooth cadence loop. It never does transport I/O of its own beyond Tx.TrySend. On a transport error it sets TransportFailed and lets the worker rebuild.

The worker reconcile runs five phases to keep I/O outside the lock: build the desired-sink list (phase 1, no I/O, taking _lock only to re-arm the #387 drop for a Bluetooth-pathed record), diff against _sinks (phase 2, under lock, no I/O), build and dispose transports (phase 3, unlocked), reconcile captures (phase 4), then notify SoundMacroService and sweep expired vendor-audio tests (phase 5). The desired-sink list walks the 16 slots through EnumerateAssignedSonyPads and FindOnlineSonyDevice, which gates on IsOnline, a non-empty DevicePath, the Sony VID, and a DualSense or DualShock 4 PID. Bluetooth is detected by the HID-over-BT service GUID in the device path. A wired first-model DualShock 4 (0x05C4) is skipped, since it has no USB audio (AudioPassthroughService.Ds4HasAudio). The second model (0x09CC) on a cable presents a USB headset, 2-channel 32 kHz out and a 16 kHz microphone, both ending in a Headset terminal (DJm00n's CUH-ZCT2E descriptor dump), and takes the USB container path like a DualSense on USB. The Sony USB wireless adapter (0x0BA0) already took that path and presents the same headset (a linux-hardware.org probe in linuxhw/LsUSB), and Sony lists only the stereo headset jack among the features it carries. Both sinks are marked HeadsetOnly (Ds4HeadsetOnly), and each takes the slot's macro sounds only while its jack reports headphones (TakesMacroSounds). GetSlotSinkMixers and the phase 5 routing flag both ask that, so with nothing plugged in the sounds play where they would without the pad.

BtThreadMain runs at CadenceMs = 10 + 2/3 (10.667 ms) and pulls 512 frames per tick. It applies a small ring-cushion drift trim, runs a two-second idle gate for the DualSense, and dispatches Ds4BtTick or SendDs5BtFrame. A DualShock 4 sink skips the gate and streams silence (#528). Timing uses timeBeginPeriod(1) and a high-resolution waitable timer. A late tick is skipped, never repaid, because the firmware drops back-to-back bursts.


System-audio mirror (WASAPI loopback)

CaptureEntry wraps one WasapiLoopbackCapture plus a half-second ring, stored in Dictionary<string, CaptureEntry> _captures keyed by endpoint ID. ReconcileCapturesOnWorker resolves the default render endpoint once, maps each passthrough-on sink's MirrorSourceId (empty string means follow the default, re-resolved every pass) to a wanted endpoint, starts the missing captures, and points each sink at its CaptureEntry. A pad's own endpoint is excluded so the mirror never feeds the pad back into itself.

StartCaptureEntry opens the device and rejects a non-active endpoint. CreateCaptureEntry then converts each DataAvailable buffer to 48 kHz stereo float in the ring, from any rate and from integer PCM (8, 16, 24 or 32-bit) or float (32 or 64-bit). CapturePcmFormat refuses any other format. SinkSource.Read holds a per-sink cursor over the shared ring, resyncs only on a genuine stall, and adds the loopback samples on top of the macro mix at full scale. Volume lives in the firmware byte, not the samples.


Macro sounds

SoundMacroService decodes WAV, MP3, M4A, AAC, WMA, and FLAC through MediaFoundationReader (or a streaming reader for pfsound:// package refs), resamples to 48 kHz stereo, and caches the PCM under a 128 MB LRU cap.

StartPlacements is the routing core. It calls AudioPassthroughService.GetSlotSinkMixers(slot, out pendingActivation, deviceFilter), which returns the MacroMixer of every live sink on the slot that takes macro sounds, each with an OwnsMaster flag (optionally filtered to one device GUID, where a headset-only pad plays whatever its jack reports). When the slot has eligible speaker pads whose sinks are not live yet, pendingActivation is true, the worker is signaled, and the caller drops the sound rather than leak it to the PC speakers. Macro demand itself is never latched in the audio service: the worker re-reads it every pass through SlotWantsMacroAudioProvider, which InputService wires to SoundMacroService.SnapshotWantsControllerAudio over the slot's macro snapshot. When the slot has no controller sink and no device filter, it falls back to a per-slot system-default WasapiOut, where the master volume scales the samples, as it does on every placement whose sink does not own it (see Master volume below).

PlayTestBeep(slot, deviceGuid) routes an 880 Hz, 200 ms tone through StartPlacements with a device filter, so the Audio-tab test hits only the selected device and never fans out to the slot's other pads.


USB transport

The USB branch of BuildTransportOnWorker first drops a stale Bluetooth link (#387, below), then reads the HID interface's PnP container ID through cfgmgr32 and matches a render MMDevice whose container ID is the same. If that endpoint is disabled in Windows it re-enables it through IPolicyConfig, then restores the prior default if Windows promoted the pad to default. UsbFrameProvider.Read reads the device's Audio Output Path once per call and shapes each frame through MapMirrorChannels. Default (enum AudioOutputPath.Automatic) and Speaker Only silence channel 0 and put the mono program mix (L+R) * 0.5 on channel 1 (the firmware speaker tap). Headphones (Stereo) puts L on channel 0 and R on channel 1. The mono headset paths put the downmix on channel 0, with channel 1 either silent or a copy for the split path. Follow Headphone Jack resolves to one of the others in ResolveOutputPath before the shaper sees it. A DualShock 4 sink skips the route (MapUsbChannels): its USB audio is a plain stereo headset, channel 0 left and channel 1 right (wChannelConfig 0x0003 in the CUH-ZCT2E dump and the adapter's probe), so the shaper copies L and R through, and the DSP chain treats it as stereo headphones. Channels 2 and 3 are the pad's voice-coil actuators: zero unless a composite persona is feeding them. Playback is WasapiOut in shared mode with event sync at 30 ms latency. Its shared-mode stream asks for AutoConvertPcm (NAudio 2.2.1, WasapiOut.Init), so Windows resamples the 48 kHz feed to a DualShock 4's 32 kHz headset.

A DualSense that holds Bluetooth and USB at once mutes its USB audio interface in both directions in firmware. This was instrument-confirmed on the owner's pad with PadForge bypassed: a tone played straight into the pad's Windows render endpoint was silent in dual mode and measured 0.50 amplitude on the pad's own microphone in USB-only mode, and the pad's capture endpoint delivered zero buffers (not silence) while dual. The endpoint enumerates Active and WasapiOut plays without error, so no counter shows the mute. SDL's USB init de-dups the Bluetooth joystick and moves input to USB, but leaves the radio link up. That lingering link is the mute.

TryDropStaleBtLinkOnce(sink) runs at the top of the USB branch, before endpoint resolution. Two gates: the record's serial must parse as a nonzero MAC (an all-zero MAC parses, the #372 lesson), and DualDropWanted(guid, path, map) must return true, which it does exactly once per (device, wired path) under _dualDropLock. The drop itself is Task.Run(BluetoothLinkHelper.TryDisconnect(serial)), the hardware-proven #162 idle-disconnect lane. The reconcile removes the device's map entry whenever its record is Bluetooth-pathed, so unplug, Bluetooth session, replug earns a fresh drop, while a deliberate re-link with the cable still in is never fought. A pad that was never on Bluetooth makes this a radio query that finds nothing. A wired DualShock 4 never reaches the site, because it has no USB audio interface and its sink never builds. The diagnostic line is SINK dual-link ... dropping stale radio. DualLinkAudioDropTests pins the edge decision and the site.

Verified end to end on hardware: the dual state formed live, the drop fired in 5 s, SDL removed the Bluetooth device, and the mirror measured 0.49 on the pad mic against a 0.50 USB-only baseline. Residuals on record: DualShock 4 dual mode is untested and its fix would run the opposite way (its audio is Bluetooth-only), and BluetoothSetServiceState disable-then-enable destroys the pairing, so it is never used as a reconnect lever.


DualSense Bluetooth (Opus over report 0x35)

Constants: 480 samples per Opus frame (10 ms at 48 kHz), 200 bytes per frame (hard CBR at 160 kbps), and a 334-byte report. CreateDs5OpusEncoder builds a Concentus encoder configured OPUS_APPLICATION_AUDIO, 160000 bps, CBR, pre-created at transport build so the first frame does not pay construction cost.

Each tick time-compresses the 512-frame pull to 480 samples, then SendDs5BtFrame encodes it and assembles the 0x35 report: byte 0 is the report ID, byte 1 carries the sequence nibble (Ds5Seq advances mod 16), a 0x11 session-header packet carries Ds5PktCounter and the mic-session byte, and an audio-lane packet carries the 200-byte Opus payload. The last four bytes are a reflected CRC32 pre-seeded with the 0xA2 Sony Bluetooth output prefix. The firmware contract from the issue #83 hardware experiment is one report per tick, never bursts.

Over Bluetooth the sink is addressed by packet ID, not by the firmware's output-path register. Ds5BtAudioLanePid returns 0x13 for the internal speaker (Default and Speaker Only) and 0x16 for every headset path. One report ID gets one stream: Ds5BtWantsBothLanes returns false, because two 0x35 reports per tick sharing one sequence counter made each lane's stream see jumps of two and warble. Every path except Headphones (Stereo) folds the frame to mono before encoding (Ds5BtFrameNeedsMonoFold), because the mono speaker taps one channel of a stereo stream rather than downmixing it.


DualShock 4 Bluetooth (SBC over report 0x17)

Ds4SbcEncoder is a clean-room SBC encoder written from the A2DP specification Appendix B, with no libsbc or GPL lineage. Fixed config is 32 kHz, 8 subbands, 16 blocks, joint stereo, SNR allocation, bitpool 48, producing 109-byte frames. The spec's segment-folded analysis window is un-folded at the analysis matrix, and the filterbank conventions were pinned against ffmpeg's decoder at 76.5 and 66.9 dB round-trip SNR.

At transport build the DualShock 4 branch sends a one-shot 0x11 control report (Ds4BtAudio.BuildControlReport) that arms the audio path. Byte 3 enables only the volume bytes it carries (0x10 and 0x20 headphone, 0x80 speaker, plus 0x40 microphone while it is armed), because the rumble and lightbar bits belong to the effects writer. The volumes are the route's (Ds4BtAudio.VolumesFor): the jack routes mute the speaker, Speaker Only mutes the jack, and Default leaves both on, with the jack at the device's Headphone Volume mapped onto 0x00 to 0x4F and the speaker at 0x4F. 0x4F is the ceiling in ds4mac and in the hbashton fork's ScaleAudioVolume, DS4AudioStreamer sends 0x50, and DS4-Sound-Link writes up to 127. The target byte alone does not isolate the two outputs. DS4-Sound-Link's output switch mutes the other output by volume, and the hbashton fork mutes the speaker for headset-only audio. The effects report asserts the volumes again when the level or the route changes.

Ds4BtTick reads the resolved Output Path every tick (#528). Headphones (Stereo) sends stereo to the jack (byte 5 = 0x24). Headphones (Mono) folds to mono and sends to the jack. Default, Speaker Only and a stored Headphones + Speaker fold to mono and send to the speaker (0x02), which taps one channel of a stereo stream. The tick then resamples 48 kHz to 32 kHz with a persistent phase and a sample carry across ticks, encodes each complete 256-sample block to an SBC frame into a queue bounded at 12 frames, and drains it preferring the four-frame 0x17 report and falling back to the two-frame 0x14. It wakes at four buffered frames. Unlike the DualSense cadence, the DualShock 4 path is availability-driven and allows bursts. Bytes 3 and 4 carry a 16-bit frame counter that advances by frames-per-report.

Byte 2 of every DualShock 4 audio report, and of the effects writer's report 0x11 (btReserved), selects the controller's inbound report mode: 0xA0 normally, 0xA1 while the headset microphone is armed. PadForge sent 0xA2 in its audio reports before #528, copied from DS4AudioStreamer, whose source labels the byte unknown. The hbashton fork of DS4Windows records from hardware sweeps that 0xA2 stops a genuine CUH-ZCT2's input after a few reports. Byte 1 stays 0x40 (stream) or 0xC0 (control), input interval 0, the fastest rate, as in every PadForge effects report.

A DualShock 4 sink skips the two-second idle gate (#528). Its tick runs for as long as the sink exists, and a silent pull encodes to silence frames, which is how the hbashton fork keeps the controller's audio clock and buffer running. The sink exists only while the mirror, controller-routed macro sounds or an audio persona want the pad.

The jack watch reads a Bluetooth DualShock 4 too: reports 0x11 to 0x19 with byte 1 bit 7 set, the CRC checked with prefix 0xA1 at the report's own length, and the status byte at raw offset 32 with headphones in bit 5 and a microphone in bit 6 (ds4drv, ds4mac). A DualShock 4 on USB, a wired second model or a pad on Sony's adapter, is watched as well, from USB report 0x01 with the same byte at raw offset 30 (Ds4BtAudio.UsbStatusOffset, ds4drv's buf[30], SDL's PS4StatePacket_t). A report shorter than 64 bytes, or one with bit 2 of byte 31 set, which Sony's adapter sends while no pad is linked, carries no status (SDL's HIDAPI_DriverPS4_IsPacketValid). A change there signals the worker, which moves the slot's macro sounds between the pad and wherever they play without it. Only a Bluetooth reading feeds the Bluetooth microphone's state.

Headset microphone

It runs on the SDL build from fork e0b84e3498 (hifihedgehog/SDL#41), which PadForge took in 865966da.

With the microphone armed the controller sends input reports 0x11 to 0x19 that carry audio, flagged by input byte 2 bit 7. The audio block follows the 75-byte controller state when byte 1 bit 7 is set and starts at byte 3 when it is clear, and the state-prefixed spot is checked again for firmware that leaves the flag stale. The block holds a 16-bit frame number, the target (0x01 on a genuine CUH-ZCT2, 0x03 in older notes) and the frames: 0xAD starts a 57-byte mSBC frame, 0x9C an SBC frame whose header gives its length. SDL accepts state from those IDs (067630aef3, 2020). Until fork e0b84e3498 it also checked every Bluetooth state report's CRC at byte 78 and read 128 bytes, which dropped the larger reports and stopped the pad's input. That build reads 547 bytes and checks each report at its own length, and Ds4BtAudioTests.MicLane_IsOn_WithAnSdlBuildThatKeepsInputFlowing reads both shipped SDL3.dll files for its table of report lengths.

SbcDecoder decodes SBC in every mode with 4 or 8 subbands, and HFP's mSBC. Its windows are the specification's printed Proto_4_40 and the encoder's Proto_8_80 at -M times their value, which google/libsbc's fixed-point windows match tap for tap. On ffmpeg-encoded mSBC, 16 kHz mono, 32 kHz stereo and 48 kHz four-subband streams its output matches ffmpeg's own decode at 77 dB (SbcDecoderTests). ffmpeg's encoder writes loudness allocation only, so SNR allocation is checked on random-payload frames, 16 kHz mono and 32 kHz joint stereo, which ffmpeg decodes too: 70.6 and 72.1 dB. Ds4MicStream accepts 16 kHz mono, as the fork does. It conceals up to four lost frames, counted by frame number, by repeating the last decoded frame 0.82 times quieter each time, then crossfades the first 16 samples of the next real frame. A longer gap restarts the decoder.

Arming follows the fork. The mode turns to 0xA1 only while a consumer wants the pad's microphone (the virtual controller's microphone endpoint is recording, or a voice-macro session listens to the pad) and the status byte shows a microphone. A want holds for 2 s, so a consumer that blinks does not flap the mode. The controller drops microphone mode after about four seconds without an 0xA1 effects report, so an armed microphone keeps the slot's effects dispatcher ticking (SlotHasArmedDs4Mic), and its 33 ms timer sends 0xA1 well inside that limit. Arming asserts the microphone gain once, at 0x40, the hardware ceiling. PadForge's software gain applies after decode.

The persona lane (Ds4BtMicLoop) mirrors the DualSense's BtMicLoop: its own synchronous HID handle, a generation check, and whole 10 ms blocks submitted to the virtual controller's microphone. The virtual DualShock 4's microphone is 16 kHz mono and takes the decoded samples as they come. A 48 kHz endpoint goes through Upsampler3x, a 96-tap polyphase interpolator. The source order is a USB pad's own endpoint, then a Bluetooth DualSense, then a Bluetooth DualShock 4. The voice lane (Ds4VoiceBtMicLoop) feeds VoiceMacroService.SubmitPadMic48k at 48 kHz and yields while the persona lane decodes the same pad. VoiceMacroService.IsPadWithEmbeddedMic, UserDevice.HasVoicePhrases and the Manage Voice Macros button all admit a DualShock 4 on Bluetooth, so its row carries the phrase sources a Bluetooth DualSense row does.


Composite USB persona lanes

A slot whose virtual controller is a composite USB persona has a real Windows endpoint the game renders into. HIDMaestro delivers that PCM on its pacing thread, in four-channel 48 kHz windows (speaker L/R, haptic L/R) for the DualSense personas and two-channel 32 kHz headset windows for the DualShock 4 persona. RegisterPersonaFeed subscribes, and RoutePersonaFrames routes each window to the slot's physical Sony pads, resampling the 32 kHz persona to 48 kHz. The feed's presence is itself sink demand, so a composite slot builds its pads' transports with passthrough off and no macros.

Before the Sony-target gate, FollowPersonaHaptics also follows the haptic channels' loudness for the slot's rumble (discussion #522). The follower is a running mean square with a 20 ms time constant (PersonaHapticRumble.Follow), read as RMS times 1.4 under HapticToneReducer's silence floor, the left channel on the low (left) motor and the right on the high (right) motor. It runs on every window and publishes HapticLevel on the feed with its time at most every 10 ms, so motor writes keep a game's own rumble rate whatever window size the host sends. Readers drop a level more than 100 ms old. A game that plays the haptics through this audio sends no rumble, so without it a pad with motors on the slot, and the slot's Bass Shakers, stayed still while a real DualSense vibrated. InputManager.WithPersonaHaptics layers the level onto the slot's rumble for every device except one that plays the haptic track itself. RendersPersonaHaptics judges that by the ring below: a device whose haptic ring the USB shaper or the Bluetooth tick polled within the last 500 ms (PersonaHapticRing.LastPolledMs) plays the haptics, and a DualShock 4's ring, which nothing polls, does not. The Bluetooth tick polls before it knows a whole tick is waiting, so a pad counts from the stream's first frame. See Input Pipeline.

The speaker channels land in _personaSpeakerRings and are summed into each sink's render path inside SinkSource.Read, so they ride the same Opus and shared-mode WASAPI transports as macros and the system mirror. The haptic channels land in _personaHapticRings and split by transport. On USB they are the pad's UAC channels 2 and 3. On Bluetooth they get their own 142-byte report 0x32 carrying packets 0x11 and 0x12, built by BuildDs5BtHapticReport, decimated 16:1 from the 48 kHz tick to 3 kHz stereo s8 by block mean. The 0x32 stream rides the same Ds5Seq / Ds5PktCounter pair as the 0x35 speaker stream. Both reports carry the packet 0x11 audio session header and the firmware tracks one session, so separate counters made that session see a counter jumping back and forth whenever both streams were live (discussion #300). When a tick carries both speaker audio and a haptic block, BuildDs5BtCombinedReport emits one 0x35 carrying the 0x11 header, the 0x12 actuator packet, and the audio-lane packet at byte 77, so the single session never sees two reports per tick. Haptics-only ticks use the 142-byte 0x32 (BuildDs5BtHapticReport).

The microphone runs the other way. On USB it is the pad's own container-matched capture endpoint. PickPersonaMics picks the persona's source: a USB DualSense's capture endpoint, else a Bluetooth DualSense, else a USB DualShock 4's capture endpoint (a wired second model or a pad on Sony's adapter), else a Bluetooth DualShock 4's headset microphone. A DualShock 4 hears only a headset in its jack, so a DualSense on either link comes first, and a peer pad takes no microphone role. On Bluetooth a parallel synchronous HID handle reads the pad's input reports, one 71-byte Opus packet per report, decoded as mono at 48 kHz. Decoding it as stereo on the strength of the packet's TOC byte produced full-scale noise on the consumer side, so BtMicChannels stays 1. Mic-open is not latched in firmware, so Ds5MicSessionByte rides report byte 4, the 0x11 packet's first payload byte, on every audio report and has to track the live session, otherwise a steady close byte re-closes the mic the persona just opened. The decoded Bluetooth mic frames also tee to VoiceMacroService.SubmitPadMic48k when that pad has a recognition session (#317), pre-gain, so the pad's voice phrases hear what the pad hears rather than what MicGain shaped for Windows. Each lane submits at the persona microphone's own rate and checks HIDMaestro's ring at that endpoint's size (MicRingFits). The virtual DualShock 4 declares 16 kHz mono, and the Bluetooth DualSense lane submitted 48 kHz blocks there until #528, which played three times too slow.


The DSP chain (#347)

Every sink owns a MirrorChain (Sink.Dsp), and SinkSource.Read runs it in place on the summed mix, after the macro, loopback, and persona sources are added and before the transport encodes the frames. Upstream of Opus on purpose: the encoder then allocates bits to the signal the user wants, and the limiter can protect it. Three rules every stage obeys, because the Bluetooth lane trims the frames it consumes per tick against an adaptive 20 to 100 ms cushion and a stage that buffered, resampled, or looked ahead would fight that control loop: process in place, preserve the frame count exactly, look ahead by nothing. Nothing allocates on the audio thread. Coefficients are rebuilt off it. The EQ swaps in a new filter array by reference, and crossfeed and the limiter rewrite a few float fields.

The three stages, in fixed order:

  • CrossfeedStage, bs2b. A level is a (cutoff Hz, feed dB) pair, which is what libbs2b itself stores (cutoff in the low word, feed in tenths of a dB in the high word). Levels 1 to 6 are the six classic presets and keep their numbers, 7 is Jan Meier (650 Hz, 9.5 dB), 8 is bs2b's default (700 Hz, 4.5 dB), 9 is Custom with its own two knobs clamped to libbs2b's BS2B_MINFCUT 300 to BS2B_MAXFCUT 2000 and BS2B_MINFEED 1.0 to BS2B_MAXFEED 15.0 dB. Coefficients come from libbs2b's own derivation rather than a hardcoded table. It carries no delay line (the inter-aural timing comes out of the IIR phase response, so the stage adds zero latency), and its g = 1 / (1 - G_hi + G_lo) normalization makes the two filters sum to exactly unity at DC, which the tests use as a precise coefficient check across every preset and the Custom range. It runs only when IsStereoHeadphoneRoute(resolved) is true, which is resolved path 0 (Default, which writes nothing and leaves the pad on firmware path 0, L_R_X) or 1 (Headphones (Stereo)). The mono headset paths and Speaker Only carry a mono mix and are skipped. Gating on path 1 alone was the reason the first cut was inaudible on any default configuration. A Bluetooth DualShock 4 uses Ds4BtAudio.IsStereoRoute instead, true for path 1 alone, because its Default is the mono speaker (#528).
  • ParametricEqStage, one BiQuadFilter per band per channel. Bands at or above EqBand.MaxFrequencyHz(rate) (0.45 of the sample rate) are clamped there, a 0 dB peak or shelf is skipped as the identity, and Reset() rebuilds the filters because BiQuadFilter exposes no history reset. EqResponse recomputes the same RBJ coefficients for the curve so the drawn response is the applied one without reading NAudio's private fields.
  • LimiterStage, feed-forward peak limiter with no lookahead (1 ms attack, 80 ms release). Because there is no lookahead, the ceiling is additionally a hard clamp, and that clamp is the guarantee the tests pin.

The crossfeed history and the limiter envelope flush to zero when they go non-finite, as they already do below 1e-20, before they can reach the denormal range. One NaN sample, from a NaN preamp for one, otherwise latched them, and Configure keeps them for an unchanged crossfeed level, so the audio stayed silent or the limiter rode its gain after the cause was corrected.

Configuration arrives through AudioPassthroughService.DspConfigProvider, a (slot, deviceGuid) callback InputService wires to the per-device DeviceSlotConfig (nine Audio* attributes: crossfeed level, custom cutoff and feed, EQ on, bands, preamp, limiter on, ceiling, and AudioOutputPath for the stereo-route test). Phase 3c of ReconcileOnWorker pushes it into each sink's chain outside the service lock, since Configure builds filter arrays. MirrorChain.Reset() runs in the unlocked phase 3 rebuild so a transport flip or a stall rebuild does not ring the old stream's tail into the new one.

The band list persists as one attribute, AudioEqBands, in EqBandCodec's form: PK:1050:-3.5:1.2:1|LSC:105:5.5:0.7:1, fields type token, frequency, gain, Q, enabled, invariant culture. AutoEqProfile.Parse reads AutoEq's published parametric format (Preamp: -6.8 dB then Filter N: ON PK Fc 21 Hz Gain 4.7 dB Q 0.66) and maps its type tokens (PK, LSC, HSC, HPQ, LPQ, and the variants) onto EqBandType. AutoEq's Graphic Eq export has no Filter lines and parses to zero bands, which the UI reports rather than silently declining.

The follow-jack reader that feeds ResolveOutputPath is sink-owned as of 4.3.2 (JackWatch in phase 3b). It used to hang off the composite-persona feed alone, so with no virtual DualSense on the slot the jack bit was never observed and Follow Headphone Jack resolved to Default forever (#346). The watch reads USB report 0x01 raw byte 54 or Bluetooth report 0x31 raw byte 55, both duaLib's /*53.0*/ PluggedHeadphones, and a failed open stays registered with a 5-second retry deadline (JackWatchRetryMs), so the reconcile retries it at most every 5 s rather than respawning a reader every pass. A changed path or transport restarts it at once. Since #528 it also reads a Bluetooth DualShock 4, as described under DualShock 4 Bluetooth, and a DualShock 4 on USB.


Master volume

Master volume is the macro sounds' level. A sink that owns its loudness applies it there, and SoundMacroService.PlacementVolume leaves its placements at the action's volume: the DualSense takes it as the firmware speaker byte, and a haptic-tone sink multiplies its own samples. Every other placement scales its samples by it: the PC fallback, a DualShock 4 on either link, and a Wii Remote. GetSlotSinkMixers marks DualSense and peer sinks OwnsMaster and DualShock 4 sinks not, and StartPlacements adds the Wii Remote mixers unowned and the haptic-tone mixers owned. SetSlotVolume retunes live sounds on every path.

For the DualSense it is asserted on every output report in UserEffectsDispatcher. When the device wants the speaker path, the dispatcher sets the valid flags, writes speakerVolume mapped from the 0-100% master onto the firmware's 0x3D to 0x64 window (0 mutes), and sets the speaker pre-gain byte. It also routes the audio-control flags to the internal speaker, but only when the resolved Audio Output Path is Default. An explicit path owns that byte and its enable bit, and the #83 block keeps just the volume half. When the sink tears down, a one-shot restores the headphone path, again only when no explicit path is forced. SetSlotVolume retunes live because the byte is read per report. On the DualShock 4 the master volume stays out of the firmware bytes. Its route's volumes, the speaker at 0x4F or muted and the jack at Headphone Volume or muted, ride the one-shot arm report and the effects report whenever the level or the route changes (#528).


HD-haptic tones (#147)

Playing macro sounds as tones through a controller's haptic actuators: the reduce step, the per-family encoders, and where the fidelity ceiling sits.

Some controllers have no speaker but do have haptic actuators: LRAs (linear resonant actuators) on the Nintendo pads, haptic transducers on the Steam pads. HapticToneService treats them as a tone generator. A Switch Joy-Con L/R, a combined Joy-Con pair, Switch Pro, Steam Controller 2015, Steam Deck, or Steam Controller 2026 (Triton) assigned to a slot becomes an output sink whose MacroMixer is returned to SoundMacroService alongside the Sony and Wii sinks, so a macro Play Sound fans out to it with no macro-layer change. FamilyOf sorts the device by VID and PID against the SDL controller_list.h table, so every transport and revision of a family (wired, BLE, dongle) is caught rather than a hand-picked PID subset. Switch 2 is excluded: no reference plays an audible tone on its actuator, so it gets no Audio tab.

FamilyOf maps the paired-set PID 0x2008 to Family.JoyConPair, the id SDL reports when it combines two Joy-Cons by default (issue #184). That set has no HID path of its own to open: SDL's nintendo_joycons_combined is a synthetic placeholder, not a real \\?\HID#... path, so CreateFileW on it fails. BuildSink calls SelectPairChildPaths to resolve both real child paths (Left 0x2006 as primary, Right 0x2007 as second) and drives both coils (#223). OpenPairSecondChild opens the second child on its own handle, timer, probed write path, and OutputReportByteLength, and runs the init subcommands (0x03 arg 0x30 input-report mode, 0x48 arg 0x01 enable vibration) on it, skipping the 0x03 mode write while Switch NFC or the Joy-Con IR hint is armed. A pair that resolves only one child degrades to single-coil and RetryPairSecondHandles picks the missing coil up on the 3-second reconcile.

Side routing follows the slot's merged vibration snapshot: left motor active plays the left coil, right the right, both or neither play both. A side stays hot for PairSideHoldMs = 300 ms after its motor drops so packet-rate motor flap does not strobe the tone, and a side going cold gets one neutral half before silence. The Audio-tab Test button and remote-driven sinks bypass the routing and always play both coils. Two simultaneous pairs no longer stack on one set of children: ResolveUnclaimedPairChildren walks FirstUnclaimed over the paths no other live pair sink already holds, so each physical Joy-Con gets exactly one writer. Residual on record: nothing at this layer proves the left it picks is the physical partner of the right it picks, because SDL owns the pairing and exposes only the combined device with an empty serial.

An LRA reproduces one frequency at a time: a tone with an amplitude envelope, not PCM. A pad with more than one actuator (a Pro or Joy-Con pair has two coils, the 2015 Steam Controller has two haptics, the Steam Controller 2026 has four LRAs, two trackpads and two grips) still plays that single mono tone. The extra actuators do not buy stereo or fidelity. Beeps, alerts, and melodic cues land. Speech and music do not. Each tick the service collapses the macro mix to one tone:

  • HapticToneReducer (PadForge.Engine/Haptics/HapticToneReducer.cs) reads the dominant frequency from the first normalized-autocorrelation peak above the voiced threshold (not the global maximum) over the playable lag range, and the amplitude from windowed RMS. Below a silence RMS the amplitude is 0. An unvoiced burst holds the last pitch so the coil does not jump around.
  • HapticToneEncoder (PadForge.Engine/Haptics/HapticToneEncoder.cs) turns that (frequency, amplitude) pair into the exact wire bytes each family expects. The bytes are grounded in the cloned references (joycon-singer, SteamControllerSinger, SteamHapticsSinger, Nintendo_Switch_Reverse_Engineering). The C# is original.

The encoders differ by family:

Family Wire form Notes
Joy-Con L/R, Pro, combined pair 0x10 HD Rumble payload, 4-byte group EncodeJoyConRumble: closed-form log2 frequency and two-segment log amplitude. Frequency folded into the [41, 626] Hz LF band. The combined pair writes each child's own packet on its own handle, side-routed by the active motor (#223).
Steam Controller 2015, Steam Deck 0x8F square-wave feature blob, 64 bytes EncodeSteamClassic: period and repeat from the note frequency. Pitch-only, no gain byte. The Deck reuses the 2015 encoder.
Steam Controller 2026 (Triton), Bluetooth 0x1303 0x83 LFO-tone OUTPUT report, 10 bytes EncodeTritonTone: frequency u16 LE plus a signed gain_db byte (0 dB unity, floored at -40). Never the 2015 0x8F feature report. Grips are driven through a per-note map so they match trackpad pitch.
Steam Controller 2026, USB 0x1302 and dongle 0x1304 / 0x1305 0x86 configure plus 0x88 stereo PCM, 64 bytes (#381) Not a tone at all. The sink reads the stereo mix through a Butterworth low-pass and a sinc downsample to 8 kHz and streams it, s16 wired and G.711 mu-law over the dongle. Cut and Fold are not applied. Falls back to 0x83 when the write ring cannot build. Full detail on Steam Controller Haptics Internals.

Why raw HID and not SDL rumble: the bundled SDL fork's Switch and Steam drivers hard-code the rumble carrier and vary only amplitude, so tone playback needs the same raw-HID writer pattern the Wii and Sony speaker paths use. The one exception is the 2015 Steam Controller, whose 0x8F feature blob is forwarded through SDL_SendGamepadEffect (with a raw HidD_SetFeature fallback when SDL did not open it as a gamepad). The Joy-Con, Deck, and Triton tone lanes each write their own raw HID handle.

High-tone cut and fold (#202)

An LRA loses fidelity above its resonance, and a loud high pitch on a wrist-worn coil is unpleasant. HapticToneService filters the reduced pitch upstream of the family switch, so every encoder sees the same clamped pair. Three settings, held in ToneFilterOff / ToneFilterCut / ToneFilterFold (0, 1, 2): off passes everything, cut and fold act above a limit. The (mode, limitHz) pair is resolved per (slot, device) through ToneFilterProvider and re-read by each sink every 250 ms on its own stream thread.

  • Cut zeroes the amplitude above the limit. It re-emits the last passing pitch rather than the cut pitch. The pitch-only 0x8F square (Steam 2015 and Deck) never reads amplitude, so a cut tick carrying the beep pitch would re-arm the square at the exact frequency the user cut.
  • Fold octave-halves the pitch into the pass band, keeping the pitch class.

The filter runs only on the tone branch. A PCM-capable Triton sink never calls it, since the stream's low-pass cutoff replaces its purpose. ApplyToneFilter clamps the user limit to 100..1300 Hz and carries a boundary-hysteresis latch (a Schmitt trigger). The reducer measures pitch on the integer-lag grid ReduceRate / lag, so a tone near the limit lands on one of two adjacent grid pitches, and per-tick noise flips which one wins. A bare > turns that flip into an octave flap at the tick rate. Once engaged, the tie's lower grid member stays engaged, so the pair folds together instead of alternating folded and unfolded. Disengage needs the measurement a full grid step below the limit. The filter runs on the Test button too, so the test never lies about the setting. Owner-side Remote Link frames are exempt, because the consumer applied its own filter before shipping.

System-audio mirror and engage gate (#185)

Like the Sony sink, the Wii and haptic-tone sinks carry their own system-audio mirror. WiiSpeakerService.ReconcileMirrors and HapticToneService.ReconcileMirrors read the same PassthroughConfigProvider and add a WasapiLoopbackCapture onto the sink's MacroMixer, so a mirrored Windows output plays through the Wii speaker, or through a haptic pad reduced to a pitch-following tone (a wired or dongle Steam Controller 2026 streams it as low-passed PCM instead).

The mirror's BufferedWaveProvider is 500 ms deep with DiscardOnBufferOverflow, and the persona haptic feed's is 250 ms. Both are filled in wall-clock time by other threads and read by the sink thread, so at idle the sink's coarse 15 ms sleep let them fill to cap and every onset queued behind stale audio (#371 follow-up). The idle catch-up drain in StreamLoop holds both at one tick of depth. The mechanism is described with the PCM lane on Steam Controller Haptics Internals, and it runs on every haptic sink that has a persona feed or a mirror, tone sinks included.

On the haptic path that mirror can gate. When ReconcileMirrors starts a mirror, StartMirror wraps only the mirror's sample provider in a GatedMirrorSampleProvider bound to a per-(slot, device) EngageCell. The gate has three modes: Always (default, the cell stays engaged), Input (engaged while a chosen descriptor is held, an empty descriptor reads as always-on), and Rumble (engaged while any of the slot's four vibration motors is nonzero). InputManager.UpdateHapticMirrorEngageStates settles every cell once per poll and applies a release-delay hold through HoldEngaged, clamped to 0..10000 ms, so the tone does not clip off the instant the source drops. The gate wraps the mirror input alone. Macro sounds and the Test button bypass it and always play.


Wii Remote speaker

The one PCM sink among the tone paths.

The Wii Remote has a real speaker, not haptic coils, so its macro-audio output is genuine 8-bit PCM at 2 kHz, not a reduced tone. It is driven by WiiSpeakerService (PadForge.App/Common/Input/WiiSpeakerService.cs) on its own raw HID handle, following the same per-slot sink model and MacroMixer handoff to SoundMacroService as the Sony path here and the tone path above. The full protocol lives in Wii Controllers Internals. It is listed here because it is one of the three macro-audio sink families SoundMacroService.StartPlacements fans into, in the order Sony, Wii, tone, and it shares the sink model. Its wire format is PCM speaker reports, distinct from the HapticToneEncoder tone bytes.


On the receive (owner) side, InputService handles the link server's FrameReceived event in OnRemoteFrameReceived, resolves the frame's device id to the exposed local source device, and calls AudioPassthroughService.FeedRemoteAudio for an Audio frame. That writes the incoming s16 48 kHz stereo PCM into a RemoteAudioRing and marks the device's remote-audio demand. The worker then builds a real Bluetooth or USB sink for it, and SinkSource.Read drains the ring instead of loopback and macros, so the relayed audio reaches the physical pad speaker.

On the produce (consumer) side, a peer:// pad becomes a sink with no local transport. The Bluetooth thread's peer lane calls ShipPeerAudioTick, which pulls the same per-pad mix, converts to s16, and flushes exact 1024-byte blocks through RemoteLinkOutputRouter.ShipAudio. The PCM is shipped full-scale. The volume is the owner's firmware byte. See Remote Link Internals for the wire side.

Haptic-tone relay (#138 x #147)

A haptic pad relays a tone pair, not PCM. On the consumer side a peer:// haptic sink runs its mixer and reducer as usual, then the stream loop ships the reduced (frequency, amplitude) pair through RemoteLinkOutputRouter.ShipHapticTone instead of writing hardware. The wire form is OutputEffectCodec.EncodeHapticTone, a small tone frame, distinct from the s16 PCM block the speaker relay sends. Silent steady state is deduped. A playing tone ships every tick, because the owner's hangover expiry needs the refresh.

On the owner side InputService hands each received frame to HapticToneService.ApplyRemoteTone, which sets the direct-drive override on the device's sink (the same idiom as the Test tone). When the owner maps that device to no local slot, ApplyRemoteTone builds a RemoteDriven sink on demand, queued off the UDP receive thread so the raw HID open never blocks, and Reconcile reaps it once frames stop arriving. The owner is a pure transcoder: it re-encodes to the device's wire bytes but does not re-apply the tone filter or the slot volume, since the consumer already did both.



Last updated for PadForge 5.2.0.