Merge pull request 'feat(pad-audio): DualSense voice-coil haptics + speaker, host to client' (#23) from feat/android-pad-audio into main
audit / bun-audit (plugin-kit) (push) Failing after 30s
audit / cargo-audit (push) Successful in 35s
apple / swift (push) Successful in 1m20s
audit / bun-audit (sdk) (push) Failing after 23s
audit / bun-audit (web) (push) Failing after 19s
audit / pnpm-audit (push) Successful in 12s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m30s
audit / license-gate (push) Successful in 6m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
deb / build-publish-client-arm64 (push) Successful in 3m8s
deb / build-publish (push) Successful in 4m48s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
arch / build-publish (push) Failing after 10m5s
ci / rust (push) Failing after 7m23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish-host (push) Successful in 5m34s
release / apple (push) Successful in 9m30s
apple / screenshots (push) Successful in 5m56s
windows-host / package (push) Successful in 18m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 2m16s
flatpak / build-publish (push) Successful in 18m26s
docker / deploy-docs (push) Successful in 18m42s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 2m10s
audit / bun-audit (plugin-kit) (push) Failing after 30s
audit / cargo-audit (push) Successful in 35s
apple / swift (push) Successful in 1m20s
audit / bun-audit (sdk) (push) Failing after 23s
audit / bun-audit (web) (push) Failing after 19s
audit / pnpm-audit (push) Successful in 12s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m30s
audit / license-gate (push) Successful in 6m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
deb / build-publish-client-arm64 (push) Successful in 3m8s
deb / build-publish (push) Successful in 4m48s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
arch / build-publish (push) Failing after 10m5s
ci / rust (push) Failing after 7m23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish-host (push) Successful in 5m34s
release / apple (push) Successful in 9m30s
apple / screenshots (push) Successful in 5m56s
windows-host / package (push) Successful in 18m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 2m16s
flatpak / build-publish (push) Successful in 18m26s
docker / deploy-docs (push) Successful in 18m42s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 2m10s
This commit was merged in pull request #23.
This commit is contained in:
Generated
+19
@@ -2893,6 +2893,7 @@ dependencies = [
|
|||||||
"ureq",
|
"ureq",
|
||||||
"wasapi",
|
"wasapi",
|
||||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||||
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3346,6 +3347,8 @@ dependencies = [
|
|||||||
"opus",
|
"opus",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uac-host",
|
||||||
|
"usbfs-iso",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uac-host"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||||
|
dependencies = [
|
||||||
|
"usbfs-iso",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uds_windows"
|
name = "uds_windows"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usbfs-iso"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "usbip-sim"
|
name = "usbip-sim"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|||||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
|||||||
Text("Grant USB access")
|
Text("Grant USB access")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> Text(
|
else -> {
|
||||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
Text(
|
||||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||||
"driven directly."
|
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||||
} else {
|
"driven directly."
|
||||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
} else {
|
||||||
"and gyro are driven directly."
|
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||||
},
|
"and gyro are driven directly."
|
||||||
style = MaterialTheme.typography.bodySmall,
|
},
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||||
|
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||||
|
// that behind a live session would make it depend on the very thing one wants
|
||||||
|
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||||
|
// 4-channel haptics device.
|
||||||
|
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
var result by remember { mutableStateOf<String?>(null) }
|
||||||
|
result?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = !testing,
|
||||||
|
onClick = {
|
||||||
|
testing = true
|
||||||
|
result = null
|
||||||
|
Thread({
|
||||||
|
// Its OWN connection: the renderer's descriptor must never be
|
||||||
|
// shared with another transfer engine, and that applies to
|
||||||
|
// this test as much as to the real path.
|
||||||
|
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||||
|
val fd = conn?.fileDescriptor ?: -1
|
||||||
|
val r = if (fd >= 0) {
|
||||||
|
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
conn?.close()
|
||||||
|
val msg = when {
|
||||||
|
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||||
|
r == -1 -> "Could not open the pad's audio interface. " +
|
||||||
|
"Some kernels refuse it; the pad still works normally."
|
||||||
|
r == -2 -> "The audio stream stopped part-way."
|
||||||
|
else -> "The stream opened but no audio reached the pad."
|
||||||
|
}
|
||||||
|
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||||
|
result = msg
|
||||||
|
testing = false
|
||||||
|
}
|
||||||
|
}, "pf-pad-selftest-ui").start()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(if (testing) "Testing…" else "Test haptics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
|||||||
// The host's approval-list / trust-store label for this device — the same
|
// The host's approval-list / trust-store label for this device — the same
|
||||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||||
Build.MODEL ?: "Android",
|
Build.MODEL ?: "Android",
|
||||||
|
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||||
|
// user with it off does not make the host provision endpoints it will never feed.
|
||||||
|
settings.padHaptics || settings.padSpeaker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,26 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val dsCapture: Boolean = true,
|
val dsCapture: Boolean = true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||||
|
*
|
||||||
|
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||||
|
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||||
|
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||||
|
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||||
|
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||||
|
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||||
|
*/
|
||||||
|
val padHaptics: Boolean = true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||||
|
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||||
|
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||||
|
* duplicates audio they are already hearing.
|
||||||
|
*/
|
||||||
|
val padSpeaker: Boolean = false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||||
@@ -271,6 +291,8 @@ class SettingsStore(context: Context) {
|
|||||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||||
|
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||||
|
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||||
@@ -308,6 +330,8 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||||
|
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||||
|
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||||
.apply()
|
.apply()
|
||||||
@@ -355,6 +379,8 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||||
const val K_SC2_CAPTURE = "sc2_capture"
|
const val K_SC2_CAPTURE = "sc2_capture"
|
||||||
const val K_DS_CAPTURE = "ds_capture"
|
const val K_DS_CAPTURE = "ds_capture"
|
||||||
|
const val K_PAD_HAPTICS = "pad_haptics"
|
||||||
|
const val K_PAD_SPEAKER = "pad_speaker"
|
||||||
const val K_MOUSE_MODE = "mouse_mode"
|
const val K_MOUSE_MODE = "mouse_mode"
|
||||||
|
|
||||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||||
|
|||||||
@@ -896,6 +896,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
|||||||
enabled = s.gamepadForwarding,
|
enabled = s.gamepadForwarding,
|
||||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||||
)
|
)
|
||||||
|
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||||
|
ToggleRow(
|
||||||
|
title = "Controller haptics",
|
||||||
|
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||||
|
"the pad keeps ordinary rumble for games that don't send them",
|
||||||
|
checked = s.padHaptics,
|
||||||
|
enabled = s.gamepadForwarding && s.dsCapture,
|
||||||
|
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Controller speaker",
|
||||||
|
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||||
|
checked = s.padSpeaker,
|
||||||
|
enabled = s.gamepadForwarding && s.dsCapture,
|
||||||
|
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -507,6 +507,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
|||||||
var dsUsbReceiver: BroadcastReceiver? = null
|
var dsUsbReceiver: BroadcastReceiver? = null
|
||||||
if (ds != null) {
|
if (ds != null) {
|
||||||
feedback.sink = ds
|
feedback.sink = ds
|
||||||
|
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||||
|
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||||
|
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||||
|
// lifetime), this decides WHETHER.
|
||||||
|
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||||
|
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||||
|
override fun start(pad: Int, fd: Int) {
|
||||||
|
val ok = NativeBridge.nativeStartPadAudio(
|
||||||
|
handle,
|
||||||
|
pad,
|
||||||
|
fd,
|
||||||
|
initialSettings.padHaptics,
|
||||||
|
initialSettings.padSpeaker,
|
||||||
|
)
|
||||||
|
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns only once the render thread is joined — DsCapture calls this before
|
||||||
|
// closing the connection whose descriptor that thread borrows.
|
||||||
|
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||||
|
}
|
||||||
|
}
|
||||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
val usbDev = ds.findUsbDevice()
|
val usbDev = ds.findUsbDevice()
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import android.view.InputDevice
|
|||||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||||
* and freed on unplug/[stop], so indices never leak.
|
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||||
|
* unplug/[stop], so indices never leak.
|
||||||
*
|
*
|
||||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||||
@@ -78,6 +79,33 @@ class DsCapture(
|
|||||||
@Volatile
|
@Volatile
|
||||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||||
|
*
|
||||||
|
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||||
|
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||||
|
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||||
|
*/
|
||||||
|
interface PadAudioHook {
|
||||||
|
fun start(pad: Int, fd: Int)
|
||||||
|
fun stop(pad: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var padAudio: PadAudioHook? = null
|
||||||
|
|
||||||
|
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||||
|
@Volatile private var padAudioStarted = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderer's OWN connection to the pad.
|
||||||
|
*
|
||||||
|
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||||
|
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||||
|
* and the audio ring. Closed only after the hook's stop has returned.
|
||||||
|
*/
|
||||||
|
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||||
|
|
||||||
val isActive: Boolean get() = model != null
|
val isActive: Boolean get() = model != null
|
||||||
|
|
||||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||||
@@ -105,12 +133,17 @@ class DsCapture(
|
|||||||
// (the same init hid-playstation/SDL send on open).
|
// (the same init hid-playstation/SDL send on open).
|
||||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||||
|
ensureSlot(m)
|
||||||
onActiveChanged?.invoke(true)
|
onActiveChanged?.invoke(true)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||||
|
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||||
|
// joined, so ordering this first is what makes the borrow sound.
|
||||||
|
stopPadAudio()
|
||||||
val m = model
|
val m = model
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
// The interfaces are about to release with the kernel driver still detached — a
|
// The interfaces are about to release with the kernel driver still detached — a
|
||||||
@@ -136,16 +169,112 @@ class DsCapture(
|
|||||||
private fun onReport(report: ByteArray, len: Int) {
|
private fun onReport(report: ByteArray, len: Int) {
|
||||||
val m = model ?: return
|
val m = model ?: return
|
||||||
if (!DsDevice.parseState(m, report, len, state)) return
|
if (!DsDevice.parseState(m, report, len, state)) return
|
||||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||||
pad = it
|
// while every wire index was taken.
|
||||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||||
} ?: return // all 16 wire indices taken — drop until one frees
|
|
||||||
mirrorTyped(p)
|
mirrorTyped(p)
|
||||||
mirrorRich(p, m)
|
mirrorRich(p, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||||
|
* indices are taken.
|
||||||
|
*
|
||||||
|
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||||
|
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||||
|
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||||
|
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||||
|
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||||
|
pad?.let { return it }
|
||||||
|
val p = router.openExternal(m.pref) ?: return null
|
||||||
|
pad = p
|
||||||
|
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||||
|
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||||
|
startPadAudio(p.index)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||||
|
private fun startPadAudio(index: Int) {
|
||||||
|
val hook = padAudio ?: return
|
||||||
|
if (padAudioStarted) return
|
||||||
|
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||||
|
val conn = usb.openAuxConnection()
|
||||||
|
val fd = conn?.fileDescriptor ?: -1
|
||||||
|
if (fd < 0) {
|
||||||
|
conn?.close()
|
||||||
|
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
padAudioConn = conn
|
||||||
|
padAudioStarted = true
|
||||||
|
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||||
|
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||||
|
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||||
|
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||||
|
// debug.punktfunk.force_parts.
|
||||||
|
val secs = runCatching {
|
||||||
|
Class.forName("android.os.SystemProperties")
|
||||||
|
.getMethod("get", String::class.java, String::class.java)
|
||||||
|
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||||
|
}.getOrNull()?.toIntOrNull() ?: 0
|
||||||
|
if (secs > 0) {
|
||||||
|
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||||
|
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||||
|
// completions, which is precisely the fault this test exists to expose.
|
||||||
|
Thread({
|
||||||
|
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||||
|
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||||
|
}, "pf-pad-selftest").start()
|
||||||
|
} else {
|
||||||
|
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
|
||||||
|
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
|
||||||
|
// clears it — so without this the stream renders into a muted actuator and looks for
|
||||||
|
// all the world like the host is sending nothing.
|
||||||
|
restoreAudioHaptics()
|
||||||
|
hook.start(index, fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
|
||||||
|
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
|
||||||
|
* the interrupt-OUT queue is busy or draining, and it is idempotent.
|
||||||
|
*/
|
||||||
|
private fun restoreAudioHaptics() {
|
||||||
|
val m = model ?: return
|
||||||
|
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
|
||||||
|
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
|
||||||
|
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||||
|
*
|
||||||
|
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||||
|
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||||
|
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||||
|
* audio and its wire rumble on the way back in.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
private fun stopPadAudio() {
|
||||||
|
if (!padAudioStarted) return
|
||||||
|
padAudioStarted = false
|
||||||
|
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||||
|
// returns — only then is it safe to close the connection that owns it.
|
||||||
|
pad?.let { padAudio?.stop(it.index) }
|
||||||
|
padAudioConn?.close()
|
||||||
|
padAudioConn = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun onLinkClosed() {
|
private fun onLinkClosed() {
|
||||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||||
|
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||||
|
stopPadAudio()
|
||||||
disarmBackstop()
|
disarmBackstop()
|
||||||
val wasActive = model != null
|
val wasActive = model != null
|
||||||
model = null
|
model = null
|
||||||
@@ -238,6 +367,10 @@ class DsCapture(
|
|||||||
// write — as this used to — meant a discarded stop left the motors running with
|
// write — as this used to — meant a discarded stop left the motors running with
|
||||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||||
|
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
|
||||||
|
// haptics stream is live the coils it drives were muted by the very write that
|
||||||
|
// silenced the motors. Give them back.
|
||||||
|
if (sent && padAudioStarted) restoreAudioHaptics()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,6 +276,21 @@ object DsDevice {
|
|||||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* B6: hand the voice coils back to the audio-haptics path.
|
||||||
|
*
|
||||||
|
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
|
||||||
|
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
|
||||||
|
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
|
||||||
|
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
|
||||||
|
*
|
||||||
|
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
|
||||||
|
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
|
||||||
|
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
|
||||||
|
* client, which is the same packet one transport over.
|
||||||
|
*/
|
||||||
|
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
|
||||||
|
|
||||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||||
|
|||||||
@@ -98,6 +98,40 @@ class HidUsbLink(
|
|||||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||||
|
*
|
||||||
|
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||||
|
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||||
|
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||||
|
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||||
|
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||||
|
* renderer — must open its own.
|
||||||
|
*
|
||||||
|
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||||
|
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||||
|
*
|
||||||
|
* The caller owns the returned connection and must close it.
|
||||||
|
*/
|
||||||
|
fun openAuxConnection(): UsbDeviceConnection? {
|
||||||
|
val dev = device ?: return null
|
||||||
|
return usb.openDevice(dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||||
|
*
|
||||||
|
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||||
|
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||||
|
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||||
|
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||||
|
* HID claim untouched.
|
||||||
|
*
|
||||||
|
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||||
|
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||||
|
*/
|
||||||
|
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||||
* obtained USB permission. Returns false when nothing could be claimed.
|
* obtained USB permission. Returns false when nothing could be claimed.
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
|||||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||||
deviceName: String?,
|
deviceName: String?,
|
||||||
|
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||||
|
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||||
|
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||||
|
padAudioOk: Boolean,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||||
@@ -332,6 +336,46 @@ object NativeBridge {
|
|||||||
*/
|
*/
|
||||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||||
|
* 4-channel USB audio device.
|
||||||
|
*
|
||||||
|
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||||
|
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||||
|
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||||
|
* connection open until [nativeStopPadAudio] returns.
|
||||||
|
*
|
||||||
|
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||||
|
*
|
||||||
|
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||||
|
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||||
|
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||||
|
*/
|
||||||
|
external fun nativeStartPadAudio(
|
||||||
|
handle: Long,
|
||||||
|
pad: Int,
|
||||||
|
fd: Int,
|
||||||
|
haptics: Boolean,
|
||||||
|
speaker: Boolean,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||||
|
*
|
||||||
|
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||||
|
* as this returns, and not before.
|
||||||
|
*/
|
||||||
|
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||||
|
*
|
||||||
|
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||||
|
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||||
|
* the main thread. Returns sample frames written, or negative on failure.
|
||||||
|
*/
|
||||||
|
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
|||||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
|
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||||
|
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||||
|
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||||
|
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||||
|
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||||
|
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||||
|
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||||
|
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|||||||
// handle.
|
// handle.
|
||||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||||
|
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||||
|
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||||
|
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||||
|
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||||
|
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||||
|
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||||
|
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||||
|
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||||
}
|
}
|
||||||
@@ -174,6 +182,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
out[3..n].copy_from_slice(&data);
|
out[3..n].copy_from_slice(&data);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
|
HidOutput::AudioCtl { .. } => {
|
||||||
|
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||||
|
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
n as jint
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ mod discovery;
|
|||||||
mod feedback;
|
mod feedback;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod mic;
|
mod mic;
|
||||||
|
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||||
|
mod pad_audio;
|
||||||
mod session;
|
mod session;
|
||||||
mod stats;
|
mod stats;
|
||||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
timeout_ms: jint,
|
timeout_ms: jint,
|
||||||
launch: JString<'local>,
|
launch: JString<'local>,
|
||||||
device_name: JString<'local>,
|
device_name: JString<'local>,
|
||||||
|
pad_audio_ok: jboolean,
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||||
// should say what the client does).
|
// should say what the client does).
|
||||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||||
|
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||||
|
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||||
|
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||||
|
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||||
|
| if pad_audio_ok != 0 {
|
||||||
|
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
audio: Mutex::new(None),
|
audio: Mutex::new(None),
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mic: Mutex::new(None),
|
mic: Mutex::new(None),
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pad_audio: Mutex::new(None),
|
||||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
|||||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||||
|
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||||
|
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||||
|
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
|||||||
fn stop_mic(&self) {
|
fn stop_mic(&self) {
|
||||||
let _ = self.mic.lock().unwrap().take();
|
let _ = self.mic.lock().unwrap().take();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||||
|
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||||
|
/// `UsbDeviceConnection`. Idempotent.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(crate) fn stop_pad_audio(&self) {
|
||||||
|
let _ = self.pad_audio.lock().unwrap().take();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for SessionHandle {
|
impl Drop for SessionHandle {
|
||||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
|||||||
self.stop_audio();
|
self.stop_audio();
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
self.stop_mic();
|
self.stop_mic();
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
self.stop_pad_audio();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||||
|
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||||
|
///
|
||||||
|
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||||
|
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||||
|
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||||
|
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||||
|
///
|
||||||
|
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||||
|
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||||
|
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||||
|
/// app-side fix worth blocking a session on.
|
||||||
|
#[no_mangle]
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pad: jni::sys::jint,
|
||||||
|
fd: jni::sys::jint,
|
||||||
|
haptics: jboolean,
|
||||||
|
speaker: jboolean,
|
||||||
|
) -> jboolean {
|
||||||
|
jni_guard(0, || {
|
||||||
|
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||||
|
// can never hold the same descriptor at once.
|
||||||
|
h.stop_pad_audio();
|
||||||
|
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||||
|
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||||
|
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||||
|
// rumble and give it nothing in return — no haptics of any kind.
|
||||||
|
match crate::pad_audio::start(
|
||||||
|
std::sync::Arc::clone(&h.client),
|
||||||
|
pad as u8,
|
||||||
|
fd,
|
||||||
|
haptics != 0,
|
||||||
|
speaker != 0,
|
||||||
|
) {
|
||||||
|
Some(p) => {
|
||||||
|
*h.pad_audio.lock().unwrap() = Some(p);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||||
|
/// tone through the real client render path, with no host and no session involved.
|
||||||
|
///
|
||||||
|
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||||
|
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||||
|
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||||
|
#[no_mangle]
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
fd: jni::sys::jint,
|
||||||
|
seconds: jni::sys::jint,
|
||||||
|
hz: jni::sys::jint,
|
||||||
|
) -> jni::sys::jint {
|
||||||
|
jni_guard(-1, || {
|
||||||
|
if fd < 0 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||||
|
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||||
|
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||||
|
///
|
||||||
|
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||||
|
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||||
|
#[no_mangle]
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
handle: jlong,
|
||||||
|
pad: jni::sys::jint,
|
||||||
|
) {
|
||||||
|
jni_guard((), || {
|
||||||
|
if handle != 0 {
|
||||||
|
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||||
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||||
|
h.stop_pad_audio();
|
||||||
|
if (0..16).contains(&pad) {
|
||||||
|
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||||
|
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||||
|
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||||
|
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||||
|
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||||
///
|
///
|
||||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||||
|
|||||||
@@ -286,6 +286,12 @@ mod session_main {
|
|||||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
crate::ctl_socket::spawn(gamepad.clone());
|
crate::ctl_socket::spawn(gamepad.clone());
|
||||||
|
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||||
|
// slots declare their render caps at open time, which happens on attach — after this.
|
||||||
|
gamepad.set_pad_audio_prefs(
|
||||||
|
settings.pad_haptics,
|
||||||
|
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||||
|
);
|
||||||
let mode = Mode {
|
let mode = Mode {
|
||||||
width: if settings.width == 0 {
|
width: if settings.width == 0 {
|
||||||
native.width
|
native.width
|
||||||
@@ -389,6 +395,11 @@ mod session_main {
|
|||||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||||
mic_enabled: settings.mic_enabled,
|
mic_enabled: settings.mic_enabled,
|
||||||
echo_cancel: settings.echo_cancel,
|
echo_cancel: settings.echo_cancel,
|
||||||
|
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||||
|
// service learns the same prefs below so tier-A slots declare their render caps
|
||||||
|
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||||
|
pad_haptics: settings.pad_haptics,
|
||||||
|
pad_speaker: settings.pad_speaker.clone(),
|
||||||
clipboard,
|
clipboard,
|
||||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
|
|||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
wasapi = "0.23"
|
wasapi = "0.23"
|
||||||
|
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
|
||||||
|
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
|
||||||
|
# property stores entirely (the same version the host pins).
|
||||||
|
winreg = "0.56"
|
||||||
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
||||||
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
||||||
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
||||||
|
|||||||
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
|||||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||||
|
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||||
|
///
|
||||||
|
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||||
|
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||||
|
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||||
|
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||||
|
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||||
|
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||||
|
pub(crate) fn device_by_id(
|
||||||
|
enumerator: &DeviceEnumerator,
|
||||||
|
direction: &Direction,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<wasapi::Device> {
|
||||||
|
let devices = enumerator
|
||||||
|
.get_device_collection(direction)
|
||||||
|
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
|
||||||
|
let count = devices
|
||||||
|
.get_nbr_devices()
|
||||||
|
.map_err(|e| anyhow!("endpoint count: {e}"))?;
|
||||||
|
for i in 0..count {
|
||||||
|
let dev = devices
|
||||||
|
.get_device_at_index(i)
|
||||||
|
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
|
||||||
|
if dev.get_id().is_ok_and(|got| got == id) {
|
||||||
|
return Ok(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::bail!("no active {direction:?} endpoint with id {id}")
|
||||||
|
}
|
||||||
|
|
||||||
fn pick_device(
|
fn pick_device(
|
||||||
enumerator: &DeviceEnumerator,
|
enumerator: &DeviceEnumerator,
|
||||||
direction: &Direction,
|
direction: &Direction,
|
||||||
var: &str,
|
var: &str,
|
||||||
) -> Result<wasapi::Device> {
|
) -> Result<wasapi::Device> {
|
||||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||||
match enumerator.get_device(&id) {
|
match device_by_id(enumerator, direction, &id) {
|
||||||
Ok(d) => {
|
Ok(d) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
var,
|
var,
|
||||||
|
|||||||
@@ -369,8 +369,14 @@ enum Ctl {
|
|||||||
Pin(Option<String>),
|
Pin(Option<String>),
|
||||||
KindOverride(GamepadPref),
|
KindOverride(GamepadPref),
|
||||||
Forwarding(bool),
|
Forwarding(bool),
|
||||||
SystemButtons { forward_raw: bool, gesture: bool },
|
SystemButtons {
|
||||||
|
forward_raw: bool,
|
||||||
|
gesture: bool,
|
||||||
|
},
|
||||||
TapButton(u32),
|
TapButton(u32),
|
||||||
|
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||||
|
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
|
||||||
|
PadAudioPrefs(u8),
|
||||||
MenuMode(bool),
|
MenuMode(bool),
|
||||||
MenuRumble(MenuPulse),
|
MenuRumble(MenuPulse),
|
||||||
}
|
}
|
||||||
@@ -573,6 +579,18 @@ impl GamepadService {
|
|||||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
|
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
|
||||||
|
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
|
||||||
|
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
|
||||||
|
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
|
||||||
|
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
|
||||||
|
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
|
||||||
|
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
|
||||||
|
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
|
||||||
|
let bits = (haptics as u8) | ((speaker as u8) << 1);
|
||||||
|
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||||
}
|
}
|
||||||
@@ -746,6 +764,8 @@ impl Ds5Feedback {
|
|||||||
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
|
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
|
||||||
/// values so the subtraction is visible at the point of definition.
|
/// values so the subtraction is visible at the point of definition.
|
||||||
const REPORT_ID_LEN: usize = 1;
|
const REPORT_ID_LEN: usize = 1;
|
||||||
|
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`): report byte 5.
|
||||||
|
const AUDIO: usize = 5 - Self::REPORT_ID_LEN;
|
||||||
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
|
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
|
||||||
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
|
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
|
||||||
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
|
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
|
||||||
@@ -782,6 +802,29 @@ impl Ds5Feedback {
|
|||||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
|
||||||
|
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
|
||||||
|
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
|
||||||
|
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
|
||||||
|
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
|
||||||
|
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
|
||||||
|
fn audio_haptics_packet() -> [u8; 47] {
|
||||||
|
[0u8; 47]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||||
|
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||||
|
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||||
|
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
|
||||||
|
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
|
||||||
|
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
|
||||||
|
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
|
||||||
|
let mut p = [0u8; 47];
|
||||||
|
p[0] = (flags & 0x1E) << 3;
|
||||||
|
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
|
||||||
|
p
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||||
@@ -818,6 +861,14 @@ struct Slot {
|
|||||||
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
||||||
/// `guide_gesture` policy is on.
|
/// `guide_gesture` policy is on.
|
||||||
gesture: SelectGesture,
|
gesture: SelectGesture,
|
||||||
|
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
|
||||||
|
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
|
||||||
|
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
|
||||||
|
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
|
||||||
|
/// disable-bit trap — see [`Worker::render_feedback`]).
|
||||||
|
audio_caps: u8,
|
||||||
|
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
|
||||||
|
rumble_suppressed_logged: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Slot {
|
impl Slot {
|
||||||
@@ -834,6 +885,8 @@ impl Slot {
|
|||||||
held_clicks: [false; 2],
|
held_clicks: [false; 2],
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
gesture: SelectGesture::default(),
|
gesture: SelectGesture::default(),
|
||||||
|
audio_caps: 0,
|
||||||
|
rumble_suppressed_logged: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,6 +1024,10 @@ struct Worker {
|
|||||||
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
|
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
|
||||||
/// down went out on receipt, the up goes out from the poll once `due` passes.
|
/// down went out on receipt, the up goes out from the poll once `due` passes.
|
||||||
synthetic_ups: Vec<(u8, u32, Instant)>,
|
synthetic_ups: Vec<(u8, u32, Instant)>,
|
||||||
|
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||||
|
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
|
||||||
|
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
|
||||||
|
pad_audio_prefs: u8,
|
||||||
attached: Option<Arc<NativeClient>>,
|
attached: Option<Arc<NativeClient>>,
|
||||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||||
escape_tx: async_channel::Sender<()>,
|
escape_tx: async_channel::Sender<()>,
|
||||||
@@ -1176,11 +1233,18 @@ impl Worker {
|
|||||||
Ok(pad) => {
|
Ok(pad) => {
|
||||||
let mut slot = Slot::new(id, index, pref, pad);
|
let mut slot = Slot::new(id, index, pref, pad);
|
||||||
Self::set_slot_sensors(&mut slot, true);
|
Self::set_slot_sensors(&mut slot, true);
|
||||||
|
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
|
||||||
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
||||||
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||||
// uses the session-default kind.
|
// uses the session-default kind.
|
||||||
if let Some(c) = &self.attached {
|
if let Some(c) = &self.attached {
|
||||||
|
// Pad-audio render caps go in FIRST — the core ORs them into this (and
|
||||||
|
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
|
||||||
|
// set (0 for non-tier-A): wire indices are reused within a connection, so
|
||||||
|
// a tier-A slot that closes must not leave its bits behind for the next
|
||||||
|
// pad on the same index (the set_rumble_quirks rule).
|
||||||
|
c.set_pad_audio_caps(index, slot.audio_caps);
|
||||||
send(
|
send(
|
||||||
c,
|
c,
|
||||||
InputKind::GamepadArrival,
|
InputKind::GamepadArrival,
|
||||||
@@ -1203,6 +1267,27 @@ impl Worker {
|
|||||||
};
|
};
|
||||||
c.set_rumble_quirks(index as u16, quirks);
|
c.set_rumble_quirks(index as u16, quirks);
|
||||||
}
|
}
|
||||||
|
if slot.audio_caps != 0 {
|
||||||
|
if slot.audio_caps & 0x01 != 0 {
|
||||||
|
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
|
||||||
|
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
|
||||||
|
// "disable audio haptics") whenever its rumble path runs — which
|
||||||
|
// would MUTE the voice coils the 0xD1 stream drives. One effects
|
||||||
|
// packet with those bits CLEARED puts the pad back on audio haptics
|
||||||
|
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||||
|
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||||
|
// render_feedback so SDL never re-arms them.
|
||||||
|
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||||
|
}
|
||||||
|
// Hand the pad to the session's renderer worker. Windows correlation
|
||||||
|
// needs the HID interface path; Linux matches the sink by signature.
|
||||||
|
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||||
|
tracing::info!(
|
||||||
|
index,
|
||||||
|
caps = slot.audio_caps,
|
||||||
|
"tier-A DualSense: pad-audio render caps declared"
|
||||||
|
);
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
id,
|
id,
|
||||||
index,
|
index,
|
||||||
@@ -1216,6 +1301,35 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
|
||||||
|
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
|
||||||
|
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
|
||||||
|
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
|
||||||
|
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
|
||||||
|
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
|
||||||
|
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
|
||||||
|
if self.pad_audio_prefs == 0 {
|
||||||
|
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
|
||||||
|
}
|
||||||
|
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||||
|
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
|
||||||
|
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
|
||||||
|
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
|
||||||
|
return 0; // not a DualSense/Edge — no wired check needed
|
||||||
|
}
|
||||||
|
use sdl3::joystick::ConnectionState;
|
||||||
|
let wired = match pad.connection_state() {
|
||||||
|
Ok(ConnectionState::Wired) => true,
|
||||||
|
Ok(ConnectionState::Wireless) => false,
|
||||||
|
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
|
||||||
|
};
|
||||||
|
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
|
||||||
|
self.pad_audio_prefs
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
||||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||||
/// already gone (unplug).
|
/// already gone (unplug).
|
||||||
@@ -1233,6 +1347,11 @@ impl Worker {
|
|||||||
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
||||||
}
|
}
|
||||||
let slot = self.slots.remove(i);
|
let slot = self.slots.remove(i);
|
||||||
|
if slot.audio_caps != 0 {
|
||||||
|
// Take the pad back from the pad-audio renderer (its device-gone path then
|
||||||
|
// re-correlates — and finds nothing until a tier-A pad registers again).
|
||||||
|
crate::pad_audio::unregister_tier_a(slot.index);
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
id = slot.id,
|
id = slot.id,
|
||||||
index = slot.index,
|
index = slot.index,
|
||||||
@@ -1654,6 +1773,7 @@ impl Worker {
|
|||||||
set_valve_hidapi(false);
|
set_valve_hidapi(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||||
Ok(Ctl::MenuMode(on)) => {
|
Ok(Ctl::MenuMode(on)) => {
|
||||||
self.menu_mode = on;
|
self.menu_mode = on;
|
||||||
if on {
|
if on {
|
||||||
@@ -1966,6 +2086,20 @@ impl Worker {
|
|||||||
// first; the physical silence backstop is in `close_slot_at`).
|
// first; the physical silence backstop is in `close_slot_at`).
|
||||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||||
|
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
|
||||||
|
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
|
||||||
|
// so a slot with tier-A haptics active never issues wire rumble (the stream
|
||||||
|
// carries the feedback; the game's rumble is in its haptics mix).
|
||||||
|
if slot.audio_caps & 0x01 != 0 {
|
||||||
|
if !slot.rumble_suppressed_logged {
|
||||||
|
slot.rumble_suppressed_logged = true;
|
||||||
|
tracing::info!(
|
||||||
|
pad = slot.index,
|
||||||
|
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2003,13 +2137,27 @@ impl Worker {
|
|||||||
.pad
|
.pad
|
||||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||||
}
|
}
|
||||||
|
// The audio-control region of a DS5 output report a game wrote host-side
|
||||||
|
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
|
||||||
|
// pad's effects packet, but only where a tier-A renderer is actually live
|
||||||
|
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
|
||||||
|
// nothing streams to would just mute/blast a future session's start state.
|
||||||
|
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
|
||||||
|
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
|
||||||
|
let _ = slot
|
||||||
|
.pad
|
||||||
|
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
|
||||||
|
}
|
||||||
// Deliberately unhandled, listed rather than left to a bare `_` so a new
|
// Deliberately unhandled, listed rather than left to a bare `_` so a new
|
||||||
// variant cannot join them silently: adaptive triggers exist only on a
|
// variant cannot join them silently: adaptive triggers exist only on a
|
||||||
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
|
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
|
||||||
// and carried by `send_effect` above when the pad is one.
|
// and carried by `send_effect` above when the pad is one. `AudioCtl` lands here
|
||||||
|
// only when the guarded arm above declined it — a non-DualSense pad, or one with
|
||||||
|
// no live tier-A renderer — which is the pre-pad-audio behaviour: drop it.
|
||||||
HidOutput::Trigger { .. }
|
HidOutput::Trigger { .. }
|
||||||
| HidOutput::TrackpadHaptic { .. }
|
| HidOutput::TrackpadHaptic { .. }
|
||||||
| HidOutput::HidRaw { .. } => {}
|
| HidOutput::HidRaw { .. }
|
||||||
|
| HidOutput::AudioCtl { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2048,6 +2196,9 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
|||||||
| HidOutput::Trigger { pad, .. }
|
| HidOutput::Trigger { pad, .. }
|
||||||
| HidOutput::TrackpadHaptic { pad, .. }
|
| HidOutput::TrackpadHaptic { pad, .. }
|
||||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||||
|
// AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or
|
||||||
|
// above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless.
|
||||||
|
HidOutput::AudioCtl { pad, .. } => *pad as u8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2075,6 +2226,7 @@ impl Worker {
|
|||||||
system_forward: true,
|
system_forward: true,
|
||||||
guide_gesture: false,
|
guide_gesture: false,
|
||||||
synthetic_ups: Vec::new(),
|
synthetic_ups: Vec::new(),
|
||||||
|
pad_audio_prefs: 0,
|
||||||
attached: None,
|
attached: None,
|
||||||
escape_tx,
|
escape_tx,
|
||||||
disconnect_tx,
|
disconnect_tx,
|
||||||
@@ -2520,6 +2672,44 @@ mod slot_tests {
|
|||||||
}),
|
}),
|
||||||
6
|
6
|
||||||
);
|
);
|
||||||
|
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
|
||||||
|
assert_eq!(
|
||||||
|
hidout_pad(&HidOutput::AudioCtl {
|
||||||
|
pad: 7,
|
||||||
|
flags: 0,
|
||||||
|
raw: [0; 6]
|
||||||
|
}),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
|
||||||
|
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
|
||||||
|
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
|
||||||
|
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
|
||||||
|
#[test]
|
||||||
|
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
|
||||||
|
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
|
||||||
|
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
|
||||||
|
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
|
||||||
|
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
|
||||||
|
// bits1..4 (0b1011) → flag0 bits 4..7.
|
||||||
|
assert_eq!(p[0], 0b1011_0000);
|
||||||
|
assert_eq!(
|
||||||
|
p[0] & 0x03,
|
||||||
|
0,
|
||||||
|
"haptics-select must NOT replay into p[0] bits 0/1"
|
||||||
|
);
|
||||||
|
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
|
||||||
|
assert!(p[1..4].iter().all(|&b| b == 0));
|
||||||
|
assert!(p[10..].iter().all(|&b| b == 0));
|
||||||
|
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
|
||||||
|
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
|
||||||
|
assert_eq!(p[0], 0);
|
||||||
|
assert_eq!(&p[4..10], &raw);
|
||||||
|
// The tier-A activation packet is the all-clear: every enable bit off — per
|
||||||
|
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
|
||||||
|
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ pub mod os;
|
|||||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||||
// the bindings live on.
|
// the bindings live on.
|
||||||
|
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
|
||||||
|
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
|
||||||
|
// the tier-A pad registry the gamepad worker feeds it through.
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
pub mod pad_audio;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,14 @@ pub struct SessionParams {
|
|||||||
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
||||||
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
||||||
pub echo_cancel: bool,
|
pub echo_cancel: bool,
|
||||||
|
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
|
||||||
|
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
|
||||||
|
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
|
||||||
|
pub pad_haptics: bool,
|
||||||
|
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
|
||||||
|
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
|
||||||
|
/// off — see [`crate::pad_audio::speaker_active`]).
|
||||||
|
pub pad_speaker: String,
|
||||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||||
pub clipboard: bool,
|
pub clipboard: bool,
|
||||||
@@ -356,6 +364,11 @@ fn pump(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
|
||||||
|
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
|
||||||
|
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
|
||||||
|
let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker);
|
||||||
|
let pad_audio_on = params.pad_haptics || pad_speaker_on;
|
||||||
let connector = match NativeClient::connect(
|
let connector = match NativeClient::connect(
|
||||||
¶ms.host,
|
¶ms.host,
|
||||||
params.port,
|
params.port,
|
||||||
@@ -379,6 +392,11 @@ fn pump(
|
|||||||
0
|
0
|
||||||
}) | (if params.phase_lock {
|
}) | (if params.phase_lock {
|
||||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
|
||||||
|
}) | (if pad_audio_on {
|
||||||
|
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}),
|
}),
|
||||||
@@ -501,6 +519,20 @@ fn pump(
|
|||||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||||
|
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||||
|
// the settings could render. The output device is opened LAZILY once frames actually
|
||||||
|
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||||
|
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||||
|
let pad_audio_thread = pad_audio_on
|
||||||
|
.then(|| {
|
||||||
|
crate::pad_audio::spawn(
|
||||||
|
connector.clone(),
|
||||||
|
stop.clone(),
|
||||||
|
params.pad_haptics,
|
||||||
|
pad_speaker_on,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||||
@@ -1066,6 +1098,9 @@ fn pump(
|
|||||||
if let Some(t) = audio_thread {
|
if let Some(t) = audio_thread {
|
||||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||||
}
|
}
|
||||||
|
if let Some(t) = pad_audio_thread {
|
||||||
|
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||||
|
}
|
||||||
if let Some(t) = clipboard_thread {
|
if let Some(t) = clipboard_thread {
|
||||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1024,6 +1024,21 @@ pub struct Settings {
|
|||||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mic_device: String,
|
pub mic_device: String,
|
||||||
|
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||||
|
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||||
|
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||||
|
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||||
|
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||||
|
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||||
|
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub pad_haptics: bool,
|
||||||
|
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||||
|
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||||
|
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||||
|
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||||
|
#[serde(default = "default_pad_speaker")]
|
||||||
|
pub pad_speaker: String,
|
||||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||||
@@ -1071,6 +1086,10 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_pad_speaker() -> String {
|
||||||
|
"pad".into()
|
||||||
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||||
@@ -1179,6 +1198,8 @@ impl Default for Settings {
|
|||||||
invert_scroll: false,
|
invert_scroll: false,
|
||||||
speaker_device: String::new(),
|
speaker_device: String::new(),
|
||||||
mic_device: String::new(),
|
mic_device: String::new(),
|
||||||
|
pad_haptics: true,
|
||||||
|
pad_speaker: "pad".into(),
|
||||||
match_window: false,
|
match_window: false,
|
||||||
last_window_w: 0,
|
last_window_w: 0,
|
||||||
last_window_h: 0,
|
last_window_h: 0,
|
||||||
|
|||||||
@@ -24,14 +24,20 @@ const RENEW_EVERY: Duration = Duration::from_millis(1000);
|
|||||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
|
||||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
|
||||||
|
/// fire).
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct HidoutDedup {
|
pub struct HidoutDedup {
|
||||||
led: Option<(u8, u8, u8)>,
|
led: Option<(u8, u8, u8)>,
|
||||||
player_leds: Option<u8>,
|
player_leds: Option<u8>,
|
||||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||||
trigger: [Option<Vec<u8>>; 2],
|
trigger: [Option<Vec<u8>>; 2],
|
||||||
|
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
|
||||||
|
audio_ctl: Option<(u8, [u8; 6])>,
|
||||||
|
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
|
||||||
|
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
|
||||||
|
haptics_select_logged: bool,
|
||||||
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
|
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
|
||||||
/// there is nothing to renew. See [`RENEW_EVERY`].
|
/// there is nothing to renew. See [`RENEW_EVERY`].
|
||||||
last_sent: Option<Instant>,
|
last_sent: Option<Instant>,
|
||||||
@@ -123,6 +129,25 @@ impl HidoutDedup {
|
|||||||
}
|
}
|
||||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||||
HidOutput::TrackpadHaptic { .. } => true,
|
HidOutput::TrackpadHaptic { .. } => true,
|
||||||
|
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||||
|
let v = Some((*flags, *raw));
|
||||||
|
if self.audio_ctl == v {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
|
||||||
|
// audio haptics (not plain rumble emulation, whose all-zero audio region
|
||||||
|
// never reaches here) — the trace that tells "the game does audio haptics"
|
||||||
|
// apart from "the client just doesn't render them".
|
||||||
|
if flags & 0x01 != 0 && !self.haptics_select_logged {
|
||||||
|
self.haptics_select_logged = true;
|
||||||
|
tracing::info!(
|
||||||
|
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.audio_ctl = v;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||||
@@ -302,4 +327,29 @@ mod tests {
|
|||||||
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
|
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
|
||||||
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
|
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
|
||||||
|
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
|
||||||
|
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
|
||||||
|
#[test]
|
||||||
|
fn audio_ctl_dedups_by_value() {
|
||||||
|
let mut d = HidoutDedup::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let audio = |flags, vol| HidOutput::AudioCtl {
|
||||||
|
pad: 0,
|
||||||
|
flags,
|
||||||
|
raw: [vol, 0, 0, 0, 0, 0],
|
||||||
|
};
|
||||||
|
// Identical twice → exactly one emission.
|
||||||
|
assert!(d.should_forward(&audio(0x17, 0x50), t));
|
||||||
|
assert!(!d.should_forward(&audio(0x17, 0x50), t));
|
||||||
|
// Either half changing (flags, or the raw region) forwards again.
|
||||||
|
assert!(d.should_forward(&audio(0x16, 0x50), t));
|
||||||
|
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||||
|
// The other kinds' state is untouched by audio traffic.
|
||||||
|
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t));
|
||||||
|
// `clear` (pad re-plug) re-arms the value dedup.
|
||||||
|
d.clear();
|
||||||
|
assert!(d.should_forward(&audio(0x16, 0x60), t));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ pub mod out_report {
|
|||||||
|
|
||||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
|
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
|
||||||
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
|
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
|
||||||
/// surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
/// surfaced — adaptive-trigger blocks and the audio-control region are forwarded raw for the client.
|
||||||
///
|
///
|
||||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||||
@@ -592,6 +592,21 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
|
||||||
|
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
|
||||||
|
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
|
||||||
|
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
|
||||||
|
// whenever an audio-valid flag is present or the region carries data; downstream dedup
|
||||||
|
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
|
||||||
|
let raw: [u8; 6] = data[5..11].try_into().unwrap();
|
||||||
|
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
|
||||||
|
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
|
||||||
|
fb.hidout.push(HidOutput::AudioCtl {
|
||||||
|
pad: pad.into(),
|
||||||
|
flags,
|
||||||
|
raw,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -917,6 +932,48 @@ mod tests {
|
|||||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
|
||||||
|
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
|
||||||
|
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
|
||||||
|
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
|
||||||
|
#[test]
|
||||||
|
fn parse_output_surfaces_audio_ctl() {
|
||||||
|
let mut data = vec![0u8; 48];
|
||||||
|
data[0] = 0x02;
|
||||||
|
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
|
||||||
|
data[5] = 0x50; // headphone volume
|
||||||
|
data[6] = 0x60; // speaker volume
|
||||||
|
data[7] = 0x70; // mic volume
|
||||||
|
data[8] = 0x05; // audio routing / enable bits
|
||||||
|
let mut fb = DsFeedback::default();
|
||||||
|
parse_ds_output(3, &data, &mut fb);
|
||||||
|
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
|
||||||
|
assert_eq!(
|
||||||
|
fb.hidout,
|
||||||
|
vec![HidOutput::AudioCtl {
|
||||||
|
pad: 3,
|
||||||
|
flags: 0b1_0111,
|
||||||
|
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
|
||||||
|
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
|
||||||
|
// the honest bytes either way.
|
||||||
|
let mut data = vec![0u8; 48];
|
||||||
|
data[0] = 0x02;
|
||||||
|
data[9] = 0x01;
|
||||||
|
let mut fb = DsFeedback::default();
|
||||||
|
parse_ds_output(0, &data, &mut fb);
|
||||||
|
assert_eq!(
|
||||||
|
fb.hidout,
|
||||||
|
vec![HidOutput::AudioCtl {
|
||||||
|
pad: 0,
|
||||||
|
flags: 0,
|
||||||
|
raw: [0, 0, 0, 0, 0x01, 0],
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A short / wrong-id report yields nothing.
|
/// A short / wrong-id report yields nothing.
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_output_rejects_garbage() {
|
fn parse_output_rejects_garbage() {
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ mod tests {
|
|||||||
index: 2,
|
index: 2,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
capabilities: 0,
|
capabilities: 0,
|
||||||
|
audio_caps: 0,
|
||||||
});
|
});
|
||||||
assert!(m.slots.get(2).is_some());
|
assert!(m.slots.get(2).is_some());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
|||||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||||
|
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||||
|
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||||
|
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||||
|
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||||
|
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||||
|
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
|
||||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||||
|
|
||||||
@@ -762,6 +768,17 @@ impl PunktfunkHidOutput {
|
|||||||
out.effect_len = 6;
|
out.effect_len = 6;
|
||||||
}
|
}
|
||||||
HidOutput::HidRaw { .. } => return None,
|
HidOutput::HidRaw { .. } => return None,
|
||||||
|
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||||
|
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
|
||||||
|
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
|
||||||
|
// because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) —
|
||||||
|
// it is enforced there, not merely assumed here.
|
||||||
|
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
|
||||||
|
out.pad = *pad as u8;
|
||||||
|
out.which = *flags;
|
||||||
|
out.effect[0..6].copy_from_slice(raw);
|
||||||
|
out.effect_len = 6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
@@ -1175,6 +1192,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
|||||||
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
|
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
|
||||||
/// design/pen-tablet-input.md.)
|
/// design/pen-tablet-input.md.)
|
||||||
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
|
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
|
||||||
|
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||||
|
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||||
|
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||||
|
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||||
|
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x40;
|
||||||
|
|
||||||
|
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||||
|
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||||
|
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||||
|
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||||
|
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||||
|
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||||
|
|
||||||
|
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||||
|
/// stream (a real DualSense's voice coils).
|
||||||
|
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
|
||||||
|
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||||
|
/// stream.
|
||||||
|
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
|
||||||
|
|
||||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||||
#[cfg(feature = "quic")]
|
#[cfg(feature = "quic")]
|
||||||
@@ -1189,6 +1225,20 @@ const _: () = {
|
|||||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||||
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
|
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
|
||||||
|
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
|
||||||
|
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
|
||||||
|
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
|
||||||
|
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
|
||||||
|
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
|
||||||
|
// `input::encode_gamepad_arrival` applies).
|
||||||
|
assert!(
|
||||||
|
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
|
||||||
|
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
|
||||||
|
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||||
|
);
|
||||||
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
|
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
|
||||||
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
|
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
|
||||||
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
|
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
|
||||||
@@ -1771,6 +1821,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
|
|||||||
/// forward-compatible.
|
/// forward-compatible.
|
||||||
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||||
|
|
||||||
|
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||||
|
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||||
|
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||||
|
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||||
|
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||||
|
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||||
|
|
||||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
||||||
@@ -2315,6 +2372,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||||
|
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||||
|
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||||
|
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||||
|
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||||
|
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||||
|
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||||
|
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||||
|
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||||
|
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||||
|
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||||
|
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||||
|
/// thread (one puller, may run alongside the other planes' pullers).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||||
|
/// `buf` is writable for `buf_len` bytes.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
out_pad: *mut u8,
|
||||||
|
out_kind: *mut u8,
|
||||||
|
out_seq: *mut u32,
|
||||||
|
out_pts_ns: *mut u64,
|
||||||
|
buf: *mut u8,
|
||||||
|
buf_len: usize,
|
||||||
|
timeout_ms: u32,
|
||||||
|
) -> i32 {
|
||||||
|
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||||
|
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||||
|
// here handles.
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return -1,
|
||||||
|
};
|
||||||
|
if buf.is_null() && buf_len != 0 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
match c
|
||||||
|
.inner
|
||||||
|
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||||
|
{
|
||||||
|
Some(f) => {
|
||||||
|
if f.opus.is_empty() || f.opus.len() > buf_len {
|
||||||
|
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
|
||||||
|
// as loss would synthesize concealment) or doesn't fit — report "nothing
|
||||||
|
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
|
||||||
|
// be undecodable anyway).
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
|
||||||
|
// checked before it is written; `buf` is a caller-owned writable region of
|
||||||
|
// `buf_len` bytes and the copy length was just bounds-checked against it.
|
||||||
|
unsafe {
|
||||||
|
if !out_pad.is_null() {
|
||||||
|
*out_pad = f.pad;
|
||||||
|
}
|
||||||
|
if !out_kind.is_null() {
|
||||||
|
*out_kind = f.kind;
|
||||||
|
}
|
||||||
|
if !out_seq.is_null() {
|
||||||
|
*out_seq = f.seq;
|
||||||
|
}
|
||||||
|
if !out_pts_ns.is_null() {
|
||||||
|
*out_pts_ns = f.pts_ns;
|
||||||
|
}
|
||||||
|
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
|
||||||
|
}
|
||||||
|
f.opus.len() as i32
|
||||||
|
}
|
||||||
|
// `None` folds timeout and closed; the shutdown flag tells them apart so the
|
||||||
|
// embedder's plane loop can exit instead of polling a dead session forever.
|
||||||
|
None if c.inner.is_session_ended() => -1,
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
r.unwrap_or(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||||
|
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||||
|
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||||
|
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||||
|
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||||
|
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||||
|
/// before. Latest-wins per pad; unknown bits are masked off.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle. Callable from any thread.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
pad: u8,
|
||||||
|
audio_caps: u8,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||||
|
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||||
|
// here handles.
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
c.inner.set_pad_audio_caps(pad, audio_caps);
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||||
@@ -4417,3 +4585,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
|||||||
PunktfunkStatus::Ok
|
PunktfunkStatus::Ok
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "quic"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||||
|
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||||
|
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||||
|
#[test]
|
||||||
|
fn hidout_abi_maps_audio_ctl() {
|
||||||
|
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
|
||||||
|
pad: 3,
|
||||||
|
flags: 0x17,
|
||||||
|
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
|
||||||
|
assert_eq!(out.pad, 3);
|
||||||
|
assert_eq!(out.which, 0x17);
|
||||||
|
assert_eq!(out.effect_len, 6);
|
||||||
|
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
|
||||||
|
assert_eq!(out.effect[6..], [0; 5]);
|
||||||
|
// A raw passthrough report still has no C representation (skipped at the pull site).
|
||||||
|
assert!(
|
||||||
|
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
|
||||||
|
pad: 0,
|
||||||
|
kind: 0,
|
||||||
|
data: vec![0x80],
|
||||||
|
})
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
|
|||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use crate::input::InputEvent;
|
use crate::input::InputEvent;
|
||||||
use crate::quic::{
|
use crate::quic::{
|
||||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
|
||||||
RfiRequest, RichInput,
|
ProbeRequest, RfiRequest, RichInput,
|
||||||
};
|
};
|
||||||
use crate::session::Frame;
|
use crate::session::Frame;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{
|
||||||
|
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
|
||||||
|
};
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
|
|||||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||||
use self::planes::{
|
use self::planes::{
|
||||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
|
||||||
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
|
||||||
};
|
};
|
||||||
use self::probe::ProbeState;
|
use self::probe::ProbeState;
|
||||||
use self::pump::run_pump;
|
use self::pump::run_pump;
|
||||||
@@ -122,6 +124,14 @@ pub struct NativeClient {
|
|||||||
rumble_sched: Arc<rumble::RumbleShared>,
|
rumble_sched: Arc<rumble::RumbleShared>,
|
||||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||||
hidout: Mutex<Receiver<HidOutput>>,
|
hidout: Mutex<Receiver<HidOutput>>,
|
||||||
|
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
|
||||||
|
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
|
||||||
|
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
|
||||||
|
pad_audio: Mutex<Receiver<PadAudioFrame>>,
|
||||||
|
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||||
|
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
|
||||||
|
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||||
|
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||||
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
|
||||||
@@ -418,6 +428,10 @@ impl NativeClient {
|
|||||||
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||||
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||||
|
let (pad_audio_tx, pad_audio_rx) =
|
||||||
|
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
|
||||||
|
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
|
||||||
|
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
|
||||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||||
let (host_timing_tx, host_timing_rx) =
|
let (host_timing_tx, host_timing_rx) =
|
||||||
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
||||||
@@ -459,6 +473,7 @@ impl NativeClient {
|
|||||||
let clock_offset_w = clock_offset.clone();
|
let clock_offset_w = clock_offset.clone();
|
||||||
let decode_lat_w = decode_lat.clone();
|
let decode_lat_w = decode_lat.clone();
|
||||||
let live_bitrate_w = live_bitrate.clone();
|
let live_bitrate_w = live_bitrate.clone();
|
||||||
|
let pad_audio_caps_w = pad_audio_caps.clone();
|
||||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||||
let worker = std::thread::Builder::new()
|
let worker = std::thread::Builder::new()
|
||||||
.name("punktfunk-client".into())
|
.name("punktfunk-client".into())
|
||||||
@@ -508,6 +523,8 @@ impl NativeClient {
|
|||||||
rumble_tx,
|
rumble_tx,
|
||||||
rumble_feed,
|
rumble_feed,
|
||||||
hidout_tx,
|
hidout_tx,
|
||||||
|
pad_audio_tx,
|
||||||
|
pad_audio_caps: pad_audio_caps_w,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
cursor_shape_tx,
|
cursor_shape_tx,
|
||||||
@@ -556,6 +573,8 @@ impl NativeClient {
|
|||||||
rumble: Mutex::new(rumble_rx),
|
rumble: Mutex::new(rumble_rx),
|
||||||
rumble_sched,
|
rumble_sched,
|
||||||
hidout: Mutex::new(hidout_rx),
|
hidout: Mutex::new(hidout_rx),
|
||||||
|
pad_audio: Mutex::new(pad_audio_rx),
|
||||||
|
pad_audio_caps,
|
||||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||||
host_timing: Mutex::new(host_timing_rx),
|
host_timing: Mutex::new(host_timing_rx),
|
||||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
cursor_shape: Mutex::new(cursor_shape_rx),
|
||||||
@@ -1061,6 +1080,33 @@ impl NativeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
|
||||||
|
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||||
|
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
|
||||||
|
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
|
||||||
|
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
|
||||||
|
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
|
||||||
|
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
|
||||||
|
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
|
||||||
|
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
|
||||||
|
/// puller per the plane contract.
|
||||||
|
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
|
||||||
|
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
|
||||||
|
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
|
||||||
|
/// Call at controller attach, BEFORE the pad's arrival is sent (like
|
||||||
|
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
|
||||||
|
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
|
||||||
|
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
|
||||||
|
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
|
||||||
|
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
|
||||||
|
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
|
||||||
|
slot.store(audio_caps & 0x03, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
|
|||||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||||
pub(crate) const HIDOUT_QUEUE: usize = 32;
|
pub(crate) const HIDOUT_QUEUE: usize = 32;
|
||||||
|
|
||||||
|
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
|
||||||
|
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
|
||||||
|
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
|
||||||
|
/// newest frame (the renderer conceals the gap).
|
||||||
|
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
|
||||||
|
|
||||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||||
pub(crate) const HDR_META_QUEUE: usize = 8;
|
pub(crate) const HDR_META_QUEUE: usize = 8;
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
rumble_tx,
|
rumble_tx,
|
||||||
rumble_feed,
|
rumble_feed,
|
||||||
hidout_tx,
|
hidout_tx,
|
||||||
|
pad_audio_tx,
|
||||||
|
pad_audio_caps,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
cursor_shape_tx,
|
cursor_shape_tx,
|
||||||
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
|
|
||||||
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
|
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
|
||||||
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
|
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
|
||||||
// (see [`input_task`]).
|
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
|
||||||
|
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
|
||||||
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
|
||||||
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
|
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||||
|
tokio::spawn(input_task::run(
|
||||||
|
conn.clone(),
|
||||||
|
input_rx,
|
||||||
|
gamepad_snapshots,
|
||||||
|
pad_audio_arrivals,
|
||||||
|
pad_audio_caps,
|
||||||
|
));
|
||||||
|
|
||||||
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
|
||||||
// Self-healing latency bound: every frame still queued once this task catches up is standing
|
// Self-healing latency bound: every frame still queued once this task catches up is standing
|
||||||
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
rumble_tx,
|
rumble_tx,
|
||||||
rumble_feed,
|
rumble_feed,
|
||||||
hidout_tx,
|
hidout_tx,
|
||||||
|
pad_audio_tx,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
encode_lat.clone(),
|
encode_lat.clone(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub(super) async fn run(
|
|||||||
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
|
||||||
rumble_feed: super::super::rumble::RumbleFeed,
|
rumble_feed: super::super::rumble::RumbleFeed,
|
||||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||||
|
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
|
||||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||||
@@ -100,6 +101,11 @@ pub(super) async fn run(
|
|||||||
let _ = hidout_tx.try_send(h);
|
let _ = hidout_tx.try_send(h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
|
||||||
|
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
|
||||||
|
let _ = pad_audio_tx.try_send(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||||
let _ = hdr_meta_tx.try_send(m);
|
let _ = hdr_meta_tx.try_send(m);
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ pub(super) async fn run(
|
|||||||
conn: quinn::Connection,
|
conn: quinn::Connection,
|
||||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||||
gamepad_snapshots: bool,
|
gamepad_snapshots: bool,
|
||||||
|
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
|
||||||
|
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
|
||||||
|
// so unexpected high bits would make it drop the kind declaration entirely.
|
||||||
|
pad_audio: bool,
|
||||||
|
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
|
||||||
|
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
|
||||||
|
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
|
||||||
) {
|
) {
|
||||||
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||||
@@ -37,6 +45,28 @@ pub(super) async fn run(
|
|||||||
const ARRIVAL_RESENDS: u8 = 2;
|
const ARRIVAL_RESENDS: u8 = 2;
|
||||||
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||||
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||||
|
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
|
||||||
|
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
|
||||||
|
// byte-identical to the plain index — the pre-pad-audio wire.
|
||||||
|
// B7: the caps a pad's LAST arrival actually carried. `set_pad_audio_caps` only stores into
|
||||||
|
// the registry — it cannot reach this task — so a declaration that lands after the arrival
|
||||||
|
// burst has drained (the renderer commits the trade only once its sink opens, which is well
|
||||||
|
// past the two 100 ms ticks) used to never reach the host at all: the client believed it had
|
||||||
|
// pad audio and the host emitted nothing on 0xD1, silently, forever. Comparing this against
|
||||||
|
// the live registry on every tick re-arms the burst by itself, with no new plumbing and no
|
||||||
|
// extra traffic when nothing changed.
|
||||||
|
let mut arrival_caps_sent: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||||
|
let caps_now = |idx: usize| -> u8 {
|
||||||
|
if pad_audio {
|
||||||
|
pad_audio_caps[idx].load(Ordering::Relaxed)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let arrival_flags = |idx: usize| -> u32 {
|
||||||
|
let caps = caps_now(idx);
|
||||||
|
crate::input::encode_gamepad_arrival(idx as u8, caps)
|
||||||
|
};
|
||||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
loop {
|
loop {
|
||||||
@@ -81,30 +111,56 @@ pub(super) async fn run(
|
|||||||
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
let _ = conn.send_datagram(rem.encode().to_vec().into());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
|
||||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
|
||||||
// so the host learns it before the pad's first frame even under loss.
|
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
|
||||||
arrival[idx] = Some(ev.code as u8);
|
// the usual source). Fold event-carried bits into the registry so the re-send
|
||||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
// burst keeps them, then send with the negotiation-gated flags word.
|
||||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
|
||||||
continue;
|
let idx = pad as usize;
|
||||||
|
if idx < MAX_PADS {
|
||||||
|
if ev_caps != 0 {
|
||||||
|
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
// Remember the declared kind (`code`) and forward it, arming a re-send
|
||||||
|
// burst so the host learns it before the pad's first frame even under loss.
|
||||||
|
arrival[idx] = Some(ev.code as u8);
|
||||||
|
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||||
|
arrival_caps_sent[idx] = caps_now(idx);
|
||||||
|
let arr = crate::input::InputEvent {
|
||||||
|
flags: arrival_flags(idx),
|
||||||
|
..ev
|
||||||
|
};
|
||||||
|
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
let _ = conn.send_datagram(ev.encode().to_vec().into());
|
||||||
}
|
}
|
||||||
_ = refresh.tick() => {
|
_ = refresh.tick() => {
|
||||||
for idx in 0..MAX_PADS {
|
for idx in 0..MAX_PADS {
|
||||||
|
// B7: caps declared after the burst drained — re-announce this pad's arrival.
|
||||||
|
// Only for a pad that HAS an arrival (so it is a live, declared controller),
|
||||||
|
// and only when the value actually moved, so a steady session sends nothing.
|
||||||
|
if arrival[idx].is_some()
|
||||||
|
&& arrival_owed[idx] == 0
|
||||||
|
&& caps_now(idx) != arrival_caps_sent[idx]
|
||||||
|
{
|
||||||
|
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||||
|
}
|
||||||
// Re-send an owed kind declaration (independent of whether the pad has state
|
// Re-send an owed kind declaration (independent of whether the pad has state
|
||||||
// yet — it may be idle-but-connected). Idempotent on the host.
|
// yet — it may be idle-but-connected). Idempotent on the host.
|
||||||
if arrival_owed[idx] > 0 {
|
if arrival_owed[idx] > 0 {
|
||||||
if let Some(kind) = arrival[idx] {
|
if let Some(kind) = arrival[idx] {
|
||||||
arrival_owed[idx] -= 1;
|
arrival_owed[idx] -= 1;
|
||||||
|
arrival_caps_sent[idx] = caps_now(idx);
|
||||||
let arr = crate::input::InputEvent {
|
let arr = crate::input::InputEvent {
|
||||||
kind: InputKind::GamepadArrival,
|
kind: InputKind::GamepadArrival,
|
||||||
_pad: [0; 3],
|
_pad: [0; 3],
|
||||||
code: kind as u32,
|
code: kind as u32,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
flags: idx as u32,
|
flags: arrival_flags(idx),
|
||||||
};
|
};
|
||||||
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
let _ = conn.send_datagram(arr.encode().to_vec().into());
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
|
|||||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::input::InputEvent;
|
use crate::input::InputEvent;
|
||||||
use crate::quic::{HdrMeta, HidOutput};
|
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
|
||||||
use std::sync::mpsc::SyncSender;
|
use std::sync::mpsc::SyncSender;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
|
|||||||
/// closed, so the command API always observes connection teardown.
|
/// closed, so the command API always observes connection teardown.
|
||||||
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||||
|
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
|
||||||
|
/// [`NativeClient::next_pad_audio`].
|
||||||
|
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
|
||||||
|
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
|
||||||
|
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
|
||||||
|
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
|
||||||
|
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
|
||||||
|
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
|
||||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||||
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ pub enum InputKind {
|
|||||||
GamepadRemove = 13,
|
GamepadRemove = 13,
|
||||||
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||||
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||||
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||||
|
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||||
|
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||||
|
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||||
|
/// Sent when the client opens a pad slot — before that pad's
|
||||||
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||||
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||||
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||||
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
|
|||||||
(flags as u8, (flags >> 24) as u8)
|
(flags as u8, (flags >> 24) as u8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||||
|
/// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||||
|
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||||
|
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||||
|
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||||
|
/// it drop the declaration).
|
||||||
|
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
|
||||||
|
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||||
|
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||||
|
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||||
|
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
|
||||||
|
|
||||||
|
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
|
||||||
|
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
|
||||||
|
/// pre-pad-audio wire bytes exactly.
|
||||||
|
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
|
||||||
|
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
|
||||||
|
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
|
||||||
|
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
|
||||||
|
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
|
||||||
|
/// An old-format word (index only) yields `audio_caps = 0`.
|
||||||
|
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
|
||||||
|
(flags as u8, ((flags >> 8) & 0x03) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||||
///
|
///
|
||||||
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
||||||
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
|
|||||||
kind: u8,
|
kind: u8,
|
||||||
/// LI_CCAP_* bits (0x02 = rumble).
|
/// LI_CCAP_* bits (0x02 = rumble).
|
||||||
capabilities: u16,
|
capabilities: u16,
|
||||||
|
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
|
||||||
|
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
|
||||||
|
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
|
||||||
|
/// express pad audio and always sets `0`, as does an old client.
|
||||||
|
audio_caps: u8,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +480,31 @@ mod tests {
|
|||||||
assert_eq!((pad, seq), (9, 123));
|
assert_eq!((pad, seq), (9, 123));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_arrival_flags_roundtrip() {
|
||||||
|
// The capability bits ride bits 8/9; the index stays the low byte.
|
||||||
|
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
|
||||||
|
let flags = encode_gamepad_arrival(pad, caps);
|
||||||
|
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
|
||||||
|
assert_eq!(flags & 0xFF, pad as u32);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
encode_gamepad_arrival(2, 0b11),
|
||||||
|
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
|
||||||
|
);
|
||||||
|
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
|
||||||
|
// old host) is byte-identical to the plain index, and decodes with caps 0.
|
||||||
|
assert_eq!(encode_gamepad_arrival(5, 0), 5);
|
||||||
|
assert_eq!(decode_gamepad_arrival(5), (5, 0));
|
||||||
|
// Undefined high bits (a future extension) never leak into the index OR the caps.
|
||||||
|
assert_eq!(
|
||||||
|
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
|
||||||
|
(9, 1)
|
||||||
|
);
|
||||||
|
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
|
||||||
|
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gamepad_snapshot_roundtrip() {
|
fn gamepad_snapshot_roundtrip() {
|
||||||
let s = GamepadSnapshot {
|
let s = GamepadSnapshot {
|
||||||
|
|||||||
@@ -132,7 +132,13 @@ pub use stats::Stats;
|
|||||||
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||||
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||||
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||||
pub const ABI_VERSION: u32 = 15;
|
/// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||||
|
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||||
|
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||||
|
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||||
|
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||||
|
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||||
|
pub const ABI_VERSION: u32 = 16;
|
||||||
|
|
||||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
|||||||
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||||
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||||
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
|
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
|
||||||
|
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||||
|
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||||
|
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||||
|
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||||
|
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||||
|
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||||
|
/// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
|
||||||
|
/// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
|
||||||
|
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||||
|
|
||||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||||
@@ -154,6 +163,16 @@ pub const HOST_CAP_PEN: u8 = 0x10;
|
|||||||
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||||
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||||
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
|
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
|
||||||
|
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||||
|
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||||
|
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||||
|
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||||
|
/// capable client marks its pads' render capabilities on their arrivals
|
||||||
|
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||||
|
/// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
|
||||||
|
/// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
|
||||||
|
/// `0x01`/`0x02` are gamepad-state / clipboard.
|
||||||
|
pub const HOST_CAP_PAD_AUDIO: u8 = 0x40;
|
||||||
|
|
||||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
@@ -337,6 +356,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pad_audio_cap_bits_are_distinct() {
|
||||||
|
// The new pad-audio bits pack into the existing caps bytes without colliding with any
|
||||||
|
// taken bit (a collision would silently negotiate an unrelated feature).
|
||||||
|
assert_eq!(
|
||||||
|
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
HOST_CAP_PAD_AUDIO
|
||||||
|
& (HOST_CAP_GAMEPAD_STATE
|
||||||
|
| HOST_CAP_CLIPBOARD
|
||||||
|
| HOST_CAP_TEXT_INPUT
|
||||||
|
| HOST_CAP_CURSOR
|
||||||
|
| HOST_CAP_PEN),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
// Single-bit values (a multi-bit cap would OR neighbours in).
|
||||||
|
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
|
||||||
|
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
|
||||||
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xD1):
|
||||||
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
|
||||||
|
//! cursor state, pad audio.
|
||||||
|
|
||||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||||
/// (0xCE, host→client).
|
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||||
|
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||||
|
/// host→client).
|
||||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||||
@@ -416,6 +419,7 @@ const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
|||||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||||
const HIDOUT_HID_RAW: u8 = 0x05;
|
const HIDOUT_HID_RAW: u8 = 0x05;
|
||||||
|
const HIDOUT_AUDIO_CTL: u8 = 0x06;
|
||||||
|
|
||||||
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||||
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||||
@@ -464,6 +468,16 @@ pub enum HidOutput {
|
|||||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||||
|
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
|
||||||
|
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
|
||||||
|
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
|
||||||
|
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
|
||||||
|
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
|
||||||
|
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
|
||||||
|
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
|
||||||
|
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
|
||||||
|
/// state on every output report.
|
||||||
|
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidOutput {
|
impl HidOutput {
|
||||||
@@ -496,6 +510,12 @@ impl HidOutput {
|
|||||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||||
}
|
}
|
||||||
|
HidOutput::AudioCtl { pad, flags, raw } => {
|
||||||
|
out.push(HIDOUT_AUDIO_CTL);
|
||||||
|
out.extend_from_slice(&pad.to_le_bytes());
|
||||||
|
out.push(*flags);
|
||||||
|
out.extend_from_slice(raw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -540,6 +560,22 @@ impl HidOutput {
|
|||||||
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
||||||
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
||||||
}),
|
}),
|
||||||
|
// B27: the pad is the only u16 index on this plane, and every consumer narrows it
|
||||||
|
// with `as u8` on the stated assumption that pads are 0..MAX_PADS. Nothing enforced
|
||||||
|
// that, so wire pad 256 silently ALIASED onto slot 0 — a malformed or hostile
|
||||||
|
// datagram steering a real controller's speaker volumes. Rejected here, at the one
|
||||||
|
// place the u16 exists, so the narrowings downstream are lossless by construction
|
||||||
|
// (the same fix R10 applied to the rumble plane).
|
||||||
|
HIDOUT_AUDIO_CTL
|
||||||
|
if b.len() >= 11
|
||||||
|
&& u16::from_le_bytes([b[2], b[3]]) < crate::input::MAX_PADS as u16 =>
|
||||||
|
{
|
||||||
|
Some(HidOutput::AudioCtl {
|
||||||
|
pad: u16::from_le_bytes([b[2], b[3]]),
|
||||||
|
flags: b[4],
|
||||||
|
raw: b[5..11].try_into().unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -798,6 +834,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||||
|
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||||
|
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||||
|
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||||
|
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||||
|
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||||
|
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||||
|
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||||
|
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||||
|
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
|
||||||
|
|
||||||
|
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||||
|
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||||
|
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
|
||||||
|
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||||
|
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||||
|
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
|
||||||
|
|
||||||
|
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
|
||||||
|
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
|
||||||
|
|
||||||
|
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
|
||||||
|
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
|
||||||
|
/// against the main audio plane.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PadAudioFrame {
|
||||||
|
/// Gamepad index (the wire pad space, same as rumble/HID-output).
|
||||||
|
pub pad: u8,
|
||||||
|
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
|
||||||
|
pub kind: u8,
|
||||||
|
pub seq: u32,
|
||||||
|
pub pts_ns: u64,
|
||||||
|
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
|
||||||
|
pub opus: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pad-audio datagram, host → client:
|
||||||
|
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
|
||||||
|
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
|
||||||
|
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
|
||||||
|
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
|
||||||
|
b.push(PAD_AUDIO_MAGIC);
|
||||||
|
b.push(pad);
|
||||||
|
b.push(kind);
|
||||||
|
b.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||||
|
b.extend_from_slice(opus);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
|
||||||
|
/// length bounds every read before it happens).
|
||||||
|
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
|
||||||
|
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PadAudioFrame {
|
||||||
|
pad: buf[1],
|
||||||
|
kind: buf[2],
|
||||||
|
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
|
||||||
|
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
|
||||||
|
opus: buf[15..].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::quic::*;
|
use crate::quic::*;
|
||||||
@@ -1281,6 +1383,12 @@ mod tests {
|
|||||||
f
|
f
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// The DS5 audio-control region (haptics-select + speaker volume asserted).
|
||||||
|
HidOutput::AudioCtl {
|
||||||
|
pad: 1,
|
||||||
|
flags: 0b0_0101,
|
||||||
|
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for ev in &cases {
|
for ev in &cases {
|
||||||
let d = ev.encode();
|
let d = ev.encode();
|
||||||
@@ -1299,6 +1407,92 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_ctl_wire_layout_and_truncation() {
|
||||||
|
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
|
||||||
|
// The pad is deliberately a REPRESENTABLE one: this used to assert that 0x0201 (513)
|
||||||
|
// round-tripped, which pinned B27's aliasing in place as if it were the contract.
|
||||||
|
let a = HidOutput::AudioCtl {
|
||||||
|
pad: 0x000B,
|
||||||
|
flags: 0x17,
|
||||||
|
raw: [1, 2, 3, 4, 5, 6],
|
||||||
|
};
|
||||||
|
let d = a.encode();
|
||||||
|
assert_eq!(d, [0xCD, 0x06, 0x0B, 0x00, 0x17, 1, 2, 3, 4, 5, 6]);
|
||||||
|
assert_eq!(HidOutput::decode(&d), Some(a));
|
||||||
|
// Truncated buffers are rejected outright (fixed length — never a partial read).
|
||||||
|
for n in 2..d.len() {
|
||||||
|
assert_eq!(HidOutput::decode(&d[..n]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pad_audio_datagram_roundtrip_and_truncation() {
|
||||||
|
let opus = [0x5Au8; 61];
|
||||||
|
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
|
||||||
|
assert_eq!(d[0], PAD_AUDIO_MAGIC);
|
||||||
|
assert_eq!(d.len(), 15 + opus.len());
|
||||||
|
let f = decode_pad_audio_datagram(&d).unwrap();
|
||||||
|
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
|
||||||
|
assert_eq!(f.opus, opus);
|
||||||
|
// Truncated headers are rejected outright (never partially read).
|
||||||
|
for n in 0..15 {
|
||||||
|
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
|
||||||
|
}
|
||||||
|
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
|
||||||
|
assert!(decode_audio_datagram(&d).is_none());
|
||||||
|
assert!(decode_mic_datagram(&d).is_none());
|
||||||
|
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||||
|
// Empty payload (DTX) is legal — header-only datagram.
|
||||||
|
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
|
||||||
|
assert_eq!(hdr.len(), 15);
|
||||||
|
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B27: the pad is the only u16 index on the 0xCD plane and every consumer narrows it with
|
||||||
|
/// `as u8`. An out-of-range one used to alias onto a real slot instead of being refused —
|
||||||
|
/// wire pad 256 steering pad 0's speaker volumes.
|
||||||
|
#[test]
|
||||||
|
fn audio_ctl_rejects_a_pad_outside_the_index_space() {
|
||||||
|
let ok = HidOutput::AudioCtl {
|
||||||
|
pad: (crate::input::MAX_PADS - 1) as u16,
|
||||||
|
flags: 0x12,
|
||||||
|
raw: [1, 2, 3, 4, 5, 6],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
HidOutput::decode(&ok.encode()),
|
||||||
|
Some(ok),
|
||||||
|
"the last valid pad must still decode"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anything at or above MAX_PADS is refused outright, not truncated.
|
||||||
|
for pad in [crate::input::MAX_PADS as u16, 256, u16::MAX] {
|
||||||
|
let d = HidOutput::AudioCtl {
|
||||||
|
pad,
|
||||||
|
flags: 0x12,
|
||||||
|
raw: [1, 2, 3, 4, 5, 6],
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert_eq!(HidOutput::decode(&d), None, "pad {pad} must not decode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The specific alias the bug produced: 256 as u8 == 0.
|
||||||
|
let d = HidOutput::AudioCtl {
|
||||||
|
pad: 256,
|
||||||
|
flags: 0,
|
||||||
|
raw: [0; 6],
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(
|
||||||
|
!matches!(
|
||||||
|
HidOutput::decode(&d),
|
||||||
|
Some(HidOutput::AudioCtl { pad: 0, .. })
|
||||||
|
),
|
||||||
|
"wire pad 256 must never surface as pad 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cursor_state_roundtrip() {
|
fn cursor_state_roundtrip() {
|
||||||
for (flags, x, y) in [
|
for (flags, x, y) in [
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||||
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||||
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xCF plane codecs,
|
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs,
|
||||||
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
||||||
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||||
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
|
||||||
|
|||||||
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
|
|||||||
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
|
||||||
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
|
||||||
"Win32_System_Com",
|
"Win32_System_Com",
|
||||||
|
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
|
||||||
|
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
|
||||||
|
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
|
||||||
|
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
|
||||||
|
# ACL repair + the devnode's pad-index marker value.
|
||||||
|
"Win32_Media_Audio",
|
||||||
|
"Win32_UI_Shell_PropertiesSystem",
|
||||||
|
"Win32_System_Com_StructuredStorage",
|
||||||
|
"Win32_System_Variant",
|
||||||
|
"Win32_Devices_Properties",
|
||||||
|
"Win32_System_Registry",
|
||||||
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
|
||||||
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
|
||||||
"Win32_System_Diagnostics_Debug",
|
"Win32_System_Diagnostics_Debug",
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
|||||||
mod audio_control;
|
mod audio_control;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
mod linux;
|
||||||
|
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||||
|
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||||
|
// `pad-endpoint` devtest.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "audio/windows/pad_endpoint.rs"]
|
||||||
|
pub(crate) mod pad_endpoint;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "audio/windows/wasapi_cap.rs"]
|
#[path = "audio/windows/wasapi_cap.rs"]
|
||||||
mod wasapi_cap;
|
mod wasapi_cap;
|
||||||
|
|||||||
@@ -143,6 +143,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
|||||||
wire_now_full(set_playback).wiring
|
wire_now_full(set_playback).wiring
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
|
||||||
|
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
|
||||||
|
/// devnode marker, registry-only reads); this is just the per-pass collection.
|
||||||
|
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||||
|
renders
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
|
||||||
|
.map(|(_, id)| id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||||
@@ -159,6 +170,10 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
|||||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|s| s.to_lowercase());
|
.map(|s| s.to_lowercase());
|
||||||
|
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
|
||||||
|
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||||
|
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||||
|
let pad_ids = pad_render_ids(&renders);
|
||||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||||
@@ -179,6 +194,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
|||||||
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
|
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
|
||||||
// cannot carry stereo cannot carry 5.1 either.
|
// cannot carry stereo cannot carry 5.1 either.
|
||||||
2,
|
2,
|
||||||
|
&pad_ids,
|
||||||
);
|
);
|
||||||
let done = |wiring: Wiring| WiredPlan {
|
let done = |wiring: Wiring| WiredPlan {
|
||||||
wiring,
|
wiring,
|
||||||
@@ -245,7 +261,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
|||||||
if let Some((mic_name, mic_id)) = &wiring.mic_render {
|
if let Some((mic_name, mic_id)) = &wiring.mic_render {
|
||||||
if default_render_id().as_deref() == Some(mic_id.as_str()) {
|
if default_render_id().as_deref() == Some(mic_id.as_str()) {
|
||||||
// Audible preference = the host_audio plan's loopback pick (real hardware first).
|
// Audible preference = the host_audio plan's loopback pick (real hardware first).
|
||||||
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
|
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
|
||||||
Some((name, id)) => match set_default_endpoint(&id) {
|
Some((name, id)) => match set_default_endpoint(&id) {
|
||||||
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
|
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
|
||||||
"default playback was the virtual-mic target — moved it so desktop \
|
"default playback was the virtual-mic target — moved it so desktop \
|
||||||
@@ -302,8 +318,10 @@ fn park_marker_path() -> std::path::PathBuf {
|
|||||||
pf_paths::config_dir().join("audio-default.prev")
|
pf_paths::config_dir().join("audio-default.prev")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current default RENDER endpoint id, if any.
|
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||||
fn default_render_id() -> Option<String> {
|
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||||
|
/// default playback device).
|
||||||
|
pub(crate) fn default_render_id() -> Option<String> {
|
||||||
wasapi::DeviceEnumerator::new()
|
wasapi::DeviceEnumerator::new()
|
||||||
.ok()?
|
.ok()?
|
||||||
.get_default_device(&Direction::Render)
|
.get_default_device(&Direction::Render)
|
||||||
@@ -430,11 +448,13 @@ pub(crate) fn restore_default_playback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open a device by endpoint id, with a name for error context.
|
/// Open a device by endpoint id, with a name for error context.
|
||||||
|
///
|
||||||
|
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||||
|
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||||
|
/// docs), so it fails at random on ids that are perfectly valid.
|
||||||
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||||
wasapi::DeviceEnumerator::new()
|
super::pad_endpoint::open_wasapi_device(&ep.1)
|
||||||
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
|
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||||
.get_device(&ep.1)
|
|
||||||
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||||
@@ -481,8 +501,9 @@ const _: () = {
|
|||||||
|
|
||||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||||
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
|
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
|
||||||
/// fails.
|
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
|
||||||
fn set_default_endpoint(device_id: &str) -> Result<()> {
|
/// through the same machinery.
|
||||||
|
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -511,7 +511,7 @@ fn capture_once(
|
|||||||
if assert_plan {
|
if assert_plan {
|
||||||
if let Some(d) = seen_default.as_deref() {
|
if let Some(d) = seen_default.as_deref() {
|
||||||
if d != dev_id {
|
if d != dev_id {
|
||||||
match judge_default(&en, wiring, d) {
|
match judge_default(wiring, d) {
|
||||||
DefaultKind::Capturable(name) => {
|
DefaultKind::Capturable(name) => {
|
||||||
tracing::info!(default = %name, planned = %dev_name,
|
tracing::info!(default = %name, planned = %dev_name,
|
||||||
"could not park the default playback on the planned endpoint — \
|
"could not park the default playback on the planned endpoint — \
|
||||||
@@ -639,7 +639,7 @@ fn capture_once(
|
|||||||
);
|
);
|
||||||
return Ok(Next::Reopen(TargetMode::Follow));
|
return Ok(Next::Reopen(TargetMode::Follow));
|
||||||
}
|
}
|
||||||
match judge_default(&en, wiring, &nid) {
|
match judge_default(wiring, &nid) {
|
||||||
DefaultKind::Capturable(name) => {
|
DefaultKind::Capturable(name) => {
|
||||||
audio_client.stop_stream().ok();
|
audio_client.stop_stream().ok();
|
||||||
tracing::info!(device = %name,
|
tracing::info!(device = %name,
|
||||||
@@ -726,8 +726,11 @@ enum DefaultKind {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
|
||||||
let Ok(dev) = en.get_device(id) else {
|
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
|
||||||
|
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
|
||||||
|
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
|
||||||
|
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
|
||||||
return DefaultKind::Unknown;
|
return DefaultKind::Unknown;
|
||||||
};
|
};
|
||||||
let name = dev.get_friendlyname().unwrap_or_default();
|
let name = dev.get_friendlyname().unwrap_or_default();
|
||||||
@@ -736,7 +739,15 @@ fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str)
|
|||||||
.mic_render
|
.mic_render
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|(_, mic_id)| mic_id == id);
|
.is_some_and(|(_, mic_id)| mic_id == id);
|
||||||
if is_mic || wiring_plan::excluded_from_loopback(&ln) {
|
// B10: a pad's audio endpoint is not ordinary hardware, and the name rules cannot see that —
|
||||||
|
// it is deliberately stamped with the controller's own name ("DualSense Wireless Controller")
|
||||||
|
// so games treat it as the pad's speaker, which means `excluded_from_loopback` passes it
|
||||||
|
// straight through as `Capturable`. The pure plan filtered these out, but the plan is not the
|
||||||
|
// only reader: this classifier drives the watchdog, Follow mode and the parked default, so a
|
||||||
|
// pad endpoint that happened to be the system default could be adopted as the desktop capture
|
||||||
|
// source — sending the whole desktop mix to a controller's voice coils. Identity, not name.
|
||||||
|
let is_pad = super::pad_endpoint::is_pad_render_endpoint(id);
|
||||||
|
if is_mic || is_pad || wiring_plan::excluded_from_loopback(&ln) {
|
||||||
DefaultKind::Dud(name)
|
DefaultKind::Dud(name)
|
||||||
} else {
|
} else {
|
||||||
DefaultKind::Capturable(name)
|
DefaultKind::Capturable(name)
|
||||||
|
|||||||
@@ -253,25 +253,16 @@ pub(crate) fn install_steam_audio_pair() -> bool {
|
|||||||
mic || spk
|
mic || spk
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory
|
||||||
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\<inf_name>`), as a NUL-terminated
|
||||||
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning
|
||||||
/// per-arch `drivers\Windows10\{arch}\` directory.
|
/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW`
|
||||||
///
|
/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the
|
||||||
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
/// environment expansion fails (existence is the caller's check).
|
||||||
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
|
||||||
/// inside, which is this function's own business.
|
use windows::core::PCWSTR;
|
||||||
fn try_install_steam_audio(inf_name: &str) -> bool {
|
|
||||||
use windows::core::{s, w, PCWSTR};
|
|
||||||
use windows::Win32::Foundation::HWND;
|
|
||||||
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
|
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
|
||||||
use windows::Win32::System::LibraryLoader::{
|
|
||||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
|
||||||
};
|
|
||||||
|
|
||||||
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
|
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
let subdir = "x64";
|
let subdir = "x64";
|
||||||
@@ -290,8 +281,33 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
|||||||
let n =
|
let n =
|
||||||
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
|
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
|
||||||
if n == 0 || n as usize > path.len() {
|
if n == 0 || n as usize > path.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
path.truncate(n as usize); // keeps the NUL
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
|
||||||
|
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
|
||||||
|
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
|
||||||
|
/// per-arch `drivers\Windows10\{arch}\` directory.
|
||||||
|
///
|
||||||
|
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
|
||||||
|
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
|
||||||
|
/// inside, which is this function's own business.
|
||||||
|
fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||||
|
use windows::core::{s, w, PCWSTR};
|
||||||
|
use windows::Win32::Foundation::HWND;
|
||||||
|
use windows::Win32::System::LibraryLoader::{
|
||||||
|
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||||
|
};
|
||||||
|
|
||||||
|
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let Some(path) = steam_driver_inf_path(inf_name) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
|
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
|
||||||
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
|
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ fn virtualish(lname: &str) -> bool {
|
|||||||
|| lname.contains("voicemeeter")
|
|| lname.contains("voicemeeter")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is this render endpoint id one of the virtual pad's audio endpoints?
|
||||||
|
///
|
||||||
|
/// Pulled out of [`plan`] because the plan is NOT the only place that must not treat these as
|
||||||
|
/// ordinary hardware — see [`excluded_from_loopback`]'s callers. A pad endpoint is deliberately
|
||||||
|
/// stamped with the controller's own name ("DualSense Wireless Controller") so games read it as
|
||||||
|
/// the pad's speaker, which means no name-based rule can recognise one; the only reliable test is
|
||||||
|
/// identity against the ids the pad-endpoint provisioner created.
|
||||||
|
pub(crate) fn is_pad_render(id: &str, pad_renders: &[String]) -> bool {
|
||||||
|
pad_renders.iter().any(|p| p == id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
|
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
|
||||||
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
|
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
|
||||||
/// flips the loopback preference to real hardware (audio audible on the host too); the default
|
/// flips the loopback preference to real hardware (audio audible on the host too); the default
|
||||||
@@ -195,8 +206,17 @@ pub(crate) fn plan(
|
|||||||
captures: &[Endpoint],
|
captures: &[Endpoint],
|
||||||
mic_want: Option<&str>,
|
mic_want: Option<&str>,
|
||||||
host_audio: bool,
|
host_audio: bool,
|
||||||
|
pad_renders: &[String],
|
||||||
) -> Wiring {
|
) -> Wiring {
|
||||||
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
|
plan_with_formats(
|
||||||
|
renders,
|
||||||
|
captures,
|
||||||
|
mic_want,
|
||||||
|
host_audio,
|
||||||
|
&no_formats,
|
||||||
|
2,
|
||||||
|
pad_renders,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
|
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
|
||||||
@@ -221,7 +241,20 @@ pub(crate) fn plan_with_formats(
|
|||||||
host_audio: bool,
|
host_audio: bool,
|
||||||
format_of: FormatProbe,
|
format_of: FormatProbe,
|
||||||
want_channels: u8,
|
want_channels: u8,
|
||||||
|
pad_renders: &[String],
|
||||||
) -> Wiring {
|
) -> Wiring {
|
||||||
|
// 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice
|
||||||
|
// would play out of a pad "speaker"), never a loopback source (a game's controller
|
||||||
|
// audio cues would stream as desktop audio), and — since this shadows `renders` for
|
||||||
|
// every tier below — never the flagged last resort either. Their names carry no virtual
|
||||||
|
// marker (they are stamped "DualSense Wireless Controller" on purpose, so games read
|
||||||
|
// them as the pad's speaker), so the name rules alone would take one for real hardware.
|
||||||
|
let renders: Vec<Endpoint> = renders
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, id)| !is_pad_render(id, pad_renders))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let renders = renders.as_slice();
|
||||||
let find_render = |needle: &str| {
|
let find_render = |needle: &str| {
|
||||||
renders
|
renders
|
||||||
.iter()
|
.iter()
|
||||||
@@ -422,7 +455,7 @@ mod tests {
|
|||||||
ep("Microphone (Webcam)"),
|
ep("Microphone (Webcam)"),
|
||||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||||
];
|
];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.mic_render.unwrap().0,
|
w.mic_render.unwrap().0,
|
||||||
"CABLE Input (VB-Audio Virtual Cable)"
|
"CABLE Input (VB-Audio Virtual Cable)"
|
||||||
@@ -451,7 +484,7 @@ mod tests {
|
|||||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||||
ep("Microphone (Steam Streaming Microphone)"),
|
ep("Microphone (Steam Streaming Microphone)"),
|
||||||
];
|
];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.mic_render.unwrap().0,
|
w.mic_render.unwrap().0,
|
||||||
"CABLE Input (VB-Audio Virtual Cable)"
|
"CABLE Input (VB-Audio Virtual Cable)"
|
||||||
@@ -471,7 +504,7 @@ mod tests {
|
|||||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||||
ep("Speakers (Steam Streaming Microphone)"),
|
ep("Speakers (Steam Streaming Microphone)"),
|
||||||
];
|
];
|
||||||
let w = plan(&renders, &[], None, true);
|
let w = plan(&renders, &[], None, true, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.loopback_render.unwrap().0,
|
w.loopback_render.unwrap().0,
|
||||||
"Speakers (Apple Audio Device)"
|
"Speakers (Apple Audio Device)"
|
||||||
@@ -488,7 +521,7 @@ mod tests {
|
|||||||
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
||||||
];
|
];
|
||||||
for host_audio in [false, true] {
|
for host_audio in [false, true] {
|
||||||
let w = plan(&renders, &[], None, host_audio);
|
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,7 +533,7 @@ mod tests {
|
|||||||
fn headless_cable_only_mic_wins() {
|
fn headless_cable_only_mic_wins() {
|
||||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert!(w.mic_render.is_some(), "mic must claim the only cable");
|
assert!(w.mic_render.is_some(), "mic must claim the only cable");
|
||||||
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
|
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
|
||||||
}
|
}
|
||||||
@@ -518,7 +551,7 @@ mod tests {
|
|||||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||||
ep("Microphone (Steam Streaming Microphone)"),
|
ep("Microphone (Steam Streaming Microphone)"),
|
||||||
];
|
];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.mic_render.unwrap().0,
|
w.mic_render.unwrap().0,
|
||||||
"CABLE Input (VB-Audio Virtual Cable)"
|
"CABLE Input (VB-Audio Virtual Cable)"
|
||||||
@@ -546,7 +579,7 @@ mod tests {
|
|||||||
ep("Speakers (Realtek HD Audio)"),
|
ep("Speakers (Realtek HD Audio)"),
|
||||||
];
|
];
|
||||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.mic_render.unwrap().0,
|
w.mic_render.unwrap().0,
|
||||||
"Speakers (Steam Streaming Microphone)"
|
"Speakers (Steam Streaming Microphone)"
|
||||||
@@ -560,7 +593,7 @@ mod tests {
|
|||||||
fn steam_mic_only_no_echo() {
|
fn steam_mic_only_no_echo() {
|
||||||
let renders = [ep("Speakers (Steam Streaming Microphone)")];
|
let renders = [ep("Speakers (Steam Streaming Microphone)")];
|
||||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert!(w.mic_render.is_some());
|
assert!(w.mic_render.is_some());
|
||||||
assert!(w.loopback_render.is_none());
|
assert!(w.loopback_render.is_none());
|
||||||
}
|
}
|
||||||
@@ -576,7 +609,7 @@ mod tests {
|
|||||||
ep("Speakers (Steam Streaming Speakers)"),
|
ep("Speakers (Steam Streaming Speakers)"),
|
||||||
];
|
];
|
||||||
for host_audio in [false, true] {
|
for host_audio in [false, true] {
|
||||||
let w = plan(&renders, &[], None, host_audio);
|
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.loopback_render.as_ref().unwrap().0,
|
w.loopback_render.as_ref().unwrap().0,
|
||||||
"Speakers (Steam Streaming Speakers)",
|
"Speakers (Steam Streaming Speakers)",
|
||||||
@@ -597,7 +630,7 @@ mod tests {
|
|||||||
ep("Altavoces (Steam Streaming Microphone)"),
|
ep("Altavoces (Steam Streaming Microphone)"),
|
||||||
];
|
];
|
||||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.mic_render.unwrap().0,
|
w.mic_render.unwrap().0,
|
||||||
"Altavoces (Steam Streaming Microphone)"
|
"Altavoces (Steam Streaming Microphone)"
|
||||||
@@ -620,7 +653,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||||
for host_audio in [false, true] {
|
for host_audio in [false, true] {
|
||||||
let w = plan(&renders, &captures, None, host_audio);
|
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.loopback_render.as_ref().unwrap().0,
|
w.loopback_render.as_ref().unwrap().0,
|
||||||
"Speakers (Realtek HD Audio)",
|
"Speakers (Realtek HD Audio)",
|
||||||
@@ -642,7 +675,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||||
for host_audio in [false, true] {
|
for host_audio in [false, true] {
|
||||||
let w = plan(&renders, &captures, None, host_audio);
|
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||||
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
|
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
|
||||||
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
|
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
|
||||||
@@ -691,7 +724,7 @@ mod tests {
|
|||||||
("steam streaming microphone", fmt(24_000, 1)),
|
("steam streaming microphone", fmt(24_000, 1)),
|
||||||
("odyssey", fmt(48_000, 2)),
|
("odyssey", fmt(48_000, 2)),
|
||||||
]);
|
]);
|
||||||
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
|
let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.loopback_render.as_ref().unwrap().0,
|
w.loopback_render.as_ref().unwrap().0,
|
||||||
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
|
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
|
||||||
@@ -721,7 +754,7 @@ mod tests {
|
|||||||
("steam streaming microphone", fmt(48_000, 2)),
|
("steam streaming microphone", fmt(48_000, 2)),
|
||||||
("realtek", fmt(48_000, 2)),
|
("realtek", fmt(48_000, 2)),
|
||||||
]);
|
]);
|
||||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.loopback_render.unwrap().0,
|
w.loopback_render.unwrap().0,
|
||||||
"Speakers (Steam Streaming Microphone)"
|
"Speakers (Steam Streaming Microphone)"
|
||||||
@@ -737,7 +770,7 @@ mod tests {
|
|||||||
ep("Speakers (Steam Streaming Microphone)"),
|
ep("Speakers (Steam Streaming Microphone)"),
|
||||||
];
|
];
|
||||||
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
|
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
|
||||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.loopback_render.as_ref().unwrap().0,
|
w.loopback_render.as_ref().unwrap().0,
|
||||||
"Speakers (Steam Streaming Microphone)"
|
"Speakers (Steam Streaming Microphone)"
|
||||||
@@ -753,7 +786,7 @@ mod tests {
|
|||||||
fn narrowing_is_reported_for_real_hardware_too() {
|
fn narrowing_is_reported_for_real_hardware_too() {
|
||||||
let renders = [ep("Headset (Hands-Free AG Audio)")];
|
let renders = [ep("Headset (Hands-Free AG Audio)")];
|
||||||
let p = probe(vec![("headset", fmt(16_000, 1))]);
|
let p = probe(vec![("headset", fmt(16_000, 1))]);
|
||||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.loopback_render.as_ref().unwrap().0,
|
w.loopback_render.as_ref().unwrap().0,
|
||||||
"Headset (Hands-Free AG Audio)"
|
"Headset (Hands-Free AG Audio)"
|
||||||
@@ -773,8 +806,8 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||||
for host_audio in [false, true] {
|
for host_audio in [false, true] {
|
||||||
let a = plan(&renders, &captures, None, host_audio);
|
let a = plan(&renders, &captures, None, host_audio, &[]);
|
||||||
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
|
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]);
|
||||||
assert_eq!(a, b, "host_audio={host_audio}");
|
assert_eq!(a, b, "host_audio={host_audio}");
|
||||||
assert!(a.loopback_narrowing.is_none());
|
assert!(a.loopback_narrowing.is_none());
|
||||||
}
|
}
|
||||||
@@ -792,7 +825,7 @@ mod tests {
|
|||||||
("steam streaming microphone", fmt(24_000, 1)),
|
("steam streaming microphone", fmt(24_000, 1)),
|
||||||
("realtek", fmt(48_000, 2)),
|
("realtek", fmt(48_000, 2)),
|
||||||
]);
|
]);
|
||||||
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
|
let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]);
|
||||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +853,7 @@ mod tests {
|
|||||||
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
|
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
|
||||||
];
|
];
|
||||||
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
||||||
let w = plan(&renders, &captures, Some("voicemeeter input"), false);
|
let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.mic_render.unwrap().0,
|
w.mic_render.unwrap().0,
|
||||||
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
|
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
|
||||||
@@ -836,7 +869,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_virtual_device() {
|
fn no_virtual_device() {
|
||||||
let renders = [ep("Speakers (Realtek HD Audio)")];
|
let renders = [ep("Speakers (Realtek HD Audio)")];
|
||||||
let w = plan(&renders, &[], None, false);
|
let w = plan(&renders, &[], None, false, &[]);
|
||||||
assert!(w.mic_render.is_none());
|
assert!(w.mic_render.is_none());
|
||||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||||
}
|
}
|
||||||
@@ -854,7 +887,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
|
||||||
for host_audio in [false, true] {
|
for host_audio in [false, true] {
|
||||||
let w = plan(&renders, &captures, None, host_audio);
|
let w = plan(&renders, &captures, None, host_audio, &[]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.mic_render.as_ref().unwrap().0,
|
w.mic_render.as_ref().unwrap().0,
|
||||||
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)",
|
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)",
|
||||||
@@ -877,7 +910,7 @@ mod tests {
|
|||||||
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
|
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
|
||||||
];
|
];
|
||||||
for host_audio in [false, true] {
|
for host_audio in [false, true] {
|
||||||
let w = plan(&renders, &[], None, host_audio);
|
let w = plan(&renders, &[], None, host_audio, &[]);
|
||||||
assert!(w.mic_render.is_some(), "host_audio={host_audio}");
|
assert!(w.mic_render.is_some(), "host_audio={host_audio}");
|
||||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||||
}
|
}
|
||||||
@@ -892,7 +925,7 @@ mod tests {
|
|||||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||||
ep("Speakers (Some Virtual Audio Device)"),
|
ep("Speakers (Some Virtual Audio Device)"),
|
||||||
];
|
];
|
||||||
let w = plan(&renders, &[], None, false);
|
let w = plan(&renders, &[], None, false, &[]);
|
||||||
assert!(w.loopback_render.is_none());
|
assert!(w.loopback_render.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,7 +951,7 @@ mod tests {
|
|||||||
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
|
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
|
||||||
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
|
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
|
||||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert!(w.loopback_unsatisfiable());
|
assert!(w.loopback_unsatisfiable());
|
||||||
let msg = describe_no_loopback(&renders, &w);
|
let msg = describe_no_loopback(&renders, &w);
|
||||||
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
|
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
|
||||||
@@ -929,10 +962,70 @@ mod tests {
|
|||||||
// anyway), while the Steam pair is the remedy that adds a capturable sink.
|
// anyway), while the Steam pair is the remedy that adds a capturable sink.
|
||||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||||
let w = plan(&renders, &captures, None, false);
|
let w = plan(&renders, &captures, None, false, &[]);
|
||||||
assert!(w.loopback_unsatisfiable());
|
assert!(w.loopback_unsatisfiable());
|
||||||
let msg = describe_no_loopback(&renders, &w);
|
let msg = describe_no_loopback(&renders, &w);
|
||||||
assert!(msg.contains("install Steam"), "{msg}");
|
assert!(msg.contains("install Steam"), "{msg}");
|
||||||
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
|
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stamped pad endpoint is invisible to the plan. Its name carries NO virtual marker — on
|
||||||
|
/// purpose, games must read it as the pad's speaker — so the name rules alone would classify
|
||||||
|
/// it as real hardware and hand it the loopback; only the id exclusion prevents that.
|
||||||
|
/// Measured fact: the wiring plan on the target box already enumerated a stamped endpoint.
|
||||||
|
#[test]
|
||||||
|
fn pad_endpoints_invisible() {
|
||||||
|
let renders = [
|
||||||
|
ep("DualSense Wireless Controller"),
|
||||||
|
ep("Speakers (Realtek HD Audio)"),
|
||||||
|
];
|
||||||
|
let pads = [renders[0].1.clone()];
|
||||||
|
let w = plan(&renders, &[], None, false, &pads);
|
||||||
|
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||||
|
// Even an operator mic override matching the pad's name must not claim it; with the
|
||||||
|
// pad as the only render endpoint there is honestly no mic target and no loopback.
|
||||||
|
let w = plan(
|
||||||
|
&renders[..1],
|
||||||
|
&[],
|
||||||
|
Some("wireless controller"),
|
||||||
|
false,
|
||||||
|
&pads,
|
||||||
|
);
|
||||||
|
assert!(w.mic_render.is_none());
|
||||||
|
assert!(w.loopback_render.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exclusion has to survive the LAST RESORT tier, which this merge introduced alongside
|
||||||
|
/// pad audio. `last_resort` matches on the Steam-Speakers name, but it reads the same
|
||||||
|
/// shadowed `renders`, so a pad can never be reached through it either — otherwise the whole
|
||||||
|
/// desktop mix would be routed into the controller's voice coils.
|
||||||
|
#[test]
|
||||||
|
fn a_pad_is_never_the_last_resort() {
|
||||||
|
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
|
||||||
|
// the plan falls all the way through to the last resort.
|
||||||
|
let renders = [
|
||||||
|
ep("DualSense Wireless Controller"),
|
||||||
|
ep("Speakers (Steam Streaming Microphone)"),
|
||||||
|
ep("Speakers (Steam Streaming Speakers)"),
|
||||||
|
];
|
||||||
|
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||||
|
let pads = [renders[0].1.clone()];
|
||||||
|
let w = plan(&renders, &captures, None, false, &pads);
|
||||||
|
assert_eq!(
|
||||||
|
w.loopback_render.as_ref().unwrap().0,
|
||||||
|
"Speakers (Steam Streaming Speakers)",
|
||||||
|
"the last resort must skip the pad"
|
||||||
|
);
|
||||||
|
assert!(w.loopback_last_resort);
|
||||||
|
|
||||||
|
// …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable
|
||||||
|
// rather than falling back onto the coils.
|
||||||
|
let w = plan(&renders[..1], &captures, None, false, &pads);
|
||||||
|
assert!(
|
||||||
|
w.loopback_render.is_none(),
|
||||||
|
"a pad was taken as the last resort"
|
||||||
|
);
|
||||||
|
assert!(!w.loopback_last_resort);
|
||||||
|
assert!(w.loopback_unsatisfiable());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
|||||||
index: idx,
|
index: idx,
|
||||||
kind: 2,
|
kind: 2,
|
||||||
capabilities: 0,
|
capabilities: 0,
|
||||||
|
audio_caps: 0,
|
||||||
});
|
});
|
||||||
println!(
|
println!(
|
||||||
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
||||||
@@ -430,6 +431,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
|||||||
index: idx,
|
index: idx,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
capabilities: 0,
|
capabilities: 0,
|
||||||
|
audio_caps: 0,
|
||||||
});
|
});
|
||||||
println!(
|
println!(
|
||||||
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
||||||
@@ -486,6 +488,119 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||||
|
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||||
|
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||||
|
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||||
|
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||||
|
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||||
|
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||||
|
use crate::audio::pad_endpoint as pe;
|
||||||
|
let idx: u8 = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--index")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
// `--endpoint <id>` drives ANY render endpoint, not just a provisioned pad one. It is the
|
||||||
|
// discriminator between "this process cannot activate anything" and "our endpoint is broken":
|
||||||
|
// aim the same binary at a known-good endpoint and see whether it succeeds there.
|
||||||
|
let endpoint_override: Option<String> = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--endpoint")
|
||||||
|
.nth(1)
|
||||||
|
.cloned();
|
||||||
|
match args.get(1).map(String::as_str) {
|
||||||
|
Some("ensure") => {
|
||||||
|
let p = pe::ensure(idx)?;
|
||||||
|
println!(
|
||||||
|
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
|
||||||
|
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some("remove") => match pe::find(idx)? {
|
||||||
|
Some(p) => {
|
||||||
|
pe::remove(&p);
|
||||||
|
println!(
|
||||||
|
"pad-endpoint remove: requested removal of {}",
|
||||||
|
p.device_instance
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// `punktfunk-host pad-endpoint <n> tone [seconds] [hz]` — drive the endpoint directly so
|
||||||
|
// the whole pad-audio chain can be exercised without a game. Without this, every attempt
|
||||||
|
// costs a game launch and a failure does not say which link broke.
|
||||||
|
Some("tone") => {
|
||||||
|
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||||
|
let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0);
|
||||||
|
let endpoint_id = match endpoint_override {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
// `find` (a system lookup), NOT `endpoint_for` (the service's in-process
|
||||||
|
// cache): this runs as a separate CLI process and has no cache of its own.
|
||||||
|
let Some(ep) = pe::find(idx)? else {
|
||||||
|
println!(
|
||||||
|
"pad-endpoint tone: no pad-audio devnode for pad {idx} — run \
|
||||||
|
`ensure` first"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if ep.endpoint_id.is_empty() {
|
||||||
|
println!("pad-endpoint tone: pad {idx} has no endpoint id yet");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ep.endpoint_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to
|
||||||
|
// exercise the speaker kind without a game that renders one.
|
||||||
|
let pair = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--pair")
|
||||||
|
.nth(1)
|
||||||
|
.map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s));
|
||||||
|
println!(
|
||||||
|
"pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s",
|
||||||
|
pair.label()
|
||||||
|
);
|
||||||
|
pe::render_test_tone(&endpoint_id, secs, hz, pair)?;
|
||||||
|
println!(
|
||||||
|
"pad-endpoint tone: done. A connected client with pad audio enabled should have \
|
||||||
|
buzzed; the host log shows whether the gate opened."
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run
|
||||||
|
// both at once to exercise render -> engine -> loopback -> pair routing with no game and
|
||||||
|
// no client attached.
|
||||||
|
Some("capture") => {
|
||||||
|
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||||
|
let endpoint_id = match endpoint_override {
|
||||||
|
Some(id) => id,
|
||||||
|
None => match pe::find(idx)? {
|
||||||
|
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
|
||||||
|
_ => {
|
||||||
|
println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s");
|
||||||
|
pe::capture_probe(&endpoint_id, secs)
|
||||||
|
}
|
||||||
|
Some("status") => pe::print_status(idx),
|
||||||
|
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
|
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
|
||||||
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
|
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
|
|||||||
index: *b.first()?,
|
index: *b.first()?,
|
||||||
kind: *b.get(1)?,
|
kind: *b.get(1)?,
|
||||||
capabilities: le16(2)? as u16,
|
capabilities: le16(2)? as u16,
|
||||||
|
// GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only.
|
||||||
|
audio_caps: 0,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,7 @@ mod tests {
|
|||||||
index,
|
index,
|
||||||
kind,
|
kind,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
..
|
||||||
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
|
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
|
||||||
else {
|
else {
|
||||||
panic!("expected Arrival");
|
panic!("expected Arrival");
|
||||||
|
|||||||
@@ -618,6 +618,10 @@ fn real_main() -> Result<()> {
|
|||||||
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
|
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
|
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
|
||||||
|
// Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal
|
||||||
|
// escape hatch (`remove`). `--index N` selects the pad slot (default 0).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
Some("pad-endpoint") => devtest::pad_endpoint(&args),
|
||||||
// Capture→encode→file pipeline spike (dev tool).
|
// Capture→encode→file pipeline spike (dev tool).
|
||||||
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
||||||
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ use pairing::pair_ceremony;
|
|||||||
mod audio;
|
mod audio;
|
||||||
use audio::audio_thread;
|
use audio::audio_thread;
|
||||||
|
|
||||||
|
/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad
|
||||||
|
/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input
|
||||||
|
/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap
|
||||||
|
/// via `pad_audio::host_cap` (`handshake`).
|
||||||
|
mod pad_audio;
|
||||||
|
|
||||||
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
|
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
|
||||||
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
|
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
|
||||||
mod input;
|
mod input;
|
||||||
@@ -345,6 +351,14 @@ pub(crate) async fn serve(
|
|||||||
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
|
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
|
||||||
// endpoint churn).
|
// endpoint churn).
|
||||||
let mic_service = crate::audio::MicPump::start();
|
let mic_service = crate::audio::MicPump::start();
|
||||||
|
// Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense
|
||||||
|
// speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a
|
||||||
|
// dedicated COM thread, results published for sessions to query by pad index
|
||||||
|
// (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the
|
||||||
|
// worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists.
|
||||||
|
// Failures log once and leave the feature off: pads still work, just without pad audio.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
crate::audio::pad_endpoint::provision_at_startup();
|
||||||
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
|
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
|
||||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||||
@@ -1203,9 +1217,14 @@ async fn serve_session(
|
|||||||
let input_handle = {
|
let input_handle = {
|
||||||
let conn = conn.clone();
|
let conn = conn.clone();
|
||||||
let gamepad = welcome.gamepad;
|
let gamepad = welcome.gamepad;
|
||||||
|
// Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned
|
||||||
|
// endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off
|
||||||
|
// the Welcome rather than recomputed, so the input thread's spawns cannot disagree
|
||||||
|
// with what the client was told.
|
||||||
|
let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0;
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk1-input".into())
|
.name("punktfunk1-input".into())
|
||||||
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
|
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on))
|
||||||
.context("spawn input thread")?
|
.context("spawn input thread")?
|
||||||
};
|
};
|
||||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
||||||
|
|||||||
@@ -640,6 +640,16 @@ pub(super) async fn negotiate(
|
|||||||
punktfunk_core::quic::HOST_CAP_AUDIO_RED
|
punktfunk_core::quic::HOST_CAP_AUDIO_RED
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
|
}
|
||||||
|
// Per-pad DualSense audio (0xD1 + HidOutput::AudioCtl): granted only when the
|
||||||
|
// client asked AND this host can capture it — Windows with the feature enabled
|
||||||
|
// and at least one pad endpoint provisioned at startup. A capable client then
|
||||||
|
// marks its pads' renderers on their arrivals; the input thread streams toward
|
||||||
|
// exactly those pads (`super::pad_audio`).
|
||||||
|
| if super::pad_audio::host_cap(hello.client_caps) {
|
||||||
|
punktfunk_core::quic::HOST_CAP_PAD_AUDIO
|
||||||
|
} else {
|
||||||
|
0
|
||||||
},
|
},
|
||||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||||
|
|||||||
@@ -515,6 +515,100 @@ impl Pads {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table
|
||||||
|
/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint —
|
||||||
|
/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival
|
||||||
|
/// declares renderer bits, reaped on remove / re-declare / session teardown.
|
||||||
|
struct PadAudioSlots {
|
||||||
|
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
|
||||||
|
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
|
||||||
|
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
|
||||||
|
/// Kind-change restarts spent per pad this session (R3). The trigger is a client-sent
|
||||||
|
/// arrival, so without a ceiling the client decides how many WASAPI captures the host opens.
|
||||||
|
restarts: [u8; MAX_WIRE_PADS],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// R3: how many times one pad may change its declared audio kinds before the host stops
|
||||||
|
/// obliging. A real controller declares once at open and never again; the re-sent arrivals are
|
||||||
|
/// identical and take the no-op path above, so this is only reached by a client that keeps
|
||||||
|
/// changing its mind.
|
||||||
|
const MAX_PAD_AUDIO_RESTARTS: u8 = 8;
|
||||||
|
|
||||||
|
impl PadAudioSlots {
|
||||||
|
fn new() -> PadAudioSlots {
|
||||||
|
PadAudioSlots {
|
||||||
|
slots: std::array::from_fn(|_| None),
|
||||||
|
restarts: [0; MAX_WIRE_PADS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
|
||||||
|
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
|
||||||
|
/// retries, since arrivals are only re-sent a few times per slot open).
|
||||||
|
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
|
||||||
|
let idx = pad as usize;
|
||||||
|
if idx >= MAX_WIRE_PADS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((have, _)) = &self.slots[idx] {
|
||||||
|
if *have == kinds {
|
||||||
|
return; // identical re-arrival — keep the running streamer
|
||||||
|
}
|
||||||
|
// R3: the restart trigger is a CLIENT-sent arrival, so the count is client-driven.
|
||||||
|
// Nothing bounded it: a client alternating its declared kinds could make the host
|
||||||
|
// tear down and re-spawn a WASAPI loopback capture indefinitely, each cycle paying a
|
||||||
|
// thread spawn and an endpoint activation. Cheap to bound, and a pad that has already
|
||||||
|
// changed its mind this many times in one session is not doing anything legitimate.
|
||||||
|
if self.restarts[idx] >= MAX_PAD_AUDIO_RESTARTS {
|
||||||
|
tracing::warn!(
|
||||||
|
pad = idx,
|
||||||
|
"pad-audio kinds changed again after {MAX_PAD_AUDIO_RESTARTS} restarts — \
|
||||||
|
ignoring; the streamer keeps its current kinds for this session"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.restarts[idx] += 1;
|
||||||
|
tracing::info!(
|
||||||
|
pad = idx,
|
||||||
|
restarts = self.restarts[idx],
|
||||||
|
"pad-audio kinds changed — restarting the streamer"
|
||||||
|
);
|
||||||
|
self.stop(idx);
|
||||||
|
}
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
|
||||||
|
self.slots[idx] = Some((kinds, h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's
|
||||||
|
/// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms
|
||||||
|
/// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just
|
||||||
|
/// not here. A failed reaper spawn falls back to the handle's own drop (signal + join).
|
||||||
|
fn stop(&mut self, idx: usize) {
|
||||||
|
if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) {
|
||||||
|
h.signal();
|
||||||
|
let _ = std::thread::Builder::new()
|
||||||
|
.name("punktfunk1-padreap".into())
|
||||||
|
.spawn(move || h.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session teardown: flag every streamer FIRST so they wind down concurrently, then join —
|
||||||
|
/// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's
|
||||||
|
/// 10 s side-thread join grace, not one per pad.
|
||||||
|
fn stop_all(&mut self) {
|
||||||
|
for s in self.slots.iter().flatten() {
|
||||||
|
s.1.signal();
|
||||||
|
}
|
||||||
|
for s in &mut self.slots {
|
||||||
|
if let Some((_, h)) = s.take() {
|
||||||
|
h.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||||
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
||||||
/// every pure-gyro motion sample up to 4 ms of quantization).
|
/// every pure-gyro motion sample up to 4 ms of quantization).
|
||||||
@@ -683,8 +777,13 @@ pub(super) fn input_thread(
|
|||||||
conn: quinn::Connection,
|
conn: quinn::Connection,
|
||||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||||
gamepad: GamepadPref,
|
gamepad: GamepadPref,
|
||||||
|
pad_audio_on: bool,
|
||||||
) {
|
) {
|
||||||
let mut pads = Pads::new(gamepad);
|
let mut pads = Pads::new(gamepad);
|
||||||
|
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
|
||||||
|
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
|
||||||
|
// declare renderer bits, reaped on remove/teardown below.
|
||||||
|
let mut pad_streams = PadAudioSlots::new();
|
||||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||||
// is 5000 u32s.
|
// is 5000 u32s.
|
||||||
@@ -854,16 +953,53 @@ pub(super) fn input_thread(
|
|||||||
&mut rumble_seen[idx],
|
&mut rumble_seen[idx],
|
||||||
&mut rumble_stop_burst[idx],
|
&mut rumble_stop_burst[idx],
|
||||||
);
|
);
|
||||||
|
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
|
||||||
|
// rest of this arm, so a reordered stale removal can't kill the
|
||||||
|
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
|
||||||
|
pad_streams.stop(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputKind::GamepadArrival => {
|
InputKind::GamepadArrival => {
|
||||||
// Per-pad controller kind declaration (mixed types): route this pad's future
|
// Per-pad controller kind declaration (mixed types): route this pad's future
|
||||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
|
// frames to a backend of the declared kind. `code` = the GamepadPref wire
|
||||||
// `flags` = pad index. Applied before the pad's first frame (the client sends it
|
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
|
||||||
// on slot open), so the device is built as the right type from the start.
|
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
|
||||||
let idx = ev.flags as usize;
|
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
|
||||||
|
// Applied before the pad's first frame (the client sends it on slot open),
|
||||||
|
// so the device is built as the right type from the start. The audio caps
|
||||||
|
// are surfaced here for the 0xD1 capture path (which emits pad audio only
|
||||||
|
// toward pads that declared a renderer).
|
||||||
|
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
|
||||||
|
let idx = pad as usize;
|
||||||
let kind = GamepadPref::from_u8(ev.code as u8);
|
let kind = GamepadPref::from_u8(ev.code as u8);
|
||||||
|
if audio_caps != 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
pad = idx,
|
||||||
|
haptics = audio_caps & 0x01 != 0,
|
||||||
|
speaker = audio_caps & 0x02 != 0,
|
||||||
|
"pad-audio render caps declared (arrival flags bits 8/9)"
|
||||||
|
);
|
||||||
|
}
|
||||||
pads.set_kind(idx, kind);
|
pads.set_kind(idx, kind);
|
||||||
|
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
|
||||||
|
// renderer, only on a session that negotiated the cap. Idempotent across
|
||||||
|
// the arrival re-sends (same kinds keeps the running streamer); a
|
||||||
|
// re-declare without bits — or as a kind with no pad audio — stops it.
|
||||||
|
if pad_audio_on {
|
||||||
|
let want = if matches!(
|
||||||
|
kind,
|
||||||
|
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||||
|
) {
|
||||||
|
audio_caps
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if want != 0 {
|
||||||
|
pad_streams.ensure(&conn, pad, want);
|
||||||
|
} else {
|
||||||
|
pad_streams.stop(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Track press/release so a mid-press disconnect can be undone below.
|
// Track press/release so a mid-press disconnect can be undone below.
|
||||||
@@ -1019,6 +1155,9 @@ pub(super) fn input_thread(
|
|||||||
flags: 0,
|
flags: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
|
||||||
|
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
|
||||||
|
pad_streams.stop_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
|
||||||
|
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
|
||||||
|
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
|
||||||
|
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
|
||||||
|
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
|
||||||
|
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
|
||||||
|
//! reopen-with-backoff on capture death, the same monotonic-seq-kept-across-reopens discipline,
|
||||||
|
//! the same power-of-two encode-warn throttle.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
|
||||||
|
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
|
||||||
|
/// `kinds` bit for the speaker stream.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
|
||||||
|
|
||||||
|
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
|
||||||
|
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
|
||||||
|
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
const HAPTICS_FRAME_MS: u32 = 5;
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
const SPEAKER_FRAME_MS: u32 = 10;
|
||||||
|
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
const HAPTICS_FRAME_SAMPLES: usize =
|
||||||
|
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
const SPEAKER_FRAME_SAMPLES: usize =
|
||||||
|
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
|
||||||
|
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
|
||||||
|
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
|
||||||
|
/// the pure splitter logic keeps its own copy).
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
const CAP_CHANNELS: usize = 4;
|
||||||
|
|
||||||
|
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
|
||||||
|
/// very frame (haptics are felt latency; the first active frame must ship). ≈ −60 dBFS.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
const GATE_OPEN_PEAK: f32 = 1e-3;
|
||||||
|
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
|
||||||
|
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
|
||||||
|
/// clipped, short enough that an idle pad costs nothing in steady state.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
const GATE_HANGOVER_MS: u32 = 250;
|
||||||
|
|
||||||
|
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
|
||||||
|
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const PAD_AUDIO_BITRATE: i32 = 64_000;
|
||||||
|
|
||||||
|
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
|
||||||
|
/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz
|
||||||
|
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
|
||||||
|
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
|
||||||
|
/// unit-tested below.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
struct SilenceGate {
|
||||||
|
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
|
||||||
|
hangover_frames: u32,
|
||||||
|
/// Consecutive sub-threshold frames seen so far while open.
|
||||||
|
quiet: u32,
|
||||||
|
/// Starts closed: a pad no game ever renders into never opens (and never sends).
|
||||||
|
open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
impl SilenceGate {
|
||||||
|
fn new(frame_ms: u32) -> SilenceGate {
|
||||||
|
SilenceGate {
|
||||||
|
hangover_frames: (GATE_HANGOVER_MS / frame_ms).max(1),
|
||||||
|
quiet: 0,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one frame; `true` = encode + send it. Signal opens the gate on THIS frame; the
|
||||||
|
/// frame that completes the hangover closes it and is itself suppressed (the client
|
||||||
|
/// already has ~250 ms of ramped-out silence by then).
|
||||||
|
fn feed(&mut self, frame: &[f32]) -> bool {
|
||||||
|
if frame.iter().any(|s| s.abs() >= GATE_OPEN_PEAK) {
|
||||||
|
self.open = true;
|
||||||
|
self.quiet = 0;
|
||||||
|
} else if self.open {
|
||||||
|
self.quiet += 1;
|
||||||
|
if self.quiet >= self.hangover_frames {
|
||||||
|
self.open = false;
|
||||||
|
self.quiet = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One kind's send-admission + seq bookkeeping (pure logic — the capture thread wraps it with
|
||||||
|
/// the encoder and the datagram send). `seq` is monotonic per (pad, kind) and NEVER advances
|
||||||
|
/// while the gate is closed: frozen-seq = deliberate silence — the client tells silence from
|
||||||
|
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
|
||||||
|
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
|
||||||
|
/// sees a gap, not a restart.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
struct LaneCtl {
|
||||||
|
gate: SilenceGate,
|
||||||
|
seq: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
impl LaneCtl {
|
||||||
|
fn new(frame_ms: u32) -> LaneCtl {
|
||||||
|
LaneCtl {
|
||||||
|
gate: SilenceGate::new(frame_ms),
|
||||||
|
seq: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admit one frame: `Some(seq)` = encode + send it with this seq (advanced for the next);
|
||||||
|
/// `None` = gated — do not send, do not advance. An encode failure AFTER admission leaves a
|
||||||
|
/// one-frame seq gap, which the client conceals exactly like datagram loss.
|
||||||
|
fn admit(&mut self, frame: &[f32]) -> Option<u32> {
|
||||||
|
if !self.gate.feed(frame) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let seq = self.seq;
|
||||||
|
self.seq = self.seq.wrapping_add(1);
|
||||||
|
Some(seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// De-interleave one 4-ch block (FL FR BL BR) into its stereo pairs: `(front, back)` — front =
|
||||||
|
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
|
||||||
|
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
|
||||||
|
/// across channels.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||||
|
let mut front = Vec::with_capacity(block.len() / 2);
|
||||||
|
let mut back = Vec::with_capacity(block.len() / 2);
|
||||||
|
for s in block.chunks_exact(CAP_CHANNELS) {
|
||||||
|
front.extend_from_slice(&s[..2]);
|
||||||
|
back.extend_from_slice(&s[2..4]);
|
||||||
|
}
|
||||||
|
(front, back)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates interleaved 4-ch capture and cuts it into the wire contract's per-kind stereo
|
||||||
|
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
|
||||||
|
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
|
||||||
|
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
struct PadFramer {
|
||||||
|
kinds: u8,
|
||||||
|
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
|
||||||
|
acc: Vec<f32>,
|
||||||
|
/// Front-pair stereo accumulation toward the next 10 ms speaker frame.
|
||||||
|
front: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
impl PadFramer {
|
||||||
|
fn new(kinds: u8) -> PadFramer {
|
||||||
|
PadFramer {
|
||||||
|
kinds,
|
||||||
|
acc: Vec::with_capacity(HAPTICS_FRAME_SAMPLES * CAP_CHANNELS * 4),
|
||||||
|
front: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one capture chunk; `emit(kind, stereo_frame)` fires for each completed frame
|
||||||
|
/// (haptics first — it is the latency-critical pair).
|
||||||
|
fn feed(&mut self, chunk: &[f32], mut emit: impl FnMut(u8, &[f32])) {
|
||||||
|
self.acc.extend_from_slice(chunk);
|
||||||
|
let block_len = HAPTICS_FRAME_SAMPLES * CAP_CHANNELS;
|
||||||
|
while self.acc.len() >= block_len {
|
||||||
|
let block: Vec<f32> = self.acc.drain(..block_len).collect();
|
||||||
|
let (front, back) = split_quad(&block);
|
||||||
|
if self.kinds & KIND_BIT_HAPTICS != 0 {
|
||||||
|
emit(punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, &back);
|
||||||
|
}
|
||||||
|
if self.kinds & KIND_BIT_SPEAKER != 0 {
|
||||||
|
self.front.extend_from_slice(&front);
|
||||||
|
let frame_len = SPEAKER_FRAME_SAMPLES * 2;
|
||||||
|
while self.front.len() >= frame_len {
|
||||||
|
let frame: Vec<f32> = self.front.drain(..frame_len).collect();
|
||||||
|
emit(punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, &frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the partial frames straddling a capture gap (reopen). The seq/gate state is NOT
|
||||||
|
/// here — [`LaneCtl`] deliberately survives reopens, so the client sees a gap, not a
|
||||||
|
/// restart.
|
||||||
|
fn clear(&mut self) {
|
||||||
|
self.acc.clear();
|
||||||
|
self.front.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running per-pad streamer. [`stop`](PadAudioHandle::stop) (or drop) flags the thread and
|
||||||
|
/// joins it; [`signal`](PadAudioHandle::signal) only flags — the input thread's teardown flags
|
||||||
|
/// every pad first so the joins overlap instead of serializing the capturer's worst-case ~5 s
|
||||||
|
/// quiet-endpoint recv timeout.
|
||||||
|
pub(super) struct PadAudioHandle {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PadAudioHandle {
|
||||||
|
/// Flag the streamer to wind down without waiting for it.
|
||||||
|
pub(super) fn signal(&self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop + reap. Bounded by the capturer's ~5 s quiet-endpoint recv timeout in the worst
|
||||||
|
/// case — the mid-session reap paths run this on a detached reaper thread for that reason
|
||||||
|
/// (`input.rs::PadAudioSlots::stop`); session teardown affords it inline (the 10 s
|
||||||
|
/// side-thread join grace covers it).
|
||||||
|
pub(super) fn stop(mut self) {
|
||||||
|
self.reap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reap(&mut self) {
|
||||||
|
self.signal();
|
||||||
|
if let Some(join) = self.join.take() {
|
||||||
|
let _ = join.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle dropped without `stop()` (reaper-spawn failure) still winds its thread down.
|
||||||
|
impl Drop for PadAudioHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.reap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this session's Welcome should advertise
|
||||||
|
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
|
||||||
|
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
|
||||||
|
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
|
||||||
|
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
|
||||||
|
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
|
||||||
|
/// picks it up on its next connect.
|
||||||
|
pub(super) fn host_cap(client_caps: u8) -> bool {
|
||||||
|
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// R5: a startup attempt that failed transiently leaves nothing latched, so retry here —
|
||||||
|
// this is the first moment in a session's life that anyone asks whether pad audio exists.
|
||||||
|
if asked {
|
||||||
|
crate::audio::pad_endpoint::ensure_provisioned();
|
||||||
|
}
|
||||||
|
asked
|
||||||
|
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
|
||||||
|
&& crate::audio::pad_endpoint::provisioned_endpoints()
|
||||||
|
.is_some_and(|eps| !eps.is_empty())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
// Only the Windows virtual DualSense exposes pad audio endpoints today.
|
||||||
|
let _ = asked;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the per-pad streamer toward `conn` for `pad`, streaming the kinds in `kinds` (bit 0 =
|
||||||
|
/// haptics, bit 1 = speaker — the arrival's audio-caps packing). `stop` is this handle's own
|
||||||
|
/// flag (fresh per spawn — pad streamers stop individually, not with the session). `None` when
|
||||||
|
/// the slot has no provisioned endpoint (provisioning failed or still running, or the slot is
|
||||||
|
/// past `PUNKTFUNK_PAD_AUDIO_SLOTS` — only 0..4 can ever have one) or the thread cannot spawn;
|
||||||
|
/// the pad itself keeps working either way, just without audio.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(super) fn spawn(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
pad: u8,
|
||||||
|
kinds: u8,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Option<PadAudioHandle> {
|
||||||
|
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(ep) = crate::audio::pad_endpoint::endpoint_for(pad) else {
|
||||||
|
tracing::debug!(
|
||||||
|
pad,
|
||||||
|
"pad-audio arrival for a slot without a provisioned endpoint — not streaming"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if ep.endpoint_id.is_empty() {
|
||||||
|
// The devnode-without-endpoint shape (`find`) — never in the provisioned set, but
|
||||||
|
// cheap to refuse rather than spin the open/backoff loop on an empty id.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if ep.needs_aeb_kick {
|
||||||
|
// R4: this flag was computed on every path and consulted nowhere past startup. It means
|
||||||
|
// the endpoint's stamps are STORED but not SERVED — the audio stack never picked up the
|
||||||
|
// DualSense identity — and startup's one restart did not fix it. Opening anyway is worse
|
||||||
|
// than refusing: `AUTOCONVERTPCM` makes a wrong-format endpoint initialize *successfully*,
|
||||||
|
// so the stream runs, the logs look healthy, and the haptics/speaker pair is mis-routed
|
||||||
|
// with nothing to point at. Decline, and say which reboot-shaped problem it is.
|
||||||
|
tracing::warn!(
|
||||||
|
pad,
|
||||||
|
endpoint = %ep.endpoint_id,
|
||||||
|
"pad endpoint stamps are stored but not served — the audio stack has not adopted the \
|
||||||
|
DualSense identity (a reboot, or a manual AudioEndpointBuilder+Audiosrv restart, \
|
||||||
|
clears it). Not streaming: the endpoint would open and mis-route."
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let stop_t = stop.clone();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name(format!("punktfunk1-pad{pad}"))
|
||||||
|
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
|
||||||
|
{
|
||||||
|
Ok(join) => Some(PadAudioHandle {
|
||||||
|
stop,
|
||||||
|
join: Some(join),
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
|
||||||
|
/// without the audio side (and never advertise the cap, see [`host_cap`]).
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
pub(super) fn spawn(
|
||||||
|
_conn: quinn::Connection,
|
||||||
|
_pad: u8,
|
||||||
|
_kinds: u8,
|
||||||
|
_stop: Arc<AtomicBool>,
|
||||||
|
) -> Option<PadAudioHandle> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
|
||||||
|
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
struct Lane {
|
||||||
|
kind: u8,
|
||||||
|
ctl: LaneCtl,
|
||||||
|
enc: opus::Encoder,
|
||||||
|
encode_errs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
|
||||||
|
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
|
||||||
|
let mut lanes = Vec::new();
|
||||||
|
for (bit, kind, frame_ms) in [
|
||||||
|
(
|
||||||
|
KIND_BIT_HAPTICS,
|
||||||
|
punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS,
|
||||||
|
HAPTICS_FRAME_MS,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
KIND_BIT_SPEAKER,
|
||||||
|
punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER,
|
||||||
|
SPEAKER_FRAME_MS,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
if kinds & bit == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut enc = opus::Encoder::new(
|
||||||
|
crate::audio::SAMPLE_RATE,
|
||||||
|
opus::Channels::Stereo,
|
||||||
|
opus::Application::LowDelay,
|
||||||
|
)?;
|
||||||
|
enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok();
|
||||||
|
enc.set_vbr(false).ok();
|
||||||
|
lanes.push(Lane {
|
||||||
|
kind,
|
||||||
|
ctl: LaneCtl::new(frame_ms),
|
||||||
|
enc,
|
||||||
|
encode_errs: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(lanes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
|
||||||
|
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
|
||||||
|
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn pad_audio_thread(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
pad: u8,
|
||||||
|
kinds: u8,
|
||||||
|
endpoint_id: String,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
use crate::audio::AudioCapturer as _;
|
||||||
|
let mut lanes = match build_lanes(kinds) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(pad, error = %e, "pad-audio opus encoder init failed — pad continues without audio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if lanes.is_empty() {
|
||||||
|
return; // spawn() refuses kinds == 0 — belt and braces
|
||||||
|
}
|
||||||
|
let mut framer = PadFramer::new(kinds);
|
||||||
|
// One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session
|
||||||
|
// plane's slack.
|
||||||
|
let mut opus_buf = vec![0u8; 1500];
|
||||||
|
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
|
||||||
|
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
|
||||||
|
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
|
||||||
|
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
|
||||||
|
let mut last_failed: Option<std::time::Instant> = None;
|
||||||
|
tracing::info!(
|
||||||
|
pad,
|
||||||
|
haptics = kinds & KIND_BIT_HAPTICS != 0,
|
||||||
|
speaker = kinds & KIND_BIT_SPEAKER != 0,
|
||||||
|
"pad audio streaming (0xD1, Opus 48 kHz, silence-gated)"
|
||||||
|
);
|
||||||
|
'session: while !stop.load(Ordering::SeqCst) {
|
||||||
|
if capturer.is_none() {
|
||||||
|
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
|
||||||
|
Ok(c) => {
|
||||||
|
if last_failed.take().is_some() {
|
||||||
|
tracing::info!(pad, "pad-audio capture reopened");
|
||||||
|
}
|
||||||
|
capturer = Some(c);
|
||||||
|
framer.clear(); // drop the partial frames straddling the gap
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(pad, error = %format!("{e:#}"), "pad-audio open failed — will retry");
|
||||||
|
last_failed = Some(std::time::Instant::now());
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An empty chunk is a QUIET endpoint (the capturer's idle timeout), not a death — keep
|
||||||
|
// it; only a genuine Err (capture thread ended) drops the capturer for reopen.
|
||||||
|
let chunk = match capturer.as_mut().unwrap().next_chunk() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(pad, error = %format!("{e:#}"), "pad-audio capture lost — reopening");
|
||||||
|
capturer = None;
|
||||||
|
last_failed = Some(std::time::Instant::now());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut session_gone = false;
|
||||||
|
framer.feed(&chunk, |kind, frame| {
|
||||||
|
if session_gone {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(lane) = lanes.iter_mut().find(|l| l.kind == kind) else {
|
||||||
|
return; // framer emits only enabled kinds — unreachable, but never panic here
|
||||||
|
};
|
||||||
|
// Gated = deliberate silence: no datagram AND a frozen seq (the client tells
|
||||||
|
// silence from loss by seq continuity).
|
||||||
|
let Some(seq) = lane.ctl.admit(frame) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let pts_ns = now_ns();
|
||||||
|
match lane.enc.encode_float(frame, &mut opus_buf) {
|
||||||
|
Ok(n) => {
|
||||||
|
let d = punktfunk_core::quic::encode_pad_audio_datagram(
|
||||||
|
pad,
|
||||||
|
kind,
|
||||||
|
seq,
|
||||||
|
pts_ns,
|
||||||
|
&opus_buf[..n],
|
||||||
|
);
|
||||||
|
if conn.send_datagram(d.into()).is_err() {
|
||||||
|
session_gone = true; // connection gone — the session is over
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
lane.encode_errs += 1;
|
||||||
|
if lane.encode_errs.is_power_of_two() {
|
||||||
|
tracing::warn!(
|
||||||
|
pad,
|
||||||
|
kind,
|
||||||
|
error = %e,
|
||||||
|
count = lane.encode_errs,
|
||||||
|
"pad-audio opus encode failed — dropping frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if session_gone {
|
||||||
|
break 'session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dropping the capturer stops its WASAPI thread. Nothing to park: pad capture is per-pad,
|
||||||
|
// per-session by design (unlike the session audio slot there is no cross-session reuse).
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
|
||||||
|
|
||||||
|
/// A stereo frame of `n` samples at a constant level.
|
||||||
|
fn frame(level: f32, n: usize) -> Vec<f32> {
|
||||||
|
vec![level; n * 2]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gate_opens_immediately_and_closes_after_hangover() {
|
||||||
|
let mut g = SilenceGate::new(HAPTICS_FRAME_MS);
|
||||||
|
// 250 ms of 5 ms frames.
|
||||||
|
assert_eq!(g.hangover_frames, 50);
|
||||||
|
// Closed from birth: an idle pad never sends.
|
||||||
|
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
// A peak at exactly the threshold opens on THIS frame (haptics are felt latency).
|
||||||
|
assert!(g.feed(&frame(GATE_OPEN_PEAK, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
// 49 quiet frames ride the hangover; the 50th completes 250 ms and is suppressed.
|
||||||
|
for _ in 0..49 {
|
||||||
|
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
}
|
||||||
|
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
// ... and stays closed.
|
||||||
|
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
// Sub-threshold wiggle does not reopen; real signal does (negative peaks count).
|
||||||
|
assert!(!g.feed(&frame(9e-4, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
assert!(g.feed(&frame(-0.5, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
// A loud frame mid-hangover rearms the full 250 ms.
|
||||||
|
for _ in 0..49 {
|
||||||
|
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
}
|
||||||
|
assert!(g.feed(&frame(0.02, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
for _ in 0..49 {
|
||||||
|
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
}
|
||||||
|
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gate_hangover_scales_with_frame_ms() {
|
||||||
|
let mut g = SilenceGate::new(SPEAKER_FRAME_MS);
|
||||||
|
assert_eq!(g.hangover_frames, 25); // 250 ms of 10 ms frames
|
||||||
|
assert!(g.feed(&frame(0.1, SPEAKER_FRAME_SAMPLES)));
|
||||||
|
for _ in 0..24 {
|
||||||
|
assert!(g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||||
|
}
|
||||||
|
assert!(!g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seq_freezes_while_gated_and_survives_reopen() {
|
||||||
|
let mut lane = LaneCtl::new(HAPTICS_FRAME_MS);
|
||||||
|
// Two audible frames: seq 0, 1.
|
||||||
|
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(0));
|
||||||
|
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(1));
|
||||||
|
// The hangover is still sent (seq advances), then the gate closes and seq FREEZES —
|
||||||
|
// deliberate silence the client tells from loss by continuity.
|
||||||
|
for i in 0..49u32 {
|
||||||
|
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), Some(2 + i));
|
||||||
|
}
|
||||||
|
for _ in 0..500 {
|
||||||
|
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), None);
|
||||||
|
}
|
||||||
|
// A capture reopen resets ONLY the framer (PadFramer::clear) — LaneCtl is deliberately
|
||||||
|
// untouched, so the next audible frame CONTINUES the sequence (gap, not restart).
|
||||||
|
assert_eq!(lane.admit(&frame(0.9, HAPTICS_FRAME_SAMPLES)), Some(51));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitter_exact_pairs() {
|
||||||
|
// Interleave [FL FR BL BR] × 2 frames with distinct values everywhere.
|
||||||
|
let quad = [0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0];
|
||||||
|
let (front, back) = split_quad(&quad);
|
||||||
|
assert_eq!(front, [0.0, 1.0, 10.0, 11.0]);
|
||||||
|
assert_eq!(back, [2.0, 3.0, 12.0, 13.0]);
|
||||||
|
// A ragged tail (never produced by the capturer) is dropped, not smeared.
|
||||||
|
let (front, back) = split_quad(&quad[..7]);
|
||||||
|
assert_eq!((front.len(), back.len()), (2, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framer_cuts_the_wire_cadence() {
|
||||||
|
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||||
|
let mut got: Vec<(u8, usize, f32)> = Vec::new();
|
||||||
|
// 10 ms of capture (480 samples), fed in ragged chunks: exactly two 5 ms haptics
|
||||||
|
// frames from the back pair, then one 10 ms speaker frame from the front pair.
|
||||||
|
let mut quad = Vec::new();
|
||||||
|
for _ in 0..2 * HAPTICS_FRAME_SAMPLES {
|
||||||
|
quad.extend_from_slice(&[0.25, 0.25, -0.5, -0.5]);
|
||||||
|
}
|
||||||
|
for chunk in quad.chunks(101) {
|
||||||
|
f.feed(chunk, |kind, frame| got.push((kind, frame.len(), frame[0])));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![
|
||||||
|
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||||
|
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
|
||||||
|
(PAD_AUDIO_KIND_SPEAKER, 2 * SPEAKER_FRAME_SAMPLES, 0.25),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framer_masks_disabled_kinds() {
|
||||||
|
// 20 ms of all-ones capture: 4 potential haptics frames, 2 potential speaker frames.
|
||||||
|
let quad = vec![1.0f32; 4 * HAPTICS_FRAME_SAMPLES * CAP_CHANNELS];
|
||||||
|
let mut kinds_seen = Vec::new();
|
||||||
|
// Haptics-only: the front pair is never split out, let alone encoded.
|
||||||
|
let mut f = PadFramer::new(KIND_BIT_HAPTICS);
|
||||||
|
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||||
|
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_HAPTICS; 4]);
|
||||||
|
// Speaker-only: no haptics frames.
|
||||||
|
let mut f = PadFramer::new(KIND_BIT_SPEAKER);
|
||||||
|
kinds_seen.clear();
|
||||||
|
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||||
|
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_SPEAKER; 2]);
|
||||||
|
// kinds = 0 is never spawned, but the framer must still be total: nothing comes out.
|
||||||
|
let mut f = PadFramer::new(0);
|
||||||
|
kinds_seen.clear();
|
||||||
|
f.feed(&quad, |kind, _| kinds_seen.push(kind));
|
||||||
|
assert!(kinds_seen.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framer_clear_drops_partials_only() {
|
||||||
|
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
|
||||||
|
let mut emitted = 0;
|
||||||
|
// 100 samples: no frame boundary reached yet.
|
||||||
|
f.feed(&vec![0.1; 100 * CAP_CHANNELS], |_, _| emitted += 1);
|
||||||
|
assert_eq!(emitted, 0);
|
||||||
|
f.clear();
|
||||||
|
// After the gap: exactly one haptics frame from 240 fresh samples — the 100 stale
|
||||||
|
// samples are gone (they would skew every later frame boundary).
|
||||||
|
f.feed(
|
||||||
|
&vec![0.2; HAPTICS_FRAME_SAMPLES * CAP_CHANNELS],
|
||||||
|
|kind, frame| {
|
||||||
|
emitted += 1;
|
||||||
|
assert_eq!(
|
||||||
|
(kind, frame.len()),
|
||||||
|
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(emitted, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_cap_requires_the_client_bit() {
|
||||||
|
// Without CLIENT_CAP_PAD_AUDIO the answer is no on EVERY platform (on Windows the
|
||||||
|
// env + provisioning legs are environment-dependent — not unit-tested here).
|
||||||
|
assert!(!host_cap(0));
|
||||||
|
assert!(!host_cap(punktfunk_core::quic::CLIENT_CAP_CURSOR));
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
-3
@@ -70,7 +70,13 @@
|
|||||||
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
|
||||||
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
|
||||||
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||||
#define PUNKTFUNK_ABI_VERSION 15
|
// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
|
||||||
|
// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
|
||||||
|
// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
|
||||||
|
// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
|
||||||
|
// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
|
||||||
|
// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
|
||||||
|
#define PUNKTFUNK_ABI_VERSION 16
|
||||||
|
|
||||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||||
@@ -94,6 +100,13 @@
|
|||||||
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||||
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
|
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
|
||||||
|
|
||||||
|
// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
|
||||||
|
// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
|
||||||
|
// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
|
||||||
|
// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
|
||||||
|
// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
|
||||||
|
#define PUNKTFUNK_HIDOUT_AUDIO_CTL 5
|
||||||
|
|
||||||
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||||
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
||||||
|
|
||||||
@@ -278,6 +291,28 @@
|
|||||||
// design/pen-tablet-input.md.)
|
// design/pen-tablet-input.md.)
|
||||||
#define PUNKTFUNK_HOST_CAP_PEN 16
|
#define PUNKTFUNK_HOST_CAP_PEN 16
|
||||||
|
|
||||||
|
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
|
||||||
|
// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
|
||||||
|
// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
|
||||||
|
// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
|
||||||
|
#define PUNKTFUNK_HOST_CAP_PAD_AUDIO 64
|
||||||
|
|
||||||
|
// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
|
||||||
|
// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
|
||||||
|
#define PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS 0
|
||||||
|
|
||||||
|
// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
|
||||||
|
// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
|
||||||
|
#define PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER 1
|
||||||
|
|
||||||
|
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
|
||||||
|
// stream (a real DualSense's voice coils).
|
||||||
|
#define PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS 1
|
||||||
|
|
||||||
|
// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
|
||||||
|
// stream.
|
||||||
|
#define PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER 2
|
||||||
|
|
||||||
// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor
|
// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor
|
||||||
// channel, `design/remote-desktop-sweep.md` M2).
|
// channel, `design/remote-desktop-sweep.md` M2).
|
||||||
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
|
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
|
||||||
@@ -288,6 +323,13 @@
|
|||||||
// forward-compatible.
|
// forward-compatible.
|
||||||
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
|
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
|
||||||
|
|
||||||
|
// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
|
||||||
|
// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
|
||||||
|
// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
|
||||||
|
// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
|
||||||
|
// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||||
|
#define PUNKTFUNK_CLIENT_CAP_PAD_AUDIO 8
|
||||||
|
|
||||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||||
@@ -367,6 +409,19 @@
|
|||||||
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
|
||||||
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
|
||||||
|
|
||||||
|
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
|
||||||
|
// forwards to) a real DualSense whose voice-coil actuators can play the
|
||||||
|
// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
|
||||||
|
// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
|
||||||
|
// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
|
||||||
|
// it drop the declaration).
|
||||||
|
#define ARRIVAL_FLAG_PAD_AUDIO_HAPTICS (1 << 8)
|
||||||
|
|
||||||
|
// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
|
||||||
|
// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
|
||||||
|
// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
|
||||||
|
#define ARRIVAL_FLAG_PAD_AUDIO_SPEAKER (1 << 9)
|
||||||
|
|
||||||
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
|
||||||
// client's snapshot fold and the host's per-pad accumulators.
|
// client's snapshot fold and the host's per-pad accumulators.
|
||||||
#define PUNKTFUNK_MAX_PADS 16
|
#define PUNKTFUNK_MAX_PADS 16
|
||||||
@@ -675,6 +730,18 @@
|
|||||||
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
|
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::client_caps`] bit: the client understands the pad-audio plane
|
||||||
|
// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
|
||||||
|
// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
|
||||||
|
// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
|
||||||
|
// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
|
||||||
|
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
|
||||||
|
// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
|
||||||
|
// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
|
||||||
|
#define CLIENT_CAP_PAD_AUDIO 8
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||||
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||||
@@ -714,6 +781,19 @@
|
|||||||
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
|
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
|
||||||
|
// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
|
||||||
|
// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
|
||||||
|
// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
|
||||||
|
// capable client marks its pads' render capabilities on their arrivals
|
||||||
|
// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
|
||||||
|
// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
|
||||||
|
// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
|
||||||
|
// `0x01`/`0x02` are gamepad-state / clipboard.
|
||||||
|
#define HOST_CAP_PAD_AUDIO 64
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
@@ -1011,7 +1091,9 @@
|
|||||||
// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||||
// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||||
// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||||
// (0xCE, host→client).
|
// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
|
||||||
|
// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
|
||||||
|
// host→client).
|
||||||
#define PUNKTFUNK_AUDIO_MAGIC 201
|
#define PUNKTFUNK_AUDIO_MAGIC 201
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -1162,6 +1244,31 @@
|
|||||||
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
|
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
|
||||||
|
// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
|
||||||
|
// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
|
||||||
|
// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
|
||||||
|
// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
|
||||||
|
// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
|
||||||
|
// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
|
||||||
|
// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
|
||||||
|
// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
|
||||||
|
#define PAD_AUDIO_MAGIC 209
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
|
||||||
|
// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
|
||||||
|
#define PAD_AUDIO_KIND_HAPTICS 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
|
||||||
|
// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
|
||||||
|
#define PAD_AUDIO_KIND_SPEAKER 1
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||||
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||||
@@ -1476,7 +1583,11 @@ enum PunktfunkInputKind
|
|||||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13,
|
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13,
|
||||||
// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||||
// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||||
// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
|
||||||
|
// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
|
||||||
|
// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
|
||||||
|
// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
|
||||||
|
// Sent when the client opens a pad slot — before that pad's
|
||||||
// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||||
// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||||
// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||||
@@ -2404,6 +2515,50 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
|
|||||||
uint32_t timeout_ms);
|
uint32_t timeout_ms);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
|
||||||
|
// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
|
||||||
|
// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
|
||||||
|
// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
|
||||||
|
// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
|
||||||
|
// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
|
||||||
|
// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
|
||||||
|
// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
|
||||||
|
// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
|
||||||
|
// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
|
||||||
|
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
|
||||||
|
// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
|
||||||
|
// thread (one puller, may run alongside the other planes' pullers).
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||||
|
// `buf` is writable for `buf_len` bytes.
|
||||||
|
int32_t punktfunk_connection_next_pad_audio(PunktfunkConnection *c,
|
||||||
|
uint8_t *out_pad,
|
||||||
|
uint8_t *out_kind,
|
||||||
|
uint32_t *out_seq,
|
||||||
|
uint64_t *out_pts_ns,
|
||||||
|
uint8_t *buf,
|
||||||
|
uintptr_t buf_len,
|
||||||
|
uint32_t timeout_ms);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
|
||||||
|
// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
|
||||||
|
// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
|
||||||
|
// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
|
||||||
|
// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
|
||||||
|
// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
|
||||||
|
// before. Latest-wins per pad; unknown bits are masked off.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle. Callable from any thread.
|
||||||
|
PunktfunkStatus punktfunk_connection_set_pad_audio_caps(PunktfunkConnection *c,
|
||||||
|
uint8_t pad,
|
||||||
|
uint8_t audio_caps);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||||
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||||
|
|||||||
Reference in New Issue
Block a user