Controller haptics and speaker were dead on the Linux client — it streamed a quad into whatever sink was named like a DualSense #261
@@ -1604,21 +1604,42 @@ pub fn show_scoped(
|
||||
"Hold Select alone for the host's guide button — a tap still goes through",
|
||||
GUIDE_GESTURE_LABELS,
|
||||
);
|
||||
// Controller audio (the 0xD1 plane): a wired DualSense's own voice coils and its little
|
||||
// built-in speaker, streamed from the host and rendered on the pad in your hands. Both are
|
||||
// negotiated — they change nothing without a capable host AND a wired DualSense — so the
|
||||
// rows say what they are for rather than promising an effect.
|
||||
//
|
||||
// Deliberately NOT profileable: which pad is in your hands is a property of this device,
|
||||
// not of the host a profile is authored against (the forwarded-pad pin below sits out for
|
||||
// the same reason).
|
||||
let haptics_row = adw::SwitchRow::builder()
|
||||
.title("Controller haptics")
|
||||
.subtitle("Play a DualSense's voice-coil haptics on the pad itself — wired pads only")
|
||||
.build();
|
||||
let pad_speaker_row = adw::SwitchRow::builder()
|
||||
.title("Controller speaker")
|
||||
.subtitle("Play the audio a game sends to the pad's own speaker on the pad, not here")
|
||||
.build();
|
||||
// The pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
// seed's `set_active` fires this only when it CHANGES the switch). Controller audio
|
||||
// belongs in that set too — forwarding off never OPENS the pad, so nothing can detect
|
||||
// that it has an audio device, let alone render on it.
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
sb.set_sensitive(seed.gamepad_forwarding);
|
||||
gg.set_sensitive(seed.gamepad_forwarding);
|
||||
let (ha, sp) = (haptics_row.clone(), pad_speaker_row.clone());
|
||||
for w in [&f, &t, &sb, &gg] {
|
||||
w.set_sensitive(seed.gamepad_forwarding);
|
||||
}
|
||||
ha.set_sensitive(seed.gamepad_forwarding);
|
||||
sp.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
sb.set_sensitive(r.is_active());
|
||||
gg.set_sensitive(r.is_active());
|
||||
for w in [&f, &t, &sb, &gg] {
|
||||
w.set_sensitive(r.is_active());
|
||||
}
|
||||
ha.set_sensitive(r.is_active());
|
||||
sp.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1632,6 +1653,8 @@ pub fn show_scoped(
|
||||
scale_row.set_selected(index::render_scale(s));
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
haptics_row.set_active(s.pad_haptics);
|
||||
pad_speaker_row.set_active(pf_client_core::pad_audio::speaker_active(&s.pad_speaker));
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
sysbtn_row.set_selected(index::system_buttons(s));
|
||||
gesture_row.set_selected(index::guide_gesture(s));
|
||||
@@ -2088,6 +2111,12 @@ pub fn show_scoped(
|
||||
controllers_group.add(pad_row.widget());
|
||||
controllers_group.add(sysbtn_row.widget());
|
||||
controllers_group.add(gesture_row.widget());
|
||||
// Global scope only — see the rows' own note. In profile scope they would have no
|
||||
// override marker and no way to record a touch, so a toggle would be silently discarded.
|
||||
if !profile_mode {
|
||||
controllers_group.add(&haptics_row);
|
||||
controllers_group.add(&pad_speaker_row);
|
||||
}
|
||||
controllers.add(&controllers_group);
|
||||
|
||||
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
|
||||
@@ -2163,6 +2192,15 @@ pub fn show_scoped(
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.gamepad_forwarding = pad_forward_row.is_active();
|
||||
s.pad_haptics = haptics_row.is_active();
|
||||
// `"mix"` is a stored value this switch cannot express (it renders as off today,
|
||||
// pending the mixer leg), so writing the switch back unconditionally would erase
|
||||
// it just by opening and closing the dialog — the same trap the gamepad-type row
|
||||
// guards above. Only write when the user actually moved it.
|
||||
let want_speaker = pad_speaker_row.is_active();
|
||||
if want_speaker != pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
|
||||
s.pad_speaker = if want_speaker { "pad" } else { "off" }.to_string();
|
||||
}
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.echo_cancel = echo_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
|
||||
@@ -821,6 +821,29 @@ mod session_main {
|
||||
};
|
||||
}
|
||||
|
||||
// `--pad-audio-test [--seconds N] [--speaker] [--coils]`: the controller-audio
|
||||
// correlation, printed, then a tone driven into the pad. The one tool that separates
|
||||
// "the plane never arrived" from "it arrived and the graph folded the coil pair away"
|
||||
// — no host, no game, no pairing needed, just a wired DualSense.
|
||||
#[cfg(target_os = "linux")]
|
||||
if arg_flag("--pad-audio-test") {
|
||||
let seconds = arg_value("--seconds")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3);
|
||||
// Coils by default: they are the half that silently disappears, so they are the
|
||||
// half worth testing. `--speaker` adds (or, with nothing else, selects) the
|
||||
// speaker pair.
|
||||
let speaker = arg_flag("--speaker");
|
||||
let coils = arg_flag("--coils") || !speaker;
|
||||
return match pf_client_core::pad_audio::pad_audio_test(seconds, coils, speaker) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("pad-audio-test: {e:#}");
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `--pair <PIN>`: enrol this machine against a host and exit. DEPRECATED — pairing is
|
||||
// a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell
|
||||
// (design/client-architecture-split.md §5). It still works, with a notice, for the one
|
||||
|
||||
@@ -789,6 +789,19 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
|
||||
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `−1` relationship rather than
|
||||
/// leaving it to a comment.
|
||||
/// A `u8` field lever: decimal, or `0x`-prefixed hex (these name DS5 report BYTES, and every
|
||||
/// reference to them — SDL's source, the reverse-engineering notes, this module's own comments —
|
||||
/// writes them in hex). `None` when unset or unparseable, so a typo falls back to the default
|
||||
/// rather than to zero.
|
||||
fn env_u8(key: &str) -> Option<u8> {
|
||||
let v = std::env::var(key).ok()?;
|
||||
let v = v.trim();
|
||||
match v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
|
||||
Some(hex) => u8::from_str_radix(hex, 16).ok(),
|
||||
None => v.parse().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
@@ -844,6 +857,39 @@ impl Ds5Feedback {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Point the pad's audio at its own SPEAKER, and give that speaker a volume.
|
||||
///
|
||||
/// Without this the speaker is silent no matter how correct the PCM routing is, which is
|
||||
/// exactly what a wired DS5 on a Steam Deck did: haptics felt, speaker inaudible. The
|
||||
/// reason is that **channel 1 of the pad's audio function is shared** — it is the headphone
|
||||
/// jack's right channel AND the built-in mono speaker — and which one physically sounds is
|
||||
/// chosen by `ucAudioEnableBits` (report byte 8, struct offset 7). A pad powers up pointing
|
||||
/// at the headphone jack, so with nothing plugged in the speaker pair goes nowhere. The
|
||||
/// voice coils are channels 2/3 and are NOT affected by that select, which is why haptics
|
||||
/// work the instant the samples are routed right and the speaker does not.
|
||||
///
|
||||
/// We only ever wrote these bytes when a host forwarded a game's [`HidOutput::AudioCtl`],
|
||||
/// so a title that manages no audio settings of its own left the speaker dead. This is the
|
||||
/// default that makes the stream audible; a later `AudioCtl` still overrides it verbatim
|
||||
/// ([`Self::audio_ctl_packet`]), so a game that does drive its own volume still wins.
|
||||
///
|
||||
/// ⚠ `ucEnableBits1` bits 0/1 stay CLEAR — they are "enable rumble emulation" and "disable
|
||||
/// audio haptics", and asserting either would mute the coils this plane drives.
|
||||
///
|
||||
/// ⚠ `path` is empirical. Measured on a DualSense (`054c:0ce6`) using the pad's OWN
|
||||
/// microphone as the detector: `0x20` was loudest (~5× the noise floor at the test tone),
|
||||
/// `0x30` also sounded, `0x10` was silent. Overridable per-run with
|
||||
/// `PUNKTFUNK_PAD_SPEAKER_PATH` / `PUNKTFUNK_PAD_SPEAKER_VOLUME` so a field report can
|
||||
/// bisect it without a rebuild.
|
||||
fn speaker_enable_packet(volume: u8, path: u8) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
// bit5 = ucSpeakerVolume is valid, bit7 = the audio-control byte is valid.
|
||||
p[0] = 0x20 | 0x80;
|
||||
p[Self::AUDIO + 1] = volume; // ucSpeakerVolume
|
||||
p[Self::AUDIO + 3] = path; // ucAudioEnableBits
|
||||
p
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1333,10 +1379,48 @@ impl Worker {
|
||||
// ("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());
|
||||
//
|
||||
// ⚠ This needs SDL's HIDAPI driver to be the one on the pad — the
|
||||
// packet is a raw DS5 effects report, and SDL can only send it where
|
||||
// it owns the HID link. On a Linux box where the kernel's
|
||||
// `hid-playstation` has the pad instead, the call fails, and it is
|
||||
// worth SAYING so: `hid-playstation` asserts the same disable bit on
|
||||
// every force-feedback update it makes, so a pad some other program
|
||||
// has rumbled stays deaf to this plane until it is re-plugged. Not
|
||||
// fatal — nothing else asserts the bit in our own path, so the pad's
|
||||
// power-on default (audio haptics live) usually still stands.
|
||||
if let Err(e) = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet()) {
|
||||
tracing::info!(
|
||||
index,
|
||||
error = %e,
|
||||
"could not re-arm the DualSense's audio-haptics bit (SDL does \
|
||||
not own this pad's HID link) — haptics still work unless \
|
||||
something else has rumbled the pad this plug-in"
|
||||
);
|
||||
}
|
||||
}
|
||||
if slot.audio_caps & 0x02 != 0 {
|
||||
// Speaker activation: point the pad's shared channel-1 output at its
|
||||
// own speaker instead of the headphone jack it powers up on, and give
|
||||
// it a volume. Without this the speaker stream is routed perfectly and
|
||||
// heard by nobody — see `speaker_enable_packet`.
|
||||
let path = env_u8("PUNKTFUNK_PAD_SPEAKER_PATH").unwrap_or(0x20);
|
||||
let volume = env_u8("PUNKTFUNK_PAD_SPEAKER_VOLUME").unwrap_or(0x7F);
|
||||
if let Err(e) = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::speaker_enable_packet(volume, path))
|
||||
{
|
||||
tracing::info!(
|
||||
index,
|
||||
error = %e,
|
||||
"could not point the DualSense at its own speaker (SDL does \
|
||||
not own this pad's HID link) — the pad's speaker may stay \
|
||||
silent even though the stream reaches it"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
// needs the HID interface path; Linux matches by card identity.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
@@ -2914,6 +2998,55 @@ mod slot_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The speaker-enable default: volume and output-path land in the audio-control region at
|
||||
/// the same offsets an `AudioCtl` fold writes them, the two validity bits are set — and,
|
||||
/// most importantly, `ucEnableBits1` bits 0/1 stay CLEAR. Asserting either would enable
|
||||
/// rumble emulation / disable audio haptics and mute the very coils this plane drives, so
|
||||
/// making the speaker audible must never cost the haptics.
|
||||
#[test]
|
||||
fn speaker_enable_sets_volume_and_path_without_touching_the_haptics_bits() {
|
||||
let p = Ds5Feedback::speaker_enable_packet(0x7F, 0x20);
|
||||
assert_eq!(
|
||||
p[0] & 0x03,
|
||||
0,
|
||||
"rumble-emulation / disable-audio-haptics must stay clear"
|
||||
);
|
||||
assert_eq!(
|
||||
p[0],
|
||||
0x20 | 0x80,
|
||||
"speaker-volume + audio-control validity bits"
|
||||
);
|
||||
// ucSpeakerVolume is report byte 6 and ucAudioEnableBits report byte 8 — struct
|
||||
// offsets 5 and 7, i.e. AUDIO+1 and AUDIO+3.
|
||||
assert_eq!(p[5], 0x7F);
|
||||
assert_eq!(p[7], 0x20);
|
||||
// Nothing else in the packet moves (no rumble, no triggers, no LEDs).
|
||||
for (i, b) in p.iter().enumerate() {
|
||||
if !matches!(i, 0 | 5 | 7) {
|
||||
assert_eq!(*b, 0, "byte {i} should be untouched");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The field levers parse hex (how every reference writes these report bytes) and decimal,
|
||||
/// and a typo falls back to the default rather than silently meaning zero.
|
||||
#[test]
|
||||
fn env_u8_reads_hex_and_decimal() {
|
||||
assert_eq!(env_u8("PF_TEST_ABSENT_KEY_XYZ"), None);
|
||||
// Parsing is what is under test; the lookup is exercised by the None case above.
|
||||
for (s, want) in [
|
||||
("0x20", Some(0x20)),
|
||||
("0X7f", Some(0x7F)),
|
||||
("32", Some(32u8)),
|
||||
] {
|
||||
let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
|
||||
Some(hex) => u8::from_str_radix(hex, 16).ok(),
|
||||
None => s.parse().ok(),
|
||||
};
|
||||
assert_eq!(parsed, want, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -224,6 +224,20 @@ regular pad). Automatic arms it only where the raw guide press can't reach the h
|
||||
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
|
||||
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
|
||||
|
||||
**Controller haptics** — *default: on*, and **Controller speaker** — *default: on* on the Linux and
|
||||
Windows apps, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a
|
||||
DualSense's voice-coil haptics, and the little speaker in the middle of the pad. Both need a
|
||||
**wired** DualSense or DualSense Edge — over Bluetooth a controller exposes no audio device at all,
|
||||
and both settings quietly do nothing. Neither costs anything without a host that sends them: the
|
||||
plane is negotiated, and silence is never encoded or transmitted, so leaving haptics on is free even
|
||||
on a pad that never gets any. Turn **Controller speaker** off if you would rather all game audio came
|
||||
out of your speakers or headset.
|
||||
|
||||
Offered by the Linux, Windows and Android apps. On Linux, the client also switches the controller's
|
||||
sound card to Pro Audio while it needs the voice coils, and puts it back afterwards — see
|
||||
[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too)
|
||||
for why that is necessary and how to turn it off.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the
|
||||
console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
|
||||
|
||||
@@ -269,6 +269,8 @@ A few knobs are read by the native **clients**, not the host:
|
||||
| `PUNKTFUNK_DECODER` | `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software` | Force the decode path. Default auto-selects hardware per GPU vendor and falls back on its own: **Linux** — Vulkan Video first on NVIDIA and AMD, VAAPI first on Intel and anything else; **Windows** — Vulkan Video first on NVIDIA and AMD, D3D11VA first on Intel and anything else. Whichever isn't first is the next thing tried, with software last (OpenH264 for H.264, rav1d for AV1 — there is no software HEVC, so a client that lands there reconnects on a codec it can decode). The names are the ones the [stats overlay](/docs/stats) prints, so a pin and a reading match. The older spellings `vulkan`, `vaapi` and `d3d11va` named the FFmpeg-backed decoders the clients used before and still work — each migrates onto the native path for the same hardware, and the client says so in its log. |
|
||||
| `PUNKTFUNK_VAAPI_DEVICE` | path, e.g. `/dev/dri/renderD129` | **(Linux)** Pin the DRM render node the `native-vaapi` decoder opens. Unset, the client tries the nodes in order and takes the first that can decode the stream — set this on a multi-GPU box when it lands on the wrong one. |
|
||||
| `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). |
|
||||
| `PUNKTFUNK_PAD_SPEAKER_PATH` · `PUNKTFUNK_PAD_SPEAKER_VOLUME` | byte, hex or decimal *(default `0x20` / `0x7F`)* | Which output a DualSense sends [controller audio](/docs/controller-audio) to, and how loud. A controller's channel 1 is shared between its headphone jack and its built-in speaker, and it powers up pointing at the jack — so with no headphones plugged in the speaker stays silent however correctly the audio is routed. Punktfunk points it at the speaker when controller-speaker is on. Change these only if your pad's speaker stays quiet; a game that sets its own audio levels still overrides them. |
|
||||
| `PUNKTFUNK_PAD_AUDIO_PROFILE` | `0` | **(Linux)** Stop the client from switching a wired DualSense's sound card to **Pro Audio** while it streams [controller audio](/docs/controller-audio) to it. The switch exists because a controller's voice coils are channels 3 and 4 of its sound card, and a controller almost never presents four channels on its own — on any other profile the haptics are folded into the speaker pair and felt as nothing. Punktfunk restores the card's profile when the session ends and never saves it. Set this if you'd rather select the card's profile yourself. |
|
||||
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
|
||||
| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. |
|
||||
|
||||
@@ -17,8 +17,9 @@ pad's speaker, channels 3–4 are the voice coils.
|
||||
- **A DualSense or DualSense Edge plugged in over USB** on the client. Bluetooth pads expose no
|
||||
audio interface at all, so they fall back to ordinary rumble — this is a limit of the
|
||||
controller, not of Punktfunk.
|
||||
- On the client, **Controller haptics** is on by default. **Controller speaker** is opt-in: turn
|
||||
it on if you want game audio coming out of the pad as well as your speakers.
|
||||
- On the client, **Controller haptics** is on by default. So is **Controller speaker** on the Linux
|
||||
and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you would
|
||||
rather all game audio came out of your speakers. On Android the speaker is opt-in.
|
||||
- On a **Linux host**, a game that speaks DualSense — which in practice means running it under
|
||||
**GE-Proton 11-5 or newer**. Stock Proton does not route controller audio.
|
||||
- On the host, controller audio is on by default (`PUNKTFUNK_PAD_AUDIO`).
|
||||
@@ -109,6 +110,65 @@ PROTON_DUALSENSE_SPLIT_AUDIO=1 %command%
|
||||
To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for a line beginning
|
||||
`Routing DualSense`. It names the device it chose and how it opened it.
|
||||
|
||||
## On a Linux client, the pad's own profile matters too
|
||||
|
||||
Everything above is about the host, where the controller-audio device is one Punktfunk mints. On a
|
||||
Linux **client** the pad is real, and the same channel-layout problem shows up from the other side:
|
||||
the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a
|
||||
controller almost never presents as a four-channel device on its own. Depending on your distribution
|
||||
it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair. Playing into
|
||||
any of those puts the haptics in the headphone jack and folds the coil channels away — audio that
|
||||
looks perfectly healthy, felt as nothing at all.
|
||||
|
||||
**Punktfunk handles this for you.** When it needs the coils and the pad is not already presenting
|
||||
four channels, it switches the controller's card to **Pro Audio** for the length of the session and
|
||||
puts your setting back afterwards. You will see the profile change in your sound settings while you
|
||||
are streaming; that is expected. It is never saved as the card's remembered profile.
|
||||
|
||||
If you would rather manage the card yourself, set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` on the client. Then
|
||||
Punktfunk uses a four-channel profile if you have already selected one and logs what it needs if you
|
||||
have not.
|
||||
|
||||
Most systems never reach the switch at all. Where your distribution ships a recent `alsa-ucm-conf` —
|
||||
Bazzite and SteamOS among them — a DualSense already exposes its four channels behind its split
|
||||
speaker and headphone outputs, and Punktfunk finds them there. The switch is the fallback for
|
||||
systems that only offer the older stereo profile. **If you run the client as a Flatpak**, your audio
|
||||
manager may not let a sandboxed app change a card's profile; if the log says so, switch the
|
||||
controller to Pro Audio yourself, which is the same fix.
|
||||
|
||||
### Checking the client side without a host
|
||||
|
||||
The client can test the whole path on its own — no host, no game, no pairing. Plug in the
|
||||
DualSense and run:
|
||||
|
||||
```sh
|
||||
punktfunk-session --pad-audio-test
|
||||
```
|
||||
|
||||
It prints every DualSense object it can see in your audio graph, says which one it chose, and then
|
||||
plays a tone into the voice coils for three seconds. **If the pad buzzes, the client side is
|
||||
working** and any remaining silence is coming from the host or the game. Add `--speaker` to test
|
||||
the pad's speaker instead, and `--seconds N` for a longer run.
|
||||
|
||||
On the Steam Deck and other flatpak installs, run it inside the sandbox:
|
||||
|
||||
```sh
|
||||
flatpak run --command=punktfunk-session io.unom.Punktfunk --pad-audio-test
|
||||
```
|
||||
|
||||
### Why the speaker needs more than routing
|
||||
|
||||
The controller's speaker and its headphone jack **share a channel**. Channel 1 of the pad's audio
|
||||
device is the headphone jack's right channel *and* the built-in speaker, and the controller decides
|
||||
which one actually sounds. It powers up pointing at the jack — so with nothing plugged in, a
|
||||
perfectly routed speaker stream is heard by nobody.
|
||||
|
||||
Punktfunk points the pad at its own speaker when **Controller speaker** is on. The voice coils are
|
||||
different channels and are not affected by that choice, which is why haptics work as soon as the
|
||||
audio is routed correctly and the speaker needs this extra step. A game that drives the pad's audio
|
||||
settings itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH`
|
||||
and `PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT.
|
||||
@@ -119,3 +179,11 @@ To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for
|
||||
- **A pad plugged into the host itself can steal the audio.** If a real DualSense is connected to
|
||||
the host while you are streaming to a different one, some titles will find the local pad's sound
|
||||
card first. Unplug it, or stream from a host that has no pad attached.
|
||||
- **The Pro Audio switch on a Linux client renames the pad's microphone too.** Switching a sound
|
||||
card's profile re-creates all of its inputs and outputs, so if you had picked the DualSense's own
|
||||
microphone as your [mic](/docs/client-settings#audio), that session falls back to your default
|
||||
one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a
|
||||
four-channel profile on the card yourself.
|
||||
- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a
|
||||
session ends normally and is never written to your saved settings, so anything that reloads the
|
||||
card — unplugging it, logging out, a reboot — brings your own profile back.
|
||||
|
||||
Reference in New Issue
Block a user