-
-
Notifications
You must be signed in to change notification settings - Fork 84
feat(controller): DualSense haptics PCM passthrough over USB audio #515
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6b5035c
011613a
e3581c1
74e50d2
d0d027c
6ccbd07
7c9d462
62b5ebf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,9 @@ import android.content.Context | |
| import android.hardware.Sensor | ||
| import android.hardware.SensorManager | ||
| import android.hardware.input.InputManager | ||
| import android.hardware.usb.UsbDeviceConnection | ||
| import android.hardware.usb.UsbEndpoint | ||
| import android.hardware.usb.UsbInterface | ||
| import android.hardware.usb.UsbManager | ||
| import android.os.Build | ||
| import android.os.Handler | ||
|
|
@@ -25,6 +28,8 @@ import com.limelight.binding.input.driver.AbstractController | |
| import com.limelight.binding.input.driver.UsbDriverListener | ||
| import com.limelight.binding.input.driver.UsbDriverService | ||
| import com.limelight.binding.input.haptics.ControllerHapticsCoordinator | ||
| import com.limelight.binding.input.haptics.Ds5HapticsPump | ||
| import com.limelight.nvstream.Ds5HapticsPcmFrame | ||
| import com.limelight.nvstream.NvConnection | ||
| import com.limelight.nvstream.input.ControllerPacket | ||
| import com.limelight.nvstream.input.MouseButtonPacket | ||
|
|
@@ -2724,6 +2729,48 @@ class ControllerHandler( | |
| } | ||
| } | ||
|
|
||
| override fun reportControllerTouch( | ||
| controllerId: Int, | ||
| eventType: Byte, | ||
| pointerId: Int, | ||
| x: Float, | ||
| y: Float | ||
| ) { | ||
| val context = usbDeviceContexts[controllerId] ?: return | ||
| if (prefConfig.multiController && !context.assignedControllerNumber) return | ||
| if (context.shortcutState.isLocalInputCaptureActive()) return | ||
|
|
||
| // The Linux DS5 backend distinguishes contact/release via pressure. | ||
| val pressure = when (eventType) { | ||
| MoonBridge.LI_TOUCH_EVENT_DOWN, MoonBridge.LI_TOUCH_EVENT_MOVE -> 1f | ||
| else -> 0f | ||
| } | ||
| conn.sendControllerTouchEvent( | ||
| context.controllerNumber.toByte(), eventType, pointerId, x, y, pressure | ||
| ) | ||
| } | ||
|
|
||
| override fun isUsbControllerReady(controllerId: Int): Boolean { | ||
| val context = usbDeviceContexts[controllerId] ?: return false | ||
| if (prefConfig.multiController && !context.assignedControllerNumber) return false | ||
| return context.controllerArrival.isReported | ||
| } | ||
|
|
||
| override fun onDs5AudioInterfaceAvailable( | ||
| controllerId: Int, | ||
| connection: UsbDeviceConnection, | ||
| streamingInterface: UsbInterface, | ||
| isoEndpoint: UsbEndpoint | ||
| ) { | ||
| val context = usbDeviceContexts[controllerId] ?: return | ||
| val pump = Ds5HapticsPump(connection, streamingInterface, isoEndpoint) | ||
| hapticsCoordinator.attachDs5HapticsPump(controllerId, context.controllerNumber, pump) | ||
| } | ||
|
|
||
| override fun onDs5AudioInterfaceGone(controllerId: Int) { | ||
| hapticsCoordinator.detachDs5HapticsPump(controllerId) | ||
| } | ||
|
|
||
| // ========== Sensor Management ========== | ||
|
|
||
| fun handleSetMotionEventState(controllerNumber: Short, motionType: Byte, reportRateHz: Short) { | ||
|
|
@@ -2874,4 +2921,8 @@ class ControllerHandler( | |
|
|
||
| fun handleSetControllerLED(controllerNumber: Short, r: Byte, g: Byte, b: Byte) = | ||
| rumbleManager.handleSetControllerLED(controllerNumber, r, g, b) | ||
|
|
||
| fun handleDs5HapticsPcm(frame: Ds5HapticsPcmFrame) { | ||
| hapticsCoordinator.submitDs5HapticsPcm(frame) | ||
|
Comment on lines
+2925
to
+2926
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Filter PCM frames by controller identity.
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,10 @@ abstract class AbstractDualSenseController( | |
| protected var inEndpt: UsbEndpoint? = null | ||
| protected var outEndpt: UsbEndpoint? = null | ||
|
|
||
| // The UAC audioStreamingOut alt setting and its iso OUT endpoint, when the | ||
| // controller exposes the DualSense audio topology. Null on non-DS5 pads. | ||
| private var audioInterface: Pair<UsbInterface, UsbEndpoint>? = null | ||
|
|
||
| // IMU data fields | ||
| protected var gyroX = 0f | ||
| protected var gyroY = 0f | ||
|
|
@@ -153,17 +157,56 @@ abstract class AbstractDualSenseController( | |
| return false | ||
| } | ||
|
|
||
| discoverAudioInterface() | ||
|
|
||
| inputThread = createInputThread() | ||
| inputThread!!.start() | ||
| return true | ||
| } | ||
|
|
||
| /** | ||
| * Discovers the UAC audio streaming OUT interface (the alt setting that | ||
| * carries the isochronous OUT endpoint) and notifies the listener so the | ||
| * haptics coordinator can create a PCM pump. The pump owns the alternate | ||
| * setting lifecycle; the interface itself was already claimed above. | ||
| */ | ||
| private fun discoverAudioInterface() { | ||
| for (i in 0 until device.interfaceCount) { | ||
| val iface = device.getInterface(i) | ||
| if (iface.interfaceClass != UsbConstants.USB_CLASS_AUDIO || | ||
| iface.interfaceSubclass != 0x02 // Audio Streaming | ||
| ) { | ||
| continue | ||
| } | ||
| // Alt 0 carries no endpoints; the alt 1 entry exposes the iso OUT | ||
| // endpoint (Android surfaces each alternate setting separately). | ||
| for (j in 0 until iface.endpointCount) { | ||
| val ep = iface.getEndpoint(j) | ||
| if (ep.direction == UsbConstants.USB_DIR_OUT && | ||
| ep.type == UsbConstants.USB_ENDPOINT_XFER_ISOC | ||
| ) { | ||
| Log.i("DualSenseController", "UAC streaming OUT iface=${iface.id} ep=0x${Integer.toHexString(ep.address)}") | ||
| audioInterface = iface to ep | ||
| listener.onDs5AudioInterfaceAvailable(deviceId, connection, iface, ep) | ||
| return | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+160
to
+195
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Blocking USB pump lifecycle runs on the controller lifecycle thread.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| override fun stop() { | ||
| synchronized(this) { | ||
| if (stopped) return | ||
| stopped = true | ||
| } | ||
|
|
||
| // Tear down the haptics pump first: it needs a live connection to | ||
| // restore the audio interface to alt 0 and release iso bandwidth. | ||
| if (audioInterface != null) { | ||
| audioInterface = null | ||
| listener.onDs5AudioInterfaceGone(deviceId) | ||
| } | ||
|
|
||
| // Hold the output lock across the final clear so a report already queued by | ||
| // the rumble worker cannot re-engage an effect after this point. | ||
| synchronized(outputLock) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Serialize pump attach and detach.
onDs5AudioInterfaceAvailable()queues the attach operation onbackgroundThreadHandler, butonDs5AudioInterfaceGone()detaches synchronously. If the gone callback runs first, the pump owner is still unset, so the detach returns. The queued task then starts a pump for an interface that is already gone.Serialize both operations on the same handler, or invalidate pending attachments before
pump.start().🤖 Prompt for AI Agents