diff --git a/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs b/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs
index 081c86b..b113347 100644
--- a/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs
+++ b/DS4Windows/DS4Control/ControlServiceDeviceOptions.cs
@@ -229,6 +229,28 @@ public enum MuteLEDMode : ushort
Pulse
}
+ public enum HapticsMode : ushort
+ {
+ Off,
+ SystemAudio,
+ RumbleToHaptics,
+ Mix,
+ }
+
+ public enum AudioOutputRoute : ushort
+ {
+ Auto, // headphone jack when plugged in, speaker otherwise
+ Headphone,
+ Speaker,
+ }
+
+ public enum AudioLatencyMode : ushort
+ {
+ Smooth, // maximum buffering; survives congested links
+ Balanced,
+ LowLatency, // minimum buffering; needs a clean link
+ }
+
private LEDBarMode ledMode = LEDBarMode.MultipleControllers;
public LEDBarMode LedMode
{
@@ -255,6 +277,112 @@ public MuteLEDMode MuteLedMode
}
public event EventHandler MuteLedModeChanged;
+ // Bluetooth audio-haptics streaming settings. Fires a single combined
+ // change event; the device applies live-safe fields or restarts the
+ // capture pipeline as needed.
+ private HapticsMode btHapticsMode = HapticsMode.Off;
+ public HapticsMode BTHapticsMode
+ {
+ get => btHapticsMode;
+ set
+ {
+ if (btHapticsMode == value) return;
+ btHapticsMode = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private double btHapticsGain = 3.0;
+ public double BTHapticsGain
+ {
+ get => btHapticsGain;
+ set
+ {
+ value = Math.Clamp(value, 0.1, 10.0);
+ if (btHapticsGain == value) return;
+ btHapticsGain = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private int btHapticsLowPassHz = 350;
+ public int BTHapticsLowPassHz
+ {
+ get => btHapticsLowPassHz;
+ set
+ {
+ value = Math.Clamp(value, 40, 1000);
+ if (btHapticsLowPassHz == value) return;
+ btHapticsLowPassHz = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private string btHapticsAudioDeviceId = string.Empty;
+ public string BTHapticsAudioDeviceId
+ {
+ get => btHapticsAudioDeviceId;
+ set
+ {
+ value ??= string.Empty;
+ if (btHapticsAudioDeviceId == value) return;
+ btHapticsAudioDeviceId = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ // Bluetooth listening-audio (headphone jack / speaker) settings.
+ private bool btAudioEnabled = false;
+ public bool BTAudioEnabled
+ {
+ get => btAudioEnabled;
+ set
+ {
+ if (btAudioEnabled == value) return;
+ btAudioEnabled = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private AudioOutputRoute btAudioRoute = AudioOutputRoute.Auto;
+ public AudioOutputRoute BTAudioRoute
+ {
+ get => btAudioRoute;
+ set
+ {
+ if (btAudioRoute == value) return;
+ btAudioRoute = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private int btAudioVolume = 50;
+ public int BTAudioVolume
+ {
+ get => btAudioVolume;
+ set
+ {
+ value = Math.Clamp(value, 0, 100);
+ if (btAudioVolume == value) return;
+ btAudioVolume = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private AudioLatencyMode btAudioLatency = AudioLatencyMode.Smooth;
+ public AudioLatencyMode BTAudioLatency
+ {
+ get => btAudioLatency;
+ set
+ {
+ if (btAudioLatency == value) return;
+ btAudioLatency = value;
+ BTHapticsOptionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public event EventHandler BTHapticsOptionChanged;
+
public DualSenseControllerOptions(InputDeviceType deviceType) :
base(deviceType)
{
diff --git a/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs b/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs
index ab85355..48b4a06 100644
--- a/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs
+++ b/DS4Windows/DS4Control/DTOXml/DualSenseControllerOptsDTO.cs
@@ -40,16 +40,80 @@ public MuteLEDMode MuteLedMode
get; set;
}
+ [XmlElement("BTHapticsMode")]
+ public HapticsMode BTHapticsMode
+ {
+ get; set;
+ } = HapticsMode.Off;
+
+ [XmlElement("BTHapticsGain")]
+ public double BTHapticsGain
+ {
+ get; set;
+ } = 3.0;
+
+ [XmlElement("BTHapticsLowPassHz")]
+ public int BTHapticsLowPassHz
+ {
+ get; set;
+ } = 350;
+
+ [XmlElement("BTHapticsAudioDeviceId")]
+ public string BTHapticsAudioDeviceId
+ {
+ get; set;
+ } = string.Empty;
+
+ [XmlElement("BTAudioEnabled")]
+ public bool BTAudioEnabled
+ {
+ get; set;
+ } = false;
+
+ [XmlElement("BTAudioRoute")]
+ public AudioOutputRoute BTAudioRoute
+ {
+ get; set;
+ } = AudioOutputRoute.Auto;
+
+ [XmlElement("BTAudioVolume")]
+ public int BTAudioVolume
+ {
+ get; set;
+ } = 50;
+
+ [XmlElement("BTAudioLatency")]
+ public AudioLatencyMode BTAudioLatency
+ {
+ get; set;
+ } = AudioLatencyMode.Smooth;
+
public void MapFrom(DualSenseControllerOptions source)
{
LEDMode = source.LedMode;
MuteLedMode = source.MuteLedMode;
+ BTHapticsMode = source.BTHapticsMode;
+ BTHapticsGain = source.BTHapticsGain;
+ BTHapticsLowPassHz = source.BTHapticsLowPassHz;
+ BTHapticsAudioDeviceId = source.BTHapticsAudioDeviceId;
+ BTAudioEnabled = source.BTAudioEnabled;
+ BTAudioRoute = source.BTAudioRoute;
+ BTAudioVolume = source.BTAudioVolume;
+ BTAudioLatency = source.BTAudioLatency;
}
public void MapTo(DualSenseControllerOptions destination)
{
destination.LedMode = LEDMode;
destination.MuteLedMode = MuteLedMode;
+ destination.BTHapticsMode = BTHapticsMode;
+ destination.BTHapticsGain = BTHapticsGain;
+ destination.BTHapticsLowPassHz = BTHapticsLowPassHz;
+ destination.BTHapticsAudioDeviceId = BTHapticsAudioDeviceId;
+ destination.BTAudioEnabled = BTAudioEnabled;
+ destination.BTAudioRoute = BTAudioRoute;
+ destination.BTAudioVolume = BTAudioVolume;
+ destination.BTAudioLatency = BTAudioLatency;
}
}
}
diff --git a/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml b/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml
index 062d5c3..116fc0b 100644
--- a/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml
+++ b/DS4Windows/DS4Forms/ControllerRegisterOptionsWindow.xaml
@@ -72,6 +72,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DS4Windows/DS4Forms/ViewModels/ControllerRegDeviceOptsViewModel.cs b/DS4Windows/DS4Forms/ViewModels/ControllerRegDeviceOptsViewModel.cs
index f282a09..5300076 100644
--- a/DS4Windows/DS4Forms/ViewModels/ControllerRegDeviceOptsViewModel.cs
+++ b/DS4Windows/DS4Forms/ViewModels/ControllerRegDeviceOptsViewModel.cs
@@ -285,12 +285,106 @@ public class DualSenseControllerOptionsWrapper
};
public List> DsMuteLEDModes { get => dsMuteLEDModes; }
+ private List> dsHapticsModes =
+ new List>()
+ {
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_HapticsModeOff,
+ DualSenseControllerOptions.HapticsMode.Off),
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_HapticsModeSystemAudio,
+ DualSenseControllerOptions.HapticsMode.SystemAudio),
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_HapticsModeRumble,
+ DualSenseControllerOptions.HapticsMode.RumbleToHaptics),
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_HapticsModeMix,
+ DualSenseControllerOptions.HapticsMode.Mix),
+ };
+ public List> DsHapticsModes { get => dsHapticsModes; }
+
+ private List hapticsAudioDevices = new List();
+ public List HapticsAudioDevices { get => hapticsAudioDevices; }
+
+ private List> dsAudioRoutes =
+ new List>()
+ {
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_AudioRouteAuto,
+ DualSenseControllerOptions.AudioOutputRoute.Auto),
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_AudioRouteHeadphones,
+ DualSenseControllerOptions.AudioOutputRoute.Headphone),
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_AudioRouteSpeaker,
+ DualSenseControllerOptions.AudioOutputRoute.Speaker),
+ };
+ public List> DsAudioRoutes { get => dsAudioRoutes; }
+
+ private List> dsAudioLatencies =
+ new List>()
+ {
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_LatencySmooth,
+ DualSenseControllerOptions.AudioLatencyMode.Smooth),
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_LatencyBalanced,
+ DualSenseControllerOptions.AudioLatencyMode.Balanced),
+ new EnumChoiceSelection(
+ Translations.Strings.ControllerRegOptWin_LatencyLow,
+ DualSenseControllerOptions.AudioLatencyMode.LowLatency),
+ };
+ public List> DsAudioLatencies { get => dsAudioLatencies; }
+
public DualSenseControllerOptionsWrapper(DualSenseControllerOptions options,
DualSenseDeviceOptions parentOpts)
{
this.options = options;
this.parentOptions = parentOpts;
parentOptions.EnabledChanged += (sender, e) => { VisibleChanged?.Invoke(this, EventArgs.Empty); };
+
+ PopulateHapticsAudioDevices();
+ }
+
+ private void PopulateHapticsAudioDevices()
+ {
+ hapticsAudioDevices.Add(new HapticsAudioDeviceChoice(
+ Translations.Strings.ControllerRegOptWin_AudioDeviceDefault, string.Empty));
+ try
+ {
+ using NAudio.CoreAudioApi.MMDeviceEnumerator enumerator = new NAudio.CoreAudioApi.MMDeviceEnumerator();
+ foreach (NAudio.CoreAudioApi.MMDevice dev in enumerator.EnumerateAudioEndPoints(
+ NAudio.CoreAudioApi.DataFlow.Render, NAudio.CoreAudioApi.DeviceState.Active))
+ {
+ hapticsAudioDevices.Add(new HapticsAudioDeviceChoice(dev.FriendlyName, dev.ID));
+ dev.Dispose();
+ }
+ }
+ catch (Exception)
+ {
+ // Endpoint enumeration is best-effort; the default entry always works.
+ }
+
+ // Keep the ComboBox binding valid if the saved endpoint disappeared.
+ if (!string.IsNullOrEmpty(options.BTHapticsAudioDeviceId) &&
+ !hapticsAudioDevices.Exists(item => item.Id == options.BTHapticsAudioDeviceId))
+ {
+ hapticsAudioDevices.Add(new HapticsAudioDeviceChoice(
+ Translations.Strings.ControllerRegOptWin_AudioDeviceUnavailable,
+ options.BTHapticsAudioDeviceId));
+ }
+ }
+ }
+
+ public class HapticsAudioDeviceChoice
+ {
+ public string DisplayName { get; }
+ public string Id { get; }
+
+ public HapticsAudioDeviceChoice(string displayName, string id)
+ {
+ DisplayName = displayName;
+ Id = id;
}
}
diff --git a/DS4Windows/DS4Library/InputDevices/DualSenseDevice.cs b/DS4Windows/DS4Library/InputDevices/DualSenseDevice.cs
index ca9c505..aa1a831 100644
--- a/DS4Windows/DS4Library/InputDevices/DualSenseDevice.cs
+++ b/DS4Windows/DS4Library/InputDevices/DualSenseDevice.cs
@@ -209,6 +209,56 @@ public byte HapticPowerLevel
private DualSenseControllerOptions nativeOptionsStore;
public DualSenseControllerOptions NativeOptionsStore { get => nativeOptionsStore; }
+ private DualSenseHapticsStreamer hapticsStreamer;
+ private volatile bool hapticsStreamerReady;
+
+ internal readonly struct BtOutputControl
+ {
+ public readonly byte EnableFlags;
+ public readonly byte PlayerLedFlags;
+ public readonly bool WriteMotorBytes;
+
+ public BtOutputControl(byte enableFlags, byte playerLedFlags, bool writeMotorBytes)
+ {
+ EnableFlags = enableFlags;
+ PlayerLedFlags = playerLedFlags;
+ WriteMotorBytes = writeMotorBytes;
+ }
+ }
+
+ internal static BtOutputControl GetBtOutputControl(bool useRumble,
+ bool useAccurateRumble, bool hapticsStreamActive)
+ {
+ if (hapticsStreamActive)
+ return new BtOutputControl(0x0C, 0x02, false);
+
+ return new BtOutputControl(
+ useRumble ? (byte)0x0F : (byte)0x0C,
+ useAccurateRumble ? (byte)0x06 : (byte)0x02,
+ useRumble || useAccurateRumble);
+ }
+
+ internal static BtOutputControl ApplyBtOutputControl(byte[] outputReport,
+ bool useRumble, bool useAccurateRumble, bool hapticsStreamActive,
+ byte lightMotor, byte heavyMotor)
+ {
+ BtOutputControl control = GetBtOutputControl(
+ useRumble, useAccurateRumble, hapticsStreamActive);
+
+ outputReport[2] = control.EnableFlags;
+ outputReport[4] = control.WriteMotorBytes ? lightMotor : (byte)0;
+ outputReport[5] = control.WriteMotorBytes ? heavyMotor : (byte)0;
+ outputReport[40] = control.PlayerLedFlags;
+ return control;
+ }
+
+ // Current rumble targets for the haptics streamer's rumble-to-haptics synth
+ internal byte CurrentRumbleHeavy => currentHap.rumbleState.RumbleMotorStrengthLeftHeavySlow;
+ internal byte CurrentRumbleLight => currentHap.rumbleState.RumbleMotorStrengthRightLightFast;
+
+ private volatile bool headsetPlugged = false;
+ public bool HeadsetPlugged => headsetPlugged;
+
public override event ReportHandler Report = null;
public override event EventHandler BatteryChanged;
public override event EventHandler ChargingChanged;
@@ -468,11 +518,52 @@ public override void StartUpdate()
ds4Input.Name = "DualSense Input thread: " + Mac;
ds4Input.IsBackground = true;
ds4Input.Start();
+
+ if (conType == ConnectionType.BT)
+ {
+ hapticsStreamerReady = true;
+ RefreshHapticsStreamerState();
+ }
}
else
Console.WriteLine("Thread already running for DS4: " + Mac);
}
+ private void RefreshHapticsStreamerState()
+ {
+ if (conType != ConnectionType.BT || nativeOptionsStore == null || !hapticsStreamerReady)
+ {
+ return;
+ }
+
+ // StartUpdate, settings loading, and option-change notifications can
+ // all refresh concurrently during discovery. Atomic publication keeps
+ // those callers on one streamer instead of leaking orphan writer threads.
+ DualSenseHapticsStreamer streamer = Volatile.Read(ref hapticsStreamer);
+ if (streamer == null)
+ {
+ DualSenseHapticsStreamer candidate = new DualSenseHapticsStreamer(this, hDevice);
+ streamer = Interlocked.CompareExchange(ref hapticsStreamer, candidate, null) ?? candidate;
+ }
+
+ streamer.Configure(nativeOptionsStore.BTHapticsMode,
+ nativeOptionsStore.BTHapticsGain,
+ nativeOptionsStore.BTHapticsLowPassHz,
+ nativeOptionsStore.BTHapticsAudioDeviceId,
+ nativeOptionsStore.BTAudioEnabled,
+ nativeOptionsStore.BTAudioRoute,
+ nativeOptionsStore.BTAudioVolume,
+ nativeOptionsStore.BTAudioLatency);
+
+ // Push a fresh 0x31 report so the rumble-emulation flags reflect the
+ // new streaming state right away.
+ queueEvent(() =>
+ {
+ outputDirty = true;
+ currentHap.dirty = true;
+ });
+ }
+
private void TimeoutTestThread()
{
while (!timeoutExecuted)
@@ -724,6 +815,10 @@ private unsafe void ReadInput()
cState.BLP = (tempByte & (1 << 6)) != 0;
cState.BRP = (tempByte & (1 << 7)) != 0;
+ // Bit 0 of the status byte flags a headset in the 3.5mm jack;
+ // used for automatic BT audio routing.
+ headsetPlugged = (inputReport[54 + reportOffset] & 0x01) != 0;
+
if ((this.featureSet & VidPidFeatureSet.NoBatteryReading) == 0)
{
tempByte = inputReport[54 + reportOffset];
@@ -981,6 +1076,8 @@ private unsafe void ReadInput()
protected override void StopOutputUpdate()
{
+ hapticsStreamerReady = false;
+ hapticsStreamer?.Stop();
SendEmptyOutputReport();
}
@@ -1192,7 +1289,16 @@ private unsafe void PrepareOutReport()
// 0x20 Enable internal speaker (even while headset is connected)
// 0x40 Enable modification of microphone volume
// 0x80 Enable internal mic (even while headset is connected)
- outputReport[2] = useRumble ? (byte)0x0F : (byte)0x0C; // 0x02 | 0x01 | 0x04 | 0x08;
+ // The firmware treats rumble emulation and the 0x32 haptic audio
+ // stream as mutually exclusive modes: any report that asserts the
+ // motor flags or the improved-rumble bit knocks it back into
+ // rumble emulation and mutes the stream. While the haptics
+ // streamer is active, keep all rumble emulation out of 0x31.
+ bool hapticsStreamActive = hapticsStreamer?.Active ?? false;
+ ApplyBtOutputControl(outputReport, useRumble, useAccurateRumble,
+ hapticsStreamActive,
+ currentHap.rumbleState.RumbleMotorStrengthRightLightFast,
+ currentHap.rumbleState.RumbleMotorStrengthLeftHeavySlow);
// 0x01 Toggling microphone LED, 0x02 Toggling Audio/Mic Mute
// 0x04 Toggling LED strips on the sides of the Touchpad, 0x08 Turn off all LED lights
@@ -1200,14 +1306,6 @@ private unsafe void PrepareOutReport()
// 0x40 Adjust overall motor/effect power, 0x80 ???
outputReport[3] = 0x55; // 0x04 | 0x01 | 0x10 | 0x40
- if (useRumble || useAccurateRumble)
- {
- // Right? High Freq Motor
- outputReport[4] = currentHap.rumbleState.RumbleMotorStrengthRightLightFast;
- // Left? Low Freq Motor
- outputReport[5] = currentHap.rumbleState.RumbleMotorStrengthLeftHeavySlow;
- }
-
/*
// Headphone volume
outputReport[6] = 0x00;
@@ -1265,8 +1363,8 @@ private unsafe void PrepareOutReport()
// 0x01 Enabled LED brightness (value in index 43)
// 0x02 Uninterruptable blue LED pulse (action in index 42)
// 0x04 Enable improved rumble emulation (Requires 2.24 firmware or newer)
- outputReport[40] = useAccurateRumble ? (byte)0x06 : (byte)0x02;
-
+ // Never assert improved rumble emulation while streaming haptic
+ // audio; it switches the firmware out of haptics mode.
// 0x01 Slowly (2s?) fade to blue (scheduled to when the regular LED settings are active)
// 0x02 Slowly (2s?) fade out (scheduled after fade-in completion) with eventual switch back to configured LED color; only a fade-out can cancel the pulse (neither index 2, 0x08, nor turning this off will cancel it!)
outputReport[43] = 0x02;
@@ -1531,6 +1629,11 @@ private void SetupOptionsEvents()
PreparePlayerLEDBarByte();
queueEvent(() => { outputDirty = true; });
};
+
+ nativeOptionsStore.BTHapticsOptionChanged += (sender, e) =>
+ {
+ RefreshHapticsStreamerState();
+ };
}
}
@@ -1540,6 +1643,7 @@ public override void LoadStoreSettings()
{
PrepareMuteLEDByte();
PreparePlayerLEDBarByte();
+ RefreshHapticsStreamerState();
}
}
}
diff --git a/DS4Windows/DS4Library/InputDevices/DualSenseHapticsStreamer.cs b/DS4Windows/DS4Library/InputDevices/DualSenseHapticsStreamer.cs
new file mode 100644
index 0000000..e7a040d
--- /dev/null
+++ b/DS4Windows/DS4Library/InputDevices/DualSenseHapticsStreamer.cs
@@ -0,0 +1,1540 @@
+/*
+DS4Windows
+Copyright (C) 2026 DS4Windows contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System;
+using System.Diagnostics;
+using System.Runtime;
+using System.Runtime.InteropServices;
+using System.Threading;
+using Concentus;
+using Concentus.Enums;
+using NAudio.CoreAudioApi;
+using NAudio.Dsp;
+using NAudio.Dmo;
+using NAudio.Wave;
+
+namespace DS4Windows.InputDevices
+{
+ ///
+ /// Streams haptic and listening audio to a Bluetooth-connected DualSense.
+ ///
+ /// Haptics-only mode uses the self-contained HID output report 0x36 carrying
+ /// controller state plus 3 kHz stereo PCM for the voice-coil actuators. When
+ /// listening audio is enabled, the same container also carries one
+ /// Opus-encoded 48 kHz stereo frame (10 ms, 160 kbps CBR) routed to the
+ /// controller's headphone jack or internal speaker.
+ ///
+ /// An integral rate servo trims the capture resampler onto the pad's real
+ /// consumption clock, stalls skip slots instead of burst-catching-up, and
+ /// the stream is silence-gated so an idle source stops consuming Bluetooth
+ /// airtime. Filler frames are encoded through the live Opus encoder so its
+ /// state remains coherent across underruns.
+ ///
+ /// Protocol research credit: egormanga/SAxense (haptics stream),
+ /// awalol/DS5Dongle, and loteran/DS5Dongle AutoHaptics (audio container,
+ /// controller state, Opus parameters, and routing).
+ ///
+ internal sealed class DualSenseHapticsStreamer
+ {
+ // 0x36 self-contained haptics report (state + signed 3 kHz PCM)
+ private const int HAPTICS_REPORT_SIZE = 398;
+ private const byte HAPTICS_REPORT_ID = 0x36;
+
+ // Packetized SetStateData command used to initialize the listening-audio amp.
+ private const int STATE_SETUP_REPORT_SIZE = 142;
+ private const byte STATE_SETUP_REPORT_ID = 0x32;
+
+ // 0x36 haptics + one listening-audio frame
+ private const int AUDIO_REPORT_SIZE = 398;
+ private const byte AUDIO_REPORT_ID = 0x36;
+ private const int OPUS_FRAME_BYTES = 200; // CBR: 160 kbps * 10 ms / 8
+ private const int OPUS_SAMPLES_PER_FRAME = 480; // one frame per report, per channel
+ private const int FRAME_SHORTS = OPUS_SAMPLES_PER_FRAME * 2; // interleaved stereo
+ private const int AUDIO_SAMPLE_RATE = 48000; // Opus codec rate
+
+ // The controller consumes one 480-sample Opus frame per ~10.667 ms haptic
+ // slot (audio is slaved to the 3 kHz haptics clock), so audio must be
+ // delivered at 480 / 10.667 ms = 45000 samples/s or the stream overruns
+ // and drops frames audibly. Same reason DS5Dongle resamples 512->480.
+ private const int AUDIO_DELIVERY_RATE = 45000;
+
+ // The PC capture clock and this loop's Stopwatch cadence are different
+ // crystals; without correction their drift slowly walks the frame
+ // backlog into an underrun or an overflow drop no matter how deep the
+ // buffers are. An integral servo trims the capture resampler's output
+ // rate using the backlog level as the error signal. Hardware validation
+ // showed the queue parking on target without dry-outs. Gain is per
+ // frame of error per tick; authority is limited to 2.5 percent.
+ internal const double AUDIO_RATE_TRIM_GAIN = 0.00003;
+ internal const double AUDIO_RATE_TRIM_LIMIT = 0.025;
+
+ // Source-energy gate: the stream only runs while the capture source
+ // carried real energy recently (or haptics are active). Windows keeps
+ // idle render pins primed with silence indefinitely; streaming that
+ // costs airtime and pad battery for nothing. ~0.005 is about -46 dBFS.
+ private const double AUDIO_ENERGY_THRESHOLD = 0.005;
+ private const double SILENCE_GATE_MS = 2000.0;
+ internal const int SILENCE_TAIL_REPORTS = 6;
+
+ // ~5 ms ramp applied to the first content frame after a rebuffer or a
+ // backlog drop so the resume edge is inaudible.
+ internal const int RESUME_FADE_FRAMES = 240;
+
+ // After an underrun the local prebuffer target escalates one frame
+ // (bounded by ring headroom); after this many clean ticks (~3 min) it
+ // decays one frame back toward the profile's base. Users get the
+ // latency they asked for on clean links and stability on bad ones.
+ private const long ADAPTIVE_DECAY_TICKS = 16875;
+
+ ///
+ /// One coherent set of buffer sizes for the whole pipeline. Bigger
+ /// buffers survive congested links (2.4 GHz Wi-Fi, wireless headset
+ /// dongles); smaller buffers cut end-to-end delay for game audio.
+ ///
+ internal readonly struct LatencyProfile
+ {
+ public readonly byte ControllerBuffer; // controller-side dejitter buffer [16,127]
+ public readonly int PrebufferFrames; // frames banked before playback starts
+ public readonly double MaxCatchupMs; // haptics-only burst catch-up window
+ public readonly int AudioRingSamples; // capture ring feeding the encoder
+ public readonly int HapticsRingBytes; // capture ring feeding the actuators
+ public readonly int HapticsPrebufferBytes;
+
+ public LatencyProfile(byte controllerBuffer, int prebufferFrames,
+ double maxCatchupMs, int audioRingSamples, int hapticsRingBytes,
+ int hapticsPrebufferBytes)
+ {
+ ControllerBuffer = controllerBuffer;
+ PrebufferFrames = prebufferFrames;
+ MaxCatchupMs = maxCatchupMs;
+ AudioRingSamples = audioRingSamples;
+ HapticsRingBytes = hapticsRingBytes;
+ HapticsPrebufferBytes = hapticsPrebufferBytes;
+ }
+ }
+
+ internal static LatencyProfile GetLatencyProfile(DualSenseControllerOptions.AudioLatencyMode latencyMode)
+ {
+ switch (latencyMode)
+ {
+ case DualSenseControllerOptions.AudioLatencyMode.LowLatency:
+ // ~80-120 ms end to end; needs a clean link
+ return new LatencyProfile(32, 2, 100.0, FRAME_SHORTS * 12, 960, 192);
+ case DualSenseControllerOptions.AudioLatencyMode.Balanced:
+ // ~150-250 ms
+ return new LatencyProfile(64, 3, 150.0, FRAME_SHORTS * 16, 1440, 384);
+ case DualSenseControllerOptions.AudioLatencyMode.Smooth:
+ default:
+ // ~300-400 ms; proven on congested 2.4 GHz environments
+ return new LatencyProfile(120, 4, 250.0, FRAME_SHORTS * 20, 1920, 576);
+ }
+ }
+
+ private const int SAMPLE_RATE = 3000; // haptic PCM rate per channel
+ private const int HAPTIC_CHUNK_BYTES = 64; // 32 stereo frames
+ private const double TICK_MS = 32 * 1000.0 / SAMPLE_RATE; // ~10.667 ms
+ private const int MAX_CONSECUTIVE_WRITE_FAILURES = 50;
+
+ // Known-good advanced-haptics state used by DS5Dongle-AutoHaptics.
+ // UseRumbleNotHaptics (state byte 0 bit 1) is clear.
+ private static readonly byte[] HAPTICS_STATE = new byte[63]
+ {
+ 0xFD, 0xF7, 0x00, 0x00,
+ 0x7F, 0x64, 0xFF, 0x09, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A,
+ 0x07, 0x00, 0x00, 0x02, 0x01, 0x00, 0xFF, 0xD7, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+ };
+
+ private const double HEAVY_FREQ_HZ = 62.0;
+ private const double LIGHT_FREQ_HZ = 170.0;
+ private const double ENVELOPE_ATTACK = 0.35;
+ private const double ENVELOPE_RELEASE = 0.015;
+
+ // Voice-coil haptics can reproduce rumble values that are too weak to
+ // move a conventional eccentric motor. Some games continuously emit
+ // those tiny values during stick movement, turning them into an
+ // unintended buzz. Match the effective noise floor of a normal motor,
+ // then rescale the remaining range so full-strength rumble stays full.
+ internal const byte RUMBLE_SYNTH_DEADZONE = 16;
+
+ private readonly DualSenseDevice device;
+ private readonly HidDevice hidDevice;
+ private readonly byte[] outputBTCrc32Head = new byte[] { 0xA2 };
+
+ private readonly object stateLock = new object();
+ private Thread streamThread;
+ private CancellationTokenSource streamCancellation;
+ private DualSenseControllerOptions.HapticsMode mode =
+ DualSenseControllerOptions.HapticsMode.Off;
+ // These three values apply without restarting the capture pipeline.
+ // Store them in atomically readable primitive fields so the WASAPI and
+ // HID threads never observe a torn value on 32-bit builds.
+ private long gainBits = BitConverter.DoubleToInt64Bits(3.0);
+ private int lowPassHz = 350;
+ private string endpointId = string.Empty;
+ private bool audioEnabled = false;
+ private int audioRouteValue =
+ (int)DualSenseControllerOptions.AudioOutputRoute.Auto;
+ private int audioVolume = 50;
+ private DualSenseControllerOptions.AudioLatencyMode latencyMode =
+ DualSenseControllerOptions.AudioLatencyMode.Smooth;
+ private LatencyProfile profile = GetLatencyProfile(DualSenseControllerOptions.AudioLatencyMode.Smooth);
+
+ // Rumble-to-haptics synth state
+ private double heavyEnv, lightEnv, heavyPhase, lightPhase;
+
+ // Rate-servo output applied by the capture callback; written by the
+ // stream thread, read by the WASAPI callback thread.
+ private double audioRateTrim;
+
+ // Stopwatch timestamp of the last capture buffer that carried real
+ // energy; drives the silence gate.
+ private long lastEnergyTimestamp;
+
+ private byte seq;
+ private byte packetCounter;
+
+ public bool Active => Volatile.Read(ref streamCancellation) != null;
+
+ public DualSenseHapticsStreamer(DualSenseDevice device, HidDevice hidDevice)
+ {
+ this.device = device;
+ this.hidDevice = hidDevice;
+ }
+
+ public void Configure(DualSenseControllerOptions.HapticsMode newMode,
+ double newGain, int newLowPassHz, string newEndpointId,
+ bool newAudioEnabled, DualSenseControllerOptions.AudioOutputRoute newAudioRoute,
+ int newAudioVolume, DualSenseControllerOptions.AudioLatencyMode newLatencyMode)
+ {
+ lock (stateLock)
+ {
+ newGain = Math.Clamp(newGain, 0.1, 10.0);
+ newLowPassHz = Math.Clamp(newLowPassHz, 40, 1000);
+ newEndpointId ??= string.Empty;
+ newAudioVolume = Math.Clamp(newAudioVolume, 0, 100);
+
+ // Gain, volume, and routing apply live; anything that changes
+ // the pipeline shape needs a restart.
+ if (Active && newMode == mode && newLowPassHz == lowPassHz &&
+ newEndpointId == endpointId && newAudioEnabled == audioEnabled &&
+ newLatencyMode == latencyMode)
+ {
+ Interlocked.Exchange(ref gainBits, BitConverter.DoubleToInt64Bits(newGain));
+ Volatile.Write(ref audioVolume, newAudioVolume);
+ Volatile.Write(ref audioRouteValue, (int)newAudioRoute);
+ return;
+ }
+
+ bool wasRunning = Active;
+ StopLocked();
+
+ mode = newMode;
+ Interlocked.Exchange(ref gainBits, BitConverter.DoubleToInt64Bits(newGain));
+ lowPassHz = newLowPassHz;
+ endpointId = newEndpointId;
+ audioEnabled = newAudioEnabled;
+ Volatile.Write(ref audioRouteValue, (int)newAudioRoute);
+ Volatile.Write(ref audioVolume, newAudioVolume);
+ latencyMode = newLatencyMode;
+ profile = GetLatencyProfile(newLatencyMode);
+
+ if (audioEnabled || mode != DualSenseControllerOptions.HapticsMode.Off)
+ {
+ StartLocked();
+ }
+ else if (wasRunning)
+ {
+ AppLogger.LogToGui($"{device.MacAddress}: BT haptics/audio streaming stopped", false);
+ }
+ }
+ }
+
+ public void Stop()
+ {
+ lock (stateLock)
+ {
+ StopLocked();
+ }
+ }
+
+ private void StartLocked()
+ {
+ CancellationTokenSource cancellationSource = new CancellationTokenSource();
+ Volatile.Write(ref audioRateTrim, 0.0);
+ Volatile.Write(ref lastEnergyTimestamp, 0L);
+ streamCancellation = cancellationSource;
+ streamThread = new Thread(() => StreamLoop(cancellationSource))
+ {
+ Priority = ThreadPriority.AboveNormal,
+ IsBackground = true,
+ Name = $"DualSense Haptics thread: {device.MacAddress}",
+ };
+ streamThread.Start();
+ AppLogger.LogToGui($"{device.MacAddress}: BT streaming started " +
+ $"(haptics: {mode}{(audioEnabled ? ", audio: on" : "")})", false);
+ }
+
+ private void StopLocked()
+ {
+ CancellationTokenSource cancellationSource = streamCancellation;
+ Thread thread = streamThread;
+
+ try
+ {
+ cancellationSource?.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // The worker may have completed between the snapshot and Cancel.
+ }
+ if (thread != null && thread.IsAlive && thread != Thread.CurrentThread)
+ {
+ thread.Join(500);
+ }
+
+ if (ReferenceEquals(streamThread, thread))
+ {
+ streamThread = null;
+ }
+
+ if (ReferenceEquals(streamCancellation, cancellationSource))
+ {
+ streamCancellation = null;
+ }
+ }
+
+ private void StreamLoop(CancellationTokenSource cancellationSource)
+ {
+ CancellationToken cancellationToken = cancellationSource.Token;
+ bool captureForHaptics = mode == DualSenseControllerOptions.HapticsMode.SystemAudio ||
+ mode == DualSenseControllerOptions.HapticsMode.Mix;
+ bool useRumbleSynth = mode == DualSenseControllerOptions.HapticsMode.RumbleToHaptics ||
+ mode == DualSenseControllerOptions.HapticsMode.Mix;
+ bool needCapture = captureForHaptics || audioEnabled;
+
+ SampleRing hapticsRing = captureForHaptics ? new SampleRing(profile.HapticsRingBytes) : null;
+ ShortRing audioRing = audioEnabled ? new ShortRing(profile.AudioRingSamples) : null;
+ WasapiLoopbackCapture capture = null;
+ IOpusEncoder opusEncoder = null;
+ IntPtr mmcssHandle = IntPtr.Zero;
+ IntPtr highResTimer = IntPtr.Zero;
+
+ EnterLatencySensitiveGC();
+ try
+ {
+ // MMCSS puts this thread in the same scheduling class WASAPI
+ // clients use; base priority alone still loses to scheduler
+ // jitter under load.
+ uint mmcssTaskIndex = 0;
+ try
+ {
+ mmcssHandle = AvSetMmThreadCharacteristicsW("Pro Audio", ref mmcssTaskIndex);
+ }
+ catch (Exception) { mmcssHandle = IntPtr.Zero; }
+
+ highResTimer = CreateHighResTimer();
+
+ if (needCapture)
+ {
+ capture = CreateCapture(hapticsRing, audioRing);
+ if (capture == null)
+ {
+ return;
+ }
+
+ capture.StartRecording();
+ }
+
+ short[] pcmFrame = null;
+ byte[] opusA = null;
+ if (audioEnabled)
+ {
+ // The controller's audio amp defaults to muted volume; it only
+ // plays the stream after headphone/speaker volume is set.
+ if (!SendAudioVolumeSetup(out int setupError))
+ {
+ AppLogger.LogToGui($"{device.MacAddress}: BT audio amplifier setup write failed " +
+ $"(Win32 error {setupError})", true);
+ }
+
+ opusEncoder = OpusCodecFactory.CreateEncoder(AUDIO_SAMPLE_RATE, 2,
+ OpusApplication.OPUS_APPLICATION_AUDIO);
+ opusEncoder.Bitrate = OPUS_FRAME_BYTES * 8 * 100;
+ opusEncoder.UseVBR = false;
+ // Complexity is a pure quality dial at fixed CBR bitrate;
+ // one 10 ms encode per tick fits the slot with margin.
+ opusEncoder.Complexity = 10;
+
+ pcmFrame = new short[FRAME_SHORTS];
+ opusA = new byte[OPUS_FRAME_BYTES];
+ }
+
+ byte[] report = new byte[audioEnabled ? AUDIO_REPORT_SIZE : HAPTICS_REPORT_SIZE];
+ byte[] chunk = new byte[HAPTIC_CHUNK_BYTES];
+ bool primed = false;
+ bool audioPrimed = false;
+ bool needFadeIn = false;
+ double fillerL = 0.0, fillerR = 0.0;
+ int targetFrames = profile.PrebufferFrames;
+ int targetFramesCap = Math.Max(profile.PrebufferFrames,
+ profile.AudioRingSamples / FRAME_SHORTS - 4);
+ int silenceTail = 0;
+ double rateTrim = 0.0;
+ long lastUnderrunTick = 0;
+
+ // Interval health telemetry (logged ~every 30 s when nonzero)
+ long audioUnderruns = 0;
+ long stallSkips = 0;
+ long slowWrites = 0;
+ double maxWriteMs = 0.0;
+ long lastHealthLogTick = 0;
+
+ int consecutiveFailures = 0;
+ int firstWriteError = 0;
+ int lastWriteError = 0;
+ long tick = 0;
+ bool synthStreamWasActive = false;
+
+ Stopwatch clock = Stopwatch.StartNew();
+ double nextDeadlineMs = 0.0;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ nextDeadlineMs += TICK_MS;
+ double wait = nextDeadlineMs - clock.Elapsed.TotalMilliseconds;
+ if (wait > 2.0)
+ {
+ WaitPrecise(highResTimer, wait - 1.0);
+ }
+
+ while (!cancellationToken.IsCancellationRequested &&
+ clock.Elapsed.TotalMilliseconds < nextDeadlineMs)
+ {
+ Thread.SpinWait(80);
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+
+ double behindMs = clock.Elapsed.TotalMilliseconds - nextDeadlineMs;
+ if (audioEnabled)
+ {
+ // A catch-up burst dequeues one audio frame per report and
+ // instantly drains the prebuffer after a scheduler or GC
+ // pause. Skip missed slots instead; the pad's dejitter
+ // buffer already covered the stall on its side.
+ if (behindMs > TICK_MS * 2.0)
+ {
+ int skip = (int)(behindMs / TICK_MS);
+ nextDeadlineMs += skip * TICK_MS;
+ stallSkips += skip;
+ }
+ }
+ else if (behindMs > profile.MaxCatchupMs)
+ {
+ // Haptics-only: back-to-back catch-up preserves rumble
+ // timing; only resync after a stall too large to absorb.
+ nextDeadlineMs = clock.Elapsed.TotalMilliseconds;
+ }
+
+ bool hapticsActive = FillHapticsChunk(chunk, hapticsRing,
+ useRumbleSynth, ref primed);
+
+ bool energyRecent = needCapture &&
+ (Stopwatch.GetTimestamp() - Volatile.Read(ref lastEnergyTimestamp)) *
+ 1000.0 / Stopwatch.Frequency < SILENCE_GATE_MS;
+
+ if (needCapture)
+ {
+ // When neither channel has anything to say, stop after a
+ // short drain tail to save Bluetooth airtime and battery.
+ if (!ShouldTransmitCaptureTick(hapticsActive, energyRecent,
+ ref silenceTail))
+ {
+ if (audioEnabled)
+ {
+ audioPrimed = false;
+ audioRing.TrimToNewest(targetFrames * FRAME_SHORTS);
+ }
+
+ tick++;
+ continue;
+ }
+ }
+ else if (useRumbleSynth)
+ {
+ // A continuous stream of nominally silent haptic packets can
+ // leave the voice-coil actuators faintly energized. In the
+ // pure rumble-synth pipeline there is no audio clock to
+ // maintain, so send one final centered chunk after an effect
+ // and stop writing until a real rumble signal arrives.
+ bool idleRumbleSynth = !hapticsActive;
+ if (idleRumbleSynth && !synthStreamWasActive)
+ {
+ tick++;
+ continue;
+ }
+
+ synthStreamWasActive = hapticsActive;
+ }
+
+ if (audioEnabled)
+ {
+ int framesAvailable = audioRing.Count / FRAME_SHORTS;
+ if (!audioPrimed && framesAvailable >= targetFrames)
+ {
+ audioPrimed = true;
+ needFadeIn = true;
+ }
+
+ bool gotAudio = audioPrimed && audioRing.ReadExact(pcmFrame);
+ if (audioPrimed && !gotAudio)
+ {
+ audioPrimed = false; // ran dry: rebuffer before resuming
+ if (energyRecent)
+ {
+ audioUnderruns++;
+ lastUnderrunTick = tick;
+ targetFrames = Math.Min(targetFrames + 1, targetFramesCap);
+ }
+ }
+
+ if (gotAudio)
+ {
+ if (audioRing.TakeDropFlag())
+ {
+ needFadeIn = true; // backlog drop: mask the splice
+ }
+
+ if (needFadeIn)
+ {
+ ApplyResumeFade(pcmFrame);
+ needFadeIn = false;
+ }
+
+ fillerL = pcmFrame[FRAME_SHORTS - 2];
+ fillerR = pcmFrame[FRAME_SHORTS - 1];
+ }
+ else
+ {
+ // No content: encode a decay-to-silence filler through
+ // the LIVE encoder. Opus delta-codes state across
+ // frames, so replaying a pre-encoded silence frame
+ // desynchronizes the pad's decoder and clicks at every
+ // underrun boundary; a live encode stays coherent.
+ BuildFillerFrame(pcmFrame, ref fillerL, ref fillerR);
+ }
+
+ EncodeOpusFrame(opusEncoder, pcmFrame, opusA);
+
+ if (audioPrimed)
+ {
+ // Integral servo: backlog below target => positive
+ // error => raise the resampler's output rate so each
+ // capture buffer yields more delivery-rate samples,
+ // and vice versa. Converges on the true clock offset
+ // within seconds and holds. Frozen while unprimed so
+ // rebuffer transients cannot wind up the integral.
+ double levelError = (targetFrames + 1) -
+ audioRing.Count / (double)FRAME_SHORTS;
+ rateTrim = ClampRateTrim(rateTrim + levelError * AUDIO_RATE_TRIM_GAIN);
+ Volatile.Write(ref audioRateTrim, rateTrim);
+ }
+
+ // With a long clean run, decay the escalated prebuffer
+ // back toward the profile's base latency.
+ if (targetFrames > profile.PrebufferFrames &&
+ tick - lastUnderrunTick > ADAPTIVE_DECAY_TICKS)
+ {
+ targetFrames--;
+ lastUnderrunTick = tick;
+ }
+
+ BuildAudioReport(report, chunk, opusA);
+ }
+ else
+ {
+ BuildHapticsReport(report, chunk);
+ }
+
+ long writeStart = Stopwatch.GetTimestamp();
+ bool wrote = hidDevice.WriteOutputReportViaInterruptWithTimeout(
+ report, 100, out int writeError);
+ double writeMs = (Stopwatch.GetTimestamp() - writeStart) * 1000.0 / Stopwatch.Frequency;
+ if (writeMs > maxWriteMs)
+ {
+ maxWriteMs = writeMs;
+ }
+
+ if (writeMs > 20.0)
+ {
+ slowWrites++;
+ }
+
+ if (wrote)
+ {
+ consecutiveFailures = 0;
+ firstWriteError = 0;
+ lastWriteError = 0;
+ }
+ else
+ {
+ if (consecutiveFailures == 0)
+ {
+ firstWriteError = writeError;
+ }
+
+ lastWriteError = writeError;
+ if (++consecutiveFailures >= MAX_CONSECUTIVE_WRITE_FAILURES)
+ {
+ AppLogger.LogToGui($"{device.MacAddress}: BT haptics/audio stream aborted after " +
+ $"{consecutiveFailures} consecutive write failures " +
+ $"(Win32 first={firstWriteError}, last={lastWriteError}, " +
+ $"report=0x{report[0]:X2}, bytes={report.Length})", true);
+ break;
+ }
+ }
+
+ if (tick - lastHealthLogTick > 2800) // ~30 s
+ {
+ long ringDrops = audioRing?.TakeDropCount() ?? 0;
+ if (audioUnderruns > 0 || stallSkips > 0 || slowWrites > 0 || ringDrops > 0)
+ {
+ AppLogger.LogToGui($"{device.MacAddress}: BT stream health: " +
+ $"underruns={audioUnderruns} drops={ringDrops} stallSkips={stallSkips} " +
+ $"slowWrites={slowWrites} maxWrite={maxWriteMs:F1}ms " +
+ $"trim={rateTrim * 1e6:F0}ppm prebuffer={targetFrames}f", false);
+ }
+
+ audioUnderruns = 0;
+ stallSkips = 0;
+ slowWrites = 0;
+ maxWriteMs = 0.0;
+ lastHealthLogTick = tick;
+ }
+
+ tick++;
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.LogToGui($"{device.MacAddress}: BT haptics/audio stream error: {ex.Message}", true);
+ }
+ finally
+ {
+ if (capture != null)
+ {
+ try
+ {
+ capture.StopRecording();
+ capture.Dispose();
+ }
+ catch (Exception) { }
+ }
+
+ (opusEncoder as IDisposable)?.Dispose();
+
+ if (highResTimer != IntPtr.Zero)
+ {
+ CloseHandle(highResTimer);
+ }
+
+ if (mmcssHandle != IntPtr.Zero)
+ {
+ try { AvRevertMmThreadCharacteristics(mmcssHandle); }
+ catch (Exception) { }
+ }
+
+ ExitLatencySensitiveGC();
+
+ // Stop and Configure may be waiting for this worker while holding
+ // stateLock. Use generation-checked atomics here so cleanup cannot
+ // deadlock or impose the full join timeout on a normal stop.
+ if (ReferenceEquals(Interlocked.CompareExchange(
+ ref streamCancellation, null, cancellationSource), cancellationSource))
+ {
+ Interlocked.CompareExchange(ref streamThread, null,
+ Thread.CurrentThread);
+ }
+
+ cancellationSource.Dispose();
+ }
+ }
+
+ ///
+ /// Fills one 64-byte haptic chunk (u8 offset-binary, silence = 0x80)
+ /// from the capture ring and/or the rumble synth.
+ ///
+ private bool FillHapticsChunk(byte[] chunk, SampleRing ring, bool useRumbleSynth, ref bool primed)
+ {
+ int captured = 0;
+ if (ring != null)
+ {
+ if (!primed && ring.Count >= profile.HapticsPrebufferBytes)
+ {
+ primed = true;
+ }
+
+ captured = primed ? ring.ReadPartial(chunk) : 0;
+ if (primed && captured == 0)
+ {
+ primed = false;
+ }
+ }
+
+ if (captured < chunk.Length)
+ {
+ Array.Fill(chunk, (byte)0x80, captured, chunk.Length - captured);
+ }
+
+ if (useRumbleSynth)
+ {
+ bool pureRumbleSynth = ring == null;
+ byte rawHeavy = device.CurrentRumbleHeavy;
+ byte rawLight = device.CurrentRumbleLight;
+ double heavyTarget = ScaleRumbleStrength(rawHeavy);
+ double lightTarget = ScaleRumbleStrength(rawLight);
+ double heavyInc = 2.0 * Math.PI * HEAVY_FREQ_HZ / SAMPLE_RATE;
+ double lightInc = 2.0 * Math.PI * LIGHT_FREQ_HZ / SAMPLE_RATE;
+ for (int i = 0; i < chunk.Length / 2; i++)
+ {
+ heavyEnv += (heavyTarget - heavyEnv) *
+ (heavyTarget > heavyEnv ? ENVELOPE_ATTACK : ENVELOPE_RELEASE);
+ lightEnv += (lightTarget - lightEnv) *
+ (lightTarget > lightEnv ? ENVELOPE_ATTACK : ENVELOPE_RELEASE);
+
+ double rumbleLeft = heavyEnv * Math.Sin(heavyPhase);
+ double rumbleRight = lightEnv * Math.Sin(lightPhase);
+ double left = (chunk[i * 2] - 128) / 127.0 + rumbleLeft;
+ double right = (chunk[i * 2 + 1] - 128) / 127.0 + rumbleRight;
+ heavyPhase += heavyInc;
+ lightPhase += lightInc;
+
+ if (pureRumbleSynth)
+ {
+ // A full XInput motor command should span the actuator's
+ // full signed PCM range. The generic soft clipper maps a
+ // unit peak to only 50%, making game rumble unnecessarily weak.
+ chunk[i * 2] = UnitSampleToU8(rumbleLeft);
+ chunk[i * 2 + 1] = UnitSampleToU8(rumbleRight);
+ }
+ else
+ {
+ // Mix mode can contain both captured PCM and synthesized
+ // rumble, so use a smooth limiter to avoid hard clipping.
+ chunk[i * 2] = TanhSampleToU8(left * 1.5);
+ chunk[i * 2 + 1] = TanhSampleToU8(right * 1.5);
+ }
+ }
+ }
+
+ return HasHapticSignal(chunk);
+ }
+
+ internal static double ScaleRumbleStrength(byte strength)
+ {
+ if (strength <= RUMBLE_SYNTH_DEADZONE)
+ {
+ return 0.0;
+ }
+
+ return (strength - RUMBLE_SYNTH_DEADZONE) /
+ (double)(byte.MaxValue - RUMBLE_SYNTH_DEADZONE);
+ }
+
+ internal static bool HasHapticSignal(byte[] chunk)
+ {
+ for (int i = 0; i < chunk.Length; i++)
+ {
+ if (chunk[i] != 0x80)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ internal static bool ShouldTransmitCaptureTick(bool hapticsActive,
+ bool energyRecent, ref int silenceTail)
+ {
+ if (hapticsActive || energyRecent)
+ {
+ silenceTail = 0;
+ return true;
+ }
+
+ silenceTail++;
+ return silenceTail <= SILENCE_TAIL_REPORTS;
+ }
+
+ internal static byte UnitSampleToU8(double sample)
+ {
+ return (byte)Math.Clamp(128.0 + sample * 127.0, 1.0, 255.0);
+ }
+
+ private static byte TanhSampleToU8(double sample)
+ {
+ return (byte)Math.Clamp(128.0 + Math.Tanh(sample) * 127.0, 1.0, 255.0);
+ }
+
+ internal static double ClampRateTrim(double trim)
+ {
+ return Math.Clamp(trim, -AUDIO_RATE_TRIM_LIMIT, AUDIO_RATE_TRIM_LIMIT);
+ }
+
+ ///
+ /// Soft-clips and quantizes one unit-range haptic sample to u8
+ /// offset-binary with 1-LSB TPDF dither. 8-bit quantization makes
+ /// quiet haptic tails feel granular; dither trades that for a far
+ /// less perceptible noise floor. Exact digital silence passes through
+ /// untouched so silence detection and idle gating still work.
+ ///
+ internal static byte DitherQuantizeU8(double x, ref uint rngState)
+ {
+ if (x == 0.0)
+ {
+ return 0x80;
+ }
+
+ double y = x / (1.0 + Math.Abs(x));
+ double dither = NextUnit(ref rngState) + NextUnit(ref rngState) - 1.0;
+ return (byte)Math.Clamp(Math.Round(128.0 + y * 127.0 + dither), 0.0, 255.0);
+ }
+
+ private static double NextUnit(ref uint state)
+ {
+ // xorshift32; cheap enough for one call per haptic sample.
+ state ^= state << 13;
+ state ^= state >> 17;
+ state ^= state << 5;
+ return (state & 0xFFFFFF) / 16777216.0;
+ }
+
+ ///
+ /// Fills one PCM frame with an exponential decay from the last sent
+ /// sample pair toward digital silence, so an underrun ends in a fade
+ /// instead of a step discontinuity. Subsequent filler frames are
+ /// effectively silent.
+ ///
+ internal static void BuildFillerFrame(short[] pcm, ref double lastL, ref double lastR)
+ {
+ const double decay = 0.985; // reaches ~-45 dB across one 480-sample frame
+ double l = lastL;
+ double r = lastR;
+ for (int i = 0; i < pcm.Length / 2; i++)
+ {
+ l *= decay;
+ r *= decay;
+ pcm[i * 2] = (short)l;
+ pcm[i * 2 + 1] = (short)r;
+ }
+
+ lastL = l;
+ lastR = r;
+ }
+
+ /// Linear ~5 ms fade-in masking the resume edge after a rebuffer.
+ internal static void ApplyResumeFade(short[] pcm)
+ {
+ for (int i = 0; i < RESUME_FADE_FRAMES && i < pcm.Length / 2; i++)
+ {
+ double scale = i / (double)RESUME_FADE_FRAMES;
+ pcm[i * 2] = (short)(pcm[i * 2] * scale);
+ pcm[i * 2 + 1] = (short)(pcm[i * 2 + 1] * scale);
+ }
+ }
+
+ private void EncodeOpusFrame(IOpusEncoder encoder, short[] pcm, byte[] dest)
+ {
+ int written = encoder.Encode(pcm, OPUS_SAMPLES_PER_FRAME, dest, dest.Length);
+ if (written < dest.Length && written > 0)
+ {
+ Array.Clear(dest, written, dest.Length - written);
+ }
+ }
+
+ ///
+ /// Self-contained report 0x36 layout (398 bytes), per DS5Dongle-AutoHaptics:
+ /// config packet 0x11, SetStateData packet 0x10, then one signed 64-byte
+ /// haptic PCM packet 0x12. Embedding state in every report is required on
+ /// the Windows Bluetooth HID path used by this controller.
+ ///
+ internal void BuildHapticsReport(byte[] report, byte[] chunk)
+ {
+ Array.Clear(report, 0, HAPTICS_REPORT_SIZE);
+ report[0] = HAPTICS_REPORT_ID;
+ report[1] = (byte)((seq & 0x0F) << 4);
+ seq = (byte)((seq + 1) & 0x0F);
+
+ WriteConfigAndState(report);
+
+ report[76] = 0x92; // haptic audio packet: PID 0x12 | sized
+ report[77] = HAPTIC_CHUNK_BYTES;
+ for (int i = 0; i < HAPTIC_CHUNK_BYTES; i++)
+ {
+ // The internal ring is u8 offset-binary; report 0x36 carries s8 PCM.
+ report[78 + i] = (byte)(chunk[i] ^ 0x80);
+ }
+
+ ApplyCrc(report, HAPTICS_REPORT_SIZE);
+ }
+
+ ///
+ /// Self-contained report 0x36 with listening audio, per
+ /// DS5Dongle-AutoHaptics: config, SetStateData, one signed haptic chunk,
+ /// and one 200-byte Opus speaker/headphone packet.
+ ///
+ internal void BuildAudioReport(byte[] report, byte[] chunk, byte[] opus)
+ {
+ Array.Clear(report, 0, AUDIO_REPORT_SIZE);
+ report[0] = AUDIO_REPORT_ID;
+ report[1] = (byte)((seq & 0x0F) << 4);
+ seq = (byte)((seq + 1) & 0x0F);
+
+ WriteConfigAndState(report);
+
+ report[76] = 0x92; // haptic packet: PID 0x12 | sized
+ report[77] = HAPTIC_CHUNK_BYTES;
+ for (int i = 0; i < HAPTIC_CHUNK_BYTES; i++)
+ {
+ report[78 + i] = (byte)(chunk[i] ^ 0x80);
+ }
+
+ DualSenseControllerOptions.AudioOutputRoute currentRoute =
+ (DualSenseControllerOptions.AudioOutputRoute)Volatile.Read(ref audioRouteValue);
+ bool headphone;
+ switch (currentRoute)
+ {
+ case DualSenseControllerOptions.AudioOutputRoute.Headphone:
+ headphone = true;
+ break;
+ case DualSenseControllerOptions.AudioOutputRoute.Speaker:
+ headphone = false;
+ break;
+ case DualSenseControllerOptions.AudioOutputRoute.Auto:
+ default:
+ headphone = device.HeadsetPlugged;
+ break;
+ }
+
+ report[142] = (byte)((headphone ? 0x16 : 0x13) | 0x80);
+ report[143] = OPUS_FRAME_BYTES;
+ Buffer.BlockCopy(opus, 0, report, 144, OPUS_FRAME_BYTES);
+
+ ApplyCrc(report, AUDIO_REPORT_SIZE);
+ }
+
+ private void WriteConfigAndState(byte[] report)
+ {
+ report[2] = 0x91; // config packet: PID 0x11 | sized
+ report[3] = 0x07;
+ report[4] = 0xFE;
+ // Controller-side dejitter depth. Low Latency (32), Balanced (64),
+ // and Smooth (120) were each validated on first-party hardware.
+ byte controllerBuffer = profile.ControllerBuffer;
+ report[5] = controllerBuffer;
+ report[6] = controllerBuffer;
+ report[7] = controllerBuffer;
+ report[8] = controllerBuffer;
+ report[9] = controllerBuffer;
+ report[10] = ++packetCounter;
+
+ report[11] = 0x90; // SetStateData packet: PID 0x10 | sized
+ report[12] = (byte)HAPTICS_STATE.Length;
+ Buffer.BlockCopy(HAPTICS_STATE, 0, report, 13, HAPTICS_STATE.Length);
+ }
+
+ ///
+ /// Sends a SetStateData container packet (PID 0x10 inside a 0x32 report)
+ /// that unmutes the controller's audio amp: headphone/speaker volume 100
+ /// with a mild speaker pre-gain boost. Mirrors what DS5Dongle emits when
+ /// the USB host sets its volume; without this the Opus stream is silent.
+ ///
+ private bool SendAudioVolumeSetup(out int win32Error)
+ {
+ byte[] pkt = new byte[STATE_SETUP_REPORT_SIZE];
+ pkt[0] = STATE_SETUP_REPORT_ID;
+ // SetStateData uses a fixed command byte here, not the rolling
+ // report sequence used by 0x11/0x12 audio containers. Sending a
+ // sequence nibble causes the controller to ignore the amplifier
+ // unmute/volume state while still accepting subsequent audio writes.
+ pkt[1] = 0x10;
+
+ pkt[2] = 0x90; // SetStateData packet: PID 0x10 | sized
+ pkt[3] = 0x3F;
+
+ // SetStateData payload starts at pkt[4] (DS5Dongle layout).
+ pkt[4] = 0xB0; // AllowHeadphoneVolume | AllowSpeakerVolume | AllowAudioControl
+ pkt[5] = 0x80; // AllowAudioControl2
+ pkt[4 + 4] = 0x64; // VolumeHeadphones (max 0x7F)
+ pkt[4 + 5] = 0x64; // VolumeSpeaker (PS5 uses 0x3D..0x64)
+ pkt[4 + 7] = 0x00; // AudioControl: mic auto, default output path
+ pkt[4 + 37] = 0x02; // AudioControl2: SpeakerCompPreGain = 2
+
+ ApplyCrc(pkt, STATE_SETUP_REPORT_SIZE);
+ return hidDevice.WriteOutputReportViaInterruptWithTimeout(
+ pkt, 100, out win32Error);
+ }
+
+ private void ApplyCrc(byte[] report, int totalSize)
+ {
+ int crcOffset = totalSize - 4;
+ uint calcCrc32 = ~Crc32Algorithm.Compute(outputBTCrc32Head);
+ calcCrc32 = ~Crc32Algorithm.CalculateBasicHash(ref calcCrc32, ref report, 0, crcOffset);
+ report[crcOffset] = (byte)calcCrc32;
+ report[crcOffset + 1] = (byte)(calcCrc32 >> 8);
+ report[crcOffset + 2] = (byte)(calcCrc32 >> 16);
+ report[crcOffset + 3] = (byte)(calcCrc32 >> 24);
+ }
+
+ internal static bool IsSupportedLoopbackFormat(WaveFormat format)
+ {
+ if (format == null || format.BitsPerSample != 32)
+ {
+ return false;
+ }
+
+ if (format.Encoding == WaveFormatEncoding.IeeeFloat)
+ {
+ return true;
+ }
+
+ return format.Encoding == WaveFormatEncoding.Extensible &&
+ format is WaveFormatExtensible extensible &&
+ extensible.SubFormat == AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT;
+ }
+
+ private WasapiLoopbackCapture CreateCapture(SampleRing hapticsRing, ShortRing audioRing)
+ {
+ WasapiLoopbackCapture capture = null;
+ try
+ {
+ string endpointName = null;
+ if (!string.IsNullOrEmpty(endpointId))
+ {
+ using MMDeviceEnumerator enumerator = new MMDeviceEnumerator();
+ try
+ {
+ MMDevice endpoint = enumerator.GetDevice(endpointId);
+ if (endpoint != null && endpoint.State == DeviceState.Active)
+ {
+ capture = new WasapiLoopbackCapture(endpoint);
+ endpointName = endpoint.FriendlyName;
+ }
+ }
+ catch (Exception)
+ {
+ AppLogger.LogToGui($"{device.MacAddress}: configured haptics audio device unavailable, using default output", true);
+ }
+ }
+
+ if (capture == null)
+ {
+ capture = new WasapiLoopbackCapture();
+ try
+ {
+ using MMDeviceEnumerator enumerator = new MMDeviceEnumerator();
+ endpointName = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia).FriendlyName;
+ }
+ catch (Exception) { }
+ }
+
+ if (!IsSupportedLoopbackFormat(capture.WaveFormat))
+ {
+ throw new NotSupportedException(
+ $"Unsupported loopback mix format {capture.WaveFormat}; " +
+ "DualSense streaming requires 32-bit IEEE float samples.");
+ }
+
+ AppLogger.LogToGui($"{device.MacAddress}: capturing audio from \"{endpointName ?? "default output"}\"", false);
+
+ int inRate = capture.WaveFormat.SampleRate;
+ int inChannels = capture.WaveFormat.Channels;
+
+ // Two cascaded biquads (4th-order, ~70 dB at the fold) before
+ // decimating to 3 kHz. The old single 2nd-order stage let bright
+ // content alias back into the tactile band as non-harmonic mud.
+ BiquadLowPass lpfL = hapticsRing != null ? new BiquadLowPass(lowPassHz, inRate) : null;
+ BiquadLowPass lpfR = hapticsRing != null ? new BiquadLowPass(lowPassHz, inRate) : null;
+ BiquadLowPass lpfL2 = hapticsRing != null ? new BiquadLowPass(lowPassHz, inRate) : null;
+ BiquadLowPass lpfR2 = hapticsRing != null ? new BiquadLowPass(lowPassHz, inRate) : null;
+ int decimPhase = 0;
+ uint ditherState = 0x9E3779B9;
+
+ WdlResampler resampler = null;
+ float[] resampleOut = null;
+ if (audioRing != null)
+ {
+ resampler = new WdlResampler();
+ // Sinc mode: materially better anti-aliasing than the
+ // default linear-interpolation mode for 96k -> 45k, at a
+ // still-trivial CPU cost.
+ resampler.SetMode(true, 0, true, 64, 32);
+ resampler.SetFilterParms();
+ resampler.SetFeedMode(true);
+ resampler.SetRates(inRate, AUDIO_DELIVERY_RATE);
+ resampleOut = new float[16384];
+ }
+
+ capture.DataAvailable += (sender, e) =>
+ {
+ double captureGain = BitConverter.Int64BitsToDouble(
+ Interlocked.Read(ref gainBits));
+ double volumeScale = Volatile.Read(ref audioVolume) / 100.0;
+ ReadOnlySpan samples = MemoryMarshal.Cast(
+ e.Buffer.AsSpan(0, e.BytesRecorded));
+ int frames = samples.Length / inChannels;
+ if (frames <= 0)
+ {
+ return;
+ }
+
+ bool energyFound = false;
+
+ if (audioRing != null)
+ {
+ // Apply the stream thread's servo correction before
+ // resampling this buffer; all resampler access stays on
+ // the capture callback thread.
+ double trim = Volatile.Read(ref audioRateTrim);
+ resampler.SetRates(inRate, AUDIO_DELIVERY_RATE * (1.0 + trim));
+
+ float[] inBuffer;
+ int inOffset;
+ resampler.ResamplePrepare(frames, 2, out inBuffer, out inOffset);
+ for (int i = 0; i < frames; i++)
+ {
+ DownmixToStereo(samples, i * inChannels, inChannels,
+ out float left, out float right);
+ if (!energyFound &&
+ (Math.Abs(left) > AUDIO_ENERGY_THRESHOLD ||
+ Math.Abs(right) > AUDIO_ENERGY_THRESHOLD))
+ {
+ energyFound = true;
+ }
+
+ inBuffer[inOffset + i * 2] = left;
+ inBuffer[inOffset + i * 2 + 1] = right;
+ }
+
+ int outFrames = resampler.ResampleOut(resampleOut, 0, frames, resampleOut.Length / 2, 2);
+ audioRing.Write(resampleOut, outFrames * 2, volumeScale);
+ }
+
+ if (hapticsRing != null)
+ {
+ for (int i = 0; i < frames; i++)
+ {
+ DownmixToStereo(samples, i * inChannels, inChannels,
+ out float left, out float right);
+ if (audioRing == null && !energyFound &&
+ (Math.Abs(left) > AUDIO_ENERGY_THRESHOLD ||
+ Math.Abs(right) > AUDIO_ENERGY_THRESHOLD))
+ {
+ energyFound = true;
+ }
+
+ double l = lpfL2.Process(lpfL.Process(left));
+ double r = lpfR2.Process(lpfR.Process(right));
+
+ decimPhase += SAMPLE_RATE;
+ if (decimPhase < inRate)
+ {
+ continue;
+ }
+
+ decimPhase -= inRate;
+ hapticsRing.Write(
+ DitherQuantizeU8(l * captureGain, ref ditherState),
+ DitherQuantizeU8(r * captureGain, ref ditherState));
+ }
+ }
+
+ if (energyFound)
+ {
+ Volatile.Write(ref lastEnergyTimestamp, Stopwatch.GetTimestamp());
+ }
+ };
+
+ return capture;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.LogToGui($"{device.MacAddress}: failed to open audio capture: {ex.Message}", true);
+ capture?.Dispose();
+ return null;
+ }
+ }
+
+ ///
+ /// Downmixes common mono, stereo, quad, 5.0, 5.1, and 7.1 channel orders
+ /// to stereo. Center is shared at -3 dB, LFE at -6 dB, and surround
+ /// channels at -3 dB. Multichannel sums use a soft limiter so loud scenes
+ /// cannot hard-clip before Opus encoding.
+ ///
+ internal static void DownmixToStereo(ReadOnlySpan samples, int offset,
+ int channels, out float left, out float right)
+ {
+ if (channels <= 0 || offset < 0 || offset + channels > samples.Length)
+ {
+ left = 0.0f;
+ right = 0.0f;
+ return;
+ }
+
+ float frontLeft = samples[offset];
+ if (channels == 1)
+ {
+ left = frontLeft;
+ right = frontLeft;
+ return;
+ }
+
+ float frontRight = samples[offset + 1];
+ if (channels == 2)
+ {
+ left = frontLeft;
+ right = frontRight;
+ return;
+ }
+
+ const float centerWeight = 0.70710678f;
+ const float surroundWeight = 0.70710678f;
+ const float lfeWeight = 0.5f;
+
+ float mixedLeft = frontLeft;
+ float mixedRight = frontRight;
+ if (channels == 3)
+ {
+ float center = samples[offset + 2] * centerWeight;
+ mixedLeft += center;
+ mixedRight += center;
+ }
+ else if (channels == 4)
+ {
+ mixedLeft += samples[offset + 2] * surroundWeight;
+ mixedRight += samples[offset + 3] * surroundWeight;
+ }
+ else if (channels == 5)
+ {
+ float center = samples[offset + 2] * centerWeight;
+ mixedLeft += center + samples[offset + 3] * surroundWeight;
+ mixedRight += center + samples[offset + 4] * surroundWeight;
+ }
+ else
+ {
+ // Standard WAVEFORMATEXTENSIBLE 5.1/7.1 order:
+ // FL, FR, FC, LFE, BL, BR, [SL, SR].
+ float center = samples[offset + 2] * centerWeight;
+ float lfe = samples[offset + 3] * lfeWeight;
+ mixedLeft += center + lfe + samples[offset + 4] * surroundWeight;
+ mixedRight += center + lfe + samples[offset + 5] * surroundWeight;
+ if (channels >= 8)
+ {
+ mixedLeft += samples[offset + 6] * surroundWeight;
+ mixedRight += samples[offset + 7] * surroundWeight;
+ }
+ }
+
+ left = MathF.Tanh(mixedLeft);
+ right = MathF.Tanh(mixedRight);
+ }
+
+ // --- Scheduling support -------------------------------------------------
+
+ // SustainedLowLatency while any streamer is active reduces blocking
+ // full collections on the timing-sensitive writer. Refcounted across pads.
+ private static readonly object gcModeGate = new object();
+ private static int gcModeUsers;
+ private static GCLatencyMode gcPreviousMode;
+
+ private static void EnterLatencySensitiveGC()
+ {
+ lock (gcModeGate)
+ {
+ if (++gcModeUsers == 1)
+ {
+ gcPreviousMode = GCSettings.LatencyMode;
+ try { GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; }
+ catch (Exception) { }
+ }
+ }
+ }
+
+ private static void ExitLatencySensitiveGC()
+ {
+ lock (gcModeGate)
+ {
+ if (gcModeUsers > 0 && --gcModeUsers == 0)
+ {
+ try { GCSettings.LatencyMode = gcPreviousMode; }
+ catch (Exception) { }
+ }
+ }
+ }
+
+ private const uint CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002;
+ private const uint TIMER_ALL_ACCESS = 0x1F0003;
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr CreateWaitableTimerExW(IntPtr attributes, IntPtr name,
+ uint flags, uint desiredAccess);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool SetWaitableTimer(IntPtr timer, ref long dueTime,
+ int period, IntPtr completionRoutine, IntPtr argToCompletionRoutine, bool resume);
+
+ [DllImport("kernel32.dll")]
+ private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
+
+ [DllImport("kernel32.dll")]
+ private static extern bool CloseHandle(IntPtr handle);
+
+ [DllImport("avrt.dll", CharSet = CharSet.Unicode)]
+ private static extern IntPtr AvSetMmThreadCharacteristicsW(string taskName, ref uint taskIndex);
+
+ [DllImport("avrt.dll")]
+ private static extern bool AvRevertMmThreadCharacteristics(IntPtr handle);
+
+ ///
+ /// High-resolution waitable timer (Windows 10 1803+); IntPtr.Zero on
+ /// older systems, in which case the loop falls back to Thread.Sleep.
+ /// Sub-millisecond wakeups without depending on the global timer
+ /// resolution and without long spin waits.
+ ///
+ private static IntPtr CreateHighResTimer()
+ {
+ try
+ {
+ return CreateWaitableTimerExW(IntPtr.Zero, IntPtr.Zero,
+ CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
+ }
+ catch (Exception)
+ {
+ return IntPtr.Zero;
+ }
+ }
+
+ private static void WaitPrecise(IntPtr timer, double milliseconds)
+ {
+ if (milliseconds <= 0.0)
+ {
+ return;
+ }
+
+ if (timer != IntPtr.Zero)
+ {
+ long dueTime = -(long)(milliseconds * 10000.0); // relative, 100 ns units
+ if (SetWaitableTimer(timer, ref dueTime, 0, IntPtr.Zero, IntPtr.Zero, false))
+ {
+ WaitForSingleObject(timer, (uint)milliseconds + 16);
+ return;
+ }
+ }
+
+ Thread.Sleep((int)milliseconds);
+ }
+
+ /// Byte ring for interleaved L/R u8 haptic samples with bounded latency.
+ private sealed class SampleRing
+ {
+ private readonly byte[] buffer;
+ private readonly object gate = new object();
+ private int head;
+ private int count;
+
+ public int Count { get { lock (gate) return count; } }
+
+ public SampleRing(int capacityBytes)
+ {
+ buffer = new byte[capacityBytes];
+ }
+
+ public void Write(byte left, byte right)
+ {
+ lock (gate)
+ {
+ if (count > buffer.Length - 2)
+ {
+ head = (head + 2) % buffer.Length;
+ count -= 2;
+ }
+
+ int tail = (head + count) % buffer.Length;
+ buffer[tail] = left;
+ buffer[(tail + 1) % buffer.Length] = right;
+ count += 2;
+ }
+ }
+
+ public int ReadPartial(byte[] dest)
+ {
+ lock (gate)
+ {
+ int n = Math.Min(count, dest.Length) & ~1;
+ for (int i = 0; i < n; i++)
+ {
+ dest[i] = buffer[head];
+ head = (head + 1) % buffer.Length;
+ }
+
+ count -= n;
+ return n;
+ }
+ }
+ }
+
+ ///
+ /// Ring of 16-bit interleaved stereo samples at the delivery rate
+ /// feeding the Opus encoder. Drops oldest data when full to bound
+ /// latency and records the drop so the reader can mask the splice.
+ ///
+ private sealed class ShortRing
+ {
+ private readonly short[] buffer;
+ private readonly object gate = new object();
+ private int head;
+ private int count;
+ private bool droppedSinceRead;
+ private long droppedTotal;
+
+ public int Count { get { lock (gate) return count; } }
+
+ public ShortRing(int capacitySamples)
+ {
+ buffer = new short[capacitySamples];
+ }
+
+ public void Write(float[] samples, int sampleCount, double volumeScale)
+ {
+ lock (gate)
+ {
+ for (int i = 0; i < sampleCount; i++)
+ {
+ if (count >= buffer.Length)
+ {
+ head = (head + 2) % buffer.Length;
+ count -= 2;
+ droppedSinceRead = true;
+ droppedTotal++;
+ }
+
+ double v = samples[i] * volumeScale * 32767.0;
+ int tail = (head + count) % buffer.Length;
+ buffer[tail] = (short)Math.Clamp(v, short.MinValue, short.MaxValue);
+ count++;
+ }
+ }
+ }
+
+ /// Reads exactly dest.Length samples or returns false leaving state unchanged.
+ public bool ReadExact(short[] dest)
+ {
+ lock (gate)
+ {
+ if (count < dest.Length)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < dest.Length; i++)
+ {
+ dest[i] = buffer[head];
+ head = (head + 1) % buffer.Length;
+ }
+
+ count -= dest.Length;
+ return true;
+ }
+ }
+
+ /// Drops oldest samples so at most maxSamples remain (keeps sample pairs aligned).
+ public void TrimToNewest(int maxSamples)
+ {
+ lock (gate)
+ {
+ if (count > maxSamples)
+ {
+ int drop = count - maxSamples;
+ drop -= drop % 2;
+ head = (head + drop) % buffer.Length;
+ count -= drop;
+ }
+ }
+ }
+
+ /// True once if an overflow drop occurred since the last call.
+ public bool TakeDropFlag()
+ {
+ lock (gate)
+ {
+ bool value = droppedSinceRead;
+ droppedSinceRead = false;
+ return value;
+ }
+ }
+
+ /// Total dropped samples since the last call (telemetry).
+ public long TakeDropCount()
+ {
+ lock (gate)
+ {
+ long value = droppedTotal;
+ droppedTotal = 0;
+ return value;
+ }
+ }
+ }
+
+ /// 2nd-order Butterworth low-pass (Q = 0.7071), direct form 1.
+ private sealed class BiquadLowPass
+ {
+ private readonly double b0, b1, b2, a1, a2;
+ private double x1, x2, y1, y2;
+
+ public BiquadLowPass(double cutoffHz, double sampleRate)
+ {
+ double w0 = 2.0 * Math.PI * cutoffHz / sampleRate;
+ double alpha = Math.Sin(w0) / (2.0 * 0.70710678);
+ double cosW0 = Math.Cos(w0);
+ double a0 = 1.0 + alpha;
+ b0 = (1.0 - cosW0) / 2.0 / a0;
+ b1 = (1.0 - cosW0) / a0;
+ b2 = b0;
+ a1 = -2.0 * cosW0 / a0;
+ a2 = (1.0 - alpha) / a0;
+ }
+
+ public double Process(double x)
+ {
+ double y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
+ x2 = x1; x1 = x;
+ y2 = y1; y1 = y;
+ return y;
+ }
+ }
+ }
+}
diff --git a/DS4Windows/DS4WinWPF.csproj b/DS4Windows/DS4WinWPF.csproj
index 27657e3..8ef2dec 100644
--- a/DS4Windows/DS4WinWPF.csproj
+++ b/DS4Windows/DS4WinWPF.csproj
@@ -73,11 +73,22 @@
+
+
+ THIRD-PARTY-NOTICES.txt
+ THIRD-PARTY-NOTICES.txt
+ PreserveNewest
+ PreserveNewest
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/DS4Windows/HidLibrary/HidDevice.cs b/DS4Windows/HidLibrary/HidDevice.cs
index d8a33a1..a617322 100644
--- a/DS4Windows/HidLibrary/HidDevice.cs
+++ b/DS4Windows/HidLibrary/HidDevice.cs
@@ -33,6 +33,7 @@ public enum ReadStatus
private SafeFileHandle safeReadHandle;
private bool isOpen;
private bool isExclusive;
+ private readonly object outputReportWriteLock = new object();
private const string BLANK_SERIAL = "00:00:00:00:00:00";
internal HidDevice(string devicePath, string description = null, string parentPath = null)
@@ -191,19 +192,80 @@ public bool WriteOutputReportViaControl(byte[] outputBuffer)
public unsafe bool WriteOutputReportViaInterrupt(byte[] outputBuffer, int timeout)
{
- SafeReadHandle ??= OpenHandle(_devicePath, true, false);
- using AutoResetEvent wait = new(false);
- var ov = new NativeOverlapped { EventHandle = wait.SafeWaitHandle.DangerousGetHandle() };
+ lock (outputReportWriteLock)
+ {
+ return WriteOutputReportViaInterruptCore(outputBuffer, null, out _);
+ }
+ }
+
+ internal unsafe bool WriteOutputReportViaInterruptWithTimeout(byte[] outputBuffer, int timeout,
+ out int win32Error)
+ {
+ lock (outputReportWriteLock)
+ {
+ return WriteOutputReportViaInterruptCore(outputBuffer, timeout, out win32Error);
+ }
+ }
+ private unsafe bool WriteOutputReportViaInterruptCore(byte[] outputBuffer, int? timeout,
+ out int win32Error)
+ {
+ // A DualSense has several independent producers (normal 0x31 state,
+ // streamed 0x36 haptics/audio, and amplifier setup). The Bluetooth
+ // HID output endpoint is one ordered byte stream, so serialize every
+ // producer without changing the legacy wait-forever behavior used by
+ // other controller types.
+ win32Error = 0;
+ SafeReadHandle ??= OpenHandle(_devicePath, true, false);
+ using AutoResetEvent wait = new(false);
+ var ov = new NativeOverlapped { EventHandle = wait.SafeWaitHandle.DangerousGetHandle() };
+ GCHandle outputPin = GCHandle.Alloc(outputBuffer, GCHandleType.Pinned);
+
+ try
+ {
+ // Overlapped I/O retains the caller's buffer until completion.
+ // Keep the managed array pinned through the wait/cancel/drain
+ // sequence, not only for the initial WriteFile call.
if (PInvoke.WriteFile(SafeReadHandle, outputBuffer, null, &ov))
return true;
- if (Marshal.GetLastWin32Error() != (uint)WIN32_ERROR.ERROR_IO_PENDING) return false;
+ int error = Marshal.GetLastWin32Error();
+ if (error != (int)WIN32_ERROR.ERROR_IO_PENDING)
+ {
+ win32Error = error;
+ return false;
+ }
- if (!PInvoke.GetOverlappedResult(SafeReadHandle, ov, out _, true))
+ if (!timeout.HasValue)
+ {
+ if (PInvoke.GetOverlappedResult(SafeReadHandle, ov, out _, true))
+ return true;
+
+ win32Error = Marshal.GetLastWin32Error();
return false;
+ }
+
+ uint waitTimeout = timeout.Value < 0 ? uint.MaxValue : (uint)timeout.Value;
+ if (PInvoke.GetOverlappedResultEx(SafeReadHandle, ov, out _, waitTimeout, false))
+ return true;
+
+ int resultError = Marshal.GetLastWin32Error();
+ if (resultError == NativeMethods.WAIT_TIMEOUT)
+ {
+ // The NativeOverlapped and its event are stack-scoped, so cancel
+ // this exact request and drain its completion before releasing
+ // either. Never cancel the input read sharing this HID handle.
+ NativeMethods.CancelIoEx(SafeReadHandle.DangerousGetHandle(), (IntPtr)(&ov));
+ PInvoke.GetOverlappedResult(SafeReadHandle, ov, out _, true);
+ }
- return true;
+ win32Error = resultError;
+ return false;
+ }
+ finally
+ {
+ outputPin.Free();
+ }
}
private SafeFileHandle OpenHandle(string devicePathName, bool isExclusive, bool enumerate)
diff --git a/DS4Windows/Properties/AssemblyInfo.cs b/DS4Windows/Properties/AssemblyInfo.cs
index 98a8e29..2020ac9 100644
--- a/DS4Windows/Properties/AssemblyInfo.cs
+++ b/DS4Windows/Properties/AssemblyInfo.cs
@@ -1,6 +1,7 @@
using System.Windows;
+using System.Runtime.CompilerServices;
-
+[assembly: InternalsVisibleTo("DS4WindowsTests")]
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
diff --git a/DS4Windows/Translations/Strings.Designer.cs b/DS4Windows/Translations/Strings.Designer.cs
index c5bd10c..2d9b766 100644
--- a/DS4Windows/Translations/Strings.Designer.cs
+++ b/DS4Windows/Translations/Strings.Designer.cs
@@ -697,6 +697,249 @@ public static string ControllerRegOptWin_EnableHomeLED {
return ResourceManager.GetString("ControllerRegOptWin.EnableHomeLED", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Default output device.
+ ///
+ public static string ControllerRegOptWin_AudioDeviceDefault {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.AudioDeviceDefault", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to (saved device, currently unavailable).
+ ///
+ public static string ControllerRegOptWin_AudioDeviceUnavailable {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.AudioDeviceUnavailable", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Output.
+ ///
+ public static string ControllerRegOptWin_AudioOutput {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.AudioOutput", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Auto (headphones when plugged in).
+ ///
+ public static string ControllerRegOptWin_AudioRouteAuto {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.AudioRouteAuto", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Headphone jack.
+ ///
+ public static string ControllerRegOptWin_AudioRouteHeadphones {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.AudioRouteHeadphones", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Built-in speaker.
+ ///
+ public static string ControllerRegOptWin_AudioRouteSpeaker {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.AudioRouteSpeaker", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Audio source.
+ ///
+ public static string ControllerRegOptWin_AudioSource {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.AudioSource", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up the localized Bluetooth audio description.
+ ///
+ public static string ControllerRegOptWin_BTAudioDescription {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.BTAudioDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Bluetooth Audio (headphones / speaker).
+ ///
+ public static string ControllerRegOptWin_BTAudioHeading {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.BTAudioHeading", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up the localized Bluetooth streaming note.
+ ///
+ public static string ControllerRegOptWin_BTStreamingNote {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.BTStreamingNote", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Buffering.
+ ///
+ public static string ControllerRegOptWin_Buffering {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.Buffering", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up the localized buffering tooltip.
+ ///
+ public static string ControllerRegOptWin_BufferingTip {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.BufferingTip", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up the localized Bluetooth haptics description.
+ ///
+ public static string ControllerRegOptWin_HapticsDescription {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.HapticsDescription", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Bluetooth Haptic Feedback (audio-based).
+ ///
+ public static string ControllerRegOptWin_HapticsHeading {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.HapticsHeading", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Haptics mode.
+ ///
+ public static string ControllerRegOptWin_HapticsMode {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.HapticsMode", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to System Audio + Rumble.
+ ///
+ public static string ControllerRegOptWin_HapticsModeMix {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.HapticsModeMix", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Off.
+ ///
+ public static string ControllerRegOptWin_HapticsModeOff {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.HapticsModeOff", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Rumble To Haptics.
+ ///
+ public static string ControllerRegOptWin_HapticsModeRumble {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.HapticsModeRumble", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to System Audio.
+ ///
+ public static string ControllerRegOptWin_HapticsModeSystemAudio {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.HapticsModeSystemAudio", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Intensity.
+ ///
+ public static string ControllerRegOptWin_Intensity {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.Intensity", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Balanced.
+ ///
+ public static string ControllerRegOptWin_LatencyBalanced {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.LatencyBalanced", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Low latency (clean link needed).
+ ///
+ public static string ControllerRegOptWin_LatencyLow {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.LatencyLow", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Smooth (most buffering).
+ ///
+ public static string ControllerRegOptWin_LatencySmooth {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.LatencySmooth", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Low-pass filter (Hz).
+ ///
+ public static string ControllerRegOptWin_LowPassFilter {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.LowPassFilter", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up the localized low-pass filter tooltip.
+ ///
+ public static string ControllerRegOptWin_LowPassFilterTip {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.LowPassFilterTip", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Send audio to controller.
+ ///
+ public static string ControllerRegOptWin_SendAudioToController {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.SendAudioToController", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Volume.
+ ///
+ public static string ControllerRegOptWin_Volume {
+ get {
+ return ResourceManager.GetString("ControllerRegOptWin.Volume", resourceCulture);
+ }
+ }
///
/// Looks up a localized string similar to Joined Gyro Provider.
diff --git a/DS4Windows/Translations/Strings.resx b/DS4Windows/Translations/Strings.resx
index 61010b4..e18cff0 100644
--- a/DS4Windows/Translations/Strings.resx
+++ b/DS4Windows/Translations/Strings.resx
@@ -949,6 +949,87 @@ Disabled:
Enable Home LED
+
+ Default output device
+
+
+ (saved device, currently unavailable)
+
+
+ Output
+
+
+ Auto (headphones when plugged in)
+
+
+ Headphone jack
+
+
+ Built-in speaker
+
+
+ Audio source
+
+
+ Streams the selected audio source to the controller's headphone jack or built-in speaker over Bluetooth — no USB cable needed.
+
+
+ Bluetooth Audio (headphones / speaker)
+
+
+ Bluetooth connections only. Applies immediately. While streaming is active, standard rumble emulation is disabled (the controller cannot do both at once) — use a haptics mode that includes Rumble to keep feeling game rumble. Pick the audio source your game actually plays through. Protocol research credit: egormanga/SAxense, awalol/DS5Dongle, and loteran/DS5Dongle AutoHaptics.
+
+
+ Buffering
+
+
+ Smooth = most stable, ~0.3-0.4 s behind. Low latency = ~0.1 s behind, but needs a clean Bluetooth link (interference causes dropouts). Applies to both audio and audio-based haptics.
+
+
+ Streams haptic audio to the controller's actuators over Bluetooth. System Audio converts what you hear into haptics; Rumble To Haptics re-voices game rumble through the haptic actuators.
+
+
+ Bluetooth Haptic Feedback (audio-based)
+
+
+ Haptics mode
+
+
+ System Audio + Rumble
+
+
+ Off
+
+
+ Rumble To Haptics
+
+
+ System Audio
+
+
+ Intensity
+
+
+ Balanced
+
+
+ Low latency (clean link needed)
+
+
+ Smooth (most buffering)
+
+
+ Low-pass filter (Hz)
+
+
+ Lower values = only deep bass becomes haptics; higher values = more texture from the audio.
+
+
+ Send audio to controller
+
+
+ Volume
+
Joined Gyro Provider
@@ -1478,4 +1559,4 @@ Please click OK to confirm that you have read the message. Press Cancel to be re
Inverse motors
-
\ No newline at end of file
+
diff --git a/DS4WindowsTests/DualSenseControllerOptionsTests.cs b/DS4WindowsTests/DualSenseControllerOptionsTests.cs
new file mode 100644
index 0000000..364d039
--- /dev/null
+++ b/DS4WindowsTests/DualSenseControllerOptionsTests.cs
@@ -0,0 +1,59 @@
+using DS4Windows;
+using DS4Windows.InputDevices;
+using DS4WinWPF.DS4Control.DTOXml;
+
+namespace DS4WindowsTests;
+
+[TestClass]
+public class DualSenseControllerOptionsTests
+{
+ [TestMethod]
+ public void BluetoothStreamingDefaults_AreSafeAndUseHalfVolume()
+ {
+ DualSenseControllerOptions options =
+ new DualSenseControllerOptions(InputDeviceType.DualSense);
+ DualSenseControllerOptsDTO dto = new DualSenseControllerOptsDTO();
+
+ Assert.AreEqual(DualSenseControllerOptions.HapticsMode.Off,
+ options.BTHapticsMode);
+ Assert.IsFalse(options.BTAudioEnabled);
+ Assert.AreEqual(50, options.BTAudioVolume);
+
+ Assert.AreEqual(DualSenseControllerOptions.HapticsMode.Off,
+ dto.BTHapticsMode);
+ Assert.IsFalse(dto.BTAudioEnabled);
+ Assert.AreEqual(50, dto.BTAudioVolume);
+ }
+
+ [TestMethod]
+ public void DtoMapping_PreservesBluetoothStreamingSettings()
+ {
+ DualSenseControllerOptions source =
+ new DualSenseControllerOptions(InputDeviceType.DualSense)
+ {
+ BTHapticsMode = DualSenseControllerOptions.HapticsMode.Mix,
+ BTHapticsGain = 4.5,
+ BTHapticsLowPassHz = 420,
+ BTHapticsAudioDeviceId = "endpoint-id",
+ BTAudioEnabled = true,
+ BTAudioRoute = DualSenseControllerOptions.AudioOutputRoute.Speaker,
+ BTAudioVolume = 37,
+ BTAudioLatency = DualSenseControllerOptions.AudioLatencyMode.LowLatency,
+ };
+ DualSenseControllerOptsDTO dto = new DualSenseControllerOptsDTO();
+ DualSenseControllerOptions destination =
+ new DualSenseControllerOptions(InputDeviceType.DualSense);
+
+ dto.MapFrom(source);
+ dto.MapTo(destination);
+
+ Assert.AreEqual(source.BTHapticsMode, destination.BTHapticsMode);
+ Assert.AreEqual(source.BTHapticsGain, destination.BTHapticsGain);
+ Assert.AreEqual(source.BTHapticsLowPassHz, destination.BTHapticsLowPassHz);
+ Assert.AreEqual(source.BTHapticsAudioDeviceId, destination.BTHapticsAudioDeviceId);
+ Assert.AreEqual(source.BTAudioEnabled, destination.BTAudioEnabled);
+ Assert.AreEqual(source.BTAudioRoute, destination.BTAudioRoute);
+ Assert.AreEqual(source.BTAudioVolume, destination.BTAudioVolume);
+ Assert.AreEqual(source.BTAudioLatency, destination.BTAudioLatency);
+ }
+}
diff --git a/DS4WindowsTests/DualSenseDeviceTests.cs b/DS4WindowsTests/DualSenseDeviceTests.cs
new file mode 100644
index 0000000..f87ecc9
--- /dev/null
+++ b/DS4WindowsTests/DualSenseDeviceTests.cs
@@ -0,0 +1,43 @@
+using DS4Windows.InputDevices;
+
+namespace DS4WindowsTests;
+
+[TestClass]
+public class DualSenseDeviceTests
+{
+ [TestMethod]
+ public void BtOutputControl_WhenStreaming_PreservesTriggersAndClearsRumble()
+ {
+ byte[] report = Enumerable.Repeat((byte)0xAA, 78).ToArray();
+
+ DualSenseDevice.BtOutputControl control = DualSenseDevice.ApplyBtOutputControl(
+ report, useRumble: true, useAccurateRumble: true,
+ hapticsStreamActive: true, lightMotor: 0x33, heavyMotor: 0x44);
+
+ Assert.AreEqual((byte)0x0C, control.EnableFlags);
+ Assert.AreEqual((byte)0x02, control.PlayerLedFlags);
+ Assert.IsFalse(control.WriteMotorBytes);
+ Assert.AreEqual((byte)0x0C, report[2]);
+ Assert.AreEqual((byte)0x00, report[4]);
+ Assert.AreEqual((byte)0x00, report[5]);
+ Assert.AreEqual((byte)0x02, report[40]);
+ }
+
+ [TestMethod]
+ public void BtOutputControl_WhenNotStreaming_RetainsConfiguredRumble()
+ {
+ byte[] report = new byte[78];
+
+ DualSenseDevice.BtOutputControl control = DualSenseDevice.ApplyBtOutputControl(
+ report, useRumble: true, useAccurateRumble: true,
+ hapticsStreamActive: false, lightMotor: 0x33, heavyMotor: 0x44);
+
+ Assert.AreEqual((byte)0x0F, control.EnableFlags);
+ Assert.AreEqual((byte)0x06, control.PlayerLedFlags);
+ Assert.IsTrue(control.WriteMotorBytes);
+ Assert.AreEqual((byte)0x0F, report[2]);
+ Assert.AreEqual((byte)0x33, report[4]);
+ Assert.AreEqual((byte)0x44, report[5]);
+ Assert.AreEqual((byte)0x06, report[40]);
+ }
+}
diff --git a/DS4WindowsTests/DualSenseHapticsStreamerTests.cs b/DS4WindowsTests/DualSenseHapticsStreamerTests.cs
new file mode 100644
index 0000000..2d7a44b
--- /dev/null
+++ b/DS4WindowsTests/DualSenseHapticsStreamerTests.cs
@@ -0,0 +1,313 @@
+using DS4Windows;
+using DS4Windows.InputDevices;
+using NAudio.Wave;
+
+namespace DS4WindowsTests;
+
+[TestClass]
+public class DualSenseHapticsStreamerTests
+{
+ [DataTestMethod]
+ [DataRow((byte)0)]
+ [DataRow(DualSenseHapticsStreamer.RUMBLE_SYNTH_DEADZONE)]
+ public void ScaleRumbleStrength_SuppressesNoiseFloor(byte strength)
+ {
+ Assert.AreEqual(0.0, DualSenseHapticsStreamer.ScaleRumbleStrength(strength));
+ }
+
+ [TestMethod]
+ public void ScaleRumbleStrength_PreservesFullStrength()
+ {
+ Assert.AreEqual(1.0, DualSenseHapticsStreamer.ScaleRumbleStrength(byte.MaxValue));
+ }
+
+ [TestMethod]
+ public void ScaleRumbleStrength_RescalesValuesAboveNoiseFloor()
+ {
+ byte strength = (byte)(DualSenseHapticsStreamer.RUMBLE_SYNTH_DEADZONE + 1);
+ double expected = 1.0 /
+ (byte.MaxValue - DualSenseHapticsStreamer.RUMBLE_SYNTH_DEADZONE);
+
+ Assert.AreEqual(expected, DualSenseHapticsStreamer.ScaleRumbleStrength(strength), 1e-12);
+ }
+
+ [TestMethod]
+ public void HasHapticSignal_TreatsCenteredChunkAsSilence()
+ {
+ byte[] chunk = Enumerable.Repeat((byte)0x80, 64).ToArray();
+
+ Assert.IsFalse(DualSenseHapticsStreamer.HasHapticSignal(chunk));
+ }
+
+ [TestMethod]
+ public void HasHapticSignal_DetectsSampleAwayFromCenter()
+ {
+ byte[] chunk = Enumerable.Repeat((byte)0x80, 64).ToArray();
+ chunk[31] = 0x81;
+
+ Assert.IsTrue(DualSenseHapticsStreamer.HasHapticSignal(chunk));
+ }
+
+ [DataTestMethod]
+ [DataRow(-1.0, (byte)1)]
+ [DataRow(0.0, (byte)128)]
+ [DataRow(1.0, (byte)255)]
+ public void UnitSampleToU8_UsesFullSignedPcmRange(double sample, byte expected)
+ {
+ Assert.AreEqual(expected, DualSenseHapticsStreamer.UnitSampleToU8(sample));
+ }
+
+ [TestMethod]
+ public void DownmixToStereo_PreservesStereoSamples()
+ {
+ float[] samples = { 0.25f, -0.5f };
+
+ DualSenseHapticsStreamer.DownmixToStereo(samples, 0, 2, out float left, out float right);
+
+ Assert.AreEqual(0.25f, left);
+ Assert.AreEqual(-0.5f, right);
+ }
+
+ [TestMethod]
+ public void DownmixToStereo_SharesSevenPointOneCenterChannel()
+ {
+ float[] samples = { 0.0f, 0.0f, 0.4f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f };
+ float expected = MathF.Tanh(0.4f * 0.70710678f);
+
+ DualSenseHapticsStreamer.DownmixToStereo(samples, 0, 8, out float left, out float right);
+
+ Assert.AreEqual(expected, left, 1e-6f);
+ Assert.AreEqual(expected, right, 1e-6f);
+ }
+
+ [TestMethod]
+ public void DownmixToStereo_RoutesSevenPointOneSideChannels()
+ {
+ float[] samples = { 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, -0.25f };
+
+ DualSenseHapticsStreamer.DownmixToStereo(samples, 0, 8, out float left, out float right);
+
+ Assert.IsTrue(left > 0.0f);
+ Assert.IsTrue(right < 0.0f);
+ Assert.IsTrue(Math.Abs(left) < 1.0f);
+ Assert.IsTrue(Math.Abs(right) < 1.0f);
+ }
+
+ [TestMethod]
+ public void DitherQuantizeU8_ExactSilencePassesThrough()
+ {
+ // Dithered silence would defeat HasHapticSignal idle detection and the
+ // silence gate, so exact digital zero must always map to exactly 0x80.
+ uint state = 0x12345678;
+ uint initialState = state;
+ byte[] chunk = new byte[64];
+ for (int i = 0; i < 100; i++)
+ {
+ chunk[i % chunk.Length] =
+ DualSenseHapticsStreamer.DitherQuantizeU8(0.0, ref state);
+ }
+
+ Assert.AreEqual(initialState, state);
+ Assert.IsFalse(DualSenseHapticsStreamer.HasHapticSignal(chunk));
+ }
+
+ [TestMethod]
+ public void DitherQuantizeU8_StaysWithinOneLsbOfUndithered()
+ {
+ const double x = 0.25;
+ double softClipped = x / (1.0 + Math.Abs(x));
+ int reference = (int)Math.Round(128.0 + softClipped * 127.0);
+
+ uint state = 0x9E3779B9;
+ byte first = DualSenseHapticsStreamer.DitherQuantizeU8(x, ref state);
+ bool observedDifferentValue = false;
+ for (int i = 0; i < 1000; i++)
+ {
+ byte q = DualSenseHapticsStreamer.DitherQuantizeU8(x, ref state);
+ Assert.IsTrue(Math.Abs(q - reference) <= 1,
+ $"dithered value {q} strayed more than 1 LSB from {reference}");
+ observedDifferentValue |= q != first;
+ }
+
+ Assert.IsTrue(observedDifferentValue, "dither did not vary the quantized output");
+ }
+
+ [DataTestMethod]
+ [DataRow(1.0, DualSenseHapticsStreamer.AUDIO_RATE_TRIM_LIMIT)]
+ [DataRow(-1.0, -DualSenseHapticsStreamer.AUDIO_RATE_TRIM_LIMIT)]
+ [DataRow(0.001, 0.001)]
+ public void ClampRateTrim_LimitsServoAuthority(double input, double expected)
+ {
+ Assert.AreEqual(expected, DualSenseHapticsStreamer.ClampRateTrim(input), 1e-12);
+ }
+
+ [TestMethod]
+ public void BuildFillerFrame_DecaysToSilenceWithoutStep()
+ {
+ short[] pcm = new short[960];
+ double lastL = 16000.0, lastR = -12000.0;
+
+ DualSenseHapticsStreamer.BuildFillerFrame(pcm, ref lastL, ref lastR);
+
+ // First sample continues from the previous output (no step discontinuity)
+ Assert.IsTrue(Math.Abs(pcm[0] - 16000.0 * 0.985) < 2.0);
+ Assert.IsTrue(Math.Abs(pcm[1] + 12000.0 * 0.985) < 2.0);
+
+ // A second filler frame is effectively silent
+ DualSenseHapticsStreamer.BuildFillerFrame(pcm, ref lastL, ref lastR);
+ Assert.IsTrue(Math.Abs(lastL) < 1.0);
+ Assert.IsTrue(Math.Abs(lastR) < 1.0);
+ }
+
+ [TestMethod]
+ public void ApplyResumeFade_RampsInFromZero()
+ {
+ short[] pcm = new short[960];
+ Array.Fill(pcm, (short)10000);
+
+ DualSenseHapticsStreamer.ApplyResumeFade(pcm);
+
+ Assert.AreEqual(0, pcm[0]);
+ Assert.AreEqual(0, pcm[1]);
+ Assert.IsTrue(pcm[2] < 100); // early ramp stays near zero
+ // Beyond the fade window the content is untouched
+ Assert.AreEqual(10000, pcm[DualSenseHapticsStreamer.RESUME_FADE_FRAMES * 2]);
+ }
+
+ [DataTestMethod]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.LowLatency, (byte)32)]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.Balanced, (byte)64)]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.Smooth, (byte)120)]
+ public void GetLatencyProfile_CarriesControllerDejitterDepth(
+ DualSenseControllerOptions.AudioLatencyMode mode, byte expectedBuffer)
+ {
+ Assert.AreEqual(expectedBuffer,
+ DualSenseHapticsStreamer.GetLatencyProfile(mode).ControllerBuffer);
+ }
+
+ [DataTestMethod]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.LowLatency)]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.Balanced)]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.Smooth)]
+ public void GetLatencyProfile_LeavesAdaptiveRingHeadroom(
+ DualSenseControllerOptions.AudioLatencyMode mode)
+ {
+ var profile = DualSenseHapticsStreamer.GetLatencyProfile(mode);
+ int ringFrames = profile.AudioRingSamples / 960;
+
+ // The adaptive prebuffer escalates up to ringFrames - 4; the base
+ // target must leave at least that headroom to escalate into.
+ Assert.IsTrue(profile.PrebufferFrames + 4 <= ringFrames,
+ $"{mode}: prebuffer {profile.PrebufferFrames}f has no headroom in {ringFrames}f ring");
+ }
+
+ [TestMethod]
+ public void ShouldTransmitCaptureTick_SendsSixTailReportsThenGates()
+ {
+ int silenceTail = 0;
+ for (int i = 0; i < DualSenseHapticsStreamer.SILENCE_TAIL_REPORTS; i++)
+ {
+ Assert.IsTrue(DualSenseHapticsStreamer.ShouldTransmitCaptureTick(
+ false, false, ref silenceTail));
+ }
+
+ Assert.IsFalse(DualSenseHapticsStreamer.ShouldTransmitCaptureTick(
+ false, false, ref silenceTail));
+
+ Assert.IsTrue(DualSenseHapticsStreamer.ShouldTransmitCaptureTick(
+ true, false, ref silenceTail));
+ Assert.AreEqual(0, silenceTail);
+
+ Assert.IsTrue(DualSenseHapticsStreamer.ShouldTransmitCaptureTick(
+ false, true, ref silenceTail));
+ Assert.AreEqual(0, silenceTail);
+ }
+
+ [DataTestMethod]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.LowLatency, (byte)32)]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.Balanced, (byte)64)]
+ [DataRow(DualSenseControllerOptions.AudioLatencyMode.Smooth, (byte)120)]
+ public void BuildHapticsReport_UsesExpectedProtocolLayoutAndBufferDepth(
+ DualSenseControllerOptions.AudioLatencyMode latencyMode, byte expectedBuffer)
+ {
+ DualSenseHapticsStreamer streamer = CreateStreamer(latencyMode,
+ DualSenseControllerOptions.AudioOutputRoute.Speaker);
+ byte[] chunk = Enumerable.Range(0, 64).Select(i => (byte)(0x80 + i)).ToArray();
+ byte[] report = new byte[398];
+
+ streamer.BuildHapticsReport(report, chunk);
+
+ Assert.AreEqual((byte)0x36, report[0]);
+ Assert.AreEqual((byte)0x00, report[1]);
+ Assert.AreEqual((byte)0x91, report[2]);
+ Assert.AreEqual((byte)0x07, report[3]);
+ Assert.AreEqual((byte)0xFE, report[4]);
+ CollectionAssert.AreEqual(Enumerable.Repeat(expectedBuffer, 5).ToArray(),
+ report[5..10]);
+ Assert.AreEqual((byte)0x01, report[10]);
+ Assert.AreEqual((byte)0x90, report[11]);
+ Assert.AreEqual((byte)63, report[12]);
+ Assert.AreEqual((byte)0x92, report[76]);
+ Assert.AreEqual((byte)64, report[77]);
+ CollectionAssert.AreEqual(chunk.Select(sample => (byte)(sample ^ 0x80)).ToArray(),
+ report[78..142]);
+ CollectionAssert.AreEqual(new byte[252], report[142..394]);
+ AssertValidReportCrc(report);
+ }
+
+ [DataTestMethod]
+ [DataRow(DualSenseControllerOptions.AudioOutputRoute.Speaker, (byte)0x93)]
+ [DataRow(DualSenseControllerOptions.AudioOutputRoute.Headphone, (byte)0x96)]
+ public void BuildAudioReport_UsesExpectedRoutePayloadPaddingAndCrc(
+ DualSenseControllerOptions.AudioOutputRoute route, byte expectedRoute)
+ {
+ DualSenseHapticsStreamer streamer = CreateStreamer(
+ DualSenseControllerOptions.AudioLatencyMode.Balanced, route);
+ byte[] chunk = Enumerable.Repeat((byte)0x80, 64).ToArray();
+ byte[] opus = Enumerable.Range(0, 200).Select(i => (byte)i).ToArray();
+ byte[] report = new byte[398];
+
+ streamer.BuildAudioReport(report, chunk, opus);
+
+ Assert.AreEqual((byte)0x36, report[0]);
+ Assert.AreEqual(expectedRoute, report[142]);
+ Assert.AreEqual((byte)200, report[143]);
+ CollectionAssert.AreEqual(opus, report[144..344]);
+ CollectionAssert.AreEqual(new byte[50], report[344..394]);
+ AssertValidReportCrc(report);
+ }
+
+ [TestMethod]
+ public void IsSupportedLoopbackFormat_AcceptsFloatAndRejectsPcm()
+ {
+ Assert.IsTrue(DualSenseHapticsStreamer.IsSupportedLoopbackFormat(
+ WaveFormat.CreateIeeeFloatWaveFormat(48000, 2)));
+ Assert.IsTrue(DualSenseHapticsStreamer.IsSupportedLoopbackFormat(
+ new WaveFormatExtensible(48000, 32, 2)));
+ Assert.IsFalse(DualSenseHapticsStreamer.IsSupportedLoopbackFormat(
+ new WaveFormat(48000, 16, 2)));
+ Assert.IsFalse(DualSenseHapticsStreamer.IsSupportedLoopbackFormat(null));
+ }
+
+ private static DualSenseHapticsStreamer CreateStreamer(
+ DualSenseControllerOptions.AudioLatencyMode latencyMode,
+ DualSenseControllerOptions.AudioOutputRoute route)
+ {
+ Crc32Algorithm.InitializeTable(Crc32Algorithm.DefaultPolynomial);
+ DualSenseHapticsStreamer streamer = new DualSenseHapticsStreamer(null, null);
+ streamer.Configure(DualSenseControllerOptions.HapticsMode.Off,
+ 3.0, 350, string.Empty, false, route, 50, latencyMode);
+ return streamer;
+ }
+
+ private static void AssertValidReportCrc(byte[] report)
+ {
+ int crcOffset = report.Length - sizeof(uint);
+ uint expected = ~Crc32Algorithm.Compute(new byte[] { 0xA2 });
+ expected = ~Crc32Algorithm.CalculateBasicHash(
+ ref expected, ref report, 0, crcOffset);
+ uint actual = BitConverter.ToUInt32(report, crcOffset);
+
+ Assert.AreEqual(expected, actual);
+ }
+}
diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt
new file mode 100644
index 0000000..ff2ad7d
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.txt
@@ -0,0 +1,176 @@
+THIRD-PARTY NOTICES
+===================
+
+This file records third-party software and research used by DS4Windows'
+DualSense Bluetooth audio and haptics support. It supplements NOTICE.txt.
+DS4Windows itself is distributed under the GNU General Public License,
+version 3 or later.
+
+
+-------------------------------------------------------------------------------
+Concentus 2.2.2
+-------------------------------------------------------------------------------
+
+Portable C# implementation of the Opus audio codec.
+
+Project: https://github.com/lostromb/concentus
+Source: https://github.com/lostromb/concentus/tree/6c2328dc19044601e33a9c11628b8d60e1f3011c
+
+Copyright (c) by various holding parties, including (but not limited to):
+Skype Limited, Xiph.Org Foundation, CSIRO, Microsoft Corporation,
+Jean-Marc Valin, Gregory Maxwell, Mark Borgerding, Timothy B. Terriberry,
+Logan Stromberg. All rights are reserved by their respective holders.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of Internet Society, IETF or IETF Trust, nor the names of
+ specific contributors, may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+This repository and its redistributable packages contain independently compiled
+versions of the Opus C reference library, which is maintained by Xiph.org and
+the Opus open-source contributors. The source code for these libraries is freely
+available at https://gitlab.xiph.org/xiph/opus/-/tags/v1.5.2, and all binaries
+are being redistributed to you under the same terms of the general Opus license
+dictated above.
+
+
+-------------------------------------------------------------------------------
+NAudio.Wasapi 2.2.1 and NAudio.Core 2.2.1
+-------------------------------------------------------------------------------
+
+Project: https://github.com/naudio/NAudio
+Source: https://github.com/naudio/NAudio/tree/v2.2.1
+
+The MIT License (MIT)
+
+Copyright 2020 Mark Heath
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+-------------------------------------------------------------------------------
+Cockos WDL resampler, through NAudio.Core's managed WdlResampler
+-------------------------------------------------------------------------------
+
+NAudio's source describes this class as based on the Cockos WDL C++ resampler,
+ported to C# for NAudio by Mark Heath, and used in NAudio with permission from
+Justin Frankel.
+
+NAudio source:
+https://github.com/naudio/NAudio/blob/v2.2.1/NAudio.Core/Dsp/WdlResampler.cs
+
+Copyright (C) 2005 and later Cockos Incorporated
+
+Portions copyright other contributors, see each source file for more information
+
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+3. This notice may not be removed or altered from any source distribution.
+
+
+-------------------------------------------------------------------------------
+DS5Dongle AutoHaptics
+-------------------------------------------------------------------------------
+
+The DualSense Bluetooth audio container framing, Opus stream parameters,
+speaker/headphone routing, and controller-state setup were informed by and
+adapted from DS5Dongle AutoHaptics, a fork of awalol/DS5Dongle.
+
+Project: https://github.com/loteran/DS5Dongle
+Source: https://github.com/loteran/DS5Dongle/tree/4884c830641f47353f2eead4475b59dca9e4076d
+Original: https://github.com/awalol/DS5Dongle
+
+The final 0x36 layout and state initializer used here were already published
+under the original repository's MIT license at these revisions:
+
+https://github.com/awalol/DS5Dongle/tree/7bbe37ba1c113066a37f84efa4ff77e78b4ba767
+https://github.com/awalol/DS5Dongle/tree/f882ff1cf5df4f57c54b5e9c999e473b77aba86b
+
+MIT License
+
+Copyright (c) awalol
+Copyright (c) 2026 loteran — auto-haptics audio engine, battery color LED,
+ runtime wake toggle, PyQt6 config app, PipeWire loopback integration,
+ and all additions since the fork
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+-------------------------------------------------------------------------------
+SAxense research acknowledgment
+-------------------------------------------------------------------------------
+
+The DualSense Bluetooth haptics framing was informed by the independent
+protocol research published by SAxense. Its source identifies the researcher
+as Sdore. Only protocol facts were consulted; no SAxense source code is copied
+or translated in this implementation.
+
+Project: https://github.com/egormanga/SAxense
+Source: https://github.com/egormanga/SAxense/tree/6fd1648e0b2168bfe270f26b766fc897562091ab
+Research page: https://apps.sdore.me/SAxense
+License: Mozilla Public License 2.0
+ https://www.mozilla.org/MPL/2.0/
diff --git a/doc/dev/dualsense_bluetooth_audio_haptics.md b/doc/dev/dualsense_bluetooth_audio_haptics.md
new file mode 100644
index 0000000..a09fb05
--- /dev/null
+++ b/doc/dev/dualsense_bluetooth_audio_haptics.md
@@ -0,0 +1,96 @@
+# DualSense Bluetooth audio and haptics
+
+This note documents the standard Bluetooth streaming path implemented in
+DS4Windows. It does not describe virtual USB devices or native-game passthrough.
+
+## User-visible behavior
+
+The DualSense Device Options page can capture a selected Windows playback
+endpoint and send:
+
+- audio-derived haptics;
+- DS4Windows rumble re-voiced through the haptic actuators;
+- a mix of both haptic sources; and
+- optional listening audio to the controller speaker or headphone jack.
+
+The feature is off by default. It does not register a Windows audio endpoint and
+does not change the Windows default playback device. Audio volume is applied to
+the captured PCM before encoding and defaults to 50 percent.
+
+## Transport
+
+Streaming uses the existing Bluetooth HID connection. A self-contained 398-byte
+output report (0x36) is emitted every 32 haptic samples, or approximately
+10.667 ms:
+
+| Offset | Length | Contents |
+| ---: | ---: | --- |
+| 0 | 1 | Report ID 0x36 |
+| 1 | 1 | Four-bit rolling sequence |
+| 2 | 2 | Sized configuration packet (0x91, length 7) |
+| 4 | 1 | Configuration flags (0xFE) |
+| 5 | 5 | Controller dejitter depth (32, 64, or 120) |
+| 10 | 1 | Rolling packet counter |
+| 11 | 2 | Sized state packet (0x90, length 63) |
+| 13 | 63 | DualSense state payload |
+| 76 | 2 | Sized haptic packet (0x92, length 64) |
+| 78 | 64 | Signed 8-bit, 3 kHz stereo haptic PCM |
+| 142 | 2 | Optional speaker (0x93) or headphone (0x96) packet header, length 200 |
+| 144 | 200 | Optional Opus frame |
+| 344 | 50 | Reserved zero padding |
+| 394 | 4 | Bluetooth CRC-32 over prefix 0xA2 and bytes 0 through 393 |
+
+Listening audio uses 48 kHz stereo Opus frames with 480 samples per channel,
+160 kbit/s constant bitrate, and a fixed 200-byte payload. Frames are delivered
+at the controller's effective 45 kHz consumption rate.
+
+Before listening audio starts, a 142-byte 0x32 state report enables the
+controller amplifier. The UI volume remains independent of that fixed amplifier
+setup.
+
+## Timing and recovery
+
+The capture pipeline uses WASAPI loopback and accepts only 32-bit IEEE-float mix
+formats. Stereo and common multichannel layouts are downmixed before haptic
+filtering or Opus resampling.
+
+An integral rate servo trims the resampler to keep the audio queue near its
+target despite clock drift. The three buffering profiles combine host
+prebuffering with controller dejitter depths of 32 (Low Latency), 64 (Balanced),
+and 120 (Smooth). Underruns temporarily increase the host prebuffer; long clean
+runs reduce it toward the selected profile.
+
+Missed audio deadlines are skipped instead of sent as catch-up bursts. Filler
+frames decay toward silence through the live Opus encoder, and resumed content
+uses a short fade-in. An energy gate sends six tail reports and then stops idle
+traffic until audio or haptics becomes active again.
+
+All HID output producers share one serialization lock. Existing controller
+output retains its wait-until-complete behavior; only this streaming path uses
+bounded writes with cancellation completion draining.
+
+## Controller-state interaction
+
+While advanced haptic streaming is active, the ordinary Bluetooth state report
+keeps input, LEDs, and adaptive-trigger flags but suppresses legacy rumble flags
+and stale motor bytes. The controller cannot run legacy rumble emulation and
+advanced haptic PCM at the same time. Modes containing "Rumble To Haptics"
+preserve game rumble by synthesizing it into the haptic stream.
+
+## Validation and limits
+
+Hardware validation on a first-party DualSense covered Low Latency, Balanced,
+and Smooth profiles. Audio was clean without clicks or choppiness, and input,
+rumble-derived haptics, audio-derived haptics, silence/resume, and routing felt
+correct. Telemetry recorded no dropped frames, stall skips, slow HID writes,
+write failures, or default-audio-device changes during the validation session.
+
+Current limits:
+
+- microphone transport is not implemented;
+- DualSense Edge hardware has not been validated;
+- Low Latency depends on Bluetooth link quality; and
+- the controller is a relay destination, not a Windows audio endpoint.
+
+Protocol research and dependency licenses are recorded in
+THIRD-PARTY-NOTICES.txt.