From d3de27c1e791266ff12a23543709b01a1ac5069a Mon Sep 17 00:00:00 2001 From: lordvicky Date: Thu, 16 Jul 2026 21:25:55 +0400 Subject: [PATCH 01/34] Strip game bindings from imported profiles Imported/library profiles carrying meta.game landed as game profiles for games never located on this machine, duplicating real game profiles. importProfile now drops meta.game so imports arrive as plain trigger profiles, and resetToLibrary preserves the locally attached game instead of adopting one from the fetched copy. --- .../src/main/trigger-profile-store.test.ts | 37 +++++++++++++++++++ .../src/main/trigger-profile-store.ts | 13 ++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ds5-bridge/companion/src/main/trigger-profile-store.test.ts b/ds5-bridge/companion/src/main/trigger-profile-store.test.ts index 58d815c..6ecc541 100644 --- a/ds5-bridge/companion/src/main/trigger-profile-store.test.ts +++ b/ds5-bridge/companion/src/main/trigger-profile-store.test.ts @@ -119,6 +119,25 @@ describe('TriggerProfileStore importProfile', () => { expect(result.ok).toBe(false); }); + it('strips meta.game so an import never lands as a game profile', () => { + const result = store.importProfile({ ...profile, meta: { game: 'Cyberpunk 2077', author: 'me' } }, 'import'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.profile.meta?.game).toBeUndefined(); + expect(result.profile.meta?.author).toBe('me'); + } + }); + + it('strips meta.game on library installs too', () => { + const result = store.importProfile( + { ...profile, meta: { game: 'Cyberpunk 2077' } }, + 'library', + 'generic-shooter.json' + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.profile.meta?.game).toBeUndefined(); + }); + it('records the library file a library install came from', () => { const result = store.importProfile(profile, 'library', 'generic-shooter.json'); expect(result.ok).toBe(true); @@ -169,6 +188,24 @@ describe('TriggerProfileStore resetToLibrary', () => { expect(store.list().filter((p) => p.id !== 'default')).toHaveLength(1); }); + it('keeps a locally attached game binding instead of taking meta.game from the fetched copy', () => { + const original = install(); + // The user attached the installed profile to a local game. + store.save({ ...original, meta: { ...original.meta, game: 'My Local Game' } }); + + const result = store.resetToLibrary(original.id, { ...installed, meta: { game: 'Publisher Game' } }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.profile.meta?.game).toBe('My Local Game'); + }); + + it('does not adopt a game binding from the fetched copy when none exists locally', () => { + const original = install(); + const result = store.resetToLibrary(original.id, { ...installed, meta: { game: 'Publisher Game' } }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.profile.meta?.game).toBeUndefined(); + }); + it('keeps the libraryFile so the profile can be reset again', () => { const original = install(); const result = store.resetToLibrary(original.id, installed); diff --git a/ds5-bridge/companion/src/main/trigger-profile-store.ts b/ds5-bridge/companion/src/main/trigger-profile-store.ts index 6151b48..04de81a 100644 --- a/ds5-bridge/companion/src/main/trigger-profile-store.ts +++ b/ds5-bridge/companion/src/main/trigger-profile-store.ts @@ -125,11 +125,16 @@ export class TriggerProfileStore { const existingNames = new Set(existing.map((entry) => entry.name)); const name = this.uniqueName(result.profile.name, existingNames); const id = uniqueTriggerProfileId(name, existingIds); + // Imported files can claim a game via meta.game, but the game was never + // located on this machine — keeping it would mint a pathless game profile + // that duplicates any real one. Imports always land as plain trigger + // profiles; the user attaches a game locally. + const { game: _droppedGame, ...incomingMeta } = result.profile.meta ?? {}; const copy: TriggerProfile = { ...result.profile, id, name, - meta: { ...result.profile.meta, source, ...(libraryFile ? { libraryFile } : {}) } + meta: { ...incomingMeta, source, ...(libraryFile ? { libraryFile } : {}) } }; return { ok: true, profile: this.save(copy) }; } @@ -145,13 +150,17 @@ export class TriggerProfileStore { if (!current) return { ok: false, error: `No profile with id ${id}` }; const result = validateTriggerProfile(parsed); if (!result.ok) return { ok: false, error: result.error }; + // A game binding is local state (attached on this machine), never part of + // the published profile — keep ours, ignore any meta.game in the fetch. + const { game: _fetchedGame, ...fetchedMeta } = result.profile.meta ?? {}; const restored: TriggerProfile = { ...result.profile, id: current.id, name: current.name, meta: { - ...result.profile.meta, + ...fetchedMeta, source: 'library', + ...(current.meta?.game ? { game: current.meta.game } : {}), ...(current.meta?.libraryFile ? { libraryFile: current.meta.libraryFile } : {}) } }; From 15554e853429a5dedcdeadebf294feea170712b6 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 08:21:38 +0400 Subject: [PATCH 02/34] Revert "Fix vdsd speaker audio pacing drift causing crackle" This reverts commit 1d8b97a7373b1387819c32793f4c9d8879fc0374. --- vds/src/platform/linux/vdsd.cc | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/vds/src/platform/linux/vdsd.cc b/vds/src/platform/linux/vdsd.cc index 7275404..74c40ce 100644 --- a/vds/src/platform/linux/vdsd.cc +++ b/vds/src/platform/linux/vdsd.cc @@ -85,14 +85,6 @@ constexpr auto kOutputTraceFeatureSlowWarn = std::chrono::milliseconds(20); * seconds. */ constexpr auto kAudioOutputInterval = std::chrono::milliseconds(10); -/* - * Cap on how far behind the fixed-cadence speaker deadline may fall before we - * resync instead of draining back-to-back. The producer (USB isochronous - * audio) delivers ~one 0x36 chunk per 10 ms; if we ever fall more than a few - * intervals behind (e.g. the speaker stream paused), replaying that backlog - * would just add latency, so we drop it and realign to the wall clock. - */ -constexpr auto kMaxAudioCatchup = std::chrono::milliseconds(50); constexpr auto kHapticsOutputBlockedRetry = std::chrono::milliseconds(2); constexpr auto kBluetoothPreemptWait = std::chrono::milliseconds(2000); constexpr auto kBluetoothPreemptPoll = std::chrono::milliseconds(100); @@ -1521,24 +1513,6 @@ int next_wakeup_timeout_ms(std::span ports, return timeout_ms; } -/* - * Advance a fixed-cadence send deadline without accumulating scheduler jitter. - * Stepping from the previous deadline (prev + interval) keeps the long-run - * average at exactly one send per interval, so the 100 Hz speaker consumer - * never drifts below the 100 Hz USB producer and stops overflowing the pending - * queue. A fresh deadline (prev == {}) or one that has fallen more than - * max_catchup behind resyncs to now + interval instead of replaying stale audio. - */ -inline Clock::time_point next_audio_deadline(Clock::time_point prev, - Clock::time_point now, - Clock::duration interval, - Clock::duration max_catchup) { - if (prev == Clock::time_point{} || now - prev > max_catchup) { - return now + interval; - } - return prev + interval; -} - bool flush_pending_audio_chunk(VirtualPort &port, vds::BtL2capBackend &bt_backend, std::uint32_t trace_flags, vds::Logger &logger) { @@ -1588,8 +1562,7 @@ bool flush_pending_audio_chunk(VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); } - port.next_haptics_send_time = next_audio_deadline( - port.next_haptics_send_time, now, kAudioOutputInterval, kMaxAudioCatchup); + port.next_haptics_send_time = now + kAudioOutputInterval; if (output_trace) { trace_output_latency(port.path, "audio_bt_send", From 59329e5b732d18f89dad94daa96e8e61b12e13ff Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 08:26:57 +0400 Subject: [PATCH 03/34] Sync vds with upstream 0.3.0-rc7 (Linux-relevant changes) Merge upstream hurryman2212/vds 1448bb8 (0.3.0-rc2, our import point) ..2d27ab0 (0.3.0-rc7) for Linux and common code, keeping all companion features (per-trigger persistent effects, V2 trigger FFB, global trigger intensity, touchpad grab, RSSI/battery reporting, lightbar handling). Brings in: - Linux microphone and headset support (BT mic decode, headset-aware audio output path, mute button and mic-select handling) - logrotate support and packaging/DKMS cleanup fixes - vds_hcd loaded before vdsd; driver versions reported in logs - upstream haptic-channel pre-gain back at the pre-0.2.1 value Our speaker-pacing fix (1d8b97a) was reverted beforehand; upstream's reworked audio sender replaces it. Windows-only changes (installer, windrv, win32 daemon) were not taken. Conflict resolutions: DsOutputState keeps the companion override layer (recompute_effective_state now also runs after upstream's new audio/mic state mutations); handle_bt_input applies companion input translation to the converted USB report after upstream's mic-audio branch; companion logging moved to the new LogScope enum (new Companion scope). --- vds/.github/workflows/build-all.yaml | 24 +- vds/99-vds-dualsense-wireplumber.conf | 23 +- vds/CMakeLists.txt | 4 +- vds/README-LINUX.md | 12 +- vds/README.md | 23 +- vds/include/uapi/vds.h | 32 +- vds/install-service.sh | 8 +- vds/logrotate-vds.conf | 12 + vds/module/vds_controller.h | 11 +- vds/module/vds_hcd_core.c | 257 +++++++++--- vds/module/vds_usb.h | 6 +- vds/module/vds_usb_core.c | 74 ++-- vds/packaging/ArchLinux/PKGINFO.in | 1 + vds/packaging/ArchLinux/build-arch.sh | 8 +- vds/packaging/ArchLinux/vds.install.in | 174 +++++---- vds/packaging/debian/build-deb.sh | 2 + vds/packaging/debian/control.in | 1 + vds/packaging/debian/postinst | 143 ++++--- vds/packaging/debian/postrm | 11 + vds/packaging/debian/prerm | 29 +- vds/src/platform/linux/vds_bluez.cc | 2 - vds/src/platform/linux/vds_bt.cc | 8 +- vds/src/platform/linux/vds_bt.hh | 4 +- vds/src/platform/linux/vdsd.cc | 515 +++++++++++++++++++------ vds/src/vds_companion.cc | 2 +- vds/src/vds_log.cc | 60 ++- vds/src/vds_log.hh | 21 +- vds/src/vds_protocol.cc | 475 +++++++++++++++++++---- vds/src/vds_protocol.hh | 68 +++- vds/src/vdsctl_common.cc | 15 +- vds/src/vdsd_common.cc | 62 ++- vds/src/vdsd_common.hh | 7 +- vds/vdsd.service.in | 1 + 33 files changed, 1554 insertions(+), 541 deletions(-) create mode 100644 vds/logrotate-vds.conf diff --git a/vds/.github/workflows/build-all.yaml b/vds/.github/workflows/build-all.yaml index c577436..c6c6ab4 100644 --- a/vds/.github/workflows/build-all.yaml +++ b/vds/.github/workflows/build-all.yaml @@ -260,14 +260,34 @@ jobs: prerelease=true fi + release_notes="$(mktemp)" + git log -1 --format=%B "$GITHUB_SHA" > "$release_notes" + cat >> "$release_notes" <<'RELEASE_NOTES' + + > [!CAUTION] + > + > The vDS Windows drivers are experimental. Installing or running them can cause + > unexpected system crashes or make Windows fail to boot. Use at your own risk. + > + > Open an elevated PowerShell session, enable Windows test signing, and reboot + > before installing the driver package: + > + > ```powershell + > bcdedit /set testsigning on + > shutdown /r /t 0 + > ``` + RELEASE_NOTES + if gh release view "$RAW_VERSION" >/dev/null 2>&1; then - gh release edit "$RAW_VERSION" --title "$RAW_VERSION" + gh release edit "$RAW_VERSION" \ + --title "$RAW_VERSION" \ + --notes-file "$release_notes" gh release upload "$RAW_VERSION" "${assets[@]}" --clobber else gh release create "$RAW_VERSION" "${assets[@]}" \ --target "$GITHUB_SHA" \ --title "$RAW_VERSION" \ - --notes "" + --notes-file "$release_notes" fi release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RAW_VERSION}" --jq .id)" diff --git a/vds/99-vds-dualsense-wireplumber.conf b/vds/99-vds-dualsense-wireplumber.conf index 4d126ea..81cf3ba 100644 --- a/vds/99-vds-dualsense-wireplumber.conf +++ b/vds/99-vds-dualsense-wireplumber.conf @@ -40,13 +40,24 @@ monitor.alsa.rules = [ "update-props": { "node.description": "Wireless Controller Mic" "node.nick": "Wireless Controller Mic" - # vDS does not support microphone input yet. Disable this source because - # capture traffic can disrupt the controller speaker, haptics output, - # and main audio playback. - "node.disabled": true + # Use passive links so idle mic monitors do not keep this source busy; + # otherwise the virtual USB mic can stay active after capture stops. + "node.passive": true + # Give this source no graph-driver priority because vDS mic pacing is + # bridged through Bluetooth and should not clock unrelated playback. + "priority.driver": 0 + # Keep this source visible but avoid selecting it as the default input. "priority.session": 1 - "priority.driver": 1 - "session.suspend-timeout-seconds": 0 + # Match PipeWire's default 128-frame graph quantum. vDS buffers the + # 10 ms Bluetooth mic packets in the virtual HCD, so forcing a 480-frame + # ALSA period makes PipeWire repeatedly resync this capture node. + "api.alsa.period-size": 128 + "api.alsa.period-num": 8 + "node.latency": "128/48000" + # Tell WirePlumber to suspend this source after 1 second of inactivity. + # This closes ALSA so idle level meters do not keep the controller mic + # stream active when no application is actually recording from it. + "session.suspend-timeout-seconds": 1 } } } diff --git a/vds/CMakeLists.txt b/vds/CMakeLists.txt index cc44ad4..72dd29d 100644 --- a/vds/CMakeLists.txt +++ b/vds/CMakeLists.txt @@ -58,7 +58,9 @@ target_include_directories(jsonl PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) if(WIN32) add_library(vds_win32 OBJECT src/platform/win32/vds_win32.cc) - target_include_directories(vds_win32 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_include_directories( + vds_win32 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/src) add_library(vds_io OBJECT src/platform/win32/vds_io.cc) target_include_directories(vds_io PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include diff --git a/vds/README-LINUX.md b/vds/README-LINUX.md index 5a18dc6..70b2a91 100644 --- a/vds/README-LINUX.md +++ b/vds/README-LINUX.md @@ -212,18 +212,10 @@ Set the `pro-audio` profile: wpctl set-profile ``` -> [!WARNING] -> -> The microphone/source node is disabled on purpose. vDS currently supports -> controller speaker and haptics output, but not microphone input. If -> WirePlumber, games, or tools such as `pavucontrol` open the capture endpoint, -> the extra USB audio traffic can disrupt controller speaker and haptics output, -> as well as main audio playback. - After setting the `pro-audio` profile, install the included WirePlumber rule. It gives the virtual controller stable display names, sets the output node to 4 -channels, disables channelmix normalization, disables the unsupported microphone -source node, and lowers its priority: +channels, disables channelmix normalization, and gives the microphone source a +low priority: ```sh mkdir -p ~/.config/wireplumber/wireplumber.conf.d diff --git a/vds/README.md b/vds/README.md index 8167c98..e6567e5 100644 --- a/vds/README.md +++ b/vds/README.md @@ -3,14 +3,17 @@ [![Sponsor](https://img.shields.io/badge/Sponsor-hurryman2212-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/hurryman2212) Virtual USB-to-Bluetooth bridge for DualSense and DualSense Edge Wireless -Controllers. Through Linux `vds_hcd.ko` and Windows `vds_usb.sys` kernel -drivers, vDS exposes a Bluetooth-connected controller as a virtual USB -DualSense-class device so games and applications can use features normally -available only over USB. The common userspace daemon `vdsd` translates the -virtual USB traffic to and from the physical controller's Bluetooth protocol. -Currently, vDS supports USB-based features that can be carried over Bluetooth, -except headset output, microphone input, and firmware updates (firmware updates -is not possible). +Controllers. vDS currently supports all USB-based DualSense features over +Bluetooth (except firmware update), including quadraphonic haptic feedback +(vibration and speaker output), adaptive triggers, microphone input, and +headphone output; microphone and headphone support was added thanks to +[@TechAntohere](https://github.com/TechAntohere). + +Through Linux `vds_hcd.ko` and Windows `vds_usb.sys` kernel drivers, vDS exposes +a Bluetooth-connected controller as a virtual USB DualSense-class device so +games and applications can use features normally available only over USB. The +common userspace daemon `vdsd` translates the virtual USB traffic to and from +the physical controller's Bluetooth protocol. Detailed DualSense output and haptics packet handling is based on [DS5Dongle](https://github.com/awalol/DS5Dongle) and protocol capture research. @@ -33,8 +36,8 @@ their addresses. ```sh vdsctl list-targets -vdsctl attach aa:bb:cc:dd:ee:01 --profile ds5 --ports 0 -vdsctl attach aa:bb:cc:dd:ee:02 --profile dse --ports 1 +vdsctl attach aa:bb:cc:dd:ee:01 --profile ds5 --ports 0 # Connect as DualSense +vdsctl attach aa:bb:cc:dd:ee:02 --profile dse --ports 1 # Connect as DualSense Edge vdsctl detach aa:bb:cc:dd:ee:02 # Detach aa:bb:cc:dd:ee:02 vdsctl list # Show registered controllers ``` diff --git a/vds/include/uapi/vds.h b/vds/include/uapi/vds.h index 6edb56b..ecd9246 100644 --- a/vds/include/uapi/vds.h +++ b/vds/include/uapi/vds.h @@ -45,6 +45,7 @@ enum vds_frame_type { VDS_FRAME_USB_INTERFACE = 7, VDS_FRAME_BT_CONTROL_PACKET = 8, VDS_FRAME_BT_INTERRUPT_PACKET = 9, + VDS_FRAME_USB_AUDIO_IN = 10, }; enum vds_status_flags { @@ -64,16 +65,19 @@ enum vds_port_info_flags { VDS_PORT_INFO_USB_PROFILE_VALID = 1u << 5, }; -enum vds_usb_interface_kind { +enum vds_usb_interface_type { VDS_USB_INTERFACE_HID = 0, VDS_USB_INTERFACE_AUDIO_OUT = 1, VDS_USB_INTERFACE_AUDIO_IN = 2, }; enum { + VDS_DRIVER_INFO_VERSION = 1, + VDS_DRIVER_VERSION_MAX = 64, VDS_PORT_INFO_VERSION = 2, VDS_PORT_BIND_VERSION = 1, VDS_FILTER_DEVICE_LIST_VERSION = 1, + VDS_FILTER_DEVICE_CHANGE_VERSION = 1, VDS_FILTER_MAX_DEVICES = 8, }; @@ -93,7 +97,7 @@ struct vds_frame_header { struct vds_usb_interface_event { __u8 interface_number; __u8 altsetting; - __u8 interface_kind; + __u8 interface_type; __u8 reserved; }; @@ -104,6 +108,12 @@ struct vds_status { __u64 frames_from_user; }; +struct vds_driver_info { + __u32 version; + __u32 size; + char driver_version[VDS_DRIVER_VERSION_MAX]; +}; + struct vds_profile_config { __u32 profile; __u32 polling_rate_mode; @@ -136,10 +146,17 @@ struct vds_filter_device_list { __u32 version; __u32 size; __u32 count; - __u32 reserved; + __u32 generation; struct vds_filter_device_info devices[VDS_FILTER_MAX_DEVICES]; }; +struct vds_filter_device_change { + __u32 version; + __u32 size; + __u32 generation; + __u32 reserved; +}; + #ifdef _WIN32 #define VDS_WIN_FILE_DEVICE_UNKNOWN 0x00000022 #define VDS_WIN_METHOD_BUFFERED 0 @@ -149,6 +166,9 @@ struct vds_filter_device_list { (((device_type) << 16) | ((access) << 14) | ((function) << 2) | \ (method)) +#define VDS_IOCTL_GET_DRIVER_INFO \ + VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x800, \ + VDS_WIN_METHOD_BUFFERED, VDS_WIN_FILE_READ_DATA) #define VDS_IOCTL_GET_PORT_INFO \ VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x802, \ VDS_WIN_METHOD_BUFFERED, VDS_WIN_FILE_READ_DATA) @@ -163,11 +183,17 @@ struct vds_filter_device_list { #define VDS_FILTER_IOCTL_GET_DEVICES \ VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x900, \ VDS_WIN_METHOD_BUFFERED, VDS_WIN_FILE_READ_DATA) +#define VDS_FILTER_IOCTL_WAIT_DEVICE_CHANGE \ + VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x901, \ + VDS_WIN_METHOD_BUFFERED, \ + VDS_WIN_FILE_READ_DATA | VDS_WIN_FILE_WRITE_DATA) #else #define VDS_IOC_GET_STATUS _IOR(VDS_IOC_MAGIC, 0x01, struct vds_status) #define VDS_IOC_SET_PROFILE _IOW(VDS_IOC_MAGIC, 0x02, struct vds_profile_config) #define VDS_IOC_CONNECT _IO(VDS_IOC_MAGIC, 0x03) #define VDS_IOC_DISCONNECT _IO(VDS_IOC_MAGIC, 0x04) +#define VDS_IOC_GET_DRIVER_INFO \ + _IOR(VDS_IOC_MAGIC, 0x05, struct vds_driver_info) #endif #endif /* _UAPI_VDS_H */ diff --git a/vds/install-service.sh b/vds/install-service.sh index 7d973f8..cf6cee2 100755 --- a/vds/install-service.sh +++ b/vds/install-service.sh @@ -60,10 +60,10 @@ fi systemctl daemon-reload -if [ "$enable_service" -eq 1 ]; then +if [ "$enable_service" -eq 1 ] && [ "$start_service" -eq 1 ]; then + systemctl enable --now "$service_name" +elif [ "$enable_service" -eq 1 ]; then systemctl enable "$service_name" -fi - -if [ "$start_service" -eq 1 ]; then +elif [ "$start_service" -eq 1 ]; then systemctl start "$service_name" fi diff --git a/vds/logrotate-vds.conf b/vds/logrotate-vds.conf new file mode 100644 index 0000000..87e436c --- /dev/null +++ b/vds/logrotate-vds.conf @@ -0,0 +1,12 @@ +/var/log/vdsd.log { + weekly + rotate 4 + missingok + notifempty + compress + delaycompress + create 0660 root root + postrotate + systemctl kill -s HUP vdsd.service >/dev/null 2>&1 || true + endscript +} diff --git a/vds/module/vds_controller.h b/vds/module/vds_controller.h index e6bc585..187d824 100644 --- a/vds/module/vds_controller.h +++ b/vds/module/vds_controller.h @@ -5,18 +5,9 @@ #include -#ifndef USB_DT_HID -#define USB_DT_HID 0x21 -#endif -#ifndef USB_DT_REPORT -#define USB_DT_REPORT 0x22 -#endif - -#define VDS_CONTROLLER_NO_INTERFACE 0xff -#define VDS_CONTROLLER_NO_ENDPOINT 0xff #define VDS_CONTROLLER_NO_AUDIO_FEATURE 0xff + #define VDS_CONTROLLER_HID_PACKET_SIZE 64 -#define VDS_CONTROLLER_AUDIO_FEATURE_COUNT 2 enum vds_controller_audio_feature { VDS_CONTROLLER_AUDIO_FEATURE_SPEAKER = 0, diff --git a/vds/module/vds_hcd_core.c b/vds/module/vds_hcd_core.c index 95e34c2..9909e13 100644 --- a/vds/module/vds_hcd_core.c +++ b/vds/module/vds_hcd_core.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +35,12 @@ #endif #define VDS_HCD_DRIVER_NAME "vds_hcd" +#define VDS_AUDIO_IN_BYTES_PER_MS 192 +#define VDS_AUDIO_OUT_BYTES_PER_MS 384 +#define VDS_AUDIO_IN_QUEUE_MS 100 +#define VDS_AUDIO_IN_QUEUE_MAX_BYTES \ + (VDS_AUDIO_IN_BYTES_PER_MS * VDS_AUDIO_IN_QUEUE_MS) + static unsigned int max_port = VDS_MAX_PORT_COUNT; module_param(max_port, uint, 0444); MODULE_PARM_DESC(max_port, "Number of virtual DualSense ports to create (1-4)"); @@ -56,6 +64,13 @@ struct vds_input_packet { u8 payload[VDS_CONTROLLER_HID_PACKET_SIZE]; }; +struct vds_audio_packet { + struct list_head list; + u32 offset; + u32 length; + u8 payload[VDS_FRAME_MAX_PAYLOAD]; +}; + enum vds_urb_context_state { VDS_URB_ACTIVE = 0, VDS_URB_PENDING_HID_IN, @@ -72,6 +87,7 @@ struct vds_urb_context { u8 feature_report_id; enum vds_urb_context_state state; bool deliver_iso_out; + bool deliver_iso_in; }; struct vds_hcd_dev { @@ -87,6 +103,7 @@ struct vds_hcd_dev { struct list_head pending_feature_get; struct list_head completed_urbs; struct list_head input_packets; + struct list_head audio_in_packets; struct task_struct *giveback_thread; u64 frames_to_user; u64 frames_from_user; @@ -101,6 +118,8 @@ struct vds_hcd_dev { bool opened; unsigned int port_index; u32 input_packet_count; + u32 audio_in_packet_count; + u32 audio_in_queued_bytes; struct vds_usb_device usb; }; @@ -394,6 +413,7 @@ static void vds_flush_buffered_frames_locked(struct vds_hcd_dev *dev) { struct vds_event *event, *event_tmp; struct vds_input_packet *packet, *packet_tmp; + struct vds_audio_packet *audio, *audio_tmp; list_for_each_entry_safe(packet, packet_tmp, &dev->input_packets, list) { @@ -402,6 +422,14 @@ static void vds_flush_buffered_frames_locked(struct vds_hcd_dev *dev) } dev->input_packet_count = 0; + list_for_each_entry_safe(audio, audio_tmp, &dev->audio_in_packets, + list) { + list_del(&audio->list); + kfree(audio); + } + dev->audio_in_packet_count = 0; + dev->audio_in_queued_bytes = 0; + list_for_each_entry_safe(event, event_tmp, &dev->events, list) { list_del(&event->list); kfree(event); @@ -488,6 +516,76 @@ static int vds_iso_out_urb(struct vds_hcd_dev *dev, struct urb *urb) return 0; } +static void vds_fill_audio_in_locked(struct vds_hcd_dev *dev, u8 *data, + u32 length) +{ + u32 copied = 0; + + while (copied < length && !list_empty(&dev->audio_in_packets)) { + struct vds_audio_packet *packet; + u32 available; + u32 copy_len; + + packet = list_first_entry(&dev->audio_in_packets, + struct vds_audio_packet, list); + available = packet->length - packet->offset; + copy_len = min(length - copied, available); + memcpy(data + copied, packet->payload + packet->offset, + copy_len); + copied += copy_len; + packet->offset += copy_len; + if (dev->audio_in_queued_bytes > copy_len) + dev->audio_in_queued_bytes -= copy_len; + else + dev->audio_in_queued_bytes = 0; + if (packet->offset == packet->length) { + list_del(&packet->list); + dev->audio_in_packet_count--; + kfree(packet); + } + } + + if (copied < length) + memset(data + copied, 0, length - copied); +} + +static int vds_iso_in_urb(struct vds_hcd_dev *dev, struct urb *urb) +{ + unsigned long flags; + u32 total = 0; + int i; + + if (!urb->transfer_buffer) + return -EPIPE; + + spin_lock_irqsave(&dev->lock, flags); + urb->error_count = 0; + for (i = 0; i < urb->number_of_packets; i++) { + struct usb_iso_packet_descriptor *desc = + &urb->iso_frame_desc[i]; + u8 *data; + + if (desc->offset > urb->transfer_buffer_length || + desc->length > urb->transfer_buffer_length - desc->offset) { + desc->status = -EOVERFLOW; + desc->actual_length = 0; + urb->error_count++; + continue; + } + + data = (u8 *)urb->transfer_buffer + desc->offset; + desc->actual_length = + min_t(u32, desc->length, VDS_AUDIO_IN_BYTES_PER_MS); + vds_fill_audio_in_locked(dev, data, desc->actual_length); + desc->status = 0; + total += desc->actual_length; + } + spin_unlock_irqrestore(&dev->lock, flags); + + urb->actual_length = total; + return 0; +} + static int vds_giveback_thread(void *data) { struct vds_hcd_dev *dev = data; @@ -516,15 +614,17 @@ static int vds_giveback_thread(void *data) ktime_t now = ktime_get(); s64 delay_us = ktime_us_delta(context->ready_time, now); - /* - * ISO audio URBs must complete at roughly realtime - * pace. Sleep in short chunks so module removal is not - * delayed by a long prequeued audio burst. - */ if (delay_us > 0) { + ktime_t expires = context->ready_time; + spin_unlock_irqrestore(&dev->lock, flags); - usleep_range(min_t(s64, delay_us, 5000), - min_t(s64, delay_us + 500, 5500)); + set_current_state(TASK_INTERRUPTIBLE); + if (!kthread_should_stop()) + schedule_hrtimeout_range_clock(&expires, + 50 * NSEC_PER_USEC, + HRTIMER_MODE_ABS, + CLOCK_MONOTONIC); + __set_current_state(TASK_RUNNING); continue; } } @@ -538,6 +638,8 @@ static int vds_giveback_thread(void *data) if (!status && context->deliver_iso_out) status = vds_iso_out_urb(dev, urb); + if (!status && context->deliver_iso_in) + status = vds_iso_in_urb(dev, urb); usb_hcd_giveback_urb(dev->hcd, urb, status); kfree(context); } @@ -701,6 +803,57 @@ static int vds_write_feature_reply(struct vds_hcd_dev *dev, const u8 *payload, return 0; } +static int vds_write_audio_in_frame(struct vds_hcd_dev *dev, const u8 *payload, + u32 length) +{ + struct vds_audio_packet *packet, *oldest; + unsigned long flags; + int ret = 0; + + if (!length) + return -EINVAL; + + packet = kmalloc_obj(*packet, GFP_KERNEL); + if (!packet) + return -ENOMEM; + + packet->offset = 0; + packet->length = length; + memcpy(packet->payload, payload, length); + + spin_lock_irqsave(&dev->lock, flags); + if (dev->stopping) { + ret = -ESHUTDOWN; + } else { + while (!list_empty(&dev->audio_in_packets) && + dev->audio_in_queued_bytes + length > + VDS_AUDIO_IN_QUEUE_MAX_BYTES) { + u32 available; + + oldest = list_first_entry(&dev->audio_in_packets, + struct vds_audio_packet, + list); + available = oldest->length - oldest->offset; + list_del(&oldest->list); + dev->audio_in_packet_count--; + if (dev->audio_in_queued_bytes > available) + dev->audio_in_queued_bytes -= available; + else + dev->audio_in_queued_bytes = 0; + kfree(oldest); + } + list_add_tail(&packet->list, &dev->audio_in_packets); + dev->audio_in_packet_count++; + dev->audio_in_queued_bytes += length; + dev->frames_from_user++; + packet = NULL; + } + spin_unlock_irqrestore(&dev->lock, flags); + + kfree(packet); + return ret; +} + static ssize_t vds_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { @@ -723,7 +876,8 @@ static ssize_t vds_write(struct file *file, const char __user *buf, if (header.flags) return -EINVAL; if (header.type != VDS_FRAME_USB_HID_IN && - header.type != VDS_FRAME_USB_FEATURE_REPLY) + header.type != VDS_FRAME_USB_FEATURE_REPLY && + header.type != VDS_FRAME_USB_AUDIO_IN) return -EINVAL; if (header.length) { @@ -736,6 +890,8 @@ static ssize_t vds_write(struct file *file, const char __user *buf, ret = vds_write_input_frame(dev, payload, header.length); else if (header.type == VDS_FRAME_USB_FEATURE_REPLY) ret = vds_write_feature_reply(dev, payload, header.length); + else if (header.type == VDS_FRAME_USB_AUDIO_IN) + ret = vds_write_audio_in_frame(dev, payload, header.length); kfree(payload); return ret ? ret : count; @@ -793,12 +949,14 @@ static void vds_set_connection(struct vds_hcd_dev *dev, bool connected) static long vds_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct vds_hcd_dev *dev = vds_from_file(file); + struct vds_driver_info driver_info; struct vds_profile_config profile; struct vds_status status; unsigned long flags; int ret; - if (cmd != VDS_IOC_GET_STATUS && vds_is_stopping(dev)) + if (cmd != VDS_IOC_GET_STATUS && cmd != VDS_IOC_GET_DRIVER_INFO && + vds_is_stopping(dev)) return -ESHUTDOWN; switch (cmd) { @@ -813,6 +971,16 @@ static long vds_ioctl(struct file *file, unsigned int cmd, unsigned long arg) if (copy_to_user((void __user *)arg, &status, sizeof(status))) return -EFAULT; return 0; + case VDS_IOC_GET_DRIVER_INFO: + memset(&driver_info, 0, sizeof(driver_info)); + driver_info.version = VDS_DRIVER_INFO_VERSION; + driver_info.size = sizeof(driver_info); + strscpy(driver_info.driver_version, VDS_VERSION, + sizeof(driver_info.driver_version)); + if (copy_to_user((void __user *)arg, &driver_info, + sizeof(driver_info))) + return -EFAULT; + return 0; case VDS_IOC_SET_PROFILE: if (copy_from_user(&profile, (void __user *)arg, sizeof(profile))) @@ -895,7 +1063,7 @@ static const struct file_operations vds_fops = { static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, struct vds_urb_context *context, - const struct urb *urb, bool input) + struct urb *urb, bool input) { unsigned int packets = max_t(unsigned int, urb->number_of_packets, 1); unsigned int duration_us = packets * 1000U; @@ -906,8 +1074,13 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, unsigned long flags; int i; - for (i = 0; i < urb->number_of_packets; i++) - total += urb->iso_frame_desc[i].length; + for (i = 0; i < urb->number_of_packets; i++) { + u32 length = urb->iso_frame_desc[i].length; + + if (input) + length = min_t(u32, length, VDS_AUDIO_IN_BYTES_PER_MS); + total += length; + } if (total) { /* * ALSA may submit a single descriptor containing multiple @@ -917,7 +1090,8 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, * or drops. Both virtual UAC1 streams are 48 kHz S16_LE: * OUT is 4ch (384 bytes/ms), IN is 2ch (192 bytes/ms). */ - u32 bytes_per_ms = input ? 192 : 384; + u32 bytes_per_ms = input ? VDS_AUDIO_IN_BYTES_PER_MS : + VDS_AUDIO_OUT_BYTES_PER_MS; u64 duration_by_bytes; duration_by_bytes = @@ -930,6 +1104,7 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, spin_lock_irqsave(&dev->lock, flags); next_ready = input ? &dev->next_iso_in_ready : &dev->next_iso_out_ready; base = ktime_after(*next_ready, now) ? *next_ready : now; + urb->start_frame = (int)ktime_to_ms(base); /* * Serialize ISO completion by the amount of PCM carried by each URB so * the host audio stack observes a realtime USB sink/source. @@ -939,39 +1114,6 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, spin_unlock_irqrestore(&dev->lock, flags); } -static int vds_iso_in_urb(struct urb *urb) -{ - u32 total = 0; - int i; - - if (!urb->transfer_buffer) - return -EPIPE; - - urb->error_count = 0; - for (i = 0; i < urb->number_of_packets; i++) { - struct usb_iso_packet_descriptor *desc = - &urb->iso_frame_desc[i]; - u8 *data; - - if (desc->offset > urb->transfer_buffer_length || - desc->length > urb->transfer_buffer_length - desc->offset) { - desc->status = -EOVERFLOW; - desc->actual_length = 0; - urb->error_count++; - continue; - } - - data = (u8 *)urb->transfer_buffer + desc->offset; - memset(data, 0, desc->length); - desc->status = 0; - desc->actual_length = desc->length; - total += desc->actual_length; - } - - urb->actual_length = total; - return 0; -} - static int vds_interrupt_out_urb(struct vds_hcd_dev *dev, struct urb *urb) { if (!urb->transfer_buffer_length) { @@ -1036,6 +1178,7 @@ static int vds_urb_enqueue(struct usb_hcd *hcd, struct urb *urb, context->feature_report_id = 0; context->state = VDS_URB_ACTIVE; context->deliver_iso_out = false; + context->deliver_iso_in = false; INIT_LIST_HEAD(&context->list); spin_lock_irqsave(&dev->lock, flags); @@ -1085,8 +1228,13 @@ static int vds_urb_enqueue(struct usb_hcd *hcd, struct urb *urb, } } else if (usb_urb_dir_in(urb) && vds_usb_device_is_audio_in(&dev->usb, endpoint)) { - ret = vds_iso_in_urb(urb); - timed_iso = ret == 0; + if (!urb->transfer_buffer) { + ret = -EPIPE; + } else { + ret = 0; + timed_iso = true; + context->deliver_iso_in = true; + } iso_input = true; } else { ret = -EPIPE; @@ -1120,12 +1268,12 @@ static int vds_urb_dequeue(struct usb_hcd *hcd, struct urb *urb, int status) context->status = status; context->ready_time = 0; wake_giveback = true; - } else { - wake_giveback = - vds_queue_urb_giveback_locked(dev, - context, - status); - } + } else { + wake_giveback = + vds_queue_urb_giveback_locked(dev, + context, + status); + } } } spin_unlock_irqrestore(&dev->lock, flags); @@ -1354,6 +1502,7 @@ static int vds_hcd_probe(struct platform_device *pdev) INIT_LIST_HEAD(&dev->pending_feature_get); INIT_LIST_HEAD(&dev->completed_urbs); INIT_LIST_HEAD(&dev->input_packets); + INIT_LIST_HEAD(&dev->audio_in_packets); dev->misc.minor = MISC_DYNAMIC_MINOR; snprintf(dev->misc_name, sizeof(dev->misc_name), "vds%u", diff --git a/vds/module/vds_usb.h b/vds/module/vds_usb.h index 0bf7ed9..92827ae 100644 --- a/vds/module/vds_usb.h +++ b/vds/module/vds_usb.h @@ -9,6 +9,8 @@ #include "vds_controller.h" +#define VDS_USB_AUDIO_FEATURE_COUNT 2 + struct vds_usb_device { /* Protects address/configuration/interface state and feature cache. */ spinlock_t lock; @@ -19,8 +21,8 @@ struct vds_usb_device { u8 audio_in_altsetting; u8 hid_idle; u8 hid_protocol; - u8 audio_mute[VDS_CONTROLLER_AUDIO_FEATURE_COUNT]; - s16 audio_volume[VDS_CONTROLLER_AUDIO_FEATURE_COUNT]; + u8 audio_mute[VDS_USB_AUDIO_FEATURE_COUNT]; + s16 audio_volume[VDS_USB_AUDIO_FEATURE_COUNT]; u16 feature_cache_len[256]; u8 feature_cache[256][VDS_CONTROLLER_HID_PACKET_SIZE]; }; diff --git a/vds/module/vds_usb_core.c b/vds/module/vds_usb_core.c index 220cd0e..ed3f58d 100644 --- a/vds/module/vds_usb_core.c +++ b/vds/module/vds_usb_core.c @@ -9,6 +9,7 @@ */ #include +#include #include #include "uapi/vds.h" @@ -31,6 +32,9 @@ #define UAC_FU_CTRL_MUTE 0x01 #define UAC_FU_CTRL_VOLUME 0x02 +#define VDS_USB_NO_INTERFACE 0xff +#define VDS_USB_NO_ENDPOINT 0xff + /* UAC1 volume values are signed 8.8 dB fixed-point units. */ #define VDS_USB_SPK_VOLUME_MIN (-100 * 256) #define VDS_USB_SPK_VOLUME_MAX 0 @@ -170,7 +174,7 @@ bool vds_usb_device_is_audio_in(const struct vds_usb_device *dev, int endpoint) const struct vds_controller_profile *profile = vds_usb_device_profile(dev); - return profile->audio_in_endpoint != VDS_CONTROLLER_NO_ENDPOINT && + return profile->audio_in_endpoint != VDS_USB_NO_ENDPOINT && endpoint == vds_usb_endpoint_number(profile->audio_in_endpoint); } @@ -249,12 +253,12 @@ static int vds_usb_enqueue_frame(const struct vds_usb_device_ops *ops, static void vds_usb_enqueue_interface_event(const struct vds_usb_device_ops *ops, void *context, u8 interface_number, - u8 altsetting, u8 interface_kind, gfp_t gfp) + u8 altsetting, u8 interface_type, gfp_t gfp) { struct vds_usb_interface_event event = { .interface_number = interface_number, .altsetting = altsetting, - .interface_kind = interface_kind, + .interface_type = interface_type, }; (void)vds_usb_enqueue_frame(ops, context, VDS_FRAME_USB_INTERFACE, @@ -304,7 +308,8 @@ static int vds_usb_audio_control(struct vds_usb_device *dev, struct urb *urb, switch (setup->bRequest) { case UAC_CS_REQ_GET_CUR: spin_lock_irqsave(&dev->lock, flags); - volume = cpu_to_le16(dev->audio_volume[feature_index]); + volume = + cpu_to_le16(dev->audio_volume[feature_index]); spin_unlock_irqrestore(&dev->lock, flags); break; case UAC_CS_REQ_GET_MIN: @@ -481,8 +486,10 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, device_desc.bDeviceProtocol = profile->device_protocol; device_desc.bMaxPacketSize0 = profile->max_packet_size0; device_desc.idVendor = cpu_to_le16(VDS_SONY_VENDOR_ID); - device_desc.idProduct = cpu_to_le16(profile->product_id); - device_desc.bcdDevice = cpu_to_le16(profile->device_version); + device_desc.idProduct = + cpu_to_le16(profile->product_id); + device_desc.bcdDevice = + cpu_to_le16(profile->device_version); device_desc.iManufacturer = 1; device_desc.iProduct = 2; device_desc.bNumConfigurations = @@ -491,8 +498,8 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, sizeof(device_desc)); case USB_DT_CONFIG: return vds_usb_copy_to_urb(urb, - profile->configuration_descriptor, - profile->configuration_descriptor_size); + profile->configuration_descriptor, + profile->configuration_descriptor_size); case USB_DT_DEVICE_QUALIFIER: return vds_usb_copy_to_urb(urb, &vds_qualifier_desc, sizeof(vds_qualifier_desc)); @@ -503,24 +510,25 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, NULL); case 1: return vds_usb_copy_string_descriptor(urb, - profile->manufacturer); + profile->manufacturer); case 2: return vds_usb_copy_string_descriptor(urb, - profile->product); + profile->product); default: return -EPIPE; } - case USB_DT_REPORT: + case HID_DT_REPORT: if ((index & 0xff) != profile->hid_interface) return -EPIPE; return vds_usb_copy_to_urb(urb, - profile->hid_report_descriptor, - profile->hid_report_descriptor_size); - case USB_DT_HID: + profile->hid_report_descriptor, + profile->hid_report_descriptor_size); + case HID_DT_HID: if ((index & 0xff) != profile->hid_interface) return -EPIPE; - return vds_usb_copy_to_urb(urb, profile->hid_descriptor, - profile->hid_descriptor_size); + return vds_usb_copy_to_urb(urb, + profile->hid_descriptor, + profile->hid_descriptor_size); default: return -EPIPE; } @@ -556,8 +564,10 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, if (value) return -EPIPE; vds_usb_enqueue_interface_event(ops, context, - profile->hid_interface, 0, - VDS_USB_INTERFACE_HID, gfp); + profile->hid_interface, + 0, + VDS_USB_INTERFACE_HID, + gfp); return 0; } if (value > 1) @@ -570,24 +580,23 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, vds_usb_set_status(ops, context, VDS_STATUS_AUDIO_ENABLED, audio_enabled); - vds_usb_enqueue_interface_event(ops, context, - profile->audio_out_interface, - value & 0xff, - VDS_USB_INTERFACE_AUDIO_OUT, - gfp); + vds_usb_enqueue_interface_event(ops, context, + profile->audio_out_interface, + value & 0xff, + VDS_USB_INTERFACE_AUDIO_OUT, + gfp); return 0; } - if (profile->audio_in_interface != - VDS_CONTROLLER_NO_INTERFACE && + if (profile->audio_in_interface != VDS_USB_NO_INTERFACE && (index & 0xff) == profile->audio_in_interface) { spin_lock_irqsave(&dev->lock, flags); dev->audio_in_altsetting = value & 0xff; spin_unlock_irqrestore(&dev->lock, flags); - vds_usb_enqueue_interface_event(ops, context, - profile->audio_in_interface, - value & 0xff, - VDS_USB_INTERFACE_AUDIO_IN, - gfp); + vds_usb_enqueue_interface_event(ops, context, + profile->audio_in_interface, + value & 0xff, + VDS_USB_INTERFACE_AUDIO_IN, + gfp); return 0; } return -EPIPE; @@ -596,7 +605,7 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, if ((index & 0xff) == profile->audio_out_interface) { configuration = dev->audio_out_altsetting; } else if (profile->audio_in_interface != - VDS_CONTROLLER_NO_INTERFACE && + VDS_USB_NO_INTERFACE && (index & 0xff) == profile->audio_in_interface) { configuration = dev->audio_in_altsetting; } else if ((index & 0xff) == profile->hid_interface) { @@ -639,7 +648,8 @@ int vds_usb_device_control_urb(struct vds_usb_device *dev, struct urb *urb, context, gfp); if (type == USB_TYPE_CLASS && (setup.bRequestType & USB_RECIP_MASK) == USB_RECIP_INTERFACE) { - if ((le16_to_cpu(setup.wIndex) & 0xff) == profile->hid_interface) + if ((le16_to_cpu(setup.wIndex) & 0xff) == + profile->hid_interface) return vds_usb_hid_control(dev, urb, &setup, profile, ops, context, gfp); return vds_usb_audio_control(dev, urb, &setup, profile); diff --git a/vds/packaging/ArchLinux/PKGINFO.in b/vds/packaging/ArchLinux/PKGINFO.in index b36f8e8..e8967af 100644 --- a/vds/packaging/ArchLinux/PKGINFO.in +++ b/vds/packaging/ArchLinux/PKGINFO.in @@ -21,6 +21,7 @@ optdepend = base-devel optdepend = clang optdepend = linux-headers optdepend = linux-lts-headers +optdepend = logrotate optdepend = pipewire optdepend = systemd optdepend = wireplumber diff --git a/vds/packaging/ArchLinux/build-arch.sh b/vds/packaging/ArchLinux/build-arch.sh index 22ece0d..cad4d3d 100755 --- a/vds/packaging/ArchLinux/build-arch.sh +++ b/vds/packaging/ArchLinux/build-arch.sh @@ -164,6 +164,8 @@ install -Dm0755 "${repo_root}/install-service.sh" \ "${package_root}/usr/share/vds/install-service.sh" install -Dm0755 "${repo_root}/uninstall-service.sh" \ "${package_root}/usr/share/vds/uninstall-service.sh" +install -Dm0644 "${repo_root}/logrotate-vds.conf" \ + "${package_root}/etc/logrotate.d/vds" install_dkms_sources "$dkms_source_dir" @@ -195,8 +197,8 @@ substitute_template "${script_dir}/vds.install.in" "${package_root}/.INSTALL" . | gzip -c >"$mtree_tmp" mv "$mtree_tmp" .MTREE trap - EXIT - bsdtar -cf - .PKGINFO .BUILDINFO .INSTALL .MTREE usr | - zstd -T0 -q -c >"$package_path" -) + bsdtar -cf - .PKGINFO .BUILDINFO .INSTALL .MTREE etc usr | + zstd -T0 -q -c >"$package_path" + ) echo "$package_path" diff --git a/vds/packaging/ArchLinux/vds.install.in b/vds/packaging/ArchLinux/vds.install.in index 90ef094..a50714d 100644 --- a/vds/packaging/ArchLinux/vds.install.in +++ b/vds/packaging/ArchLinux/vds.install.in @@ -12,38 +12,44 @@ _is_udev_running() { command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] } -_package_version_without_release() { - local version=$1 - - version=${version%-*} - version=${version#*:} - printf '%s' "$version" +_unload_vds_hcd_module() { + if [ -d "/sys/module/$_dkms_name" ]; then + modprobe -r "$_dkms_name" + fi } -_dkms_registered() { - local version=$1 - - dkms status -m "$_dkms_name" -v "$version" 2>/dev/null | - grep -Fq "$_dkms_name/$version" +_dkms_remove_all_versions() { + local ignore_errors=${1:-} + local line + local module + local module_version + local version + + while IFS= read -r line; do + module_version=${line%%,*} + module_version=${module_version%%:*} + module=${module_version%%/*} + version=${module_version#*/} + case "$module" in + "$_dkms_name" | "$_dkms_name"-*) + if [ -n "$version" ] && [ "$version" != "$module_version" ]; then + if ! dkms remove -m "$module" -v "$version" --all; then + [ "$ignore_errors" = "--ignore-errors" ] || return 1 + fi + fi + ;; + esac + done < <(dkms status 2>/dev/null || true) } _dkms_install_current() { - if _dkms_registered "$_dkms_version"; then - dkms remove -m "$_dkms_name" -v "$_dkms_version" --all - fi + _unload_vds_hcd_module || return 1 + _dkms_remove_all_versions dkms add -m "$_dkms_name" -v "$_dkms_version" dkms build -m "$_dkms_name" -v "$_dkms_version" dkms install -m "$_dkms_name" -v "$_dkms_version" } -_dkms_remove_version() { - local version=$1 - - if [ -n "$version" ] && _dkms_registered "$version"; then - dkms remove -m "$_dkms_name" -v "$version" --all || true - fi -} - _reload_udev_rules() { if _is_udev_running; then udevadm control --reload-rules || true @@ -53,7 +59,7 @@ _reload_udev_rules() { _install_vdsd_service() { if [ -x "$_install_service" ]; then - "$_install_service" --start || true + "$_install_service" --enable --start fi } @@ -71,87 +77,92 @@ _reload_systemd() { _print_bluetooth_pairing_notice() { cat <<'NOTICE' - -vDS initial Bluetooth pairing ------------------------------ - -Current limitation: run bluetoothd with the input plugin disabled -(--noplugin=input). vDS needs direct ownership of the Bluetooth HID Control -and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it -can claim the controller first and expose it as a regular Bluetooth gamepad -instead of letting vDS bridge it as a virtual USB controller. - -In simpler terms, using vDS on Linux currently means giving up Bluetooth -devices like mice or keyboards. A BlueZ userspace stack patch is being prepared -to remove this limitation. - -In the meantime, the helper script can install or remove the required -bluetoothd systemd drop-in override: - - sudo /usr/share/vds/override-bluetoothd.sh disable-input --restart - sudo /usr/share/vds/override-bluetoothd.sh enable-input --restart - -Pair each physical controller once before registering it with vdsctl. Start -bluetoothctl, put the controller in Bluetooth pairing mode by holding Create +## Initial Bluetooth Pairing + +> [!IMPORTANT] +> +> Current limitation: run `bluetoothd` with the input plugin disabled +> (`--noplugin=input`). vDS needs direct ownership of the Bluetooth HID Control +> and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it +> can claim the controller first and expose it as a regular Bluetooth gamepad +> instead of letting vDS bridge it as a virtual USB controller. +> +> In simpler terms, using vDS on Linux currently means giving up Bluetooth +> devices like mice or keyboards. :( A BlueZ userspace stack patch is being +> prepared to remove this limitation. +> +> In the meantime, the helper script `override-bluetoothd.sh` can install or +> remove the required `bluetooth.service` drop-in override: +> +> ```sh +> sudo ./override-bluetoothd.sh disable-input --restart +> sudo ./override-bluetoothd.sh enable-input --restart +> ``` + +Pair each physical controller once before registering it with `vdsctl`. Start +`bluetoothctl`, put the controller in Bluetooth pairing mode by holding Create and PS until the light bar blinks rapidly, then use the MAC address printed by -scan on: - - agent NoInputNoOutput - default-agent - pairable on - scan on - pair XX:XX:XX:XX:XX:XX - trust XX:XX:XX:XX:XX:XX - scan off - quit +`scan on`: + +```text +agent NoInputNoOutput +default-agent +pairable on +scan on +pair XX:XX:XX:XX:XX:XX +trust XX:XX:XX:XX:XX:XX +scan off +quit +``` When re-pairing a controller that BlueZ already knows, run -remove XX:XX:XX:XX:XX:XX before scan on. +`remove XX:XX:XX:XX:XX:XX` before `scan on`. NOTICE } _print_audio_setup_notice() { cat <<'NOTICE' - -vDS audio setup ---------------- +## Audio Setup Configure the virtual controller audio output as 48 kHz 4-channel S16_LE PCM. The exact setup differs by audio stack, such as PipeWire, PulseAudio, ALSA, or JACK. -For PipeWire/WirePlumber, set the controller card profile to pro-audio. Find +For PipeWire/WirePlumber, set the controller card profile to `pro-audio`. Find the WirePlumber device id: - wpctl status +```sh +wpctl status +``` List the available profile indexes for that device: - pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' +```sh +pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' +``` -Set the pro-audio profile: +Set the `pro-audio` profile: - wpctl set-profile +```sh +wpctl set-profile +``` -WARNING: -The microphone/source node is disabled on purpose. vDS currently supports -controller speaker and haptics output, but not microphone input. If -WirePlumber, games, or tools such as pavucontrol open the capture endpoint, -the extra USB audio traffic can disrupt controller speaker and haptics output, -as well as main audio playback. - -After setting the pro-audio profile, install the included WirePlumber rule. It +After setting the `pro-audio` profile, install the included WirePlumber rule. It gives the virtual controller stable display names, sets the output node to 4 -channels, disables channelmix normalization, disables the unsupported microphone -source node, and lowers its priority: +channels, disables channelmix normalization, and gives the microphone source a +low priority: - mkdir -p ~/.config/wireplumber/wireplumber.conf.d - cp /usr/share/doc/vds/examples/99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +```sh +mkdir -p ~/.config/wireplumber/wireplumber.conf.d +cp 99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +``` Restart the user audio services after changing this file: - systemctl --user restart pipewire pipewire-pulse wireplumber +```sh +systemctl --user restart pipewire pipewire-pulse wireplumber +``` NOTICE } @@ -165,11 +176,9 @@ post_install() { } pre_upgrade() { - local old_version - - old_version=$(_package_version_without_release "$2") _stop_vdsd_service - _dkms_remove_version "$old_version" + _unload_vds_hcd_module || return 1 + _dkms_remove_all_versions --ignore-errors } post_upgrade() { @@ -184,7 +193,8 @@ pre_remove() { if [ -x "$_uninstall_service" ]; then "$_uninstall_service" --stop --disable || true fi - _dkms_remove_version "$_dkms_version" + _unload_vds_hcd_module || return 1 + _dkms_remove_all_versions --ignore-errors } post_remove() { diff --git a/vds/packaging/debian/build-deb.sh b/vds/packaging/debian/build-deb.sh index cb6ab1c..f27c095 100755 --- a/vds/packaging/debian/build-deb.sh +++ b/vds/packaging/debian/build-deb.sh @@ -133,6 +133,8 @@ install -Dm0755 "${repo_root}/install-service.sh" \ "${package_root}/usr/share/vds/install-service.sh" install -Dm0755 "${repo_root}/uninstall-service.sh" \ "${package_root}/usr/share/vds/uninstall-service.sh" +install -Dm0644 "${repo_root}/logrotate-vds.conf" \ + "${package_root}/etc/logrotate.d/vds" install -d "$dkms_source_dir/include" install -m0644 "${repo_root}/module/Makefile" \ diff --git a/vds/packaging/debian/control.in b/vds/packaging/debian/control.in index 0fabd34..d78e034 100644 --- a/vds/packaging/debian/control.in +++ b/vds/packaging/debian/control.in @@ -6,6 +6,7 @@ Architecture: @ARCH@ Maintainer: Jihong Min Installed-Size: @INSTALLED_SIZE@ Depends: bluez, dkms, kmod, make, libc6, libbluetooth3, libdbus-1-3, libopus0, libstdc++6, libudev1 +Recommends: logrotate Suggests: linux-headers-amd64 | linux-headers-generic, clang, build-essential, pipewire, systemd, udev, wireplumber Description: Virtual DualSense Bluetooth-to-USB bridge vDS exposes Bluetooth-connected DualSense-class controllers through a virtual diff --git a/vds/packaging/debian/postinst b/vds/packaging/debian/postinst index 9d278d6..d3015a8 100755 --- a/vds/packaging/debian/postinst +++ b/vds/packaging/debian/postinst @@ -9,98 +9,125 @@ is_udev_running() { command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] } -print_bluetooth_pairing_notice() { - cat <<'NOTICE' - -vDS initial Bluetooth pairing ------------------------------ - -Current limitation: run bluetoothd with the input plugin disabled -(--noplugin=input). vDS needs direct ownership of the Bluetooth HID Control -and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it -can claim the controller first and expose it as a regular Bluetooth gamepad -instead of letting vDS bridge it as a virtual USB controller. - -In simpler terms, using vDS on Linux currently means giving up Bluetooth -devices like mice or keyboards. A BlueZ userspace stack patch is being prepared -to remove this limitation. - -In the meantime, the helper script can install or remove the required -bluetoothd systemd drop-in override: +unload_vds_hcd_module() { + if [ -d "/sys/module/$DKMS_NAME" ]; then + modprobe -r "$DKMS_NAME" + fi +} - sudo /usr/share/vds/override-bluetoothd.sh disable-input --restart - sudo /usr/share/vds/override-bluetoothd.sh enable-input --restart +remove_all_dkms_versions() { + dkms status 2>/dev/null | + while IFS= read -r line; do + module_version=${line%%,*} + module_version=${module_version%%:*} + module=${module_version%%/*} + version=${module_version#*/} + case "$module" in + "$DKMS_NAME" | "$DKMS_NAME"-*) + if [ -n "$version" ] && [ "$version" != "$module_version" ]; then + dkms remove -m "$module" -v "$version" --all + fi + ;; + esac + done +} -Pair each physical controller once before registering it with vdsctl. Start -bluetoothctl, put the controller in Bluetooth pairing mode by holding Create +print_bluetooth_pairing_notice() { + cat <<'NOTICE' +## Initial Bluetooth Pairing + +> [!IMPORTANT] +> +> Current limitation: run `bluetoothd` with the input plugin disabled +> (`--noplugin=input`). vDS needs direct ownership of the Bluetooth HID Control +> and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it +> can claim the controller first and expose it as a regular Bluetooth gamepad +> instead of letting vDS bridge it as a virtual USB controller. +> +> In simpler terms, using vDS on Linux currently means giving up Bluetooth +> devices like mice or keyboards. :( A BlueZ userspace stack patch is being +> prepared to remove this limitation. +> +> In the meantime, the helper script `override-bluetoothd.sh` can install or +> remove the required `bluetooth.service` drop-in override: +> +> ```sh +> sudo ./override-bluetoothd.sh disable-input --restart +> sudo ./override-bluetoothd.sh enable-input --restart +> ``` + +Pair each physical controller once before registering it with `vdsctl`. Start +`bluetoothctl`, put the controller in Bluetooth pairing mode by holding Create and PS until the light bar blinks rapidly, then use the MAC address printed by -scan on: - - agent NoInputNoOutput - default-agent - pairable on - scan on - pair XX:XX:XX:XX:XX:XX - trust XX:XX:XX:XX:XX:XX - scan off - quit +`scan on`: + +```text +agent NoInputNoOutput +default-agent +pairable on +scan on +pair XX:XX:XX:XX:XX:XX +trust XX:XX:XX:XX:XX:XX +scan off +quit +``` When re-pairing a controller that BlueZ already knows, run -remove XX:XX:XX:XX:XX:XX before scan on. +`remove XX:XX:XX:XX:XX:XX` before `scan on`. NOTICE } print_audio_setup_notice() { cat <<'NOTICE' - -vDS audio setup ---------------- +## Audio Setup Configure the virtual controller audio output as 48 kHz 4-channel S16_LE PCM. The exact setup differs by audio stack, such as PipeWire, PulseAudio, ALSA, or JACK. -For PipeWire/WirePlumber, set the controller card profile to pro-audio. Find +For PipeWire/WirePlumber, set the controller card profile to `pro-audio`. Find the WirePlumber device id: - wpctl status +```sh +wpctl status +``` List the available profile indexes for that device: - pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' - -Set the pro-audio profile: +```sh +pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' +``` - wpctl set-profile +Set the `pro-audio` profile: -WARNING: -The microphone/source node is disabled on purpose. vDS currently supports -controller speaker and haptics output, but not microphone input. If -WirePlumber, games, or tools such as pavucontrol open the capture endpoint, -the extra USB audio traffic can disrupt controller speaker and haptics output, -as well as main audio playback. +```sh +wpctl set-profile +``` -After setting the pro-audio profile, install the included WirePlumber rule. It +After setting the `pro-audio` profile, install the included WirePlumber rule. It gives the virtual controller stable display names, sets the output node to 4 -channels, disables channelmix normalization, disables the unsupported microphone -source node, and lowers its priority: +channels, disables channelmix normalization, and gives the microphone source a +low priority: - mkdir -p ~/.config/wireplumber/wireplumber.conf.d - cp /usr/share/doc/vds/examples/99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +```sh +mkdir -p ~/.config/wireplumber/wireplumber.conf.d +cp 99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +``` Restart the user audio services after changing this file: - systemctl --user restart pipewire pipewire-pulse wireplumber +```sh +systemctl --user restart pipewire pipewire-pulse wireplumber +``` NOTICE } case "$1" in configure) - if dkms status -m "$DKMS_NAME" -v "$DKMS_VERSION" 2>/dev/null | grep -Fq "$DKMS_NAME/$DKMS_VERSION"; then - dkms remove -m "$DKMS_NAME" -v "$DKMS_VERSION" --all - fi + unload_vds_hcd_module + remove_all_dkms_versions dkms add -m "$DKMS_NAME" -v "$DKMS_VERSION" dkms build -m "$DKMS_NAME" -v "$DKMS_VERSION" dkms install -m "$DKMS_NAME" -v "$DKMS_VERSION" @@ -110,7 +137,7 @@ configure) udevadm trigger --subsystem-match=input || true fi if [ -x "$INSTALL_SERVICE" ]; then - "$INSTALL_SERVICE" --start || true + "$INSTALL_SERVICE" --enable --start fi print_bluetooth_pairing_notice print_audio_setup_notice diff --git a/vds/packaging/debian/postrm b/vds/packaging/debian/postrm index ea65834..6f48627 100755 --- a/vds/packaging/debian/postrm +++ b/vds/packaging/debian/postrm @@ -9,6 +9,11 @@ is_udev_running() { command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] } +purge_runtime_state() { + rm -rf /var/lib/vds + rm -f /var/log/vdsd.log /var/log/vdsd.log.* +} + case "$1" in remove | purge | upgrade | failed-upgrade | abort-install | abort-upgrade | disappear) if is_systemd_running; then @@ -21,4 +26,10 @@ remove | purge | upgrade | failed-upgrade | abort-install | abort-upgrade | disa ;; esac +case "$1" in +purge) + purge_runtime_state + ;; +esac + exit 0 diff --git a/vds/packaging/debian/prerm b/vds/packaging/debian/prerm index 6d392e5..1449057 100755 --- a/vds/packaging/debian/prerm +++ b/vds/packaging/debian/prerm @@ -2,9 +2,31 @@ set -e DKMS_NAME="vds_hcd" -DKMS_VERSION="@DKMS_VERSION@" UNINSTALL_SERVICE="/usr/share/vds/uninstall-service.sh" +unload_vds_hcd_module() { + if [ -d "/sys/module/$DKMS_NAME" ]; then + modprobe -r "$DKMS_NAME" + fi +} + +remove_all_dkms_versions() { + dkms status 2>/dev/null | + while IFS= read -r line; do + module_version=${line%%,*} + module_version=${module_version%%:*} + module=${module_version%%/*} + version=${module_version#*/} + case "$module" in + "$DKMS_NAME" | "$DKMS_NAME"-*) + if [ -n "$version" ] && [ "$version" != "$module_version" ]; then + dkms remove -m "$module" -v "$version" --all || true + fi + ;; + esac + done +} + case "$1" in remove | deconfigure) if [ -x "$UNINSTALL_SERVICE" ]; then @@ -20,9 +42,8 @@ esac case "$1" in remove | upgrade | deconfigure) - if dkms status -m "$DKMS_NAME" -v "$DKMS_VERSION" 2>/dev/null | grep -Fq "$DKMS_NAME/$DKMS_VERSION"; then - dkms remove -m "$DKMS_NAME" -v "$DKMS_VERSION" --all || true - fi + unload_vds_hcd_module + remove_all_dkms_versions ;; esac diff --git a/vds/src/platform/linux/vds_bluez.cc b/vds/src/platform/linux/vds_bluez.cc index 63ad629..a5cf8a1 100644 --- a/vds/src/platform/linux/vds_bluez.cc +++ b/vds/src/platform/linux/vds_bluez.cc @@ -55,8 +55,6 @@ class DBusErrorGuard { return ::dbus_error_has_name(&error_, name) != 0; } - bool is_set() const { return ::dbus_error_is_set(&error_) != 0; } - std::string message() const { if (error_.message != nullptr) { return error_.message; diff --git a/vds/src/platform/linux/vds_bt.cc b/vds/src/platform/linux/vds_bt.cc index d5e4f15..8cfcec5 100644 --- a/vds/src/platform/linux/vds_bt.cc +++ b/vds/src/platform/linux/vds_bt.cc @@ -23,6 +23,7 @@ #include "unique_fd.hh" #include "vds_bt.hh" #include "vds_io.hh" +#include "vds_protocol.hh" namespace vds { @@ -269,7 +270,8 @@ std::optional> BtL2capBackend::read_feature_report() { std::span(buffer.data(), static_cast(got))); } -std::optional BtL2capBackend::read_input_report() { +std::optional> +BtL2capBackend::read_interrupt_packet() { std::array buffer{}; const ssize_t got = ::read(interrupt_fd_, buffer.data(), buffer.size()); if (got < 0) { @@ -282,8 +284,8 @@ std::optional BtL2capBackend::read_input_report() { if (got == 0) { throw std::runtime_error("Bluetooth L2CAP interrupt channel closed"); } - return bt_input_to_usb_input( - std::span(buffer.data(), static_cast(got))); + return std::vector( + buffer.begin(), buffer.begin() + static_cast(got)); } } // namespace vds diff --git a/vds/src/platform/linux/vds_bt.hh b/vds/src/platform/linux/vds_bt.hh index d0adfcc..22f351c 100644 --- a/vds/src/platform/linux/vds_bt.hh +++ b/vds/src/platform/linux/vds_bt.hh @@ -9,7 +9,6 @@ #include #include "unique_fd.hh" -#include "vds_protocol.hh" namespace vds { @@ -50,13 +49,12 @@ public: int control_fd() const { return control_fd_; } int interrupt_fd() const { return interrupt_fd_; } - const std::string &address() const { return address_; } void send_output_report(std::span report); bool try_send_output_report(std::span report); void send_feature_get(std::uint8_t report_id); void send_feature_set(std::span report); std::optional> read_feature_report(); - std::optional read_input_report(); + std::optional> read_interrupt_packet(); private: std::string address_; diff --git a/vds/src/platform/linux/vdsd.cc b/vds/src/platform/linux/vdsd.cc index 74c40ce..47acc78 100644 --- a/vds/src/platform/linux/vdsd.cc +++ b/vds/src/platform/linux/vdsd.cc @@ -58,7 +58,6 @@ namespace { using Clock = std::chrono::steady_clock; using vds::duration_us; using vds::hex_bytes; -using vds::hex_u16; using vds::hex_u8; constexpr const char *kDefaultControlSocket = "/run/vdsd.sock"; @@ -69,6 +68,11 @@ constexpr const char *kLinuxVirtualPortProviderUnavailable = constexpr std::size_t kTraceDumpMaxBytes = 96; constexpr std::size_t kUsbInputButtonsOffset = 8; constexpr std::size_t kUsbInputButtonsSize = 4; +constexpr std::size_t kUsbInputMuteButtonOffset = 10; +constexpr std::size_t kUsbInputHeadsetOffset = 54; +constexpr std::uint8_t kUsbInputHeadphonesPluggedMask = 0x01; +constexpr std::uint8_t kUsbInputMicPluggedMask = 0x02; +constexpr std::uint8_t kUsbInputMuteButtonMask = 0x04; constexpr std::uint64_t kInputTraceSummaryInterval = 1000; constexpr std::uint64_t kOutputTraceSummaryInterval = 250; constexpr int kMaxPortFramesPerWake = 64; @@ -90,6 +94,7 @@ constexpr auto kBluetoothPreemptWait = std::chrono::milliseconds(2000); constexpr auto kBluetoothPreemptPoll = std::chrono::milliseconds(100); constexpr int kInitialFeatureReportPollMs = 5000; constexpr std::size_t kMaxPendingAudioChunks = 8; +constexpr std::size_t kFreshPendingAudioChunks = 4; constexpr std::uint8_t kTestCommandReportId = 0x80; constexpr std::uint8_t kTestCommandResultReportId = 0x81; constexpr std::uint8_t kTestCommandCompleteStatus = 0x02; @@ -101,12 +106,13 @@ constexpr std::uint32_t kSpeakerWaveoutFrequencyHz = 1000; constexpr std::uint32_t kSpeakerWaveoutPeriodFrames = VDS_AUDIO_SAMPLE_RATE / kSpeakerWaveoutFrequencyHz; constexpr double kSpeakerWaveoutTwoPi = 6.28318530717958647692; -/* Arbitrary fixed amplitude used only for synthetic speaker test output. */ +/* Arbitrary fixed amplitude used only for synthetic WebHID speaker waveout. */ constexpr std::int16_t kSpeakerWaveoutAmplitude = 12000; constexpr std::array kInitialFeatureReportIds = {0x09, 0x20, 0x05}; volatile sig_atomic_t g_stop_requested = 0; +volatile sig_atomic_t g_log_reopen_requested = 0; struct Options { std::string socket = kDefaultControlSocket; @@ -125,12 +131,17 @@ struct TraceState { std::uint64_t dropped_usb_frame_count = 0; std::uint64_t dropped_audio_haptics_count = 0; std::uint64_t queue_dropped_audio_haptics_count = 0; + std::uint64_t stale_audio_haptics_count = 0; std::uint64_t blocked_audio_haptics_count = 0; std::uint64_t deferred_bt_state_count = 0; std::uint64_t coalesced_bt_state_count = 0; std::uint64_t blocked_bt_state_count = 0; std::uint64_t audio_usb_frame_count = 0; std::uint64_t bt_input_count = 0; + std::uint64_t bt_mic_packet_count = 0; + std::uint64_t bt_mic_drop_count = 0; + std::uint64_t bt_mic_decode_fail_count = 0; + std::uint64_t audio_in_forward_count = 0; LatencyTraceStats output_hid_latency; LatencyTraceStats output_feature_latency; LatencyTraceStats output_audio_latency; @@ -149,12 +160,10 @@ struct TraceState { std::uint64_t haptics_burst_chunks = 0; }; -using vds::active_trace_name; -using vds::kTraceAll; -using vds::kTraceInput; +using vds::kTraceInputAudio; +using vds::kTraceInputControl; using vds::kTraceOutput; using vds::trace_enabled; -using vds::trace_scope_name; using vds::trim_command; struct VirtualPort { @@ -162,6 +171,7 @@ struct VirtualPort { vds::UniqueFd fd; vds::PcmAudioExtractor extractor; vds::PcmAudioExtractor waveout_extractor; + vds::MicAudioDecoder mic_decoder; vds::HapticsPacketBuilder haptics_builder; vds::DsOutputState output_state; std::deque pending_audio_chunks; @@ -169,6 +179,12 @@ struct VirtualPort { std::optional pending_bt_state; std::optional pending_bt_state_report; Clock::time_point next_haptics_send_time{}; + bool audio_out_stream_active = false; + bool audio_in_stream_active = false; + bool mic_muted = false; + bool mute_button_down = false; + bool headset_plugged = false; + bool headset_mic_plugged = false; bool speaker_waveout_selected = true; bool speaker_waveout_active = false; std::uint32_t speaker_waveout_phase = 0; @@ -192,7 +208,7 @@ struct ControllerRuntime { std::string last_error; }; -enum class EventKind : std::uint32_t { +enum class EventType : std::uint32_t { Control = 1, Port = 2, BtControl = 3, @@ -203,7 +219,7 @@ enum class EventKind : std::uint32_t { }; struct EventSource { - EventKind kind; + EventType type; std::size_t index; }; @@ -219,7 +235,23 @@ class SocketPathGuard { std::string path_; }; -void signal_handler(int) { g_stop_requested = 1; } +void signal_handler(int signal) { + if (signal == SIGHUP) { + g_log_reopen_requested = 1; + return; + } + g_stop_requested = 1; +} + +void install_signal_handler(int signal) { + struct sigaction action{}; + action.sa_handler = signal_handler; + sigemptyset(&action.sa_mask); + if (::sigaction(signal, &action, nullptr) < 0) { + throw std::runtime_error("sigaction failed: " + + std::string(std::strerror(errno))); + } +} Options parse_platform_args(int argc, char **argv) { Options options; @@ -270,13 +302,13 @@ void trace_output_latency(const std::string &device, std::string_view label, stats.max_us = std::max(stats.max_us, elapsed_us); if (duration >= slow_threshold) { - logger.log("output", vds::LogLevel::Warn, + logger.log(vds::LogScope::Output, vds::LogLevel::Warn, device + " slow " + std::string(label) + " latency count=" + std::to_string(stats.count) + " latency_us=" + std::to_string(elapsed_us)); } if (stats.count == 1 || stats.count % kOutputTraceSummaryInterval == 0) { - logger.log("output", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, device + " " + std::string(label) + " latency summary count=" + std::to_string(stats.count) + " avg_us=" + std::to_string(stats.total_us / stats.count) + @@ -305,7 +337,7 @@ void trace_hid_out_report(const std::string &device, if (payload.size() >= 40) { line << " light_flags=" << hex_u8(payload[39]); } - logger.log("hid", vds::LogLevel::Debug, line.str()); + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, line.str()); const bool hid_out_changed = trace_state.last_hid_out.size() != payload.size() || @@ -327,11 +359,11 @@ void trace_hid_out_report(const std::string &device, changed << " right_trigger=" << hex_bytes(payload.subspan(11, 11), 11) << " left_trigger=" << hex_bytes(payload.subspan(22, 11), 11); } - logger.log("hid", vds::LogLevel::Debug, changed.str()); + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, changed.str()); } -const char *usb_interface_kind_name(std::uint8_t kind) { - switch (kind) { +const char *usb_interface_type_name(std::uint8_t type) { + switch (type) { case VDS_USB_INTERFACE_HID: return "hid"; case VDS_USB_INTERFACE_AUDIO_OUT: @@ -430,7 +462,7 @@ bool write_vds_frame(VirtualPort &port, std::span bytes, if (port.trace_state.dropped_usb_frame_count == 1 || port.trace_state.dropped_usb_frame_count % 1000 == 0) { logger.log( - "port", vds::LogLevel::Warn, + vds::LogScope::Port, vds::LogLevel::Warn, port.path + " vDS write queue full count=" + std::to_string(port.trace_state.dropped_usb_frame_count)); } @@ -473,7 +505,7 @@ bool flush_pending_bt_state_report(VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " flushed pending BT 0x31 state report"); } return true; @@ -482,7 +514,7 @@ bool flush_pending_bt_state_report(VirtualPort &port, ++port.trace_state.blocked_bt_state_count; if (output_trace && (port.trace_state.blocked_bt_state_count == 1 || port.trace_state.blocked_bt_state_count % 1000 == 0)) { - logger.log("hid", vds::LogLevel::Warn, + logger.log(vds::LogScope::Hid, vds::LogLevel::Warn, port.path + " pending BT 0x31 state report still blocked " "count=" + @@ -507,7 +539,7 @@ void forward_bt_state_if_changed(VirtualPort &port, ++port.trace_state.coalesced_bt_state_count; } if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " " + std::string(reason) + " skipped: BT state unchanged"); } @@ -515,7 +547,7 @@ void forward_bt_state_if_changed(VirtualPort &port, } if (port.pending_bt_state && *port.pending_bt_state == state) { if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " " + std::string(reason) + " skipped: BT state already pending"); } @@ -528,7 +560,7 @@ void forward_bt_state_if_changed(VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " " + std::string(reason) + " forwarded as BT 0x31 state report"); } @@ -543,7 +575,7 @@ void forward_bt_state_if_changed(VirtualPort &port, port.pending_bt_state_report = packet; if (output_trace && (port.trace_state.deferred_bt_state_count == 1 || port.trace_state.deferred_bt_state_count % 1000 == 0)) { - logger.log("hid", vds::LogLevel::Warn, + logger.log(vds::LogScope::Hid, vds::LogLevel::Warn, port.path + " deferred BT 0x31 state report count=" + std::to_string(port.trace_state.deferred_bt_state_count) + " coalesced=" + @@ -572,6 +604,7 @@ void ioctl_set_profile(int fd, std::uint32_t profile) { void reset_virtual_port(VirtualPort &port) { port.extractor = vds::PcmAudioExtractor{}; port.waveout_extractor = vds::PcmAudioExtractor{}; + port.mic_decoder = vds::MicAudioDecoder{}; port.haptics_builder = vds::HapticsPacketBuilder{}; port.output_state = vds::DsOutputState{}; port.pending_audio_chunks.clear(); @@ -579,6 +612,12 @@ void reset_virtual_port(VirtualPort &port) { port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); port.next_haptics_send_time = {}; + port.audio_out_stream_active = false; + port.audio_in_stream_active = false; + port.mic_muted = false; + port.mute_button_down = false; + port.headset_plugged = false; + port.headset_mic_plugged = false; port.speaker_waveout_selected = true; port.speaker_waveout_active = false; port.speaker_waveout_phase = 0; @@ -594,14 +633,18 @@ void reset_virtual_port(VirtualPort &port) { void disconnect_virtual_port(VirtualPort &port, vds::Logger &logger) { port.pending_audio_chunks.clear(); port.next_haptics_send_time = {}; + port.audio_out_stream_active = false; + port.audio_in_stream_active = false; + port.mic_muted = false; + port.mute_button_down = false; port.speaker_waveout_active = false; port.speaker_waveout_phase = 0; try { ioctl_noarg(port.fd.get(), VDS_IOC_DISCONNECT, "VDS_IOC_DISCONNECT"); - logger.log("usb", vds::LogLevel::Info, + logger.log(vds::LogScope::Usb, vds::LogLevel::Info, port.path + " virtual USB disconnected"); } catch (const std::exception &error) { - logger.log("usb", vds::LogLevel::Warn, + logger.log(vds::LogScope::Usb, vds::LogLevel::Warn, port.path + " disconnect failed: " + error.what()); } } @@ -612,7 +655,7 @@ void handle_frame(const vds_frame_header &header, VirtualPort &port, vds::Logger &logger) { if (header.type == VDS_FRAME_USB_INTERFACE) { if (payload.size() != sizeof(vds_usb_interface_event)) { - logger.log("usb", vds::LogLevel::Warn, + logger.log(vds::LogScope::Usb, vds::LogLevel::Warn, port.path + " malformed USB interface event len=" + std::to_string(payload.size())); return; @@ -622,15 +665,47 @@ void handle_frame(const vds_frame_header &header, std::memcpy(&event, payload.data(), sizeof(event)); std::ostringstream line; - line << port.path << " USB set_interface kind=" - << usb_interface_kind_name(event.interface_kind) + line << port.path << " USB set_interface type=" + << usb_interface_type_name(event.interface_type) << " number=" << static_cast(event.interface_number) << " alt=" << static_cast(event.altsetting); - if (event.interface_kind == VDS_USB_INTERFACE_AUDIO_OUT || - event.interface_kind == VDS_USB_INTERFACE_AUDIO_IN) { + if (event.interface_type == VDS_USB_INTERFACE_AUDIO_OUT || + event.interface_type == VDS_USB_INTERFACE_AUDIO_IN) { line << " stream=" << (event.altsetting != 0 ? "on" : "off"); } - logger.log("usb", vds::LogLevel::Info, line.str()); + logger.log(vds::LogScope::Usb, vds::LogLevel::Info, line.str()); + if (event.interface_type == VDS_USB_INTERFACE_AUDIO_OUT) { + port.audio_out_stream_active = event.altsetting != 0; + if (!port.audio_out_stream_active) { + port.pending_audio_chunks.clear(); + port.extractor = vds::PcmAudioExtractor{}; + } else { + port.output_state.set_audio_out_stream_active(true, + port.headset_plugged); + if (bt_backend) { + forward_bt_state_if_changed(port, *bt_backend, trace_flags, logger, + "audio out interface"); + } + } + } else if (event.interface_type == VDS_USB_INTERFACE_AUDIO_IN) { + port.audio_in_stream_active = event.altsetting != 0; + port.mic_decoder = vds::MicAudioDecoder{}; + if (bt_backend) { + const auto state_report = port.output_state.build_bt_mic_state_report( + port.audio_in_stream_active, port.mic_muted); + bt_backend->try_send_output_report(state_report); + const auto report = + port.output_state.build_bt_mic_report(port.audio_in_stream_active); + const bool sent = bt_backend->try_send_output_report(report); + if (trace_enabled(trace_flags, kTraceInputAudio)) { + logger.log( + vds::LogScope::InputAudio, vds::LogLevel::Info, + port.path + " mic " + + std::string(port.audio_in_stream_active ? "open" : "close") + + " sent=" + (sent ? "yes" : "no")); + } + } + } return; } @@ -672,7 +747,7 @@ void handle_frame(const vds_frame_header &header, } if (emit_trace) { - logger.log("usb", vds::LogLevel::Debug, line.str()); + logger.log(vds::LogScope::Usb, vds::LogLevel::Debug, line.str()); } } @@ -683,13 +758,16 @@ void handle_frame(const vds_frame_header &header, } if (!port.output_state.apply_usb_output_report(payload)) { if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " hid out ignored: unsupported output report"); } return; } + if (port.audio_out_stream_active || port.speaker_waveout_active) { + port.output_state.set_audio_out_stream_active(true, port.headset_plugged); + } if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " hid out accepted for BT state update"); } @@ -719,7 +797,7 @@ void handle_frame(const vds_frame_header &header, : (payload.size() >= 2 ? static_cast(payload[1]) : 0); const bool cache_hit = port.feature_cached[report_id]; if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " feature get report=" + hex_u8(report_id) + " request_len=" + std::to_string(requested_length) + " cache=" + (cache_hit ? "hit" : "miss") + " forwarded=" + @@ -755,11 +833,11 @@ void handle_frame(const vds_frame_header &header, } const std::uint8_t report_id = payload[0]; if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " feature set report=" + hex_u8(report_id) + " len=" + std::to_string(payload.size()) + " forwarded=" + (bt_backend ? "yes" : "no")); - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " feature set payload=" + hex_bytes(payload, kTraceDumpMaxBytes)); } @@ -781,7 +859,7 @@ void handle_frame(const vds_frame_header &header, test_result[3] = kTestCommandCompleteStatus; cache_feature_report(port, test_result, output_trace, logger); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " cached WebHID test result device=" + hex_u8(command_device) + " action=" + hex_u8(command_action) + " status=complete"); @@ -793,7 +871,7 @@ void handle_frame(const vds_frame_header &header, payload.size() > command_data_offset + 2 && payload[command_data_offset + 2] == kTestCommandSpeakerParam; if (output_trace) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " WebHID waveout target=" + std::string(port.speaker_waveout_selected ? "speaker" @@ -807,7 +885,9 @@ void handle_frame(const vds_frame_header &header, port.speaker_waveout_active = speaker_waveout; port.speaker_waveout_phase = 0; port.waveout_extractor = vds::PcmAudioExtractor{}; - port.output_state.set_audio_out_stream_active(speaker_waveout); + port.output_state.set_audio_out_stream_active(speaker_waveout, + port.headset_plugged); + port.audio_out_stream_active = speaker_waveout; if (!speaker_waveout) { port.pending_audio_chunks.clear(); } @@ -818,7 +898,7 @@ void handle_frame(const vds_frame_header &header, : "WebHID waveout route off"); } if (output_trace) { - logger.log("audio", vds::LogLevel::Info, + logger.log(vds::LogScope::Output, vds::LogLevel::Info, port.path + " WebHID waveout " + std::string(enable ? "on" : "off") + " target=" + std::string(port.speaker_waveout_selected @@ -858,14 +938,14 @@ void handle_frame(const vds_frame_header &header, if (output_trace) { if (chunk.has_haptics_signal) { if (!port.trace_state.haptics_burst_active) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " haptics burst start"); port.trace_state.haptics_burst_active = true; port.trace_state.haptics_burst_chunks = 0; } ++port.trace_state.haptics_burst_chunks; } else if (port.trace_state.haptics_burst_active) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " haptics burst end chunks=" + std::to_string(port.trace_state.haptics_burst_chunks)); port.trace_state.haptics_burst_active = false; @@ -893,7 +973,7 @@ void handle_frame(const vds_frame_header &header, (port.trace_state.dropped_audio_haptics_count == dropped_chunks || port.trace_state.dropped_audio_haptics_count % 1000 == 0)) { logger.log( - "audio", vds::LogLevel::Warn, + vds::LogScope::Output, vds::LogLevel::Warn, port.path + " dropped BT 0x36 haptics packets count=" + std::to_string(port.trace_state.dropped_audio_haptics_count) + " queue=" + @@ -903,7 +983,7 @@ void handle_frame(const vds_frame_header &header, } if (output_trace && queued_chunks > 0) { logger.log( - "audio", vds::LogLevel::Debug, + vds::LogScope::Output, vds::LogLevel::Debug, port.path + " audio out queued " + std::to_string(queued_chunks) + " BT 0x36 haptics packets pending=" + std::to_string(port.pending_audio_chunks.size()) + @@ -956,17 +1036,78 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, vds::CompanionRuntime &companion, std::uint32_t trace_flags, vds::Logger &logger) { const auto read_time = Clock::now(); - auto report = bt_backend.read_input_report(); - if (!report) { + const auto packet = bt_backend.read_interrupt_packet(); + if (!packet) { return false; } + + if (vds::bt_input_payload_type(*packet) == vds::BtInputPayloadType::Audio) { + const bool input_audio_trace = trace_enabled(trace_flags, kTraceInputAudio); + ++port.trace_state.bt_mic_packet_count; + if (!port.audio_in_stream_active) { + ++port.trace_state.bt_mic_drop_count; + if (input_audio_trace && + (port.trace_state.bt_mic_drop_count == 1 || + port.trace_state.bt_mic_drop_count % 1000 == 0)) { + logger.log(vds::LogScope::InputAudio, vds::LogLevel::Debug, + port.path + " dropped mic packet count=" + + std::to_string(port.trace_state.bt_mic_drop_count) + + " reason=audio_in_stream_off"); + } + return true; + } + + const auto opus_payload = vds::bt_mic_opus_payload(*packet); + if (!opus_payload) { + ++port.trace_state.bt_mic_decode_fail_count; + if (input_audio_trace) { + logger.log(vds::LogScope::InputAudio, vds::LogLevel::Warn, + port.path + " malformed mic packet len=" + + std::to_string(packet->size())); + } + return true; + } + + try { + auto pcm = port.mic_decoder.decode(*opus_payload); + const auto frame = vds::frame_bytes(VDS_FRAME_USB_AUDIO_IN, pcm); + const bool wrote = + write_vds_frame(port, frame, input_audio_trace, logger); + ++port.trace_state.audio_in_forward_count; + if (input_audio_trace && (port.trace_state.audio_in_forward_count == 1 || + port.trace_state.audio_in_forward_count % + kInputTraceSummaryInterval == + 0)) { + logger.log(vds::LogScope::InputAudio, vds::LogLevel::Debug, + port.path + " mic forwarded count=" + + std::to_string(port.trace_state.audio_in_forward_count) + + " len=" + std::to_string(pcm.size()) + + " written=" + (wrote ? "yes" : "no")); + } + } catch (const std::exception &error) { + ++port.trace_state.bt_mic_decode_fail_count; + if (input_audio_trace) { + logger.log( + vds::LogScope::InputAudio, vds::LogLevel::Warn, + port.path + " mic decode failed count=" + + std::to_string(port.trace_state.bt_mic_decode_fail_count) + + ": " + error.what()); + } + } + return true; + } + + auto report = vds::bt_input_to_usb_input(*packet); + if (!report) { + return true; + } vds::companion_translate_input(companion, port.companion_input, std::span(*report)); // USB input payload offset 52 (report byte 53) carries the DualSense // power status: low nibble battery capacity 0-10, high nibble state. port.battery_status = (*report)[53]; - const bool input_trace = trace_enabled(trace_flags, kTraceInput); + const bool input_trace = trace_enabled(trace_flags, kTraceInputControl); if (input_trace) { ++port.trace_state.bt_input_count; if (port.trace_state.have_last_input_time) { @@ -976,7 +1117,7 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, port.trace_state.input_gap_max_us = std::max(port.trace_state.input_gap_max_us, gap_us); if (gap >= kInputTraceGapWarn) { - logger.log("input", vds::LogLevel::Warn, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Warn, port.path + " input gap count=" + std::to_string(port.trace_state.bt_input_count) + " gap_us=" + std::to_string(gap_us)); @@ -995,12 +1136,66 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, std::copy(buttons.begin(), buttons.end(), port.trace_state.last_input_buttons.begin()); port.trace_state.have_last_input_buttons = true; - logger.log("input", vds::LogLevel::Debug, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Debug, port.path + " buttons changed raw=" + hex_bytes(buttons, kUsbInputButtonsSize)); } } + const std::uint8_t headset_status = report->at(kUsbInputHeadsetOffset); + const bool headset_plugged = + (headset_status & kUsbInputHeadphonesPluggedMask) != 0; + const bool headset_mic_plugged = + (headset_status & kUsbInputMicPluggedMask) != 0; + const bool mute_button_down = + (report->at(kUsbInputMuteButtonOffset) & kUsbInputMuteButtonMask) != 0; + bool headset_mic_changed = false; + bool mic_mute_changed = false; + bool output_state_changed = false; + if (mute_button_down && !port.mute_button_down) { + port.mic_muted = !port.mic_muted; + mic_mute_changed = true; + } + port.mute_button_down = mute_button_down; + if (headset_mic_plugged != port.headset_mic_plugged) { + port.headset_mic_plugged = headset_mic_plugged; + headset_mic_changed = true; + } + if (mic_mute_changed || + (headset_mic_changed && port.audio_in_stream_active)) { + const auto report = port.output_state.build_bt_mic_state_report( + port.audio_in_stream_active, port.mic_muted); + bt_backend.try_send_output_report(report); + if (input_trace) { + logger.log(vds::LogScope::InputControl, vds::LogLevel::Info, + port.path + " mic " + + std::string(port.mic_muted ? "muted" : "unmuted")); + } + } + if (headset_plugged != port.headset_plugged) { + port.headset_plugged = headset_plugged; + if (port.audio_out_stream_active || port.speaker_waveout_active) { + port.output_state.set_audio_out_stream_active(true, port.headset_plugged); + output_state_changed = true; + } + if (input_trace) { + logger.log( + vds::LogScope::InputControl, vds::LogLevel::Info, + port.path + " headset " + + std::string(port.headset_plugged ? "plugged" : "unplugged")); + } + } + if (output_state_changed) { + forward_bt_state_if_changed(port, bt_backend, trace_flags, logger, + "headset route change"); + } + if (headset_mic_changed && input_trace) { + logger.log( + vds::LogScope::InputControl, vds::LogLevel::Info, + port.path + " headset mic " + + std::string(port.headset_mic_plugged ? "plugged" : "unplugged")); + } + vds_frame_header header{}; header.type = VDS_FRAME_USB_HID_IN; header.length = static_cast(report->size()); @@ -1023,7 +1218,7 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, std::max(port.trace_state.input_write_max_us, write_us); if (write_duration >= kInputTraceSlowWriteWarn) { - logger.log("input", vds::LogLevel::Warn, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Warn, port.path + " slow input write count=" + std::to_string(port.trace_state.bt_input_count) + " write_us=" + std::to_string(write_us) + @@ -1036,7 +1231,7 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, ? 0 : port.trace_state.input_write_total_us / port.trace_state.input_write_count; - logger.log("input", vds::LogLevel::Debug, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Debug, port.path + " input summary count=" + std::to_string(port.trace_state.bt_input_count) + " len=" + std::to_string(report->size()) + @@ -1069,7 +1264,7 @@ bool handle_bt_control(VirtualPort &port, vds::BtL2capBackend &bt_backend, const bool usb_waiting = pending != port.pending_feature_reports.end(); const bool output_trace = trace_enabled(trace_flags, kTraceOutput); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " bt feature cached report=" + hex_u8(report_id) + " len=" + std::to_string(report->size()) + " usb_waiting=" + (usb_waiting ? "yes" : "no")); @@ -1126,7 +1321,7 @@ void initialize_bt_controller(vds::BtL2capBackend &bt_backend, } } - logger.log("hid", vds::LogLevel::Info, + logger.log(vds::LogScope::Hid, vds::LogLevel::Info, port.path + " primed initial Bluetooth feature cache"); } @@ -1236,7 +1431,7 @@ void drop_bt_backend(ControllerRuntime &controller, VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "backend disconnected address=" + controller.config.address + " device=" + controller.device + " reason=" + reason); controller.backend.reset(); @@ -1268,7 +1463,28 @@ void sync_virtual_ports(std::vector &ports, } vds::UniqueFd fd(open_required(device, O_RDWR | O_NONBLOCK | O_CLOEXEC)); - logger.log("port", vds::LogLevel::Info, "opened " + device); + vds_driver_info driver_info{}; + std::string driver_message = "driver connected name=vds_hcd path=" + device; + vds::LogLevel driver_log_level = vds::LogLevel::Info; + if (::ioctl(fd.get(), VDS_IOC_GET_DRIVER_INFO, &driver_info) < 0) { + driver_log_level = vds::LogLevel::Warn; + driver_message = + "driver version unavailable name=vds_hcd path=" + device + + " detail=" + std::strerror(errno); + } else if (driver_info.version != VDS_DRIVER_INFO_VERSION || + driver_info.size != sizeof(driver_info) || + driver_info.driver_version[0] == '\0' || + driver_info.driver_version[VDS_DRIVER_VERSION_MAX - 1] != '\0') { + driver_log_level = vds::LogLevel::Warn; + driver_message = + "driver version unavailable name=vds_hcd path=" + device + + " detail=invalid driver version reply"; + } else { + driver_message = "driver connected name=vds_hcd version=" + + std::string(driver_info.driver_version) + + " path=" + device; + } + logger.log(vds::LogScope::Port, driver_log_level, driver_message); updated.push_back(VirtualPort{ .path = device, .fd = std::move(fd), @@ -1293,11 +1509,13 @@ void sync_virtual_ports(std::vector &ports, for (std::size_t i = 0; i < ports.size(); ++i) { if (!moved[i]) { - logger.log("port", vds::LogLevel::Info, "closed " + ports[i].path); + logger.log(vds::LogScope::Port, vds::LogLevel::Info, + "closed " + ports[i].path); } } if (updated.empty()) { - logger.log("port", vds::LogLevel::Warn, "no /dev/vds* endpoints found"); + logger.log(vds::LogScope::Port, vds::LogLevel::Warn, + "no /dev/vds* endpoints found"); } ports = std::move(updated); } @@ -1308,15 +1526,15 @@ void preempt_default_bluetooth_owner(const std::string &address, return; } - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "preempting default Bluetooth HID owner address=" + address); try { if (!vds::disconnect_bluez_device(address)) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "BlueZ device not found for disconnect address=" + address); } } catch (const std::exception &error) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "BlueZ disconnect failed address=" + address + " error=" + error.what()); } @@ -1324,14 +1542,14 @@ void preempt_default_bluetooth_owner(const std::string &address, const auto deadline = Clock::now() + kBluetoothPreemptWait; while (Clock::now() < deadline) { if (!vds::bluetooth_hid_device_present(address)) { - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "default stack released address=" + address); return; } std::this_thread::sleep_for(kBluetoothPreemptPoll); } - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "default Bluetooth HID owner still present address=" + address); } @@ -1350,7 +1568,7 @@ void complete_pending_controller(ControllerRuntime &controller, initialize_bt_controller(candidate, port, logger); candidate.send_output_report(port.output_state.build_bt_init_report()); port.last_sent_bt_state = port.output_state.state(); - logger.log("hid", vds::LogLevel::Info, + logger.log(vds::LogScope::Hid, vds::LogLevel::Info, port.path + " sent initial Bluetooth state report"); controller.backend = std::move(candidate); @@ -1360,7 +1578,7 @@ void complete_pending_controller(ControllerRuntime &controller, controller.last_error.clear(); logger.log( - "bluetooth", vds::LogLevel::Info, + vds::LogScope::Bluetooth, vds::LogLevel::Info, "raw L2CAP backend connected address=" + controller.config.address + " device=" + port.path + " profile=" + profile_name(profile)); } @@ -1379,13 +1597,13 @@ void handle_bt_accept(std::vector &ports, ControllerRuntime *controller = controller_for_address(controllers, accepted->address); if (!controller) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "rejected raw HID channel from unregistered address=" + accepted->address); continue; } if (controller->backend) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "rejected duplicate raw HID channel address=" + accepted->address); continue; @@ -1402,7 +1620,7 @@ void handle_bt_accept(std::vector &ports, if (!port_index) { if (ports.empty()) { controller->last_error = kLinuxVirtualPortProviderUnavailable; - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, "rejected raw HID channel address=" + accepted->address + " reason=" + kVirtualPortProviderUnavailableReason + " detail=" + kLinuxVirtualPortProviderUnavailable); @@ -1411,13 +1629,13 @@ void handle_bt_accept(std::vector &ports, port_index = available_port_index(ports, controllers, *controller); if (!port_index) { controller->last_error = "no available virtual port"; - logger.log("port", vds::LogLevel::Warn, + logger.log(vds::LogScope::Port, vds::LogLevel::Warn, "rejected raw HID channel address=" + accepted->address + " reason=no available virtual port"); continue; } controller->device = ports[*port_index].path; - logger.log("config", vds::LogLevel::Info, + logger.log(vds::LogScope::Config, vds::LogLevel::Info, "controller assigned address=" + controller->config.address + " device=" + controller->device + " profile=\"" + vds::controller_profile_name(controller->config.profile) + @@ -1426,12 +1644,12 @@ void handle_bt_accept(std::vector &ports, if (control_channel) { controller->pending_control_fd = std::move(accepted->fd); - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "accepted raw HID control channel address=" + accepted->address); } else { controller->pending_interrupt_fd = std::move(accepted->fd); - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "accepted raw HID interrupt channel address=" + accepted->address); } @@ -1446,7 +1664,7 @@ void handle_bt_accept(std::vector &ports, controller->pending_interrupt_fd.reset(); controller->device.clear(); controller->last_error = "assigned virtual port disappeared"; - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, "controller inactive address=" + controller->config.address + " reason=assigned port disappeared"); continue; @@ -1462,7 +1680,7 @@ void handle_bt_accept(std::vector &ports, controller->virtual_connected = false; controller->device.clear(); controller->last_error = error.what(); - logger.log("bluetooth", vds::LogLevel::Error, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Error, "connect failed address=" + controller->config.address + " device=" + ports[*port_index].path + " error=" + error.what()); @@ -1527,6 +1745,28 @@ bool flush_pending_audio_chunk(VirtualPort &port, } const bool output_trace = trace_enabled(trace_flags, kTraceOutput); + std::size_t stale_dropped = 0; + while (port.pending_audio_chunks.size() > kFreshPendingAudioChunks) { + port.pending_audio_chunks.pop_front(); + ++stale_dropped; + ++port.trace_state.dropped_audio_haptics_count; + ++port.trace_state.queue_dropped_audio_haptics_count; + ++port.trace_state.stale_audio_haptics_count; + } + if (stale_dropped > 0 && + (port.trace_state.stale_audio_haptics_count == stale_dropped || + port.trace_state.stale_audio_haptics_count % 1000 == 0)) { + logger.log( + vds::LogScope::Output, vds::LogLevel::Warn, + port.path + " dropped stale BT 0x36 audio packets count=" + + std::to_string(port.trace_state.stale_audio_haptics_count) + + " pending=" + std::to_string(port.pending_audio_chunks.size()) + + " dropped=" + + std::to_string(port.trace_state.dropped_audio_haptics_count) + + " blocked=" + + std::to_string(port.trace_state.blocked_audio_haptics_count)); + } + const auto &chunk = port.pending_audio_chunks.front(); vds::HapticsChunk haptics = chunk.haptics; if (port.haptics_gain_percent != 100) { @@ -1537,19 +1777,27 @@ bool flush_pending_audio_chunk(VirtualPort &port, } } const auto packet = port.haptics_builder.build_packet( - haptics, chunk.speaker, port.output_state.state()); + haptics, chunk.speaker, port.output_state.state(), true, + port.headset_plugged); const auto send_start = Clock::now(); if (!bt_backend.try_send_output_report(packet)) { ++port.trace_state.dropped_audio_haptics_count; ++port.trace_state.blocked_audio_haptics_count; + port.pending_audio_chunks.pop_front(); port.next_haptics_send_time = Clock::now() + kHapticsOutputBlockedRetry; - if (output_trace && - (port.trace_state.blocked_audio_haptics_count == 1 || - port.trace_state.blocked_audio_haptics_count % 1000 == 0)) { + if (port.trace_state.blocked_audio_haptics_count == 1 || + port.trace_state.blocked_audio_haptics_count % 1000 == 0) { logger.log( - "audio", vds::LogLevel::Warn, - port.path + " pending BT 0x36 haptics packet still blocked count=" + - std::to_string(port.trace_state.blocked_audio_haptics_count)); + vds::LogScope::Output, vds::LogLevel::Warn, + port.path + + " dropped BT 0x36 audio packet after HID queue blocked " + "count=" + + std::to_string(port.trace_state.blocked_audio_haptics_count) + + " pending=" + std::to_string(port.pending_audio_chunks.size()) + + " dropped=" + + std::to_string(port.trace_state.dropped_audio_haptics_count) + + " stale=" + + std::to_string(port.trace_state.stale_audio_haptics_count)); } return false; } @@ -1578,10 +1826,10 @@ void enqueue_speaker_waveout_chunk(VirtualPort &port, std::uint32_t trace_flags, return; } - std::array + std::array pcm{}; - for (std::size_t frame = 0; frame < vds::kSpeakerInputFrames; ++frame) { + for (std::size_t frame = 0; frame < vds::kPcmWindowFrames; ++frame) { const double angle = kSpeakerWaveoutTwoPi * static_cast(port.speaker_waveout_phase) / static_cast(kSpeakerWaveoutPeriodFrames); @@ -1607,7 +1855,7 @@ void enqueue_speaker_waveout_chunk(VirtualPort &port, std::uint32_t trace_flags, port.pending_audio_chunks.push_back(chunk); } if (trace_enabled(trace_flags, kTraceOutput) && !chunks.empty()) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " queued WebHID speaker waveout chunk pending=" + std::to_string(port.pending_audio_chunks.size())); } @@ -1674,12 +1922,12 @@ void reconcile_controller_configs(std::vector &ports, sync_virtual_ports(ports, devices, logger); if (!virtual_port_provider_available) { - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, std::string(kVirtualPortProviderUnavailableReason) + " detail=" + kLinuxVirtualPortProviderUnavailable); } const vds::ConfigDb db = vds::load_config_db(db_path); - logger.log("config", vds::LogLevel::Info, + logger.log(vds::LogScope::Config, vds::LogLevel::Info, "loaded controller config count=" + std::to_string(db.controllers.size())); @@ -1690,11 +1938,11 @@ void reconcile_controller_configs(std::vector &ports, for (const auto &config : db.controllers) { if (!virtual_port_provider_available) { - logger.log("config", vds::LogLevel::Warn, + logger.log(vds::LogScope::Config, vds::LogLevel::Warn, "controller binding unavailable address=" + config.address + " reason=" + kVirtualPortProviderUnavailableReason); } else if (!controller_has_present_allowed_port(ports, config)) { - logger.log("config", vds::LogLevel::Warn, + logger.log(vds::LogScope::Config, vds::LogLevel::Warn, "controller binding unavailable address=" + config.address + " reason=no allowed virtual port present ports=[" + vds::format_ports(config.ports) + "]"); @@ -1746,7 +1994,7 @@ void reconcile_controller_configs(std::vector &ports, disconnect_virtual_port(ports[*old_port_index], logger); } if (old.backend) { - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "raw backend removed address=" + old.config.address + " device=" + old.device); } @@ -1760,7 +2008,7 @@ void reconcile_controller_configs(std::vector &ports, break; } - logger.log("config", vds::LogLevel::Info, + logger.log(vds::LogScope::Config, vds::LogLevel::Info, "controller registered address=" + config.address + " profile=\"" + vds::controller_profile_name(config.profile) + "\""); @@ -1779,7 +2027,7 @@ void reconcile_controller_configs(std::vector &ports, } if (controllers[i].backend) { logger.log( - "bluetooth", vds::LogLevel::Info, + vds::LogScope::Bluetooth, vds::LogLevel::Info, "raw backend removed address=" + controllers[i].config.address + " device=" + controllers[i].device); } @@ -1821,8 +2069,8 @@ std::optional read_controller_rssi(const std::string &address) { return result; } -void handle_control_client(int control_fd, std::span ports, - std::span controllers, +void handle_control_client(int control_fd, std::span ports, + std::span controllers, const std::string &db_path, std::uint32_t &trace_flags, bool &reload_requested, vds::CompanionRuntime &companion, @@ -1932,7 +2180,7 @@ void handle_control_client(int control_fd, std::span ports, vds::write_full(client_fd.get(), reply); } catch (const std::exception &error) { if (trace_flags != 0) { - logger.log("control", vds::LogLevel::Error, + logger.log(vds::LogScope::Control, vds::LogLevel::Error, std::string("reply failed: ") + error.what()); } } @@ -1965,12 +2213,12 @@ std::vector grab_dualsense_touchpads(vds::Logger &logger) { continue; } if (::ioctl(fd.get(), EVIOCGRAB, 1) < 0) { - logger.log("companion", vds::LogLevel::Warn, + logger.log(vds::LogScope::Companion, vds::LogLevel::Warn, node + " touchpad grab failed: " + std::string(std::strerror(errno))); continue; } - logger.log("companion", vds::LogLevel::Info, + logger.log(vds::LogScope::Companion, vds::LogLevel::Info, node + " touchpad pointer grabbed (" + name + ")"); grabs.push_back(std::move(fd)); } @@ -2055,7 +2303,7 @@ void apply_companion_state(std::vector &ports, } } else if (!touchpad_grabs.empty()) { touchpad_grabs.clear(); - logger.log("companion", vds::LogLevel::Info, + logger.log(vds::LogScope::Companion, vds::LogLevel::Info, "touchpad pointer released"); } @@ -2072,28 +2320,28 @@ void apply_companion_state(std::vector &ports, forward_bt_state_if_changed(port, *controller->backend, trace_flags, logger, "companion update"); } catch (const std::exception &error) { - logger.log("companion", vds::LogLevel::Warn, + logger.log(vds::LogScope::Companion, vds::LogLevel::Warn, port.path + " companion state send failed: " + error.what()); } } } -std::uint64_t encode_event(EventKind kind, std::size_t index) { - return (static_cast(kind) << 32) | +std::uint64_t encode_event(EventType type, std::size_t index) { + return (static_cast(type) << 32) | static_cast(index); } EventSource decode_event(std::uint64_t data) { return EventSource{ - .kind = static_cast(data >> 32), + .type = static_cast(data >> 32), .index = static_cast(data), }; } -void add_epoll_fd(int epoll_fd, int fd, EventKind kind, std::size_t index) { +void add_epoll_fd(int epoll_fd, int fd, EventType type, std::size_t index) { epoll_event event{}; event.events = EPOLLIN | EPOLLERR | EPOLLHUP; - event.data.u64 = encode_event(kind, index); + event.data.u64 = encode_event(type, index); if (::epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) < 0) { throw std::runtime_error("epoll add failed: " + std::string(std::strerror(errno))); @@ -2110,23 +2358,23 @@ vds::UniqueFd rebuild_epoll(int control_fd, vds::BtL2capAcceptor &bt_acceptor, std::string(std::strerror(errno))); } - add_epoll_fd(epoll_fd.get(), control_fd, EventKind::Control, 0); - add_epoll_fd(epoll_fd.get(), vds_monitor.fd(), EventKind::Udev, 0); + add_epoll_fd(epoll_fd.get(), control_fd, EventType::Control, 0); + add_epoll_fd(epoll_fd.get(), vds_monitor.fd(), EventType::Udev, 0); add_epoll_fd(epoll_fd.get(), bt_acceptor.control_listener_fd(), - EventKind::BtAcceptControl, 0); + EventType::BtAcceptControl, 0); add_epoll_fd(epoll_fd.get(), bt_acceptor.interrupt_listener_fd(), - EventKind::BtAcceptInterrupt, 0); + EventType::BtAcceptInterrupt, 0); for (std::size_t i = 0; i < ports.size(); ++i) { - add_epoll_fd(epoll_fd.get(), ports[i].fd.get(), EventKind::Port, i); + add_epoll_fd(epoll_fd.get(), ports[i].fd.get(), EventType::Port, i); } for (std::size_t i = 0; i < controllers.size(); ++i) { if (!controllers[i].backend) { continue; } add_epoll_fd(epoll_fd.get(), controllers[i].backend->control_fd(), - EventKind::BtControl, i); + EventType::BtControl, i); add_epoll_fd(epoll_fd.get(), controllers[i].backend->interrupt_fd(), - EventKind::BtInterrupt, i); + EventType::BtInterrupt, i); } return epoll_fd; } @@ -2151,16 +2399,17 @@ void disconnect_all(std::vector &ports, int main(int argc, char **argv) { try { ::signal(SIGPIPE, SIG_IGN); - ::signal(SIGINT, signal_handler); - ::signal(SIGTERM, signal_handler); + install_signal_handler(SIGINT); + install_signal_handler(SIGTERM); + install_signal_handler(SIGHUP); const Options options = parse_platform_args(argc, argv); vds::Logger logger(options.log_path); - logger.log("daemon", vds::LogLevel::Info, + logger.log(vds::LogScope::Daemon, vds::LogLevel::Info, "started socket=" + options.socket + " log=" + options.log_path + " db=" + options.db_path); if (vds::discover_vds_devices().empty()) { - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, std::string(kVirtualPortProviderUnavailableReason) + " detail=" + kLinuxVirtualPortProviderUnavailable); throw std::runtime_error(kLinuxVirtualPortProviderUnavailable); @@ -2169,7 +2418,7 @@ int main(int argc, char **argv) { vds::UniqueFd control_fd(open_control_socket(options.socket)); vds::BtL2capAcceptor bt_acceptor; vds::VdsDeviceMonitor vds_monitor; - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "listening for controller-initiated raw HID channels"); SocketPathGuard control_socket_path(options.socket); std::vector ports; @@ -2182,13 +2431,25 @@ int main(int argc, char **argv) { std::uint64_t applied_companion_version = 0; while (g_stop_requested == 0) { + if (g_log_reopen_requested != 0) { + g_log_reopen_requested = 0; + try { + logger.reopen(); + logger.log(vds::LogScope::Daemon, vds::LogLevel::Info, + "reopened log file"); + } catch (const std::exception &error) { + logger.log(vds::LogScope::Daemon, vds::LogLevel::Error, + std::string("log reopen failed: ") + error.what()); + } + } + if (reload_requested) { try { reconcile_controller_configs(ports, controllers, options.db_path, logger); epoll_dirty = true; } catch (const std::exception &error) { - logger.log("config", vds::LogLevel::Error, + logger.log(vds::LogScope::Config, vds::LogLevel::Error, std::string("reload failed: ") + error.what()); } reload_requested = false; @@ -2244,7 +2505,7 @@ int main(int argc, char **argv) { const EventSource source = decode_event(events[i].data.u64); const std::uint32_t revents = events[i].events; - if (source.kind == EventKind::Control) { + if (source.type == EventType::Control) { if ((revents & EPOLLIN) != 0) { handle_control_client(control_fd.get(), ports, controllers, options.db_path, trace_flags, @@ -2256,11 +2517,11 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::BtAcceptControl || - source.kind == EventKind::BtAcceptInterrupt) { + if (source.type == EventType::BtAcceptControl || + source.type == EventType::BtAcceptInterrupt) { if ((revents & EPOLLIN) != 0) { handle_bt_accept(ports, controllers, bt_acceptor, - source.kind == EventKind::BtAcceptControl, logger, + source.type == EventType::BtAcceptControl, logger, epoll_dirty); } if ((revents & (EPOLLERR | EPOLLHUP)) != 0) { @@ -2269,9 +2530,9 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::Udev) { + if (source.type == EventType::Udev) { if ((revents & EPOLLIN) != 0 && vds_monitor.drain()) { - logger.log("port", vds::LogLevel::Info, + logger.log(vds::LogScope::Port, vds::LogLevel::Info, "virtual endpoint udev event; reloading"); reload_requested = true; epoll_dirty = true; @@ -2282,7 +2543,7 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::Port) { + if (source.type == EventType::Port) { if (source.index >= ports.size()) { continue; } @@ -2309,7 +2570,7 @@ int main(int argc, char **argv) { } } if ((revents & (EPOLLERR | EPOLLHUP)) != 0) { - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, port.path + " epoll error; reloading ports"); reload_requested = true; epoll_dirty = true; @@ -2331,7 +2592,7 @@ int main(int argc, char **argv) { } auto &port = ports[*port_index]; - if (source.kind == EventKind::BtControl) { + if (source.type == EventType::BtControl) { if ((revents & EPOLLIN) != 0) { for (int packet = 0; packet < kMaxBtPacketsPerWake; ++packet) { try { @@ -2357,7 +2618,7 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::BtInterrupt) { + if (source.type == EventType::BtInterrupt) { if ((revents & EPOLLIN) != 0) { for (int packet = 0; packet < kMaxBtPacketsPerWake; ++packet) { try { @@ -2396,7 +2657,7 @@ int main(int argc, char **argv) { epoll_dirty); } - logger.log("daemon", vds::LogLevel::Info, "stopping"); + logger.log(vds::LogScope::Daemon, vds::LogLevel::Info, "stopping"); disconnect_all(ports, controllers, logger); return 0; } catch (const std::exception &error) { diff --git a/vds/src/vds_companion.cc b/vds/src/vds_companion.cc index 53908ba..e2648a5 100644 --- a/vds/src/vds_companion.cc +++ b/vds/src/vds_companion.cc @@ -474,7 +474,7 @@ std::uint8_t apply_command(CompanionRuntime &runtime, settings.touchpad_pointer_enabled = value != 0; return kAckOk; default: - logger.log("companion", LogLevel::Warn, + logger.log(LogScope::Companion, LogLevel::Warn, "unknown companion command id " + std::to_string(command_id)); return kAckErrUnknownCommand; } diff --git a/vds/src/vds_log.cc b/vds/src/vds_log.cc index e91eb53..e5af600 100644 --- a/vds/src/vds_log.cc +++ b/vds/src/vds_log.cc @@ -11,6 +11,7 @@ #include #include #include +#include #ifndef _WIN32 #include @@ -107,24 +108,35 @@ void prepare_log_file(const std::string &path) { namespace vds { -Logger::Logger(const std::string &path) { - const std::filesystem::path log_path(path); +Logger::Logger(const std::string &path) : path_(path) { file_ = open_file(); } + +std::ofstream Logger::open_file() const { + const std::filesystem::path log_path(path_); const std::filesystem::path directory = log_path.parent_path(); if (!directory.empty()) { std::filesystem::create_directories(directory); } - prepare_log_file(path); - file_.open(path, std::ios::app); - if (!file_) { - throw std::runtime_error("failed to open log file: " + path); + prepare_log_file(path_); + std::ofstream file(path_, std::ios::app); + if (!file) { + throw std::runtime_error("failed to open log file: " + path_); } + return file; } -void Logger::log(std::string_view scope, LogLevel level, - std::string_view message) { +void Logger::reopen() { + std::ofstream file = open_file(); + std::lock_guard guard(mutex_); - file_ << local_timestamp() << '|' << scope << '|' << log_level_name(level) - << '|'; + file_.flush(); + file_.close(); + file_ = std::move(file); +} + +void Logger::log(LogScope scope, LogLevel level, std::string_view message) { + std::lock_guard guard(mutex_); + file_ << local_timestamp() << '|' << log_scope_name(scope) << '|' + << log_level_name(level) << '|'; write_log_message(file_, message); file_ << '\n'; file_.flush(); @@ -144,4 +156,32 @@ const char *log_level_name(LogLevel level) { return "ERROR"; } +const char *log_scope_name(LogScope scope) { + switch (scope) { + case LogScope::Bluetooth: + return "bluetooth"; + case LogScope::Companion: + return "companion"; + case LogScope::Config: + return "config"; + case LogScope::Control: + return "control"; + case LogScope::Daemon: + return "daemon"; + case LogScope::Hid: + return "hid"; + case LogScope::InputAudio: + return "input-audio"; + case LogScope::InputControl: + return "input-control"; + case LogScope::Output: + return "output"; + case LogScope::Port: + return "port"; + case LogScope::Usb: + return "usb"; + } + return "daemon"; +} + } // namespace vds diff --git a/vds/src/vds_log.hh b/vds/src/vds_log.hh index d862945..be53b6b 100644 --- a/vds/src/vds_log.hh +++ b/vds/src/vds_log.hh @@ -22,17 +22,36 @@ enum class LogLevel { Error, }; +enum class LogScope { + Bluetooth, + Companion, + Config, + Control, + Daemon, + Hid, + InputAudio, + InputControl, + Output, + Port, + Usb, +}; + class Logger { public: explicit Logger(const std::string &path); - void log(std::string_view scope, LogLevel level, std::string_view message); + void reopen(); + void log(LogScope scope, LogLevel level, std::string_view message); private: + std::ofstream open_file() const; + std::mutex mutex_; + std::string path_; std::ofstream file_; }; const char *log_level_name(LogLevel level); +const char *log_scope_name(LogScope scope); } // namespace vds diff --git a/vds/src/vds_protocol.cc b/vds/src/vds_protocol.cc index 2bb5765..ca57d4a 100644 --- a/vds/src/vds_protocol.cc +++ b/vds/src/vds_protocol.cc @@ -4,8 +4,7 @@ // Partly influenced by DS5Dongle source: // Copyright (c) 2026 awalol, released under the MIT license. // -// DualSense USB/Bluetooth protocol helpers: CRC, output state merging, HID -// report framing, and USB audio to Bluetooth haptics/speaker packet conversion. +// Thanks to @TechAntohere for Microphone and Headphones support. #include #include @@ -38,13 +37,18 @@ constexpr std::size_t kSetStateSize = VDS_SET_STATE_SIZE; constexpr std::size_t kBtStateOffset = 3; constexpr std::size_t kSpeakerBlockOffset = 142; constexpr std::size_t kSpeakerDataOffset = 144; +constexpr std::uint8_t kSpeakerBlockControllerSpeaker = 0x13; +constexpr std::uint8_t kSpeakerBlockHeadphones = 0x16; constexpr int kSpeakerOpusBitrate = kSpeakerOpusSize * 8 * 100; -constexpr std::uint8_t kBtHapticsAudioBufferLength = 64; +constexpr std::uint8_t kBtAudioBufferLength = 64; constexpr std::size_t kOutputFlag0Offset = 0; constexpr std::size_t kOutputFlag1Offset = 1; constexpr std::size_t kOutputHeadphoneVolumeOffset = 4; constexpr std::size_t kOutputSpeakerVolumeOffset = 5; +constexpr std::size_t kOutputMicVolumeOffset = 6; constexpr std::size_t kOutputAudioControlOffset = 7; +constexpr std::size_t kOutputMuteLedOffset = 8; +constexpr std::size_t kOutputPowerSaveControlOffset = 9; constexpr std::size_t kOutputAudioControl2Offset = 37; constexpr std::size_t kOutputLightBrightnessOffset = offsetof(vds_set_state_data, light_brightness); @@ -56,23 +60,95 @@ constexpr std::uint8_t kBtHidpFeaturePrefix = 0xa3; constexpr std::uint8_t kBtHidpGetFeaturePrefix = 0x43; constexpr std::uint8_t kBtHidpSetFeaturePrefix = 0x53; constexpr std::size_t kBtInputReportIdOffset = 1; +constexpr std::size_t kBtInputHeaderOffset = 2; constexpr std::size_t kBtInputUsbPayloadOffset = 3; constexpr std::size_t kBtInputMinimumSize = kBtInputUsbPayloadOffset + VDS_USB_INPUT_PAYLOAD_SIZE; +constexpr std::size_t kBtMicOpusOffset = 4; +constexpr std::uint8_t kBtInputPayloadTypeMask = 0x0f; +constexpr std::uint8_t kBtInputPayloadTypeControl = 0x01; +constexpr std::uint8_t kBtInputPayloadTypeAudio = 0x02; constexpr std::uint8_t kOutputFlag0HeadphoneVolumeEnable = 0x10; constexpr std::uint8_t kOutputFlag0SpeakerVolumeEnable = 0x20; +constexpr std::uint8_t kOutputFlag0MicVolumeEnable = 0x40; constexpr std::uint8_t kOutputFlag0AudioControlEnable = 0x80; +constexpr std::uint8_t kOutputFlag1MicMuteLedControlEnable = 0x01; +constexpr std::uint8_t kOutputFlag1PowerSaveControlEnable = 0x02; constexpr std::uint8_t kOutputFlag1AudioControl2Enable = 0x80; -constexpr std::uint8_t kOutputHeadphoneVolumeMax = 0x7f; -constexpr std::uint8_t kOutputSpeakerVolumeMax = 0x64; +constexpr std::uint8_t kOutputMicSelectMask = 0x03; +constexpr std::uint8_t kOutputMicSelectAuto = 0x00; +constexpr std::uint8_t kOutputMicSelectInternal = 0x01; +constexpr std::uint8_t kOutputMicAudioControl = 0x09; +constexpr std::uint8_t kOutputMicVolumeDefault = 0x08; +constexpr std::uint8_t kOutputAudioControl2Default = 0x01; +constexpr std::uint8_t kOutputAudioControlNonPathMask = 0xcf; constexpr std::uint8_t kOutputPathHeadphones = 0x00; constexpr std::uint8_t kOutputPathSpeaker = 0x30; -constexpr std::uint8_t kOutputSpeakerPreampGain = 0x02; -constexpr DsState kInitialDsState{ - 0xfd, 0xf7, 0x00, 0x00, 0x7f, 0x64, 0xff, 0x09, 0x00, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x0a, 0x07, 0x00, 0x00, 0x02, 0x01, 0x00, 0xff, 0xd7, 0x00}; +constexpr std::uint8_t kOutputPathMask = 0x30; +constexpr std::uint8_t kOutputPowerSaveMicMute = 0x10; +constexpr std::uint8_t kBtAudioSectionsEnable = 0xff; +constexpr std::uint8_t kBtAudioSectionsDisableMic = 0xfe; +constexpr std::uint8_t kBtMicReportId = 0x32; +constexpr std::uint8_t kBtMicOpen = 0xff; +constexpr std::uint8_t kBtMicClose = 0xfe; +constexpr std::array kInitialSetStateData{ + 0xfd, + 0xf7, + 0x00, + 0x00, + 0x7f, + 0x64, + kOutputMicVolumeDefault, + kOutputMicAudioControl, + 0x00, + 0x0f, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + kOutputAudioControl2Default, + 0x07, + 0x00, + 0x00, + 0x02, + 0x01, + 0x00, + 0xff, + 0xd7, + 0x00}; + +constexpr DsState initial_ds_state() { + DsState state{}; + for (std::size_t i = 0; i < kInitialSetStateData.size(); ++i) { + state[i] = kInitialSetStateData[i]; + } + return state; +} + +constexpr DsState kInitialDsState = initial_ds_state(); static_assert(kHapticsSampleSize % kSpeakerChannels == 0); constexpr std::uint32_t crc32_table_entry(std::uint32_t index) { @@ -103,6 +179,13 @@ std::int16_t read_le_s16(const std::uint8_t *data) { return static_cast(lo | hi); } +std::uint8_t audio_control_with_output_path(std::uint8_t audio_control, + std::uint8_t output_path) { + return static_cast( + (audio_control & static_cast(~kOutputPathMask)) | + output_path); +} + std::int16_t lerp_i16(std::int16_t left, std::int16_t right, std::uint32_t numerator, std::uint32_t denominator) { const auto left_part = static_cast(left) * @@ -115,55 +198,55 @@ std::int16_t lerp_i16(std::int16_t left, std::int16_t right, } std::array -resample_speaker_input(std::span input, - std::size_t input_frames) { - std::array output{}; +resample_speaker_pcm(std::span pcm, + std::size_t pcm_frames) { + std::array opus_pcm{}; // USB audio is accumulated in platform-sized speaker windows, then resampled // one 10 ms, 480-frame Opus speaker block for the 0x36 packet. for (std::size_t frame = 0; frame < kSpeakerOpusFrames; ++frame) { - const std::size_t source_numerator = frame * input_frames; + const std::size_t source_numerator = frame * pcm_frames; const std::size_t source_frame = source_numerator / kSpeakerOpusFrames; const auto fraction = static_cast(source_numerator % kSpeakerOpusFrames); - const std::size_t next_frame = std::min(source_frame + 1, input_frames - 1); + const std::size_t next_frame = std::min(source_frame + 1, pcm_frames - 1); for (std::size_t channel = 0; channel < kSpeakerChannels; ++channel) { - output[frame * kSpeakerChannels + channel] = - lerp_i16(input[source_frame * kSpeakerChannels + channel], - input[next_frame * kSpeakerChannels + channel], fraction, + opus_pcm[frame * kSpeakerChannels + channel] = + lerp_i16(pcm[source_frame * kSpeakerChannels + channel], + pcm[next_frame * kSpeakerChannels + channel], fraction, kSpeakerOpusFrames); } } - return output; + return opus_pcm; } -HapticsChunk resample_haptics_input(std::span input, - std::size_t input_frames) { - HapticsChunk output{}; - constexpr std::size_t output_frames = kHapticsSampleSize / kSpeakerChannels; +HapticsChunk resample_haptics_pcm(std::span pcm, + std::size_t pcm_frames) { + HapticsChunk chunk{}; + constexpr std::size_t haptics_frames = kHapticsSampleSize / kSpeakerChannels; - for (std::size_t frame = 0; frame < output_frames; ++frame) { - const std::size_t begin = frame * input_frames / output_frames; - const std::size_t end = (frame + 1) * input_frames / output_frames; + for (std::size_t frame = 0; frame < haptics_frames; ++frame) { + const std::size_t begin = frame * pcm_frames / haptics_frames; + const std::size_t end = (frame + 1) * pcm_frames / haptics_frames; const std::size_t count = std::max(1, end - begin); std::int32_t left_sum = 0; std::int32_t right_sum = 0; for (std::size_t sample = begin; sample < end; ++sample) { - left_sum += input[sample * kSpeakerChannels + 0]; - right_sum += input[sample * kSpeakerChannels + 1]; + left_sum += pcm[sample * kSpeakerChannels + 0]; + right_sum += pcm[sample * kSpeakerChannels + 1]; } const auto left = static_cast(left_sum / static_cast(count)); const auto right = static_cast(right_sum / static_cast(count)); - output[frame * kSpeakerChannels + 0] = s16_to_s8_haptic(left); - output[frame * kSpeakerChannels + 1] = s16_to_s8_haptic(right); + chunk[frame * kSpeakerChannels + 0] = s16_to_s8_haptic(left); + chunk[frame * kSpeakerChannels + 1] = s16_to_s8_haptic(right); } - return output; + return chunk; } void opus_ctl_checked(int result, const char *name) { @@ -256,34 +339,82 @@ struct PcmAudioExtractor::SpeakerEncoder { SpeakerEncoder(const SpeakerEncoder &) = delete; SpeakerEncoder &operator=(const SpeakerEncoder &) = delete; - SpeakerChunk encode(std::span input, - std::size_t input_frames) { - SpeakerChunk output{}; - const auto opus_input = resample_speaker_input(input, input_frames); + SpeakerChunk encode(std::span pcm, + std::size_t pcm_frames) { + SpeakerChunk chunk{}; + const auto opus_pcm = resample_speaker_pcm(pcm, pcm_frames); const int bytes = - opus_encode(encoder, opus_input.data(), kSpeakerOpusFrames, - output.data(), static_cast(output.size())); + opus_encode(encoder, opus_pcm.data(), kSpeakerOpusFrames, chunk.data(), + static_cast(chunk.size())); if (bytes < 0) { throw std::runtime_error("opus_encode failed: " + std::string(opus_strerror(bytes))); } - if (static_cast(bytes) < output.size()) { - std::fill(output.begin() + static_cast(bytes), - output.end(), 0); + if (static_cast(bytes) < chunk.size()) { + std::fill(chunk.begin() + static_cast(bytes), chunk.end(), + 0); } - return output; + return chunk; } OpusEncoder *encoder = nullptr; }; -PcmAudioExtractor::PcmAudioExtractor(std::size_t speaker_input_frames) +struct MicAudioDecoder::Decoder { + Decoder() { + int error = OPUS_OK; + decoder = opus_decoder_create(VDS_AUDIO_SAMPLE_RATE, 1, &error); + if (!decoder || error != OPUS_OK) { + throw std::runtime_error("opus_decoder_create failed: " + + std::string(opus_strerror(error))); + } + } + + ~Decoder() { + if (decoder) { + opus_decoder_destroy(decoder); + } + } + + Decoder(const Decoder &) = delete; + Decoder &operator=(const Decoder &) = delete; + + std::vector + decode(std::span payload) { + std::array mono{}; + const int frames = opus_decode( + decoder, payload.data(), static_cast(payload.size()), + mono.data(), static_cast(mono.size()), 0); + if (frames < 0) { + throw std::runtime_error("opus_decode failed: " + + std::string(opus_strerror(frames))); + } + + std::vector pcm(static_cast(frames) * + kMicUsbChannels * sizeof(std::int16_t)); + for (int frame = 0; frame < frames; ++frame) { + const auto sample = static_cast(mono[frame]); + const auto low = static_cast(sample & 0xff); + const auto high = static_cast((sample >> 8) & 0xff); + const std::size_t offset = static_cast(frame) * + kMicUsbChannels * sizeof(std::int16_t); + pcm[offset + 0] = 0; + pcm[offset + 1] = 0; + pcm[offset + 2] = low; + pcm[offset + 3] = high; + } + return pcm; + } + + OpusDecoder *decoder = nullptr; +}; + +PcmAudioExtractor::PcmAudioExtractor(std::size_t pcm_window_frames) : speaker_encoder_(std::make_unique()), - speaker_input_frames_(speaker_input_frames) { - if (speaker_input_frames_ == 0 || - speaker_input_frames_ > kSpeakerInputFrames || - speaker_input_frames_ % kHapticsDecimation != 0) { - throw std::invalid_argument("unsupported speaker input frame window"); + pcm_window_frames_(pcm_window_frames) { + if (pcm_window_frames_ == 0 || pcm_window_frames_ > kPcmWindowFrames || + pcm_window_frames_ % kHapticsDecimation != 0) { + throw std::invalid_argument("unsupported PCM frame window"); } } @@ -354,7 +485,8 @@ feature_set_packet(std::span report) { std::optional bt_input_to_usb_input(std::span packet) { if (packet.size() < kBtInputMinimumSize || packet[0] != kBtHidpInputPrefix || - packet[kBtInputReportIdOffset] != VDS_BT_STATE_REPORT_ID) { + packet[kBtInputReportIdOffset] != VDS_BT_STATE_REPORT_ID || + bt_input_payload_type(packet) != BtInputPayloadType::Control) { return std::nullopt; } @@ -367,6 +499,33 @@ bt_input_to_usb_input(std::span packet) { return report; } +BtInputPayloadType bt_input_payload_type(std::span packet) { + if (packet.size() <= kBtInputHeaderOffset || + packet[0] != kBtHidpInputPrefix || + packet[kBtInputReportIdOffset] != VDS_BT_STATE_REPORT_ID) { + return BtInputPayloadType::Unknown; + } + + switch (packet[kBtInputHeaderOffset] & kBtInputPayloadTypeMask) { + case kBtInputPayloadTypeControl: + return BtInputPayloadType::Control; + case kBtInputPayloadTypeAudio: + return BtInputPayloadType::Audio; + default: + return BtInputPayloadType::Unknown; + } +} + +std::optional> +bt_mic_opus_payload(std::span packet) { + if (bt_input_payload_type(packet) != BtInputPayloadType::Audio || + packet.size() < kBtMicOpusOffset + kMicOpusSize) { + return std::nullopt; + } + return std::span( + packet.data() + kBtMicOpusOffset, kMicOpusSize); +} + std::optional> bt_feature_to_usb_feature_reply(std::span packet) { if (packet.size() < 2 || packet[0] != kBtHidpFeaturePrefix) { @@ -648,10 +807,32 @@ void encode_companion_trigger_effect_v2( } } +MicAudioDecoder::MicAudioDecoder() : decoder_(std::make_unique()) {} + +MicAudioDecoder::~MicAudioDecoder() = default; + +MicAudioDecoder::MicAudioDecoder(MicAudioDecoder &&) noexcept = default; + +MicAudioDecoder & +MicAudioDecoder::operator=(MicAudioDecoder &&) noexcept = default; + +std::vector +MicAudioDecoder::decode(std::span payload) { + return decoder_->decode(payload); +} + DsOutputState::DsOutputState() : state_(kInitialDsState), light_color_{state_[kOutputLedColorOffset + 0], state_[kOutputLedColorOffset + 1], state_[kOutputLedColorOffset + 2]}, + headphones_volume_(state_[kOutputHeadphoneVolumeOffset]), + speaker_volume_(state_[kOutputSpeakerVolumeOffset]), + mic_volume_(state_[kOutputMicVolumeOffset]), + audio_control_(state_[kOutputAudioControlOffset] & + kOutputAudioControlNonPathMask), + audio_output_path_(state_[kOutputAudioControlOffset] & kOutputPathMask), + audio_control2_(state_[kOutputAudioControl2Offset]), + power_save_control_(state_[kOutputPowerSaveControlOffset]), light_brightness_(state_[kOutputLightBrightnessOffset]) { recompute_effective_state(); } @@ -740,6 +921,16 @@ void DsOutputState::recompute_effective_state() { } } +void DsOutputState::apply_mic_select() { + audio_control_ = + static_cast(audio_control_ & ~kOutputMicSelectMask); + audio_control_ |= + headset_mic_plugged_ ? kOutputMicSelectAuto : kOutputMicSelectInternal; + state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); +} + BtInitReport DsOutputState::build_bt_init_report() { BtInitReport report{}; report[0] = 0x32; @@ -778,30 +969,51 @@ bool DsOutputState::apply_usb_output_report( } if (decoded.allow_headphone_volume) { state_[kOutputFlag0Offset] |= kOutputFlag0HeadphoneVolumeEnable; + headphones_volume_ = decoded.volume_headphones; copy_state_bytes(state_, update, offsetof(vds_set_state_data, volume_headphones), sizeof(decoded.volume_headphones)); } if (decoded.allow_speaker_volume) { state_[kOutputFlag0Offset] |= kOutputFlag0SpeakerVolumeEnable; + speaker_volume_ = decoded.volume_speaker; copy_state_bytes(state_, update, offsetof(vds_set_state_data, volume_speaker), sizeof(decoded.volume_speaker)); } + if (decoded.allow_mic_volume) { + state_[kOutputFlag0Offset] |= kOutputFlag0MicVolumeEnable; + mic_volume_ = decoded.volume_mic; + state_[kOutputMicVolumeOffset] = mic_volume_; + } if (decoded.allow_audio_control) { state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable; - copy_state_bytes(state_, update, kOutputAudioControlOffset, 1); + const std::uint8_t requested_control = update[kOutputAudioControlOffset]; + const std::uint8_t requested_non_path = + requested_control & kOutputAudioControlNonPathMask; + if (requested_non_path != 0) { + audio_control_ = requested_non_path; + } + audio_output_path_ = requested_control & kOutputPathMask; + apply_mic_select(); } if (decoded.allow_audio_control2) { state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; + audio_control2_ = update[kOutputAudioControl2Offset]; copy_state_bytes(state_, update, kOutputAudioControl2Offset, 1); } + if (decoded.allow_audio_mute) { + state_[kOutputFlag1Offset] |= kOutputFlag1PowerSaveControlEnable; + power_save_control_ = update[kOutputPowerSaveControlOffset]; + state_[kOutputPowerSaveControlOffset] = power_save_control_; + } if (decoded.allow_speaker_volume && decoded.volume_speaker != 0) { state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable | kOutputFlag0SpeakerVolumeEnable; state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; - state_[kOutputAudioControlOffset] = kOutputPathSpeaker; - state_[kOutputAudioControl2Offset] = kOutputSpeakerPreampGain; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, kOutputPathSpeaker); + state_[kOutputAudioControl2Offset] = audio_control2_; } if (decoded.allow_mute_light) { copy_state_bytes(state_, update, @@ -852,16 +1064,30 @@ bool DsOutputState::apply_usb_output_report( return true; } -void DsOutputState::set_audio_out_stream_active(bool active) { +void DsOutputState::set_audio_out_stream_active(bool active, + bool headset_plugged) { state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable; - state_[kOutputHeadphoneVolumeOffset] = kOutputHeadphoneVolumeMax; + state_[kOutputHeadphoneVolumeOffset] = headphones_volume_; if (active) { - state_[kOutputFlag0Offset] |= kOutputFlag0SpeakerVolumeEnable; - state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; - state_[kOutputSpeakerVolumeOffset] = kOutputSpeakerVolumeMax; - state_[kOutputAudioControlOffset] = kOutputPathSpeaker; - state_[kOutputAudioControl2Offset] = kOutputSpeakerPreampGain; + if (headset_plugged) { + state_[kOutputFlag0Offset] |= kOutputFlag0HeadphoneVolumeEnable; + state_[kOutputFlag0Offset] &= ~kOutputFlag0SpeakerVolumeEnable; + state_[kOutputFlag1Offset] &= ~kOutputFlag1AudioControl2Enable; + state_[kOutputSpeakerVolumeOffset] = 0; + state_[kOutputAudioControl2Offset] = 0; + audio_output_path_ = kOutputPathHeadphones; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); + } else { + state_[kOutputFlag0Offset] |= kOutputFlag0SpeakerVolumeEnable; + state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; + state_[kOutputSpeakerVolumeOffset] = speaker_volume_; + state_[kOutputAudioControl2Offset] = audio_control2_; + audio_output_path_ = kOutputPathSpeaker; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); + } recompute_effective_state(); return; } @@ -869,11 +1095,86 @@ void DsOutputState::set_audio_out_stream_active(bool active) { state_[kOutputFlag0Offset] &= ~kOutputFlag0SpeakerVolumeEnable; state_[kOutputFlag1Offset] &= ~kOutputFlag1AudioControl2Enable; state_[kOutputSpeakerVolumeOffset] = 0; - state_[kOutputAudioControlOffset] = kOutputPathHeadphones; state_[kOutputAudioControl2Offset] = 0; + audio_output_path_ = kOutputPathHeadphones; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); + recompute_effective_state(); +} + +void DsOutputState::set_headset_mic_plugged(bool plugged) { + headset_mic_plugged_ = plugged; + state_[kOutputFlag0Offset] |= + kOutputFlag0MicVolumeEnable | kOutputFlag0AudioControlEnable; + state_[kOutputMicVolumeOffset] = mic_volume_; + apply_mic_select(); recompute_effective_state(); } +BtStateReport DsOutputState::build_bt_mic_state_report(bool active, + bool muted) { + const std::uint8_t power_save_control = + active && !muted + ? static_cast( + power_save_control_ & + static_cast(~kOutputPowerSaveMicMute)) + : static_cast(power_save_control_ | + kOutputPowerSaveMicMute); + + state_[kOutputFlag0Offset] |= + kOutputFlag0MicVolumeEnable | kOutputFlag0AudioControlEnable; + state_[kOutputFlag1Offset] |= kOutputFlag1MicMuteLedControlEnable | + kOutputFlag1PowerSaveControlEnable | + kOutputFlag1AudioControl2Enable; + const std::uint8_t audio_control = + audio_control_with_output_path(audio_control_, audio_output_path_); + state_[kOutputMicVolumeOffset] = active && !muted ? mic_volume_ : 0; + state_[kOutputAudioControlOffset] = audio_control; + state_[kOutputAudioControl2Offset] = audio_control2_; + state_[kOutputMuteLedOffset] = 0; + state_[kOutputPowerSaveControlOffset] = power_save_control; + recompute_effective_state(); + + BtStateReport report{}; + report[0] = VDS_BT_STATE_REPORT_ID; + report[1] = report_sequence_ << 4; + report_sequence_ = (report_sequence_ + 1) & 0x0f; + report[2] = 0x10; + report[kBtStateOffset + kOutputFlag0Offset] = + kOutputFlag0MicVolumeEnable | kOutputFlag0AudioControlEnable; + report[kBtStateOffset + kOutputFlag1Offset] = + kOutputFlag1MicMuteLedControlEnable | kOutputFlag1PowerSaveControlEnable | + kOutputFlag1AudioControl2Enable; + report[kBtStateOffset + kOutputMicVolumeOffset] = + active && !muted ? mic_volume_ : 0; + report[kBtStateOffset + kOutputAudioControlOffset] = audio_control; + report[kBtStateOffset + kOutputMuteLedOffset] = 0; + report[kBtStateOffset + kOutputPowerSaveControlOffset] = power_save_control; + report[kBtStateOffset + kOutputAudioControl2Offset] = audio_control2_; + fill_output_report_checksum(report); + return report; +} + +BtInitReport DsOutputState::build_bt_mic_report(bool active) { + BtInitReport report{}; + report[0] = kBtMicReportId; + report[1] = mic_sequence_ << 4; + report[2] = 0x91; + report[3] = 0x07; + report[4] = active ? kBtMicOpen : kBtMicClose; + report[5] = kBtAudioBufferLength; + report[6] = kBtAudioBufferLength; + report[7] = kBtAudioBufferLength; + report[8] = kBtAudioBufferLength; + report[9] = kBtAudioBufferLength; + report[10] = mic_sequence_; + report[11] = 0x92; + report[12] = 0x40; + mic_sequence_ = (mic_sequence_ + 1) & 0x0f; + fill_output_report_checksum(report); + return report; +} + BtStateReport DsOutputState::build_bt_state_report() { BtStateReport report{}; report[0] = VDS_BT_STATE_REPORT_ID; @@ -890,19 +1191,21 @@ BtStateReport DsOutputState::build_bt_state_report() { BtReport HapticsPacketBuilder::build_packet( std::span haptics, std::span speaker, - std::span state) { + std::span state, + bool audio_sections_enabled, bool headset_plugged) { BtReport packet{}; packet[0] = VDS_BT_HAPTICS_REPORT_ID; packet[1] = report_sequence_ << 4; report_sequence_ = (report_sequence_ + 1) & 0x0f; packet[2] = 0x11 | (1 << 7); packet[3] = 7; - packet[4] = 0xfe; - packet[5] = kBtHapticsAudioBufferLength; - packet[6] = kBtHapticsAudioBufferLength; - packet[7] = kBtHapticsAudioBufferLength; - packet[8] = kBtHapticsAudioBufferLength; - packet[9] = kBtHapticsAudioBufferLength; + packet[4] = audio_sections_enabled ? kBtAudioSectionsEnable + : kBtAudioSectionsDisableMic; + packet[5] = kBtAudioBufferLength; + packet[6] = kBtAudioBufferLength; + packet[7] = kBtAudioBufferLength; + packet[8] = kBtAudioBufferLength; + packet[9] = kBtAudioBufferLength; packet[10] = packet_sequence_++; packet[11] = 0x10 | (1 << 7); packet[12] = kDsStateSize; @@ -910,7 +1213,10 @@ BtReport HapticsPacketBuilder::build_packet( packet[76] = 0x12 | (1 << 7); packet[77] = kHapticsSampleSize; std::memcpy(packet.data() + 78, haptics.data(), haptics.size()); - packet[kSpeakerBlockOffset] = 0x13 | (1 << 7); + packet[kSpeakerBlockOffset] = + (headset_plugged ? kSpeakerBlockHeadphones + : kSpeakerBlockControllerSpeaker) | + (1 << 7); packet[kSpeakerBlockOffset + 1] = static_cast(kSpeakerOpusSize); std::memcpy(packet.data() + kSpeakerDataOffset, speaker.data(), speaker.size()); @@ -921,8 +1227,7 @@ BtReport HapticsPacketBuilder::build_packet( std::vector PcmAudioExtractor::push_usb_audio(std::span pcm_bytes) { std::vector chunks; - chunks.reserve(pcm_bytes.size() / (kPcmFrameSize * speaker_input_frames_) + - 1); + chunks.reserve(pcm_bytes.size() / (kPcmFrameSize * pcm_window_frames_) + 1); const auto consume_frame = [this, &chunks](const std::uint8_t *base) { std::array samples{}; @@ -931,27 +1236,27 @@ PcmAudioExtractor::push_usb_audio(std::span pcm_bytes) { chunk_has_signal_ |= samples[channel] != 0; } - speaker_input_[speaker_frame_pos_ * kSpeakerChannels + 0] = samples[0]; - speaker_input_[speaker_frame_pos_ * kSpeakerChannels + 1] = samples[1]; - haptics_input_[speaker_frame_pos_ * kSpeakerChannels + 0] = samples[2]; - haptics_input_[speaker_frame_pos_ * kSpeakerChannels + 1] = samples[3]; + speaker_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 0] = samples[0]; + speaker_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 1] = samples[1]; + haptics_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 0] = samples[2]; + haptics_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 1] = samples[3]; chunk_has_haptics_signal_ |= samples[2] != 0 || samples[3] != 0; - ++speaker_frame_pos_; + ++pcm_window_frame_pos_; - if (speaker_frame_pos_ != speaker_input_frames_) { + if (pcm_window_frame_pos_ != pcm_window_frames_) { return; } - speaker_frame_pos_ = 0; + pcm_window_frame_pos_ = 0; AudioChunk chunk{}; - chunk.haptics = resample_haptics_input( - std::span(haptics_input_.data(), - speaker_input_frames_ * kSpeakerChannels), - speaker_input_frames_); + chunk.haptics = resample_haptics_pcm( + std::span(haptics_pcm_.data(), + pcm_window_frames_ * kSpeakerChannels), + pcm_window_frames_); chunk.speaker = speaker_encoder_->encode( - std::span(speaker_input_.data(), - speaker_input_frames_ * kSpeakerChannels), - speaker_input_frames_); + std::span(speaker_pcm_.data(), + pcm_window_frames_ * kSpeakerChannels), + pcm_window_frames_); chunk.has_signal = chunk_has_signal_; chunk.has_haptics_signal = chunk_has_haptics_signal_; chunks.push_back(chunk); @@ -1013,6 +1318,8 @@ std::string frame_type_name(std::uint16_t type) { return "USB_FEATURE_SET"; case VDS_FRAME_USB_AUDIO_OUT: return "USB_AUDIO_OUT"; + case VDS_FRAME_USB_AUDIO_IN: + return "USB_AUDIO_IN"; case VDS_FRAME_USB_HID_IN: return "USB_HID_IN"; case VDS_FRAME_USB_FEATURE_REPLY: diff --git a/vds/src/vds_protocol.hh b/vds/src/vds_protocol.hh index 146dbc4..4b9aaf8 100644 --- a/vds/src/vds_protocol.hh +++ b/vds/src/vds_protocol.hh @@ -22,20 +22,30 @@ constexpr std::size_t kHapticsSampleSize = VDS_HAPTICS_SAMPLE_SIZE; constexpr std::size_t kUsbInputReportSize = VDS_USB_INPUT_REPORT_SIZE; constexpr std::size_t kDsStateSize = 63; constexpr std::size_t kSpeakerChannels = 2; -constexpr std::size_t kSpeakerInputFrames = 512; +constexpr std::size_t kPcmWindowFrames = 512; constexpr std::size_t kSpeakerOpusFrames = 480; constexpr std::size_t kSpeakerOpusSize = 200; +constexpr std::size_t kMicOpusFrames = 480; +constexpr std::size_t kMicOpusSize = 71; +constexpr std::size_t kMicUsbChannels = 2; +constexpr std::size_t kMicPcmSize = + kMicOpusFrames * kMicUsbChannels * sizeof(std::int16_t); using BtReport = std::array; using BtInitReport = std::array; using BtStateReport = std::array; using HapticsChunk = std::array; -using SpeakerInput = - std::array; +using PcmWindow = std::array; using SpeakerChunk = std::array; using DsState = std::array; using UsbInputReport = std::array; +enum class BtInputPayloadType { + Unknown, + Control, + Audio, +}; + struct AudioChunk { HapticsChunk haptics; SpeakerChunk speaker; @@ -50,17 +60,41 @@ hidp_output_packet(std::span report); std::vector feature_get_packet(std::uint8_t report_id); std::vector feature_set_packet(std::span report); +BtInputPayloadType bt_input_payload_type(std::span packet); +std::optional> +bt_mic_opus_payload(std::span packet); std::optional bt_input_to_usb_input(std::span packet); std::optional> bt_feature_to_usb_feature_reply(std::span packet); +class MicAudioDecoder { +public: + MicAudioDecoder(); + ~MicAudioDecoder(); + + MicAudioDecoder(MicAudioDecoder &&) noexcept; + MicAudioDecoder &operator=(MicAudioDecoder &&) noexcept; + + MicAudioDecoder(const MicAudioDecoder &) = delete; + MicAudioDecoder &operator=(const MicAudioDecoder &) = delete; + + std::vector + decode(std::span payload); + +private: + struct Decoder; + + std::unique_ptr decoder_; +}; + class HapticsPacketBuilder { public: BtReport build_packet(std::span haptics, std::span speaker, - std::span state); + std::span state, + bool audio_sections_enabled, bool headset_plugged); private: std::uint8_t report_sequence_ = 0; @@ -117,7 +151,10 @@ public: DsOutputState(); bool apply_usb_output_report(std::span report); - void set_audio_out_stream_active(bool active); + void set_audio_out_stream_active(bool active, bool headset_plugged = false); + void set_headset_mic_plugged(bool plugged); + BtStateReport build_bt_mic_state_report(bool active, bool muted); + BtInitReport build_bt_mic_report(bool active); void set_companion_overrides(const DsCompanionOverrides &overrides); BtInitReport build_bt_init_report(); BtStateReport build_bt_state_report(); @@ -125,20 +162,29 @@ public: private: void recompute_effective_state(); + void apply_mic_select(); DsState state_{}; DsState effective_state_{}; DsCompanionOverrides companion_; std::array light_color_{}; + std::uint8_t headphones_volume_ = 0; + std::uint8_t speaker_volume_ = 0; + std::uint8_t mic_volume_ = 0; + std::uint8_t audio_control_ = 0; + std::uint8_t audio_output_path_ = 0; + std::uint8_t audio_control2_ = 0; + std::uint8_t power_save_control_ = 0; std::uint8_t light_brightness_ = 0; bool emulate_light_brightness_ = false; + bool headset_mic_plugged_ = false; std::uint8_t report_sequence_ = 0; + std::uint8_t mic_sequence_ = 0; }; class PcmAudioExtractor { public: - explicit PcmAudioExtractor( - std::size_t speaker_input_frames = kSpeakerInputFrames); + explicit PcmAudioExtractor(std::size_t pcm_window_frames = kPcmWindowFrames); ~PcmAudioExtractor(); PcmAudioExtractor(PcmAudioExtractor &&) noexcept; @@ -156,11 +202,11 @@ private: std::unique_ptr speaker_encoder_; std::array pending_frame_{}; - SpeakerInput speaker_input_{}; - SpeakerInput haptics_input_{}; - std::size_t speaker_input_frames_ = kSpeakerInputFrames; + PcmWindow speaker_pcm_{}; + PcmWindow haptics_pcm_{}; + std::size_t pcm_window_frames_ = kPcmWindowFrames; std::size_t pending_frame_pos_ = 0; - std::size_t speaker_frame_pos_ = 0; + std::size_t pcm_window_frame_pos_ = 0; bool chunk_has_signal_ = false; bool chunk_has_haptics_signal_ = false; }; diff --git a/vds/src/vdsctl_common.cc b/vds/src/vdsctl_common.cc index 56a044b..e69824a 100644 --- a/vds/src/vdsctl_common.cc +++ b/vds/src/vdsctl_common.cc @@ -68,7 +68,10 @@ std::string vdsctl_usage(std::string_view version, " vdsctl detach
\n" " vdsctl list\n" " vdsctl list-targets\n" - " vdsctl trace on|off [--scope input[,output...]]\n"; + " vdsctl trace on|off [--scope [,...]]\n" + "\n" + "trace scopes:\n" + " all, input, input-audio, input-control, output\n"; return text; } @@ -95,10 +98,14 @@ int run_vdsctl_app(int argc, char **argv, std::string_view version, std::string_view build_year, const VdsctlPlatform &platform) { try { - if (argc < 2 || std::string_view(argv[1]) == "-h" || + if (argc < 2) { + throw std::runtime_error("command is required"); + } + + if (std::string_view(argv[1]) == "-h" || std::string_view(argv[1]) == "--help") { std::cerr << vdsctl_usage(version, build_year); - return argc < 2 ? 1 : 0; + return 0; } switch (parse_vdsctl_command(argv[1])) { @@ -168,7 +175,7 @@ VdsctlTraceCommand parse_vdsctl_trace(int argc, char **argv) { const std::string_view mode = argv[2]; VdsctlTraceCommand command{ .enabled = false, - .scope = "input,output", + .scope = "all", }; if (argc == 5) { if (std::string_view(argv[3]) != "--scope") { diff --git a/vds/src/vdsd_common.cc b/vds/src/vdsd_common.cc index ef6d6ee..6a56897 100644 --- a/vds/src/vdsd_common.cc +++ b/vds/src/vdsd_common.cc @@ -90,13 +90,21 @@ std::uint32_t parse_trace_scope(std::string_view scope) { while (true) { const std::size_t separator = scope.find(','); const std::string_view item = scope.substr(0, separator); - if (item == "input") { + if (item == "input" || item == "all") { flags |= kTraceInput; + if (item == "all") { + flags |= kTraceOutput; + } + } else if (item == "input-audio") { + flags |= kTraceInputAudio; + } else if (item == "input-control") { + flags |= kTraceInputControl; } else if (item == "output") { flags |= kTraceOutput; } else { throw std::runtime_error( - "trace scope must be input, output, or comma-separated input/output"); + "trace scope must be all, input, input-audio, input-control, output, " + "or comma-separated scopes"); } if (separator == std::string_view::npos) { return flags; @@ -112,10 +120,32 @@ std::string trace_scope_name(std::uint32_t scope) { if (scope == kTraceInput) { return "input"; } + if (scope == kTraceInputAudio) { + return "input-audio"; + } + if (scope == kTraceInputControl) { + return "input-control"; + } if (scope == kTraceOutput) { return "output"; } - return "none"; + std::string name; + if (trace_enabled(scope, kTraceInputAudio)) { + name += "input-audio"; + } + if (trace_enabled(scope, kTraceInputControl)) { + if (!name.empty()) { + name += ','; + } + name += "input-control"; + } + if (trace_enabled(scope, kTraceOutput)) { + if (!name.empty()) { + name += ','; + } + name += "output"; + } + return name.empty() ? "none" : name; } std::string format_controller_config(const ControllerConfig &config) { @@ -438,8 +468,15 @@ std::string format_control_trace_reply(bool ok, std::string_view error, reply += jsonl_bool_field("trace", (trace_flags & kTraceAll) != 0); reply += ",\"scope\":["; bool wrote_scope = false; - if (trace_enabled(trace_flags, kTraceInput)) { - reply += jsonl_string_value("input"); + if (trace_enabled(trace_flags, kTraceInputAudio)) { + reply += jsonl_string_value("input-audio"); + wrote_scope = true; + } + if (trace_enabled(trace_flags, kTraceInputControl)) { + if (wrote_scope) { + reply += ','; + } + reply += jsonl_string_value("input-control"); wrote_scope = true; } if (trace_enabled(trace_flags, kTraceOutput)) { @@ -483,7 +520,7 @@ std::string handle_attach_control_request( if (!config.address.empty()) { const std::string error = "address is already registered"; - logger.log("control", LogLevel::Warn, + logger.log(vds::LogScope::Control, LogLevel::Warn, "attach rejected " + error + ": " + config.address); return format_control_attach_reply(false, error, config); } @@ -498,7 +535,8 @@ std::string handle_attach_control_request( const std::string error = "address is not paired, not supported, or not attachable; run " "vdsctl list-targets"; - logger.log("control", LogLevel::Warn, "attach rejected " + error); + logger.log(vds::LogScope::Control, LogLevel::Warn, + "attach rejected " + error); config = {}; return format_control_attach_reply(false, error, config); } @@ -517,7 +555,7 @@ std::string handle_attach_control_request( }); if (already_registered) { const std::string error = "address is already registered"; - logger.log("control", LogLevel::Warn, + logger.log(vds::LogScope::Control, LogLevel::Warn, "attach rejected " + error + ": " + config.address); return format_control_attach_reply(false, error, config); } @@ -526,7 +564,7 @@ std::string handle_attach_control_request( } reload_requested = true; - logger.log("control", LogLevel::Info, + logger.log(vds::LogScope::Control, LogLevel::Info, "attached controller config " + format_controller_config(config)); return format_control_attach_reply(true, "", config); } @@ -563,7 +601,7 @@ std::string handle_detach_control_request(std::span fields, return format_control_detach_reply(false, error.what(), address); } reload_requested = removed; - logger.log("control", LogLevel::Info, + logger.log(vds::LogScope::Control, LogLevel::Info, std::string(removed ? "detached " : "detach ignored ") + address); if (!removed) { @@ -624,7 +662,7 @@ std::string handle_trace_control_request(std::span fields, return format_control_trace_reply(false, error.what(), trace_flags); } - logger.log("control", LogLevel::Info, + logger.log(vds::LogScope::Control, LogLevel::Info, "trace " + trace_scope_name(scope) + " " + (enabled ? "enabled" : "disabled") + " active=" + active_trace_name(trace_flags)); @@ -665,7 +703,7 @@ std::string handle_vdsd_control_command( controllers, logger); } - logger.log("control", LogLevel::Warn, + logger.log(vds::LogScope::Control, LogLevel::Warn, "unknown command: " + std::string(command)); return format_control_error_reply("unknown command: " + command); } catch (const std::exception &error) { diff --git a/vds/src/vdsd_common.hh b/vds/src/vdsd_common.hh index 52d99cb..3af604b 100644 --- a/vds/src/vdsd_common.hh +++ b/vds/src/vdsd_common.hh @@ -18,8 +18,11 @@ namespace vds { class Logger; struct CompanionRuntime; -inline constexpr std::uint32_t kTraceInput = 1u << 0; -inline constexpr std::uint32_t kTraceOutput = 1u << 1; +inline constexpr std::uint32_t kTraceInputAudio = 1u << 0; +inline constexpr std::uint32_t kTraceInputControl = 1u << 1; +inline constexpr std::uint32_t kTraceInput = + kTraceInputAudio | kTraceInputControl; +inline constexpr std::uint32_t kTraceOutput = 1u << 2; inline constexpr std::uint32_t kTraceAll = kTraceInput | kTraceOutput; struct VdsdCommonOptions { diff --git a/vds/vdsd.service.in b/vds/vdsd.service.in index cc487bf..ff075a4 100644 --- a/vds/vdsd.service.in +++ b/vds/vdsd.service.in @@ -5,6 +5,7 @@ Wants=bluetooth.service [Service] Type=simple +ExecStartPre=modprobe vds_hcd ExecStart=@VDS_SYSTEMD_VDSD@ Restart=on-failure RestartSec=1s From 9036c425a7f318579f74e16f01aa41aa19994e68 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 09:15:51 +0400 Subject: [PATCH 04/34] Actuate SET_HAPTICS_BUFFER_LENGTH in the Linux daemon The companion 0x0B command was acked and discarded, so the app's Audio Buffer Length slider never changed backend behavior; the speaker/haptics queue depth was fixed at 8 chunks (80 ms) with a 4-chunk stale-drop threshold. Store the slider value (16-128, 3 kHz haptics samples) in CompanionSettings and derive the per-port pending 0x36 chunk cap from it in apply_companion_state: one chunk per 10 ms speaker frame (30 samples), clamped to 2-16 chunks, stale-drop threshold at half the cap. Value 0 (never set) keeps the old 8/4 defaults, and ports reset to those on disconnect. Out-of-range values return kAckErrInvalidValue. Adds companion_buffer_length_test covering store, clamps, and rejection. --- vds/CMakeLists.txt | 5 ++ vds/src/platform/linux/vdsd.cc | 21 ++++++- vds/src/vds_companion.cc | 7 ++- vds/src/vds_companion.hh | 3 + vds/tests/companion_buffer_length_test.cc | 74 +++++++++++++++++++++++ 5 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 vds/tests/companion_buffer_length_test.cc diff --git a/vds/CMakeLists.txt b/vds/CMakeLists.txt index 72dd29d..7f72388 100644 --- a/vds/CMakeLists.txt +++ b/vds/CMakeLists.txt @@ -179,6 +179,11 @@ endif() add_executable(trigger_effect_v2_test tests/trigger_effect_v2_test.cc) target_link_libraries(trigger_effect_v2_test PRIVATE vds_protocol) +add_executable(companion_buffer_length_test tests/companion_buffer_length_test.cc) +target_link_libraries(companion_buffer_length_test + PRIVATE vdsd_common vds_protocol vds_log vds_config + vds_common jsonl) + if(WIN32) set(VDS_RUNTIME_INSTALL_DIR ".") set(VDS_INSTALLED_VDSD "${CMAKE_INSTALL_PREFIX}/vdsd.exe") diff --git a/vds/src/platform/linux/vdsd.cc b/vds/src/platform/linux/vdsd.cc index 47acc78..d31ef49 100644 --- a/vds/src/platform/linux/vdsd.cc +++ b/vds/src/platform/linux/vdsd.cc @@ -189,6 +189,10 @@ struct VirtualPort { bool speaker_waveout_active = false; std::uint32_t speaker_waveout_phase = 0; std::uint16_t haptics_gain_percent = 100; + // Speaker/haptics queue depth in 10 ms chunks, from the companion + // SET_HAPTICS_BUFFER_LENGTH setting; defaults match the old constants. + std::size_t max_pending_audio_chunks = kMaxPendingAudioChunks; + std::size_t fresh_pending_audio_chunks = kFreshPendingAudioChunks; std::uint8_t battery_status = 0xff; vds::CompanionInputState companion_input; std::array, 256> feature_cache; @@ -622,6 +626,8 @@ void reset_virtual_port(VirtualPort &port) { port.speaker_waveout_active = false; port.speaker_waveout_phase = 0; port.haptics_gain_percent = 100; + port.max_pending_audio_chunks = kMaxPendingAudioChunks; + port.fresh_pending_audio_chunks = kFreshPendingAudioChunks; port.battery_status = 0xff; port.companion_input = {}; port.feature_cache = {}; @@ -959,7 +965,7 @@ void handle_frame(const vds_frame_header &header, * speaker frame interval, otherwise speaker/haptics audio turns into * audible bursts. */ - if (port.pending_audio_chunks.size() >= kMaxPendingAudioChunks) { + if (port.pending_audio_chunks.size() >= port.max_pending_audio_chunks) { port.pending_audio_chunks.pop_front(); ++dropped_chunks; ++port.trace_state.dropped_audio_haptics_count; @@ -1746,7 +1752,7 @@ bool flush_pending_audio_chunk(VirtualPort &port, const bool output_trace = trace_enabled(trace_flags, kTraceOutput); std::size_t stale_dropped = 0; - while (port.pending_audio_chunks.size() > kFreshPendingAudioChunks) { + while (port.pending_audio_chunks.size() > port.fresh_pending_audio_chunks) { port.pending_audio_chunks.pop_front(); ++stale_dropped; ++port.trace_state.dropped_audio_haptics_count; @@ -1849,7 +1855,7 @@ void enqueue_speaker_waveout_chunk(VirtualPort &port, std::uint32_t trace_flags, const auto chunks = port.waveout_extractor.push_usb_audio(pcm); for (const auto &chunk : chunks) { - if (port.pending_audio_chunks.size() >= kMaxPendingAudioChunks) { + if (port.pending_audio_chunks.size() >= port.max_pending_audio_chunks) { break; } port.pending_audio_chunks.push_back(chunk); @@ -2315,6 +2321,15 @@ void apply_companion_state(std::vector &ports, continue; } port.haptics_gain_percent = settings.haptics_gain_percent; + if (settings.haptics_buffer_samples != 0) { + // Slider value is 3 kHz haptics samples; each queued chunk covers a + // 10 ms speaker frame (30 samples). Keep at least two chunks so a + // single late URB burst does not immediately drop audio. + const std::size_t chunks = std::clamp( + (settings.haptics_buffer_samples + 15) / 30, 2, 16); + port.max_pending_audio_chunks = chunks; + port.fresh_pending_audio_chunks = std::max(1, chunks / 2); + } port.output_state.set_companion_overrides(overrides); try { forward_bt_state_if_changed(port, *controller->backend, trace_flags, diff --git a/vds/src/vds_companion.cc b/vds/src/vds_companion.cc index e2648a5..7ed0112 100644 --- a/vds/src/vds_companion.cc +++ b/vds/src/vds_companion.cc @@ -302,8 +302,13 @@ std::uint8_t apply_command(CompanionRuntime &runtime, runtime.pending_input_events.clear(); return kAckOk; } + case 0x0B: // SET_HAPTICS_BUFFER_LENGTH (3 kHz haptics samples) + if (value < 16 || value > 128) { + return kAckErrInvalidValue; + } + settings.haptics_buffer_samples = value; + return kAckOk; // Settings accepted and stored by the app but not yet actuated here. - case 0x0B: // SET_HAPTICS_BUFFER_LENGTH case 0x12: // SET_POLLING_RATE_MODE case 0x19: // SET_DUPLEX_ENABLED case 0x1D: // SET_SPEAKER_VOLUME_SHORTCUT_ENABLED diff --git a/vds/src/vds_companion.hh b/vds/src/vds_companion.hh index f206f6b..23f3ae6 100644 --- a/vds/src/vds_companion.hh +++ b/vds/src/vds_companion.hh @@ -31,6 +31,9 @@ struct CompanionSettings { bool idle_disconnect_enabled = false; std::uint16_t idle_disconnect_timeout_minutes = 10; std::uint16_t speaker_volume_percent = 100; + // Speaker/haptics buffering in 3 kHz haptics samples (app slider 16-128, + // about 5-43 ms). 0 keeps the daemon's built-in queue depth. + std::uint16_t haptics_buffer_samples = 0; std::uint8_t speaker_gain_level = 4; std::uint8_t lightbar_red = 0; std::uint8_t lightbar_green = 0; diff --git a/vds/tests/companion_buffer_length_test.cc b/vds/tests/companion_buffer_length_test.cc new file mode 100644 index 0000000..b12752f --- /dev/null +++ b/vds/tests/companion_buffer_length_test.cc @@ -0,0 +1,74 @@ +// Verifies SET_HAPTICS_BUFFER_LENGTH (0x0B) is stored in CompanionSettings +// through the control-request path (it was previously ack-and-ignore). +#include +#include +#include +#include + +#include "jsonl.hh" +#include "vds_companion.hh" +#include "vds_log.hh" + +namespace { + +std::string command_request_json(std::uint16_t value) { + std::vector report(vds::kCompanionReportLength, 0); + report[0] = 0x02; // command report id + report[1] = 'D'; + report[2] = 'S'; + report[3] = '5'; + report[4] = 'B'; + report[5] = vds::kCompanionProtocolMajor; + report[6] = vds::kCompanionProtocolMinor; + report[7] = 0x0B; // SET_HAPTICS_BUFFER_LENGTH + report[8] = 1; // sequence + report[9] = value & 0xff; + report[10] = (value >> 8) & 0xff; + std::string json = "{\"command\":\"companion\",\"op\":\"write\",\"report\":["; + for (std::size_t i = 0; i < report.size(); ++i) { + if (i != 0) { + json += ","; + } + json += std::to_string(report[i]); + } + json += "]}"; + return json; +} + +void send_command(vds::CompanionRuntime &runtime, std::uint16_t value, + vds::Logger &logger) { + const auto fields = + vds::parse_jsonl_object(command_request_json(value), "test"); + vds::handle_companion_control_request(fields, runtime, "/dev/null", {}, + logger); +} + +} // namespace + +int main() { + vds::Logger logger("/tmp/companion_buffer_length_test.log"); + vds::CompanionRuntime runtime; + + assert(runtime.settings.haptics_buffer_samples == 0); + + send_command(runtime, 115, logger); + assert(runtime.last_result_code == 0); // kAckOk + assert(runtime.settings.haptics_buffer_samples == 115); + + // Out-of-range values are rejected and do not clobber the stored value. + send_command(runtime, 8, logger); + assert(runtime.last_result_code != 0); + assert(runtime.settings.haptics_buffer_samples == 115); + + send_command(runtime, 300, logger); + assert(runtime.last_result_code != 0); + assert(runtime.settings.haptics_buffer_samples == 115); + + send_command(runtime, 16, logger); + assert(runtime.settings.haptics_buffer_samples == 16); + send_command(runtime, 128, logger); + assert(runtime.settings.haptics_buffer_samples == 128); + + std::puts("companion_buffer_length_test OK"); + return 0; +} From 67dc181d268b113de624df5bf30f6eea4306fb50 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 09:38:05 +0400 Subject: [PATCH 05/34] Extend audio buffer slider range to 240 samples (80 ms) With SET_HAPTICS_BUFFER_LENGTH now actuated, the old 16-128 range only reached 43 ms while the daemon's previous fixed queue was 80 ms, so the whole slider traded resilience for latency with no way back. Raise the max to 240 samples (8 chunks = the old queue depth) across the renderer slider, bridge-service clamp, settings store, and the vdsd validator. Existing zone labels (High stutter / Risky / Safe) keep their sample thresholds. 692 app tests and the vds companion test pass. --- ds5-bridge/companion/src/main/bridge-service.test.ts | 4 ++-- ds5-bridge/companion/src/main/bridge-service.ts | 2 +- ds5-bridge/companion/src/main/settings-store.test.ts | 4 ++-- ds5-bridge/companion/src/main/settings-store.ts | 2 +- ds5-bridge/companion/src/renderer/App.tsx | 2 +- ds5-bridge/companion/src/renderer/app-behavior.test.ts | 2 +- vds/src/vds_companion.cc | 2 +- vds/src/vds_companion.hh | 4 ++-- vds/tests/companion_buffer_length_test.cc | 4 ++-- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ds5-bridge/companion/src/main/bridge-service.test.ts b/ds5-bridge/companion/src/main/bridge-service.test.ts index 9b18237..e4c3c53 100644 --- a/ds5-bridge/companion/src/main/bridge-service.test.ts +++ b/ds5-bridge/companion/src/main/bridge-service.test.ts @@ -1101,8 +1101,8 @@ describe('BridgeService', () => { command = device.sentReports.at(-1); expect(command?.[7]).toBe(COMMAND_ID.SET_HAPTICS_BUFFER_LENGTH); - expect(command?.[9]).toBe(128); - expect(snapshot.settings.hapticsBufferLength).toBe(128); + expect(command?.[9]).toBe(240); + expect(snapshot.settings.hapticsBufferLength).toBe(240); }); it('sends and stores adaptive trigger intensity', async () => { diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 981d120..24350a7 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -2292,7 +2292,7 @@ export class BridgeService extends EventEmitter { } async setHapticsBufferLength(length: number): Promise { - const value = Math.max(16, Math.min(128, Math.round(length))); + const value = Math.max(16, Math.min(240, Math.round(length))); await this.sendSettingCommand(COMMAND_ID.SET_HAPTICS_BUFFER_LENGTH, value, { hapticsBufferLength: value }); return this.getSnapshot(); } diff --git a/ds5-bridge/companion/src/main/settings-store.test.ts b/ds5-bridge/companion/src/main/settings-store.test.ts index 7cf49d5..dfec56b 100644 --- a/ds5-bridge/companion/src/main/settings-store.test.ts +++ b/ds5-bridge/companion/src/main/settings-store.test.ts @@ -430,8 +430,8 @@ describe('SettingsStore', () => { expect(store.update({ hapticsBufferLength: 2 }).hapticsBufferLength).toBe(16); expect(store.update({ hapticsBufferLength: 44.4 }).hapticsBufferLength).toBe(44); - expect(store.update({ hapticsBufferLength: 255 }).hapticsBufferLength).toBe(128); - expect(persistedSettings(userDataPath).hapticsBufferLength).toBe(128); + expect(store.update({ hapticsBufferLength: 255 }).hapticsBufferLength).toBe(240); + expect(persistedSettings(userDataPath).hapticsBufferLength).toBe(240); }); it('persists audio haptics app-session sources', () => { diff --git a/ds5-bridge/companion/src/main/settings-store.ts b/ds5-bridge/companion/src/main/settings-store.ts index 51a7877..59ccbea 100644 --- a/ds5-bridge/companion/src/main/settings-store.ts +++ b/ds5-bridge/companion/src/main/settings-store.ts @@ -897,7 +897,7 @@ function normalizeSettings(value: Partial | null | undefined) ? value.feedbackBoostEnabled : DEFAULT_SETTINGS.feedbackBoostEnabled, hapticsBufferLength: Number.isFinite(value?.hapticsBufferLength) - ? Math.max(16, Math.min(128, Math.round(value!.hapticsBufferLength!))) + ? Math.max(16, Math.min(240, Math.round(value!.hapticsBufferLength!))) : DEFAULT_SETTINGS.hapticsBufferLength, classicRumbleEnabled: typeof value?.classicRumbleEnabled === 'boolean' ? value.classicRumbleEnabled diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 1c5b300..4a9df06 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -280,7 +280,7 @@ const BOOSTED_FEEDBACK_GAIN_PERCENT = 500; const SPEAKER_VOLUME_STEP = 10; const MIC_VOLUME_STEP = 10; const AUDIO_BUFFER_LENGTH_MIN = 16; -const AUDIO_BUFFER_LENGTH_MAX = 128; +const AUDIO_BUFFER_LENGTH_MAX = 240; const AUDIO_BUFFER_LENGTH_HIGH_STUTTER_MAX = 44; const AUDIO_BUFFER_LENGTH_RISKY_MAX = 63; const LIGHTBAR_BRIGHTNESS_STEP = 10; diff --git a/ds5-bridge/companion/src/renderer/app-behavior.test.ts b/ds5-bridge/companion/src/renderer/app-behavior.test.ts index d63e2f0..e815868 100644 --- a/ds5-bridge/companion/src/renderer/app-behavior.test.ts +++ b/ds5-bridge/companion/src/renderer/app-behavior.test.ts @@ -202,7 +202,7 @@ describe('renderer behavior guards', () => { it('exposes the firmware-gated audio buffer length control', () => { expect(appSource).toContain('const AUDIO_BUFFER_LENGTH_MIN = 16;'); - expect(appSource).toContain('const AUDIO_BUFFER_LENGTH_MAX = 128;'); + expect(appSource).toContain('const AUDIO_BUFFER_LENGTH_MAX = 240;'); expect(appSource).toContain('audioBufferLengthControlSupported'); expect(appSource).toContain('firmwareFlags.hapticsBufferLengthControl'); expect(appSource).toContain('window.bridge.setHapticsBufferLength(snappedValue)'); diff --git a/vds/src/vds_companion.cc b/vds/src/vds_companion.cc index 7ed0112..85245f9 100644 --- a/vds/src/vds_companion.cc +++ b/vds/src/vds_companion.cc @@ -303,7 +303,7 @@ std::uint8_t apply_command(CompanionRuntime &runtime, return kAckOk; } case 0x0B: // SET_HAPTICS_BUFFER_LENGTH (3 kHz haptics samples) - if (value < 16 || value > 128) { + if (value < 16 || value > 240) { return kAckErrInvalidValue; } settings.haptics_buffer_samples = value; diff --git a/vds/src/vds_companion.hh b/vds/src/vds_companion.hh index 23f3ae6..988f730 100644 --- a/vds/src/vds_companion.hh +++ b/vds/src/vds_companion.hh @@ -31,8 +31,8 @@ struct CompanionSettings { bool idle_disconnect_enabled = false; std::uint16_t idle_disconnect_timeout_minutes = 10; std::uint16_t speaker_volume_percent = 100; - // Speaker/haptics buffering in 3 kHz haptics samples (app slider 16-128, - // about 5-43 ms). 0 keeps the daemon's built-in queue depth. + // Speaker/haptics buffering in 3 kHz haptics samples (app slider 16-240, + // about 5-80 ms). 0 keeps the daemon's built-in queue depth. std::uint16_t haptics_buffer_samples = 0; std::uint8_t speaker_gain_level = 4; std::uint8_t lightbar_red = 0; diff --git a/vds/tests/companion_buffer_length_test.cc b/vds/tests/companion_buffer_length_test.cc index b12752f..4c37083 100644 --- a/vds/tests/companion_buffer_length_test.cc +++ b/vds/tests/companion_buffer_length_test.cc @@ -66,8 +66,8 @@ int main() { send_command(runtime, 16, logger); assert(runtime.settings.haptics_buffer_samples == 16); - send_command(runtime, 128, logger); - assert(runtime.settings.haptics_buffer_samples == 128); + send_command(runtime, 240, logger); + assert(runtime.settings.haptics_buffer_samples == 240); std::puts("companion_buffer_length_test OK"); return 0; From 05096fb3a5a1b1621b56914333e4c8560f952889 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 09:51:17 +0400 Subject: [PATCH 06/34] Align audio buffer zone thresholds with the Linux chunk queue The High stutter / Risky / Safe boundaries (44/63) came from the Windows WASAPI buffer. The Linux daemon rounds the value to whole 10 ms chunks (30 samples each) with a 2-chunk floor, so 16-45 all produce the same 2-chunk queue. Move the boundaries to the chunk edges: <=45 stutter (2-chunk floor), 46-75 risky (3 chunks), >=76 safe (4+ chunks). --- ds5-bridge/companion/src/renderer/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 4a9df06..8ec7098 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -281,8 +281,11 @@ const SPEAKER_VOLUME_STEP = 10; const MIC_VOLUME_STEP = 10; const AUDIO_BUFFER_LENGTH_MIN = 16; const AUDIO_BUFFER_LENGTH_MAX = 240; -const AUDIO_BUFFER_LENGTH_HIGH_STUTTER_MAX = 44; -const AUDIO_BUFFER_LENGTH_RISKY_MAX = 63; +// Zone boundaries follow the Linux daemon's 10 ms chunk queue (30 samples per +// chunk, floor of 2 chunks): <=45 all map to the 2-chunk floor, 46-75 to +// 3 chunks, and 4+ chunks (>=76) give enough headroom for bursty USB arrival. +const AUDIO_BUFFER_LENGTH_HIGH_STUTTER_MAX = 45; +const AUDIO_BUFFER_LENGTH_RISKY_MAX = 75; const LIGHTBAR_BRIGHTNESS_STEP = 10; const TRIGGER_EFFECT_STEP = 10; const CONTROLLER_POWER_SAVING_CAP_PERCENT = 60; From 6ec50bc7f99f534fa6a3e267b095eeacedfca36c Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 09:59:15 +0400 Subject: [PATCH 07/34] Sync mic mute with the controller button; default buffer 120; mic gray-out fix Mute sync (both directions): - vdsd: a physical mute-button toggle now mirrors into companion.settings.mic_muted (and bumps the settings revision), so the STATUS report reflects it and the app's existing micMuted reconcile picks it up. - vdsd: apply_companion_state actuates an app-initiated SET_MIC_MUTE on the controller with the same BT mic-state report the button path uses. Mic grayed out: - Disabling the audio section turned off mic pass-through, but enabling it back only restored the speaker, leaving duplexMicEnabled stuck off and every mic control disabled. Re-enabling audio now restores the mic too. Audio buffer default: - Default hapticsBufferLength 64 -> 120 (4 chunks / 40 ms): 64 sat on the daemon's 2-chunk floor. Zone boundaries corrected to the actual integer chunk rounding: <=74 floor, 75-104 = 3 chunks, >=105 safe. 692 app tests and the vds companion test pass. --- .../companion/src/main/settings-store.ts | 2 +- ds5-bridge/companion/src/renderer/App.tsx | 19 +++++++++++++------ vds/src/platform/linux/vdsd.cc | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/ds5-bridge/companion/src/main/settings-store.ts b/ds5-bridge/companion/src/main/settings-store.ts index 59ccbea..5c209ff 100644 --- a/ds5-bridge/companion/src/main/settings-store.ts +++ b/ds5-bridge/companion/src/main/settings-store.ts @@ -150,7 +150,7 @@ export const DEFAULT_SETTINGS: CompanionSettings = { hapticsEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.hapticsEnabled, hapticsGainPercent: DEFAULT_CONTROLLER_PROFILE_SETTINGS.hapticsGainPercent, feedbackBoostEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.feedbackBoostEnabled, - hapticsBufferLength: 64, + hapticsBufferLength: 120, classicRumbleEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.classicRumbleEnabled, classicRumbleGainPercent: DEFAULT_CONTROLLER_PROFILE_SETTINGS.classicRumbleGainPercent, classicRumbleV1Enabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.classicRumbleV1Enabled, diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 8ec7098..44377e3 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -281,11 +281,12 @@ const SPEAKER_VOLUME_STEP = 10; const MIC_VOLUME_STEP = 10; const AUDIO_BUFFER_LENGTH_MIN = 16; const AUDIO_BUFFER_LENGTH_MAX = 240; -// Zone boundaries follow the Linux daemon's 10 ms chunk queue (30 samples per -// chunk, floor of 2 chunks): <=45 all map to the 2-chunk floor, 46-75 to -// 3 chunks, and 4+ chunks (>=76) give enough headroom for bursty USB arrival. -const AUDIO_BUFFER_LENGTH_HIGH_STUTTER_MAX = 45; -const AUDIO_BUFFER_LENGTH_RISKY_MAX = 75; +// Zone boundaries follow the Linux daemon's 10 ms chunk queue: it rounds the +// value to whole chunks (30 samples each, floor of 2), so <=74 all map to the +// 2-chunk floor, 75-104 to 3 chunks, and 4+ chunks (>=105) give enough +// headroom for bursty USB arrival. +const AUDIO_BUFFER_LENGTH_HIGH_STUTTER_MAX = 74; +const AUDIO_BUFFER_LENGTH_RISKY_MAX = 104; const LIGHTBAR_BRIGHTNESS_STEP = 10; const TRIGGER_EFFECT_STEP = 10; const CONTROLLER_POWER_SAVING_CAP_PERCENT = 60; @@ -872,7 +873,7 @@ function snapMicVolume(value: number): number { function clampAudioBufferLength(value: number): number { if (!Number.isFinite(value)) { - return 64; + return 120; } return Math.max(AUDIO_BUFFER_LENGTH_MIN, Math.min(AUDIO_BUFFER_LENGTH_MAX, Math.round(value))); } @@ -5724,6 +5725,12 @@ export function App() { if (!enabled && next.settings.duplexMicEnabled) { next = await window.bridge.setDuplexMicEnabled(false); } + if (enabled && !next.settings.duplexMicEnabled) { + // Re-enabling the audio section restores mic pass-through too; + // otherwise the mic stays disabled (and its controls grayed out) + // until the mic toggle is found and pressed separately. + next = await window.bridge.setDuplexMicEnabled(true); + } return next; }); } diff --git a/vds/src/platform/linux/vdsd.cc b/vds/src/platform/linux/vdsd.cc index d31ef49..c9ede87 100644 --- a/vds/src/platform/linux/vdsd.cc +++ b/vds/src/platform/linux/vdsd.cc @@ -1167,6 +1167,13 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, port.headset_mic_plugged = headset_mic_plugged; headset_mic_changed = true; } + if (mic_mute_changed) { + // Mirror the physical mute-button toggle into the companion settings so + // the app's STATUS poll picks it up (the app reconciles micMuted from + // status when it did not initiate the change). + companion.settings.mic_muted = port.mic_muted; + ++companion.settings_revision; + } if (mic_mute_changed || (headset_mic_changed && port.audio_in_stream_active)) { const auto report = port.output_state.build_bt_mic_state_report( @@ -2330,6 +2337,18 @@ void apply_companion_state(std::vector &ports, port.max_pending_audio_chunks = chunks; port.fresh_pending_audio_chunks = std::max(1, chunks / 2); } + if (port.mic_muted != settings.mic_muted) { + // App-initiated mute toggle (SET_MIC_MUTE): actuate it on the + // controller the same way the physical mute button does. + port.mic_muted = settings.mic_muted; + const auto mic_report = port.output_state.build_bt_mic_state_report( + port.audio_in_stream_active, port.mic_muted); + controller->backend->try_send_output_report(mic_report); + logger.log(vds::LogScope::Companion, vds::LogLevel::Info, + port.path + " mic " + + std::string(port.mic_muted ? "muted" : "unmuted") + + " via companion"); + } port.output_state.set_companion_overrides(overrides); try { forward_bt_state_if_changed(port, *controller->backend, trace_flags, From 515dc354689d5073516bf949a76cc021d91a0f90 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 10:02:06 +0400 Subject: [PATCH 08/34] Don't treat a pinned trigger profile as the active game Manually pinning a trigger profile (matchedBy 'pin') set the engine's activeProfileId, which both the game-profile card and the game-settings coordinator read as 'this game is running': the card showed the game as active and the coordinator swapped in the full game settings set. Require matchedBy 'process' in both places so a manual trigger override stays a trigger override, and auto switching state follows actual game detection only. Adds a coordinator regression test (693 total). --- .../src/main/game-settings-coordinator.test.ts | 15 +++++++++++++++ .../src/main/game-settings-coordinator.ts | 6 +++++- ds5-bridge/companion/src/renderer/App.tsx | 4 ++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts b/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts index f9d9b36..d456e2b 100644 --- a/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts +++ b/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts @@ -84,6 +84,21 @@ describe('GameSettingsCoordinator', () => { expect(coordinator.getStatus().appliedProfileId).toBeNull(); }); + it('ignores a manually pinned trigger profile (pin is not the game running)', async () => { + service.controllerProfileId = 'my-profile'; + service.gameSettings.add('cyberpunk'); + const coordinator = new GameSettingsCoordinator(service, dir); + + coordinator.onEngineStatus({ + ...engineStatus('cyberpunk'), + matchedBy: 'pin', + matchedName: null + }); + await settle(coordinator); + expect(service.controllerProfileId).toBe('my-profile'); + expect(coordinator.getStatus().appliedProfileId).toBeNull(); + }); + it('leaves selections alone for a game without game settings', async () => { const coordinator = new GameSettingsCoordinator(service, dir); coordinator.onEngineStatus(engineStatus('elden-ring')); diff --git a/ds5-bridge/companion/src/main/game-settings-coordinator.ts b/ds5-bridge/companion/src/main/game-settings-coordinator.ts index 882b1db..d63e212 100644 --- a/ds5-bridge/companion/src/main/game-settings-coordinator.ts +++ b/ds5-bridge/companion/src/main/game-settings-coordinator.ts @@ -95,7 +95,11 @@ export class GameSettingsCoordinator extends EventEmitter { * running, and flapping the whole settings set for that would be wrong. */ onEngineStatus(status: EngineStatus): void { - const gameId = status.enabled && status.activeProfileId !== DEFAULT_PROFILE_ID + // Only a detected running game activates the game scope. A manual pin + // (matchedBy 'pin') is a trigger-profile override, not the game running, + // so it must not swap the full settings set or mark the game active. + const gameId = status.enabled && status.matchedBy === 'process' + && status.activeProfileId !== DEFAULT_PROFILE_ID ? status.activeProfileId : null; if (gameId === this.activeGameProfileId) return; diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 44377e3..c106a80 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -3168,8 +3168,12 @@ export function App() { const openGameProfileEntry = openGameProfileId ? gameProfiles.find((profile) => profile.id === openGameProfileId) ?? null : null; + // A manually pinned trigger profile (matchedBy 'pin') is an override of the + // trigger engine only — it must not present the game profile card as the + // active game. const activeGameProfile = triggerProfileEngineStatus && triggerProfileEngineStatus.enabled + && triggerProfileEngineStatus.matchedBy === 'process' && triggerProfileEngineStatus.activeProfileId !== DEFAULT_PROFILE_ID ? gameProfiles.find((profile) => profile.id === triggerProfileEngineStatus.activeProfileId) ?? null : null; From 54543762dac64b460a4184f37e50310602db8a24 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 10:47:54 +0400 Subject: [PATCH 09/34] Only commit app-initiated mic mute after the BT report sends Review finding: port.mic_muted was flipped before try_send_output_report, so a blocked HID queue dropped the mic-state report with no retry and the controller LED desynced from the app. Keep the mismatch until the report actually goes out; the next companion write retries. --- vds/src/platform/linux/vdsd.cc | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/vds/src/platform/linux/vdsd.cc b/vds/src/platform/linux/vdsd.cc index c9ede87..4965391 100644 --- a/vds/src/platform/linux/vdsd.cc +++ b/vds/src/platform/linux/vdsd.cc @@ -2339,15 +2339,18 @@ void apply_companion_state(std::vector &ports, } if (port.mic_muted != settings.mic_muted) { // App-initiated mute toggle (SET_MIC_MUTE): actuate it on the - // controller the same way the physical mute button does. - port.mic_muted = settings.mic_muted; + // controller the same way the physical mute button does. Only commit + // the new state when the report went out, so a blocked HID queue + // leaves the mismatch in place and the next companion write retries. const auto mic_report = port.output_state.build_bt_mic_state_report( - port.audio_in_stream_active, port.mic_muted); - controller->backend->try_send_output_report(mic_report); - logger.log(vds::LogScope::Companion, vds::LogLevel::Info, - port.path + " mic " + - std::string(port.mic_muted ? "muted" : "unmuted") + - " via companion"); + port.audio_in_stream_active, settings.mic_muted); + if (controller->backend->try_send_output_report(mic_report)) { + port.mic_muted = settings.mic_muted; + logger.log(vds::LogScope::Companion, vds::LogLevel::Info, + port.path + " mic " + + std::string(port.mic_muted ? "muted" : "unmuted") + + " via companion"); + } } port.output_state.set_companion_overrides(overrides); try { From 4bd5381fc6bf04bb7e490f3efb11c29bbde1ab2b Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 10:56:58 +0400 Subject: [PATCH 10/34] Light the DualSense mute LED when the mic is muted The BT mic-state report hardcoded the mute LED byte to 0, so app- or button-initiated mute toggles produced no visible feedback on the controller: an app-side mute looked like it did nothing. Set the LED from the mute state (solid while muted), matching native PS5 behavior. --- vds/src/vds_protocol.cc | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vds/src/vds_protocol.cc b/vds/src/vds_protocol.cc index ca57d4a..4179f8d 100644 --- a/vds/src/vds_protocol.cc +++ b/vds/src/vds_protocol.cc @@ -1131,7 +1131,11 @@ BtStateReport DsOutputState::build_bt_mic_state_report(bool active, state_[kOutputMicVolumeOffset] = active && !muted ? mic_volume_ : 0; state_[kOutputAudioControlOffset] = audio_control; state_[kOutputAudioControl2Offset] = audio_control2_; - state_[kOutputMuteLedOffset] = 0; + // Drive the DualSense mute LED from the mute state so toggles initiated by + // the host (companion app) are visible on the controller, matching native + // PS5 behavior. 1 = solid on while muted. + const std::uint8_t mute_led = muted ? 1 : 0; + state_[kOutputMuteLedOffset] = mute_led; state_[kOutputPowerSaveControlOffset] = power_save_control; recompute_effective_state(); @@ -1148,7 +1152,7 @@ BtStateReport DsOutputState::build_bt_mic_state_report(bool active, report[kBtStateOffset + kOutputMicVolumeOffset] = active && !muted ? mic_volume_ : 0; report[kBtStateOffset + kOutputAudioControlOffset] = audio_control; - report[kBtStateOffset + kOutputMuteLedOffset] = 0; + report[kBtStateOffset + kOutputMuteLedOffset] = mute_led; report[kBtStateOffset + kOutputPowerSaveControlOffset] = power_save_control; report[kBtStateOffset + kOutputAudioControl2Offset] = audio_control2_; fill_output_report_checksum(report); From a96f26e7b654467a3d7328d5603996af27899769 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 11:03:51 +0400 Subject: [PATCH 11/34] Hold off micMuted status reconcile right after an app mute command The status poll reads the STATUS report, then does several other feature reads before reconciling micMuted. A mute toggled in the app inside that window was compared against the pre-command status and snapped back to the stale state, fighting the daemon (alternating mute/unmute in the vdsd log). Skip the reconcile for 2 s after this app sends SET_MIC_MUTE; controller-button changes still reconcile on the next poll. --- ds5-bridge/companion/src/main/bridge-service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 24350a7..4247ca1 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -97,6 +97,7 @@ const POLL_INTERVAL_MS = 500; const SHORTCUT_POLL_INTERVAL_MS = 50; const SHORTCUT_POLL_ERROR_RETRY_MS = 250; const AUDIO_STATUS_READ_INTERVAL_MS = 500; +const MIC_MUTE_RECONCILE_HOLDOFF_MS = 2000; const AUDIO_DEBUG_READ_INTERVAL_MS = 500; const TRIGGER_TRACE_READ_INTERVAL_MS = 250; const FEEDBACK_TRACE_READ_INTERVAL_MS = 250; @@ -1396,6 +1397,10 @@ export class BridgeService extends EventEmitter { private systemAudioHapticsPassthroughActive = false; private commandQueue: Promise = Promise.resolve(); private lastAudioStatusReadAt = 0; + // Set when this app sends SET_MIC_MUTE. The status-poll micMuted reconcile + // is skipped inside this window: the polled status report may predate the + // command, and adopting it would snap the UI back to the stale state. + private lastMicMuteCommandAt = 0; private lastAudioDebugReadAt = 0; private lastTriggerTraceReadAt = 0; private lastFeedbackTraceReadAt = 0; @@ -2433,6 +2438,7 @@ export class BridgeService extends EventEmitter { } async setMicMute(enabled: boolean): Promise { + this.lastMicMuteCommandAt = Date.now(); await this.sendCommand(COMMAND_ID.SET_MIC_MUTE, enabled ? 1 : 0, { expectSettingsRevisionChange: true }); @@ -2500,6 +2506,7 @@ export class BridgeService extends EventEmitter { async setDuplexMicEnabled(enabled: boolean): Promise { const nextEnabled = enabled; + this.lastMicMuteCommandAt = Date.now(); if (!nextEnabled) { await this.sendCommand(COMMAND_ID.SET_MIC_MUTE, 1, { expectSettingsRevisionChange: true @@ -3620,6 +3627,7 @@ export class BridgeService extends EventEmitter { this.reappliedSessionKey === this.sessionKey && settings.duplexMicEnabled && settings.micMuted !== status.micMuted + && Date.now() - this.lastMicMuteCommandAt > MIC_MUTE_RECONCILE_HOLDOFF_MS ) { settings = this.settingsStore.update(customSettingUpdate({ micMuted: status.micMuted })); } From 3523bc25c55c54393eae82dc5f8d1d3362d92d3b Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 11:10:53 +0400 Subject: [PATCH 12/34] Keep queued BT state reports fresh across mic-state changes Occasional stale mute LED: a 0x31 state report queued while the HID queue was blocked was built from the pre-toggle output state, and when it flushed after the mic-state report it re-asserted the old mute LED (controller showed muted after an unmute). Rebuild any queued state report from the current state after every mic-state send, and when the button path's mic report itself fails to send, queue a full state report so the flush loop delivers the change instead of dropping it. --- vds/src/platform/linux/vdsd.cc | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/vds/src/platform/linux/vdsd.cc b/vds/src/platform/linux/vdsd.cc index 4965391..ac1e714 100644 --- a/vds/src/platform/linux/vdsd.cc +++ b/vds/src/platform/linux/vdsd.cc @@ -587,6 +587,20 @@ void forward_bt_state_if_changed(VirtualPort &port, } } +/* + * A mic-state report mutates the output state (mute LED, mic volume, power + * save). Any 0x31 state report queued while the HID queue was blocked was + * built from the pre-change state; if it flushed later it would re-assert + * the stale mute LED. Rebuild the queued report from the current state. + */ +void refresh_pending_bt_state(VirtualPort &port) { + if (!port.pending_bt_state) { + return; + } + port.pending_bt_state = port.output_state.state(); + port.pending_bt_state_report = port.output_state.build_bt_state_report(); +} + void ioctl_noarg(int fd, unsigned long request, const char *name) { if (::ioctl(fd, request) < 0) { throw std::runtime_error(std::string(name) + @@ -700,6 +714,7 @@ void handle_frame(const vds_frame_header &header, const auto state_report = port.output_state.build_bt_mic_state_report( port.audio_in_stream_active, port.mic_muted); bt_backend->try_send_output_report(state_report); + refresh_pending_bt_state(port); const auto report = port.output_state.build_bt_mic_report(port.audio_in_stream_active); const bool sent = bt_backend->try_send_output_report(report); @@ -1178,7 +1193,14 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, (headset_mic_changed && port.audio_in_stream_active)) { const auto report = port.output_state.build_bt_mic_state_report( port.audio_in_stream_active, port.mic_muted); - bt_backend.try_send_output_report(report); + if (bt_backend.try_send_output_report(report)) { + refresh_pending_bt_state(port); + } else { + // HID queue blocked: queue a full state report (it carries the mute + // LED and mic flags) so the flush loop delivers the change. + port.pending_bt_state = port.output_state.state(); + port.pending_bt_state_report = port.output_state.build_bt_state_report(); + } if (input_trace) { logger.log(vds::LogScope::InputControl, vds::LogLevel::Info, port.path + " mic " + @@ -2346,6 +2368,7 @@ void apply_companion_state(std::vector &ports, port.audio_in_stream_active, settings.mic_muted); if (controller->backend->try_send_output_report(mic_report)) { port.mic_muted = settings.mic_muted; + refresh_pending_bt_state(port); logger.log(vds::LogScope::Companion, vds::LogLevel::Info, port.path + " mic " + std::string(port.mic_muted ? "muted" : "unmuted") + From 9eb5e937489489f0a16a90d4a25c57c27d800fd0 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 11:31:19 +0400 Subject: [PATCH 13/34] Ignore host mute-LED/mic-mute output-report bits; daemon owns mic mute Root cause of the recurring stale mute LED (trace-confirmed): the host kernel's hid-playstation driver bound to the virtual DualSense toggles its own mic-mute state on every mute-button press and sends output reports with allow_mute_light/allow_audio_mute. It cannot see companion-initiated mute changes, so after an app mute its phase is inverted and its next output report re-asserts the old LED and mic-mute bit over ours. Drop allow_mute_light entirely and preserve our mic-mute power-save bit when merging host reports; other power-save bits still pass through. --- vds/src/vds_protocol.cc | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/vds/src/vds_protocol.cc b/vds/src/vds_protocol.cc index 4179f8d..df45594 100644 --- a/vds/src/vds_protocol.cc +++ b/vds/src/vds_protocol.cc @@ -1003,8 +1003,18 @@ bool DsOutputState::apply_usb_output_report( copy_state_bytes(state_, update, kOutputAudioControl2Offset, 1); } if (decoded.allow_audio_mute) { + // The host-side hid-playstation driver toggles its own mic-mute state on + // every mute-button press, but it cannot see companion-initiated mute + // changes, so its phase drifts from ours. The daemon (companion + button + // handling) owns mic mute: take the host's other power-save bits but + // preserve our mic-mute bit. state_[kOutputFlag1Offset] |= kOutputFlag1PowerSaveControlEnable; - power_save_control_ = update[kOutputPowerSaveControlOffset]; + const std::uint8_t preserved_mic_mute = + state_[kOutputPowerSaveControlOffset] & kOutputPowerSaveMicMute; + power_save_control_ = static_cast( + (update[kOutputPowerSaveControlOffset] & + static_cast(~kOutputPowerSaveMicMute)) | + preserved_mic_mute); state_[kOutputPowerSaveControlOffset] = power_save_control_; } if (decoded.allow_speaker_volume && decoded.volume_speaker != 0) { @@ -1015,11 +1025,9 @@ bool DsOutputState::apply_usb_output_report( audio_control_with_output_path(audio_control_, kOutputPathSpeaker); state_[kOutputAudioControl2Offset] = audio_control2_; } - if (decoded.allow_mute_light) { - copy_state_bytes(state_, update, - offsetof(vds_set_state_data, mute_light_mode), - sizeof(decoded.mute_light_mode)); - } + // decoded.allow_mute_light is deliberately ignored: the mute LED mirrors + // the daemon-owned mic-mute state (build_bt_mic_state_report), and the + // host driver's out-of-phase toggles would otherwise override it. if (decoded.allow_right_trigger_ffb) { copy_state_bytes(state_, update, offsetof(vds_set_state_data, right_trigger_ffb), From 588e883c5fa9023d79b701e8eb3e0ed782d27cbc Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 13:14:59 +0400 Subject: [PATCH 14/34] Toast on every mic mute/unmute Emit an 'OpenDS5: Microphone muted/unmuted' toast from a single helper on all four mute-change paths: app toggle (setMicMute), controller button adoption via the status reconcile, the controller mic-mute shortcut event, and the implicit change when mic pass-through is enabled/disabled. Change-detected so redundant commands stay silent. --- .../companion/src/main/bridge-service.test.ts | 22 +++++++++++++++++++ .../companion/src/main/bridge-service.ts | 17 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/ds5-bridge/companion/src/main/bridge-service.test.ts b/ds5-bridge/companion/src/main/bridge-service.test.ts index e4c3c53..7697f76 100644 --- a/ds5-bridge/companion/src/main/bridge-service.test.ts +++ b/ds5-bridge/companion/src/main/bridge-service.test.ts @@ -2214,6 +2214,28 @@ describe('BridgeService', () => { expect(snapshot.settings.notifyLowBattery).toBe(true); }); + it('emits mic mute toasts for app toggles', async () => { + const service = serviceFixture(); + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: true }); + const toasts: Array<{ title: string; body: string }> = []; + service.on('toast', (toast) => toasts.push(toast)); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + await poll(service); + + await service.setMicMute(true); + expect(toasts.at(-1)?.body).toBe('Microphone muted'); + + await service.setMicMute(false); + expect(toasts.at(-1)?.body).toBe('Microphone unmuted'); + expect(toasts).toHaveLength(2); + + // No state change, no toast. + await service.setMicMute(false); + expect(toasts).toHaveLength(2); + }); + it('emits controller connect and disconnect toasts on status transitions', async () => { const service = serviceFixture(); const device = new MockHidDevice(); diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 4247ca1..5ed25fe 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -2442,6 +2442,9 @@ export class BridgeService extends EventEmitter { await this.sendCommand(COMMAND_ID.SET_MIC_MUTE, enabled ? 1 : 0, { expectSettingsRevisionChange: true }); + if (this.settingsStore.get().micMuted !== enabled) { + this.emitMicMuteToast(enabled); + } this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ micMuted: enabled })); @@ -2520,6 +2523,9 @@ export class BridgeService extends EventEmitter { expectSettingsRevisionChange: true }); } + if (this.settingsStore.get().micMuted !== !nextEnabled) { + this.emitMicMuteToast(!nextEnabled); + } this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ duplexMicEnabled: nextEnabled, micMuted: !nextEnabled @@ -2848,11 +2854,21 @@ export class BridgeService extends EventEmitter { await this.sleepController(); } + private emitMicMuteToast(muted: boolean): void { + this.emit('toast', { + title: 'OpenDS5', + body: muted ? 'Microphone muted' : 'Microphone unmuted' + } satisfies BridgeToast); + } + private async applyControllerMicMuteEvent(micMuted: boolean): Promise { const settings = this.settingsStore.get(); if (!settings.duplexMicEnabled || settings.muteButtonMode !== 'normal') { return; } + if (settings.micMuted !== micMuted) { + this.emitMicMuteToast(micMuted); + } this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ micMuted })); if (this.snapshot.status) { this.snapshot.status.micMuted = micMuted; @@ -3630,6 +3646,7 @@ export class BridgeService extends EventEmitter { && Date.now() - this.lastMicMuteCommandAt > MIC_MUTE_RECONCILE_HOLDOFF_MS ) { settings = this.settingsStore.update(customSettingUpdate({ micMuted: status.micMuted })); + this.emitMicMuteToast(status.micMuted); } const state = transition ? 'transitioning' : 'connected'; From 62a799ed87675df04c1d143c01442ce6ba3a654f Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 13:53:59 +0400 Subject: [PATCH 15/34] Fix audio-haptics feedback loop with headset; add capture-device picker Feedback loop: with the headset plugged into the controller the default sink can be the bridge's own 4-channel device. The haptics helper captured the default sink monitor without a channel map, so PipeWire's 4ch->2ch downmix folded the rear haptics channels we play back into the capture - the engine amplified its own output into a constant buzz. Pin the capture to FL,FR (identical on stereo sinks; front-only on the bridge sink). Capture device picker: the Audio Haptics source dropdown now lists the system's output sinks (bridge sink excluded) alongside app sessions. Default stays 'System', which follows the default output device; picking a device pins pw-record --target to that sink's monitor. New 'output-device' source kind plumbed through protocol types, settings normalization, engine args (--haptics-output-device), Linux helper (--list-output-sinks mode), IPC, and the renderer picker. --- .../companion/native/audio-helper-linux.mjs | 32 ++++++++ ds5-bridge/companion/src/main/audio-helper.ts | 74 +++++++++++++++++++ .../companion/src/main/bridge-service.ts | 18 +++++ ds5-bridge/companion/src/main/main.ts | 1 + .../companion/src/main/settings-store.ts | 12 +++ ds5-bridge/companion/src/preload.ts | 4 + ds5-bridge/companion/src/renderer/App.tsx | 48 +++++++++++- ds5-bridge/companion/src/shared/protocol.ts | 16 +++- 8 files changed, 202 insertions(+), 3 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index a925822..cb19f96 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -157,10 +157,19 @@ async function runRenderLoopbackHaptics(args) { const target = nodeProps(sink)['node.name']; const processor = new HapticsProcessor(readHapticsConfig(args)); + // Optional capture pin: monitor a specific output device instead of + // following the system default sink. + const captureDevice = argValue(args, '--haptics-output-device'); const record = spawn('pw-record', [ '--raw', '-P', '{ stream.capture.sink = true }', + ...(captureDevice ? ['--target', captureDevice] : []), + // Capture the front channels only. When the headset is plugged in, the + // default sink can be the bridge's own 4-channel device; an unmapped + // stereo capture downmixes the rear haptics channels we play into it, + // feeding our own output back (constant buzz / self-oscillation). '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '2', + '--channel-map', 'FL,FR', '--latency', '256', '-' ], { stdio: ['ignore', 'pipe', 'pipe'] }); @@ -303,6 +312,27 @@ async function defaultSinkName() { }); } +// Prints the audio output sinks as a JSON array for the Audio Haptics +// capture-device picker. The bridge's own sink is excluded: capturing it +// would feed the haptics we play back into the processor. +async function runListOutputSinks() { + const objects = await pwDump(); + const current = await defaultSinkName(); + const devices = objects + .filter((object) => isAudioSink(object) && !isBridgeSink(object)) + .map((object) => { + const props = nodeProps(object); + const nodeName = props['node.name'] ?? ''; + return { + nodeName, + displayName: props['node.description'] || nodeName, + isDefault: nodeName === (current?.name ?? '') + }; + }) + .filter((device) => device.nodeName); + process.stdout.write(`${JSON.stringify(devices)}\n`); +} + async function runDefaultRenderStatus() { const current = await defaultSinkName(); const deviceName = current?.description || current?.name || ''; @@ -400,6 +430,8 @@ async function main() { await runPlayTestTone(args); } else if (args.includes('--play-test-haptics')) { await runPlayTestHaptics(args); + } else if (args.includes('--list-output-sinks')) { + await runListOutputSinks(); } else if (args.includes('--default-render-status')) { await runDefaultRenderStatus(); } else if (args.includes('--set-default-render-bridge')) { diff --git a/ds5-bridge/companion/src/main/audio-helper.ts b/ds5-bridge/companion/src/main/audio-helper.ts index d2390c0..d427f6c 100644 --- a/ds5-bridge/companion/src/main/audio-helper.ts +++ b/ds5-bridge/companion/src/main/audio-helper.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { EventEmitter } from 'node:events'; import { CompanionDebugConfig, DEBUG_ENV } from './debug-config'; import type { + AudioOutputDevice, AudioReactiveHapticsSource, AudioReactiveHapticsAttack, AudioReactiveHapticsBassFocus, @@ -207,6 +208,10 @@ export class SystemAudioHapticsEngine extends EventEmitter { '--haptics-release', config.release ]; + const deviceSource = audioReactiveHapticsOutputDeviceSource(config.source); + if (deviceSource) { + args.push('--haptics-output-device', deviceSource.nodeName); + } const appSource = audioReactiveHapticsAppSource(config.source); if (appSource) { if (Number.isFinite(appSource.processId) && appSource.processId > 0) { @@ -517,6 +522,18 @@ function normalizeAudioReactiveHapticsSource(source: AudioReactiveHapticsSource if (source === 'controller-audio' || source === 'system-audio') { return source; } + const deviceSource = audioReactiveHapticsOutputDeviceSource(source); + if (deviceSource) { + const nodeName = normalizeOptionalText(deviceSource.nodeName); + if (!nodeName) { + return 'system-audio'; + } + return { + kind: 'output-device', + nodeName, + displayName: normalizeOptionalText(deviceSource.displayName) + }; + } const appSource = audioReactiveHapticsAppSource(source); if (!appSource) { return 'system-audio'; @@ -539,7 +556,17 @@ function audioReactiveHapticsAppSource(source: AudioReactiveHapticsSource | unde : null; } +function audioReactiveHapticsOutputDeviceSource(source: AudioReactiveHapticsSource | undefined) { + return source && typeof source === 'object' && source.kind === 'output-device' + ? source + : null; +} + function audioReactiveHapticsSourceKey(source: AudioReactiveHapticsSource): string { + const deviceSource = audioReactiveHapticsOutputDeviceSource(source); + if (deviceSource) { + return `output-device:${deviceSource.nodeName}`; + } const appSource = audioReactiveHapticsAppSource(source); if (!appSource) { return source === 'controller-audio' ? 'controller-audio' : 'system-audio'; @@ -763,6 +790,53 @@ async function waitForHelperRecordingStarted( }); } +// Lists system audio output devices (sinks) for the Audio Haptics capture +// picker. The helper prints one JSON array on stdout and exits. +export async function listAudioOutputDevices(): Promise { + const launch = helperLaunch(['--list-output-sinks']); + const helper = spawn(launch.command, launch.args, { + env: launch.env, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + return new Promise((resolve) => { + let stdout = ''; + const timeout = setTimeout(() => { + if (!helper.killed) { + helper.kill('SIGKILL'); + } + resolve([]); + }, 5000); + helper.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + helper.on('error', () => { + clearTimeout(timeout); + resolve([]); + }); + helper.on('exit', () => { + clearTimeout(timeout); + try { + const parsed = JSON.parse(stdout); + resolve(Array.isArray(parsed) + ? parsed.filter((device): device is AudioOutputDevice => ( + Boolean(device) && typeof device.nodeName === 'string' && device.nodeName.length > 0 + )).map((device) => ({ + nodeName: device.nodeName, + displayName: typeof device.displayName === 'string' && device.displayName + ? device.displayName + : device.nodeName, + isDefault: Boolean(device.isDefault) + })) + : []); + } catch { + resolve([]); + } + }); + }); +} + export async function playBridgeSpeakerTestTone( speakerVolumePercent = 100, hostPersonaMode: HostPersonaMode = 'dualsense' diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 5ed25fe..c33b14d 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -43,6 +43,7 @@ import type { AudioReactiveHapticsBassFocus, AudioReactiveHapticsConfig, AudioReactiveHapticsMode, + AudioOutputDevice, AudioReactiveHapticsRelease, AudioReactiveHapticsResponse, AudioReactiveHapticsSource, @@ -81,6 +82,7 @@ import { AudioHapticsSessionMonitor, MicKeepaliveEngine, SystemAudioHapticsEngine, + listAudioOutputDevices, playBridgeHapticsTestPattern, playBridgeSpeakerTestTone, getDefaultRenderEndpointStatus, @@ -507,6 +509,15 @@ function normalizeAudioReactiveHapticsSource(source: unknown): AudioReactiveHapt if (!source || typeof source !== 'object') { return 'system-audio'; } + const deviceCandidate = source as Partial>; + if (deviceCandidate.kind === 'output-device') { + const nodeName = normalizeOptionalString(deviceCandidate.nodeName); + if (!nodeName) { + return 'system-audio'; + } + const displayName = normalizeOptionalString(deviceCandidate.displayName); + return { kind: 'output-device', nodeName, ...(displayName ? { displayName } : {}) }; + } const candidate = source as Partial>; if (candidate.kind !== 'app-session') { return 'system-audio'; @@ -539,6 +550,9 @@ function audioReactiveHapticsSourceKey(source: AudioReactiveHapticsSource): stri if (source === 'controller-audio' || source === 'system-audio') { return source; } + if (source.kind === 'output-device') { + return `output-device:${source.nodeName}`; + } if (source.processPath) { return `app-path:${source.processPath.toLowerCase()}`; } @@ -1555,6 +1569,10 @@ export class BridgeService extends EventEmitter { await this.micKeepaliveEngine.stop(); } + async listAudioOutputDevices(): Promise { + return listAudioOutputDevices(); + } + async listAudioHapticsSessions(): Promise { if (!this.controllerAudioReady()) { await this.stopAudioHapticsSessionPolling(); diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index 1237ef5..62cd4f8 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -1333,6 +1333,7 @@ function registerIpc( ipcMain.handle('bridge:getStatus', () => service.getSnapshot()); ipcMain.handle('bridge:listDevices', () => service.listDevices()); + ipcMain.handle('bridge:listAudioOutputDevices', async () => service.listAudioOutputDevices()); ipcMain.handle('bridge:listAudioHapticsSessions', async () => ( addAudioHapticsSessionIcons(await service.listAudioHapticsSessions()) )); diff --git a/ds5-bridge/companion/src/main/settings-store.ts b/ds5-bridge/companion/src/main/settings-store.ts index 5c209ff..c646ab4 100644 --- a/ds5-bridge/companion/src/main/settings-store.ts +++ b/ds5-bridge/companion/src/main/settings-store.ts @@ -245,6 +245,18 @@ function normalizeAudioReactiveHapticsSource(value: unknown): AudioReactiveHapti if (!value || typeof value !== 'object') { return DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsSource; } + const deviceCandidate = value as Partial>; + if (deviceCandidate.kind === 'output-device') { + const nodeName = normalizeOptionalString(deviceCandidate.nodeName); + if (!nodeName) { + return DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsSource; + } + return { + kind: 'output-device', + nodeName, + displayName: normalizeOptionalString(deviceCandidate.displayName) + }; + } const candidate = value as Partial>; if (candidate.kind !== 'app-session') { return DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsSource; diff --git a/ds5-bridge/companion/src/preload.ts b/ds5-bridge/companion/src/preload.ts index fcf76d0..4f0ab0b 100644 --- a/ds5-bridge/companion/src/preload.ts +++ b/ds5-bridge/companion/src/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { AdaptiveTriggerPreviewEffect, + AudioOutputDevice, AudioReactiveHapticsConfig, BridgePresetId, ChordAssignment, @@ -56,6 +57,9 @@ const api = { listAudioHapticsSessions: (): Promise => ( ipcRenderer.invoke('bridge:listAudioHapticsSessions') ), + listAudioOutputDevices: (): Promise => ( + ipcRenderer.invoke('bridge:listAudioOutputDevices') + ), applyPreset: (value: BridgePresetId): Promise => ipcRenderer.invoke('bridge:applyPreset', value), selectControllerProfile: (profileId: string): Promise => ( ipcRenderer.invoke('bridge:selectControllerProfile', profileId) diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index c106a80..1ef7efb 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -116,6 +116,7 @@ import type { AudioReactiveHapticsBassFocus, AudioReactiveHapticsConfig, AudioReactiveHapticsMode, + AudioOutputDevice, AudioReactiveHapticsSource, AudioReactiveHapticsAttack, AudioReactiveHapticsRelease, @@ -827,7 +828,17 @@ function audioHapticsSessionKey(session: AudioHapticsSession): string { return `app-pid:${session.processId}`; } +function audioHapticsOutputDeviceSource(source: AudioReactiveHapticsSource | null | undefined) { + return source && typeof source === 'object' && source.kind === 'output-device' + ? source + : null; +} + function audioHapticsSourceKey(source: AudioReactiveHapticsSource | null | undefined): string { + const deviceSource = audioHapticsOutputDeviceSource(source); + if (deviceSource) { + return `output-device:${deviceSource.nodeName}`; + } const appSource = audioHapticsAppSource(source); if (!appSource) { return 'system-audio'; @@ -854,6 +865,10 @@ function audioHapticsSourceFromSession(session: AudioHapticsSession): AudioReact } function audioHapticsSourceDisplayName(source: AudioReactiveHapticsSource | null | undefined): string { + const deviceSource = audioHapticsOutputDeviceSource(source); + if (deviceSource) { + return deviceSource.displayName || deviceSource.nodeName; + } const appSource = audioHapticsAppSource(source); if (!appSource) { return 'System'; @@ -2815,6 +2830,7 @@ export function App() { const [triggerEffectIntensityValue, setTriggerEffectIntensityValue] = useState(100); const [audioHapticsOpen, setAudioHapticsOpen] = useState(false); const [audioHapticsSessions, setAudioHapticsSessions] = useState([]); + const [audioOutputDevices, setAudioOutputDevices] = useState([]); const [audioHapticsSessionsLoading, setAudioHapticsSessionsLoading] = useState(false); const [triggerProfiles, setTriggerProfiles] = useState([]); const [triggerProfilesEnabled, setTriggerProfilesEnabled] = useState(false); @@ -3571,13 +3587,18 @@ export function App() { refreshInFlight = true; setAudioHapticsSessionsLoading(true); try { - const sessions = await window.bridge.listAudioHapticsSessions(); + const [sessions, devices] = await Promise.all([ + window.bridge.listAudioHapticsSessions(), + window.bridge.listAudioOutputDevices() + ]); if (!cancelled) { setAudioHapticsSessions(sessions); + setAudioOutputDevices(devices); } } catch { if (!cancelled) { setAudioHapticsSessions([]); + setAudioOutputDevices([]); } } finally { refreshInFlight = false; @@ -3954,14 +3975,28 @@ export function App() { }, [audioHapticsSessions]); const selectedAudioHapticsSourceDisplayName = audioHapticsSessionByKey.get(audioReactiveHapticsSourceKey)?.displayName ?? audioHapticsSourceDisplayName(audioReactiveHapticsSource); + const audioOutputDeviceByKey = useMemo(() => { + const devices = new Map(); + for (const device of audioOutputDevices) { + devices.set(`output-device:${device.nodeName}`, device); + } + return devices; + }, [audioOutputDevices]); const audioHapticsSourceOptions = useMemo>(() => { - const options: Array<[string, string]> = [['System', 'system-audio']]; + const options: Array<[string, string]> = [['System (follows default output)', 'system-audio']]; + for (const device of audioOutputDevices) { + options.push([ + device.isDefault ? `${device.displayName} (default)` : device.displayName, + `output-device:${device.nodeName}` + ]); + } for (const session of audioHapticsSessions) { options.push([session.displayName, audioHapticsSessionKey(session)]); } if ( audioReactiveHapticsSourceKey !== 'system-audio' && !audioHapticsSessionByKey.has(audioReactiveHapticsSourceKey) + && !audioOutputDeviceByKey.has(audioReactiveHapticsSourceKey) ) { options.push([`${audioHapticsSourceDisplayName(audioReactiveHapticsSource)} unavailable`, audioReactiveHapticsSourceKey]); } @@ -3969,6 +4004,8 @@ export function App() { }, [ audioHapticsSessionByKey, audioHapticsSessions, + audioOutputDeviceByKey, + audioOutputDevices, audioReactiveHapticsSource, audioReactiveHapticsSourceKey ]); @@ -4803,6 +4840,13 @@ export function App() { void commitAudioReactiveHapticsConfig({ source: 'system-audio' }); return; } + const device = audioOutputDeviceByKey.get(value); + if (device) { + void commitAudioReactiveHapticsConfig({ + source: { kind: 'output-device', nodeName: device.nodeName, displayName: device.displayName } + }); + return; + } const session = audioHapticsSessionByKey.get(value); if (!session) { return; diff --git a/ds5-bridge/companion/src/shared/protocol.ts b/ds5-bridge/companion/src/shared/protocol.ts index 1f3ae96..2faf1de 100644 --- a/ds5-bridge/companion/src/shared/protocol.ts +++ b/ds5-bridge/companion/src/shared/protocol.ts @@ -153,7 +153,21 @@ export interface AudioReactiveHapticsAppSource { sessionIdentifier?: string; sessionInstanceIdentifier?: string; } -export type AudioReactiveHapticsSource = 'controller-audio' | 'system-audio' | AudioReactiveHapticsAppSource; +export interface AudioReactiveHapticsOutputDeviceSource { + kind: 'output-device'; + nodeName: string; + displayName?: string; +} +export interface AudioOutputDevice { + nodeName: string; + displayName: string; + isDefault: boolean; +} +export type AudioReactiveHapticsSource = + | 'controller-audio' + | 'system-audio' + | AudioReactiveHapticsAppSource + | AudioReactiveHapticsOutputDeviceSource; export type AudioReactiveHapticsMode = 'mix' | 'replace'; export type AudioReactiveHapticsBassFocus = 'deep' | 'balanced' | 'punchy' | 'wide'; export type AudioReactiveHapticsResponse = 'subtle' | 'balanced' | 'strong'; From 02792b11345acb56b1bfc36d3bcc09ff9064f746 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 13:59:12 +0400 Subject: [PATCH 16/34] Capture 4 discrete channels in the haptics helper to defeat rear fold-down The FL,FR channel-map alone did not break the feedback loop: PipeWire's channel mixer still folds the 4ch bridge monitor's rear (haptics) channels into a narrower capture's fronts (standard rear fold-down), so the helper kept hearing its own output. Capture 4ch FL,FR,RL,RR - a matching format links passthrough with no mixing - and read only the front channels in the processor. Stereo sinks upmix with silent rears, fronts intact. --- .../companion/native/audio-helper-linux.mjs | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index cb19f96..6d47040 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -120,13 +120,14 @@ class HapticsProcessor { this.lowpassRight = biquadLowpass(cutoff); } - // stereo f32 in -> 4ch f32 out (speaker channels silent, haptics on 3-4) + // 4ch f32 in (front channels used, rears ignored) -> 4ch f32 out + // (speaker channels silent, haptics on 3-4) process(input) { - const frames = input.length / 2; + const frames = input.length / 4; const output = new Float32Array(frames * 4); for (let frame = 0; frame < frames; frame += 1) { - const left = biquadStep(this.lowpassLeft, input[frame * 2]); - const right = biquadStep(this.lowpassRight, input[frame * 2 + 1]); + const left = biquadStep(this.lowpassLeft, input[frame * 4]); + const right = biquadStep(this.lowpassRight, input[frame * 4 + 1]); const peak = Math.max(Math.abs(left), Math.abs(right)); const coeff = peak > this.envelope ? this.attackCoeff : this.releaseCoeff; this.envelope = coeff * this.envelope + (1 - coeff) * peak; @@ -164,12 +165,15 @@ async function runRenderLoopbackHaptics(args) { '--raw', '-P', '{ stream.capture.sink = true }', ...(captureDevice ? ['--target', captureDevice] : []), - // Capture the front channels only. When the headset is plugged in, the - // default sink can be the bridge's own 4-channel device; an unmapped - // stereo capture downmixes the rear haptics channels we play into it, - // feeding our own output back (constant buzz / self-oscillation). - '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '2', - '--channel-map', 'FL,FR', + // Capture four discrete channels and let the processor use the fronts + // only. When the headset is plugged in, the default sink can be the + // bridge's own 4-channel device; any narrower capture goes through + // PipeWire's channel mixer, which folds the rear haptics channels we + // play back into the fronts (constant buzz / self-oscillation). With a + // matching 4ch format no mixing happens; stereo sinks upmix with + // silent rears, leaving the fronts intact either way. + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', '--latency', '256', '-' ], { stdio: ['ignore', 'pipe', 'pipe'] }); @@ -191,7 +195,7 @@ async function runRenderLoopbackHaptics(args) { let carry = Buffer.alloc(0); record.stdout.on('data', (chunk) => { let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; - const frameBytes = 2 * 4; + const frameBytes = 4 * 4; // 4 channels x f32 const usable = data.length - (data.length % frameBytes); carry = data.subarray(usable); if (usable === 0) { From ad70d31d229a07ae34a8edda553ee94e3beb2cdd Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 14:27:57 +0400 Subject: [PATCH 17/34] Pin the vds card to the pro-audio profile in the shipped wireplumber conf The Direct 4-channel PCM is the device the whole audio stack targets (daemon, haptics helper, app). Without pinning, WirePlumber can bring the card up in a convenience profile whose stereo path goes through the channel mixer and software volume, which audibly degrades the BT audio and hides the haptics channels. Users previously needed a separate local pro-audio override (50-vds-proaudio.conf from beta setups); the shipped 99-vds conf now covers it in the existing card rule. --- vds/99-vds-dualsense-wireplumber.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vds/99-vds-dualsense-wireplumber.conf b/vds/99-vds-dualsense-wireplumber.conf index 81cf3ba..3ebf452 100644 --- a/vds/99-vds-dualsense-wireplumber.conf +++ b/vds/99-vds-dualsense-wireplumber.conf @@ -9,6 +9,12 @@ monitor.alsa.rules = [ "update-props": { "device.description": "DualSense Wireless Controller" "device.nick": "DualSense" + # Always bring the card up in the pro-audio profile so the raw + # 4-channel speaker+haptics PCM ("Direct DualSense Wireless + # Controller") exists deterministically after every reconnect. + # Convenience profiles route stereo through PipeWire's channel + # mixer and software volume, which audibly degrades the BT path. + "device.profile": "pro-audio" } } } From ac826400895c9cbca1a7b7e5e5906b415e99aab2 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 15:22:07 +0400 Subject: [PATCH 18/34] Add design spec for pre-fold per-app haptics capture --- ...26-07-17-haptics-prefold-capture-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md diff --git a/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md b/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md new file mode 100644 index 0000000..7cd5c2b --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md @@ -0,0 +1,112 @@ +# Pre-fold Per-App Haptics Capture — Design + +Date: 2026-07-17 +Branch: `feature/haptics-prefold-capture` (off `dev`) +Status: approved by user (LFE weight hardcoded at 1.0, no new UI) + +## Problem + +Audio Haptics on Linux always captures the default sink's monitor +(`audio-helper-linux.mjs`, render-loopback path). That signal is +post-fold-down: when a game renders 5.1/7.1 and the user listens on stereo +headphones, PipeWire's mixdown attenuates or drops the LFE channel — the +channel sound designers use for exactly the content haptics should convey. +It also mixes every app's audio together (Discord, music) into the haptics. + +The engine already sends `--haptics-app-process-id`, +`--haptics-app-process-path`, and `--haptics-app-executable` when the user +picks an app in the Audio Haptics source picker (`audio-helper.ts` +startInternal), but the Linux helper ignores them. + +## Goals + +1. When an app source is selected, capture that app's own + `Stream/Output/Audio` node pre-fold, preserving its native channel + layout (stereo, 5.1, 7.1, …). +2. Sum channels deliberately: `left = FL + 0.5×FC + 1.0×LFE`, + `right = FR + 0.5×FC + 1.0×LFE`. Rears/sides are ignored (they rarely + carry bass; excluding them also avoids re-inviting the fold-down + feedback loop fixed in 02792b1). Stereo streams reduce exactly to + today's FL+FR behavior. +3. Per-app isolation as a side benefit even for stereo games. +4. No app selected → current sink-monitor path, byte-for-byte unchanged. + +Non-goals: LFE emphasis UI (deferred — hardcode weight 1.0; revisit after +hardware tuning shows a boost is distinguishable), Windows changes, +forcing games to render surround (user does that in game settings). + +## Design + +### Helper: app-source capture (`audio-helper-linux.mjs`) + +- `runRenderLoopbackHaptics` reads the three `--haptics-app-*` flags. +- **Node resolution:** poll `pw-dump` (2 s cadence, same as the existing + session monitor) for a `Stream/Output/Audio` node whose + `application.process.id` matches; fall back to matching + `application.process.binary` against the executable name. Prefer a + `running` node when several match. +- **Format discovery:** read the node's negotiated channel count and + position map from its `Format` param in the pw-dump output. If the + format cannot be determined, assume stereo FL,FR. +- **Capture:** spawn `pw-record --target ` with the stream's + native `--channels`/`--channel-map` (no `stream.capture.sink`, since the + target is an output stream, not a sink monitor). +- **Hot-attach lifecycle:** helper starts in "waiting" state and emits + `status: recording-started` on first attach (the app already tolerates + the 8 s startup window). If the record process exits or the node + vanishes (game recreates its stream on level load), reap the process, + return to polling, re-attach when the node reappears. Playback side + (`pw-play` to the bridge sink) persists across re-attaches. +- No app flags present → existing sink-monitor code path runs unchanged. + +### Processor: layout-aware summing (`HapticsProcessor`) + +- `process()` gains knowledge of the input layout: frame stride and the + indices of FL, FR, FC, LFE derived from the negotiated channel map + (set when a capture attaches; re-set on re-attach). +- Per frame: `left = FL + 0.5×FC + LFE`, `right = FR + 0.5×FC + LFE` + (missing channels contribute 0). Downstream pipeline (low-pass, + envelope, gate, gain, tanh) unchanged. Output stays 4ch with haptics on + channels 3–4. +- Sink-monitor path keeps the current fixed 4ch FL,FR,RL,RR input with + fronts only — identical numerics to today. + +### Engine / UI + +No changes. The app picker, flag plumbing, and Audio Haptics card already +exist; this makes the Linux helper honor what they send. + +## Error handling + +- App selected but never appears: helper stays in waiting state, keeps + polling; no audio flows (correct — the chosen source is silent/absent). + Existing `status:` lines on stderr report state transitions for logs. +- `pw-dump` failure during polling: log to stderr, retry next tick. +- Malformed/unsupported channel map: fall back to stereo interpretation + of the first two channels. + +## Testing + +- Unit (helper is plain .mjs; tests colocated with the existing companion + test suite pattern): + - node matching: pid hit, binary fallback, multiple matches, no match; + - channel-map parsing → FL/FR/FC/LFE indices for stereo, 5.1, 7.1, and + an unknown map (stereo fallback); + - summing math for stereo (identical to current fronts-only) and 5.1 + (FC/LFE weights applied); + - hot-attach state machine with mocked pw-dump/process events. +- Hardware verification: + - surround-rendering game forced to 5.1: LFE-driven punch present while + listening on stereo headphones; per-app isolation (Discord/music do + not drive haptics); + - regression: sink-monitor path with 3.5 mm headset shows no feedback + buzz (02792b1 fix intact); + - stream-restart mid-game (level load / game restart) re-attaches. + +## Caveats + +- Games must actually render surround; PipeWire may offer stereo when the + default output is stereo, so users force 5.1/7.1 in game settings. +- LFE weight (1.0) and the FC weight (0.5) get sanity-tuned during + hardware verification; a Normal/Boosted toggle is a small follow-up if + tuning shows a boost is worth exposing. From 12eec3294d620f98f56e8685f1d16f02f5d8ec8a Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 15:26:55 +0400 Subject: [PATCH 19/34] Note rear-channel tuning checkpoint in haptics capture spec --- .../specs/2026-07-17-haptics-prefold-capture-design.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md b/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md index 7cd5c2b..5bc57f2 100644 --- a/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md +++ b/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md @@ -110,3 +110,7 @@ exist; this makes the Linux helper honor what they send. - LFE weight (1.0) and the FC weight (0.5) get sanity-tuned during hardware verification; a Normal/Boosted toggle is a small follow-up if tuning shows a boost is worth exposing. +- Tuning checkpoint: rears/sides are excluded from the summing by design + (bass management routes low end to LFE/fronts). During hardware tuning, + if a surround game demonstrably puts felt-worthy bass in RL/RR, add + them at a small weight (~0.3×) — a one-line change in the summing. From 277358ba2dfd2ed94f61e99d34f1badb6f1bf98a Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 15:29:48 +0400 Subject: [PATCH 20/34] Add implementation plan for pre-fold per-app haptics capture --- .../2026-07-17-haptics-prefold-capture.md | 664 ++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md diff --git a/docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md b/docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md new file mode 100644 index 0000000..fd8e78f --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md @@ -0,0 +1,664 @@ +# Pre-fold Per-App Haptics Capture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Linux audio helper honor the app-source flags the engine already sends, capturing the selected app's PipeWire stream pre-fold-down and summing FL/FR/FC/LFE deliberately so surround games drive haptics with their discrete LFE channel. + +**Architecture:** All changes live in the single-file Linux helper `ds5-bridge/companion/native/audio-helper-linux.mjs` (it is packaged as one file — do not split it). Pure logic (channel-layout parsing, node matching, summing) is exported for vitest; process orchestration (hot-attach loop) stays in the same file behind an import guard. The engine (`audio-helper.ts`) and UI need no changes. + +**Tech Stack:** Node.js ESM (.mjs), PipeWire CLI tools (`pw-dump`, `pw-record`, `pw-play`), vitest (tests live under `src/` because `test:companion` is `vitest run src`). + +**Spec:** `docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md` + +## Global Constraints + +- Sink-monitor path (no app selected) must stay numerically identical to today (feedback fix 02792b1 intact). +- Summing weights: `left = FL + 0.5×FC + 1.0×LFE`, `right = FR + 0.5×FC + 1.0×LFE`; rears/sides excluded. +- Unknown/missing channel layout falls back to stereo interpretation of the first two channels. +- Hot-attach poll cadence: 2000 ms (matches the existing session monitor). +- Helper output is always 4ch f32 with haptics on channels 3–4. +- Never push to any remote; commit locally only. No Co-Authored-By trailers. +- Run tests from `ds5-bridge/companion/`. + +--- + +### Task 1: Make the helper importable and export its internals + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (bottom of file, `main()` invocation; add `export` keywords) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (create) + +**Interfaces:** +- Produces: named exports from the .mjs — `HapticsProcessor`, plus (added in later tasks) `nodeChannelLayout`, `channelIndices`, `matchAppStreamNode`. Importing the module must have no side effects (no `pw-dump`, no exit). + +- [ ] **Step 1: Write the failing test** + +Create `ds5-bridge/companion/src/main/audio-helper-linux.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { HapticsProcessor } from '../../native/audio-helper-linux.mjs'; + +describe('audio-helper-linux exports', () => { + it('imports without running main and exposes HapticsProcessor', () => { + const processor = new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); + expect(typeof processor.process).toBe('function'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `HapticsProcessor` is not exported (SyntaxError: does not provide an export), or the import hangs/exits because `main()` runs. Either failure mode confirms the gap. + +- [ ] **Step 3: Export internals and guard main()** + +In `native/audio-helper-linux.mjs`: + +Add to the imports at the top: + +```js +import { pathToFileURL } from 'node:url'; +``` + +Change `class HapticsProcessor {` to `export class HapticsProcessor {`. + +Replace the last line of the file: + +```js +main().catch((error) => fail(error.message)); +``` + +with: + +```js +const isCliEntry = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isCliEntry) { + main().catch((error) => fail(error.message)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: PASS (1 test) + +- [ ] **Step 5: Sanity-check the CLI still runs** + +Run: `node ds5-bridge/companion/native/audio-helper-linux.mjs --list-output-sinks` +Expected: a JSON array on stdout (device list; contents depend on machine). This proves the import guard still executes `main()` when invoked as a CLI. + +- [ ] **Step 6: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Make Linux audio helper importable for unit tests" +``` + +--- + +### Task 2: Channel-layout parsing and index mapping + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (add two exported functions near `nodeProps`) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (append) + +**Interfaces:** +- Consumes: pw-dump node objects (`object.info.params.Format[0]` carries `{ channels, position }` for a negotiated audio node). +- Produces: + - `nodeChannelLayout(node) -> { channels: number, position: string[] }` — stereo `{channels: 2, position: ['FL','FR']}` fallback when the format is absent/invalid. + - `channelIndices(position: string[]) -> { stride: number, fl: number, fr: number, fc: number, lfe: number }` — indices are `-1` when the channel is absent; `stride` equals `position.length`; if FL/FR are missing, `fl = 0` and `fr = Math.min(1, stride - 1)` (mono maps both sides to channel 0). + +- [ ] **Step 1: Write the failing tests** + +Append to `src/main/audio-helper-linux.test.ts`: + +```ts +import { channelIndices, nodeChannelLayout } from '../../native/audio-helper-linux.mjs'; + +describe('nodeChannelLayout', () => { + it('reads channels and position from the Format param', () => { + const node = { info: { params: { Format: [{ mediaType: 'audio', channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }); + }); + + it('falls back to stereo when the format is missing', () => { + expect(nodeChannelLayout({ info: { params: {} } })).toEqual({ channels: 2, position: ['FL', 'FR'] }); + expect(nodeChannelLayout(undefined)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); + + it('falls back to stereo when position length disagrees with channels', () => { + const node = { info: { params: { Format: [{ channels: 6, position: ['FL', 'FR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); +}); + +describe('channelIndices', () => { + it('maps stereo', () => { + expect(channelIndices(['FL', 'FR'])).toEqual({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps 5.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'])).toEqual({ stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('maps 7.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR', 'SL', 'SR'])).toEqual({ stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('treats an unknown map as first-two-channels stereo', () => { + expect(channelIndices(['AUX0', 'AUX1', 'AUX2'])).toEqual({ stride: 3, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps mono to both sides', () => { + expect(channelIndices(['MONO'])).toEqual({ stride: 1, fl: 0, fr: 0, fc: -1, lfe: -1 }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `nodeChannelLayout`/`channelIndices` not exported. + +- [ ] **Step 3: Implement** + +Add after `nodeProps` in `native/audio-helper-linux.mjs`: + +```js +const STEREO_LAYOUT = { channels: 2, position: ['FL', 'FR'] }; + +// Negotiated audio format of a pw-dump node; stereo fallback when absent +// or inconsistent (spec: unknown layouts read as first-two-channel stereo). +export function nodeChannelLayout(node) { + const format = node?.info?.params?.Format?.[0]; + const channels = Number(format?.channels ?? 0); + const position = Array.isArray(format?.position) ? format.position : null; + if (!position || channels < 1 || position.length !== channels) { + return { ...STEREO_LAYOUT, position: [...STEREO_LAYOUT.position] }; + } + return { channels, position: [...position] }; +} + +export function channelIndices(position) { + const stride = position.length; + let fl = position.indexOf('FL'); + let fr = position.indexOf('FR'); + if (fl < 0 || fr < 0) { + fl = 0; + fr = Math.min(1, stride - 1); + } + return { stride, fl, fr, fc: position.indexOf('FC'), lfe: position.indexOf('LFE') }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: PASS (9 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Parse PipeWire stream channel layouts in the Linux audio helper" +``` + +--- + +### Task 3: Layout-aware summing in HapticsProcessor + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (`HapticsProcessor`) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (append) + +**Interfaces:** +- Consumes: `channelIndices` result shape from Task 2. +- Produces: `HapticsProcessor.setInputLayout({ stride, fl, fr, fc, lfe })`. Default layout (set in the constructor) is `{ stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }` — exactly today's fixed 4ch fronts-only behavior, so the sink-monitor path needs no call-site change. `process(input)` consumes `stride` channels per frame and still emits 4ch output with haptics on channels 3–4. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/main/audio-helper-linux.test.ts`: + +```ts +function makeProcessor() { + return new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); +} + +describe('HapticsProcessor layouts', () => { + it('default 4ch layout matches explicit FL,FR,RL,RR layout sample-for-sample', () => { + const frames = 64; + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + input[f * 4] = Math.sin(f / 3) * 0.5; // FL + input[f * 4 + 1] = Math.cos(f / 3) * 0.5; // FR + input[f * 4 + 2] = 0.9; // RL: must be ignored + input[f * 4 + 3] = -0.9; // RR: must be ignored + } + const byDefault = makeProcessor().process(input); + const explicit = makeProcessor(); + explicit.setInputLayout({ stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }); + expect(Array.from(explicit.process(input))).toEqual(Array.from(byDefault)); + }); + + it('5.1 layout blends FC at 0.5x and LFE at 1x into both sides', () => { + const frames = 64; + const layout = { stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }; + // Only FC and LFE carry signal: expect output driven purely by the blend. + const surround = new Float32Array(frames * 6); + for (let f = 0; f < frames; f += 1) { + surround[f * 6 + 2] = Math.sin(f / 4) * 0.4; // FC + surround[f * 6 + 3] = Math.sin(f / 4) * 0.4; // LFE + } + // Equivalent stereo signal: FL = FR = 0.5*FC + 1.0*LFE. + const folded = new Float32Array(frames * 2); + for (let f = 0; f < frames; f += 1) { + const blend = 0.5 * surround[f * 6 + 2] + surround[f * 6 + 3]; + folded[f * 2] = blend; + folded[f * 2 + 1] = blend; + } + const surroundProcessor = makeProcessor(); + surroundProcessor.setInputLayout(layout); + const stereoProcessor = makeProcessor(); + stereoProcessor.setInputLayout({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + const a = surroundProcessor.process(surround); + const b = stereoProcessor.process(folded); + expect(a.length).toBe(frames * 4); + for (let i = 0; i < a.length; i += 1) { + expect(a[i]).toBeCloseTo(b[i], 6); + } + }); + + it('rears and sides in a 7.1 stream never reach the output', () => { + const frames = 32; + const quiet = new Float32Array(frames * 8); // silence everywhere + const noisyRears = new Float32Array(frames * 8); + for (let f = 0; f < frames; f += 1) { + for (const ch of [4, 5, 6, 7]) { + noisyRears[f * 8 + ch] = 0.8; + } + } + const layout = { stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }; + const p1 = makeProcessor(); p1.setInputLayout(layout); + const p2 = makeProcessor(); p2.setInputLayout(layout); + expect(Array.from(p1.process(noisyRears))).toEqual(Array.from(p2.process(quiet))); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `setInputLayout` is not a function. + +- [ ] **Step 3: Implement** + +In `HapticsProcessor`: + +Add to the constructor, before `this.setConfig(config)`: + +```js + this.layout = { stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }; +``` + +Add the method after `setConfig`: + +```js + setInputLayout(layout) { + this.layout = layout; + } +``` + +Replace the `process` method (keep the existing gate/drive comment block): + +```js + // Layout-aware input (see setInputLayout) -> 4ch f32 out + // (speaker channels silent, haptics on 3-4). Blend per spec: + // left/right = FL/FR + 0.5*FC + 1.0*LFE; rears and sides ignored. + process(input) { + const { stride, fl, fr, fc, lfe } = this.layout; + const frames = Math.floor(input.length / stride); + const output = new Float32Array(frames * 4); + for (let frame = 0; frame < frames; frame += 1) { + const base = frame * stride; + const center = fc >= 0 ? input[base + fc] * 0.5 : 0; + const bass = lfe >= 0 ? input[base + lfe] : 0; + const left = biquadStep(this.lowpassLeft, input[base + fl] + center + bass); + const right = biquadStep(this.lowpassRight, input[base + fr] + center + bass); + const peak = Math.max(Math.abs(left), Math.abs(right)); + const coeff = peak > this.envelope ? this.attackCoeff : this.releaseCoeff; + this.envelope = coeff * this.envelope + (1 - coeff) * peak; + // Music bass peaks well below full scale and the daemon quantizes + // haptics to 8 bits, so drive hard into the tanh limiter; gate the + // noise floor so silence does not buzz the actuators. + const gate = this.envelope < 0.003 ? 0 : 1; + const drive = this.gain * this.responseGain * 4 * gate; + output[frame * 4 + 2] = Math.tanh(left * drive); + output[frame * 4 + 3] = Math.tanh(right * drive); + } + return output; + } +``` + +- [ ] **Step 4: Run the full companion suite** + +Run: `cd ds5-bridge/companion && npx vitest run src` +Expected: all tests PASS (the new layout tests plus the existing suite — proves the default layout keeps sink-monitor numerics identical). + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Blend FC and LFE into haptics with layout-aware summing" +``` + +--- + +### Task 4: App stream node matching + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (add exported function near `listOutputStreamSessions`) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (append) + +**Interfaces:** +- Consumes: pw-dump object array; app source descriptor `{ processId, executableName, processPath }` (any field may be null/0 — mirrors the optional CLI flags in `audio-helper.ts:216-226`). +- Produces: `matchAppStreamNode(objects, appSource) -> node | null`. Match priority: `application.process.id` equals `processId`; else `application.process.binary` equals `executableName` or the basename of `processPath`. Among several matches, prefer `info.state === 'running'`; otherwise first match. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/main/audio-helper-linux.test.ts`: + +```ts +import { matchAppStreamNode } from '../../native/audio-helper-linux.mjs'; + +function streamNode(id: number, props: Record, state = 'running') { + return { + id, + type: 'PipeWire:Interface:Node', + info: { state, props: { 'media.class': 'Stream/Output/Audio', ...props } } + }; +} + +describe('matchAppStreamNode', () => { + const game = streamNode(40, { 'application.process.id': 1234, 'application.process.binary': 'game-bin' }); + const music = streamNode(41, { 'application.process.id': 999, 'application.process.binary': 'spotify' }); + const sinkNode = { id: 50, type: 'PipeWire:Interface:Node', info: { state: 'running', props: { 'media.class': 'Audio/Sink' } } }; + + it('matches by process id', () => { + expect(matchAppStreamNode([music, game, sinkNode], { processId: 1234, executableName: null, processPath: null })?.id).toBe(40); + }); + + it('falls back to the executable name', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(40); + }); + + it('falls back to the process path basename', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: null, processPath: '/opt/game/game-bin' })?.id).toBe(40); + }); + + it('prefers a running node over an idle one', () => { + const idle = streamNode(42, { 'application.process.binary': 'game-bin' }, 'idle'); + const running = streamNode(43, { 'application.process.binary': 'game-bin' }, 'running'); + expect(matchAppStreamNode([idle, running], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(43); + }); + + it('returns null when nothing matches or only non-streams exist', () => { + expect(matchAppStreamNode([music, sinkNode], { processId: 1234, executableName: 'game-bin', processPath: null })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `matchAppStreamNode` not exported. + +- [ ] **Step 3: Implement** + +Add before `listOutputStreamSessions` in `native/audio-helper-linux.mjs`: + +```js +export function matchAppStreamNode(objects, { processId, executableName, processPath }) { + const pathBasename = processPath ? processPath.split('/').pop() : null; + const matches = objects.filter((object) => { + if (object.type !== 'PipeWire:Interface:Node') { + return false; + } + const props = nodeProps(object); + if (props['media.class'] !== 'Stream/Output/Audio') { + return false; + } + if (processId > 0 && Number(props['application.process.id'] ?? 0) === processId) { + return true; + } + const binary = props['application.process.binary'] ?? null; + return Boolean(binary && (binary === executableName || binary === pathBasename)); + }); + return matches.find((object) => object.info?.state === 'running') ?? matches[0] ?? null; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: PASS (17 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Match the selected app's PipeWire output stream in the Linux helper" +``` + +--- + +### Task 5: Hot-attach app capture loop + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (`runRenderLoopbackHaptics`) + +**Interfaces:** +- Consumes: `matchAppStreamNode`, `nodeChannelLayout`, `channelIndices`, `HapticsProcessor.setInputLayout` from Tasks 2–4; CLI flags `--haptics-app-process-id`, `--haptics-app-process-path`, `--haptics-app-executable` (sent by `audio-helper.ts` startInternal). +- Produces: when any app flag is present, the helper hot-attaches to the app's stream instead of the sink monitor. Stderr status lines: `status: waiting-for-app` on entering polling, `status: recording-started` on first attach (the app waits up to 8 s for this — emitting it on the *record spawn*, as today, is preserved). Playback pipe to the bridge sink persists across re-attaches; `stop`/SIGTERM tear everything down. + +- [ ] **Step 1: Restructure runRenderLoopbackHaptics** + +Replace the body of `runRenderLoopbackHaptics` from the `const captureDevice = ...` line through the `record.on('exit', ...)` / `play.on('exit', ...)` lines (keep the sink lookup, processor construction, and the trailing control/SIGTERM block) with: + +```js + const appSource = { + processId: Number(argValue(args, '--haptics-app-process-id') ?? 0), + processPath: argValue(args, '--haptics-app-process-path'), + executableName: argValue(args, '--haptics-app-executable') + }; + const hasAppSource = appSource.processId > 0 || Boolean(appSource.processPath) + || Boolean(appSource.executableName); + + // Optional capture pin: monitor a specific output device instead of + // following the system default sink. + const captureDevice = argValue(args, '--haptics-output-device'); + + const play = spawn('pw-play', [ + '--raw', + '--target', target, + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ], { stdio: ['pipe', 'ignore', 'pipe'] }); + play.stderr.on('data', (chunk) => process.stderr.write(chunk)); + + let record = null; + let attachTimer = null; + let stopping = false; + let announcedRecording = false; + + const shutdown = (code, detail) => { + stopping = true; + if (detail) { + process.stderr.write(`${detail}\n`); + } + clearTimeout(attachTimer); + record?.kill(); + play.kill(); + process.exit(code); + }; + play.on('exit', (code) => shutdown(code ?? 1, 'playback stream ended')); + + const pipeRecordToProcessor = (proc, stride) => { + let carry = Buffer.alloc(0); + proc.stdout.on('data', (chunk) => { + let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; + const frameBytes = stride * 4; // stride channels x f32 + const usable = data.length - (data.length % frameBytes); + carry = data.subarray(usable); + if (usable === 0) { + return; + } + const input = new Float32Array(data.buffer, data.byteOffset, usable / 4); + const output = processor.process(input); + if (play.stdin.writable) { + play.stdin.write(Buffer.from(output.buffer, 0, output.byteLength)); + } + }); + proc.stderr.on('data', (chunk) => process.stderr.write(chunk)); + proc.on('spawn', () => { + if (!announcedRecording) { + announcedRecording = true; + process.stderr.write('status: recording-started\n'); + } + }); + }; + + const startSinkMonitorCapture = () => { + record = spawn('pw-record', [ + '--raw', + '-P', '{ stream.capture.sink = true }', + ...(captureDevice ? ['--target', captureDevice] : []), + // Capture four discrete channels and let the processor use the fronts + // only. When the headset is plugged in, the default sink can be the + // bridge's own 4-channel device; any narrower capture goes through + // PipeWire's channel mixer, which folds the rear haptics channels we + // play back into the fronts (constant buzz / self-oscillation). With a + // matching 4ch format no mixing happens; stereo sinks upmix with + // silent rears, leaving the fronts intact either way. + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, 4); + record.on('exit', (code) => { + if (!stopping) { + shutdown(code ?? 1, 'capture stream ended'); + } + }); + }; + + // Pre-fold app capture: target the app's own output stream so discrete + // surround channels (especially LFE) survive even when the listening + // device is stereo. The node comes and goes with the game, so poll and + // re-attach instead of failing hard. + const APP_POLL_MS = 2000; + const pollForAppNode = async () => { + if (stopping) { + return; + } + let node = null; + try { + node = matchAppStreamNode(await pwDump(), appSource); + } catch (error) { + process.stderr.write(`app node poll failed: ${error.message}\n`); + } + if (!node) { + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + return; + } + const layout = nodeChannelLayout(node); + processor.setInputLayout(channelIndices(layout.position)); + record = spawn('pw-record', [ + '--raw', + '--target', `${node.id}`, + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, + '--channels', `${layout.channels}`, + '--channel-map', layout.position.join(','), + '--latency', '256', + '-' + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, layout.channels); + record.on('exit', () => { + // Game restarted its stream (level load, restart): go back to polling. + record = null; + if (!stopping) { + process.stderr.write('status: waiting-for-app\n'); + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + } + }); + }; + + if (hasAppSource) { + process.stderr.write('status: waiting-for-app\n'); + await pollForAppNode(); + } else { + startSinkMonitorCapture(); + } +``` + +The existing `control`/`SIGTERM`/`SIGINT` block at the end of the function stays as-is (it already calls `shutdown`), and the old top-level `record`/`carry`/`shutdown` definitions it replaced must be deleted. + +- [ ] **Step 2: Run the full companion suite** + +Run: `cd ds5-bridge/companion && npx vitest run src` +Expected: all tests PASS (no regressions; this task is exercised by hand next). + +- [ ] **Step 3: Manual smoke — sink-monitor path unchanged** + +With the controller connected and no app flags: + +Run: `DS5_BRIDGE_ALLOW_PARALLEL_AUTOMATION_INSTANCE=1 node ds5-bridge/companion/native/audio-helper-linux.mjs --source render-loopback --haptics-only --haptics-gain 100 --haptics-bass-focus balanced --haptics-response balanced --haptics-attack balanced --haptics-release balanced` +Expected: `status: recording-started` on stderr; playing music makes the controller rumble; Ctrl-C exits cleanly. + +- [ ] **Step 4: Manual smoke — app capture and hot-attach** + +Start the helper with an app flag before the app plays audio: + +Run: `DS5_BRIDGE_ALLOW_PARALLEL_AUTOMATION_INSTANCE=1 node ds5-bridge/companion/native/audio-helper-linux.mjs --source render-loopback --haptics-only --haptics-gain 100 --haptics-bass-focus balanced --haptics-response balanced --haptics-attack balanced --haptics-release balanced --haptics-app-executable ` +Expected: `status: waiting-for-app`, then `status: recording-started` once the app starts playing; only that app's audio drives haptics (music from another app does not). Kill and restart the app's audio: helper prints `status: waiting-for-app` and re-attaches. + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs +git commit -m "Hot-attach pre-fold app stream capture for audio haptics" +``` + +--- + +### Task 6: Hardware verification (user-assisted) + +**Files:** none (verification only) + +- [ ] **Step 1: Full test suite** + +Run: `cd ds5-bridge/companion && npm run test:companion` +Expected: all PASS. + +- [ ] **Step 2: Surround LFE verification** + +In the app, select a surround-capable game (forced to 5.1/7.1 in its own audio settings) as the Audio Haptics source while listening on stereo headphones. Confirm with `pw-dump` that the game's stream negotiated >2 channels, and that LFE-heavy moments (explosions) produce a stronger punch than the sink-monitor source does. Tuning checkpoints from the spec: sanity-check the 0.5× FC weight, and check whether any felt-worthy bass lives in RL/RR (if so, consider adding rears at ~0.3× as a follow-up). + +- [ ] **Step 3: Regression — 3.5 mm feedback fix** + +Switch the source back to system audio (sink monitor) with the 3.5 mm headset plugged in. Expected: no constant buzz (02792b1 fix intact). + +- [ ] **Step 4: Isolation check** + +With the game selected as source, play music/Discord audio from another app. Expected: haptics respond only to the game. From f9acf01959a69c22780e615ef31affe7cc050e3d Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 15:33:06 +0400 Subject: [PATCH 21/34] Make Linux audio helper importable for unit tests --- ds5-bridge/companion/native/audio-helper-linux.mjs | 9 +++++++-- .../companion/src/main/audio-helper-linux.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 ds5-bridge/companion/src/main/audio-helper-linux.test.ts diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index 6d47040..646ae15 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -8,6 +8,7 @@ import { spawn, execFile } from 'node:child_process'; import { createInterface } from 'node:readline'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; const SAMPLE_RATE = 48000; const BRIDGE_NODE_PATTERN = /dualsense|vds/i; @@ -104,7 +105,7 @@ function envelopeCoefficient(milliseconds) { return Math.exp(-1 / (SAMPLE_RATE * (milliseconds / 1000))); } -class HapticsProcessor { +export class HapticsProcessor { constructor(config) { this.envelope = 0; this.setConfig(config); @@ -451,4 +452,8 @@ async function main() { } } -main().catch((error) => fail(error.message)); +const isCliEntry = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isCliEntry) { + main().catch((error) => fail(error.message)); +} diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts new file mode 100644 index 0000000..bc5858f --- /dev/null +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { HapticsProcessor } from '../../native/audio-helper-linux.mjs'; + +describe('audio-helper-linux exports', () => { + it('imports without running main and exposes HapticsProcessor', () => { + const processor = new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); + expect(typeof processor.process).toBe('function'); + }); +}); From 9918d955a40ac31ce8d164f8f49c68a00fa9bb81 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 15:36:10 +0400 Subject: [PATCH 22/34] Parse PipeWire stream channel layouts in the Linux audio helper --- .../companion/native/audio-helper-linux.mjs | 25 +++++++++++ .../src/main/audio-helper-linux.test.ts | 41 ++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index 646ae15..c437aad 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -48,6 +48,31 @@ function nodeProps(object) { return object?.info?.props ?? {}; } +const STEREO_LAYOUT = { channels: 2, position: ['FL', 'FR'] }; + +// Negotiated audio format of a pw-dump node; stereo fallback when absent +// or inconsistent (spec: unknown layouts read as first-two-channel stereo). +export function nodeChannelLayout(node) { + const format = node?.info?.params?.Format?.[0]; + const channels = Number(format?.channels ?? 0); + const position = Array.isArray(format?.position) ? format.position : null; + if (!position || channels < 1 || position.length !== channels) { + return { ...STEREO_LAYOUT, position: [...STEREO_LAYOUT.position] }; + } + return { channels, position: [...position] }; +} + +export function channelIndices(position) { + const stride = position.length; + let fl = position.indexOf('FL'); + let fr = position.indexOf('FR'); + if (fl < 0 || fr < 0) { + fl = 0; + fr = Math.min(1, stride - 1); + } + return { stride, fl, fr, fc: position.indexOf('FC'), lfe: position.indexOf('LFE') }; +} + function isAudioSink(object) { return object.type === 'PipeWire:Interface:Node' && nodeProps(object)['media.class'] === 'Audio/Sink'; diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index bc5858f..d48cf68 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { HapticsProcessor } from '../../native/audio-helper-linux.mjs'; +import { channelIndices, HapticsProcessor, nodeChannelLayout } from '../../native/audio-helper-linux.mjs'; describe('audio-helper-linux exports', () => { it('imports without running main and exposes HapticsProcessor', () => { @@ -10,3 +10,42 @@ describe('audio-helper-linux exports', () => { expect(typeof processor.process).toBe('function'); }); }); + +describe('nodeChannelLayout', () => { + it('reads channels and position from the Format param', () => { + const node = { info: { params: { Format: [{ mediaType: 'audio', channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }); + }); + + it('falls back to stereo when the format is missing', () => { + expect(nodeChannelLayout({ info: { params: {} } })).toEqual({ channels: 2, position: ['FL', 'FR'] }); + expect(nodeChannelLayout(undefined)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); + + it('falls back to stereo when position length disagrees with channels', () => { + const node = { info: { params: { Format: [{ channels: 6, position: ['FL', 'FR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); +}); + +describe('channelIndices', () => { + it('maps stereo', () => { + expect(channelIndices(['FL', 'FR'])).toEqual({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps 5.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'])).toEqual({ stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('maps 7.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR', 'SL', 'SR'])).toEqual({ stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('treats an unknown map as first-two-channels stereo', () => { + expect(channelIndices(['AUX0', 'AUX1', 'AUX2'])).toEqual({ stride: 3, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps mono to both sides', () => { + expect(channelIndices(['MONO'])).toEqual({ stride: 1, fl: 0, fr: 0, fc: -1, lfe: -1 }); + }); +}); From 10dfb8fd0a3d4510330f8df027a657402654288b Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 15:39:01 +0400 Subject: [PATCH 23/34] Blend FC and LFE into haptics with layout-aware summing --- .../companion/native/audio-helper-linux.mjs | 20 ++++-- .../src/main/audio-helper-linux.test.ts | 67 +++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index c437aad..9835f40 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -133,6 +133,7 @@ function envelopeCoefficient(milliseconds) { export class HapticsProcessor { constructor(config) { this.envelope = 0; + this.layout = { stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }; this.setConfig(config); } @@ -146,14 +147,23 @@ export class HapticsProcessor { this.lowpassRight = biquadLowpass(cutoff); } - // 4ch f32 in (front channels used, rears ignored) -> 4ch f32 out - // (speaker channels silent, haptics on 3-4) + setInputLayout(layout) { + this.layout = layout; + } + + // Layout-aware input (see setInputLayout) -> 4ch f32 out + // (speaker channels silent, haptics on 3-4). Blend per spec: + // left/right = FL/FR + 0.5*FC + 1.0*LFE; rears and sides ignored. process(input) { - const frames = input.length / 4; + const { stride, fl, fr, fc, lfe } = this.layout; + const frames = Math.floor(input.length / stride); const output = new Float32Array(frames * 4); for (let frame = 0; frame < frames; frame += 1) { - const left = biquadStep(this.lowpassLeft, input[frame * 4]); - const right = biquadStep(this.lowpassRight, input[frame * 4 + 1]); + const base = frame * stride; + const center = fc >= 0 ? input[base + fc] * 0.5 : 0; + const bass = lfe >= 0 ? input[base + lfe] : 0; + const left = biquadStep(this.lowpassLeft, input[base + fl] + center + bass); + const right = biquadStep(this.lowpassRight, input[base + fr] + center + bass); const peak = Math.max(Math.abs(left), Math.abs(right)); const coeff = peak > this.envelope ? this.attackCoeff : this.releaseCoeff; this.envelope = coeff * this.envelope + (1 - coeff) * peak; diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index d48cf68..9109162 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -49,3 +49,70 @@ describe('channelIndices', () => { expect(channelIndices(['MONO'])).toEqual({ stride: 1, fl: 0, fr: 0, fc: -1, lfe: -1 }); }); }); + +function makeProcessor() { + return new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); +} + +describe('HapticsProcessor layouts', () => { + it('default 4ch layout matches explicit FL,FR,RL,RR layout sample-for-sample', () => { + const frames = 64; + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + input[f * 4] = Math.sin(f / 3) * 0.5; // FL + input[f * 4 + 1] = Math.cos(f / 3) * 0.5; // FR + input[f * 4 + 2] = 0.9; // RL: must be ignored + input[f * 4 + 3] = -0.9; // RR: must be ignored + } + const byDefault = makeProcessor().process(input); + const explicit = makeProcessor(); + explicit.setInputLayout({ stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }); + expect(Array.from(explicit.process(input))).toEqual(Array.from(byDefault)); + }); + + it('5.1 layout blends FC at 0.5x and LFE at 1x into both sides', () => { + const frames = 64; + const layout = { stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }; + // Only FC and LFE carry signal: expect output driven purely by the blend. + const surround = new Float32Array(frames * 6); + for (let f = 0; f < frames; f += 1) { + surround[f * 6 + 2] = Math.sin(f / 4) * 0.4; // FC + surround[f * 6 + 3] = Math.sin(f / 4) * 0.4; // LFE + } + // Equivalent stereo signal: FL = FR = 0.5*FC + 1.0*LFE. + const folded = new Float32Array(frames * 2); + for (let f = 0; f < frames; f += 1) { + const blend = 0.5 * surround[f * 6 + 2] + surround[f * 6 + 3]; + folded[f * 2] = blend; + folded[f * 2 + 1] = blend; + } + const surroundProcessor = makeProcessor(); + surroundProcessor.setInputLayout(layout); + const stereoProcessor = makeProcessor(); + stereoProcessor.setInputLayout({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + const a = surroundProcessor.process(surround); + const b = stereoProcessor.process(folded); + expect(a.length).toBe(frames * 4); + for (let i = 0; i < a.length; i += 1) { + expect(a[i]).toBeCloseTo(b[i], 6); + } + }); + + it('rears and sides in a 7.1 stream never reach the output', () => { + const frames = 32; + const quiet = new Float32Array(frames * 8); // silence everywhere + const noisyRears = new Float32Array(frames * 8); + for (let f = 0; f < frames; f += 1) { + for (const ch of [4, 5, 6, 7]) { + noisyRears[f * 8 + ch] = 0.8; + } + } + const layout = { stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }; + const p1 = makeProcessor(); p1.setInputLayout(layout); + const p2 = makeProcessor(); p2.setInputLayout(layout); + expect(Array.from(p1.process(noisyRears))).toEqual(Array.from(p2.process(quiet))); + }); +}); From 5ac0e3b4a3bac11bcc0c2b9472abe9705cb12b8f Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 15:42:05 +0400 Subject: [PATCH 24/34] Match the selected app's PipeWire output stream in the Linux helper --- .../companion/native/audio-helper-linux.mjs | 19 ++++++++++ .../src/main/audio-helper-linux.test.ts | 38 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index 9835f40..bcf606c 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -388,6 +388,25 @@ async function runSetDefaultRenderBridge() { }); } +export function matchAppStreamNode(objects, { processId, executableName, processPath }) { + const pathBasename = processPath ? processPath.split('/').pop() : null; + const matches = objects.filter((object) => { + if (object.type !== 'PipeWire:Interface:Node') { + return false; + } + const props = nodeProps(object); + if (props['media.class'] !== 'Stream/Output/Audio') { + return false; + } + if (processId > 0 && Number(props['application.process.id'] ?? 0) === processId) { + return true; + } + const binary = props['application.process.binary'] ?? null; + return Boolean(binary && (binary === executableName || binary === pathBasename)); + }); + return matches.find((object) => object.info?.state === 'running') ?? matches[0] ?? null; +} + async function listOutputStreamSessions() { const objects = await pwDump(); const bridge = await findBridgeSink().catch(() => null); diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index 9109162..7dca126 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -116,3 +116,41 @@ describe('HapticsProcessor layouts', () => { expect(Array.from(p1.process(noisyRears))).toEqual(Array.from(p2.process(quiet))); }); }); + +import { matchAppStreamNode } from '../../native/audio-helper-linux.mjs'; + +function streamNode(id: number, props: Record, state = 'running') { + return { + id, + type: 'PipeWire:Interface:Node', + info: { state, props: { 'media.class': 'Stream/Output/Audio', ...props } } + }; +} + +describe('matchAppStreamNode', () => { + const game = streamNode(40, { 'application.process.id': 1234, 'application.process.binary': 'game-bin' }); + const music = streamNode(41, { 'application.process.id': 999, 'application.process.binary': 'spotify' }); + const sinkNode = { id: 50, type: 'PipeWire:Interface:Node', info: { state: 'running', props: { 'media.class': 'Audio/Sink' } } }; + + it('matches by process id', () => { + expect(matchAppStreamNode([music, game, sinkNode], { processId: 1234, executableName: null, processPath: null })?.id).toBe(40); + }); + + it('falls back to the executable name', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(40); + }); + + it('falls back to the process path basename', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: null, processPath: '/opt/game/game-bin' })?.id).toBe(40); + }); + + it('prefers a running node over an idle one', () => { + const idle = streamNode(42, { 'application.process.binary': 'game-bin' }, 'idle'); + const running = streamNode(43, { 'application.process.binary': 'game-bin' }, 'running'); + expect(matchAppStreamNode([idle, running], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(43); + }); + + it('returns null when nothing matches or only non-streams exist', () => { + expect(matchAppStreamNode([music, sinkNode], { processId: 1234, executableName: 'game-bin', processPath: null })).toBeNull(); + }); +}); From 9798ac38e3c140e2a04dd9a4172b61fc3034b9f8 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 18:23:23 +0400 Subject: [PATCH 25/34] Hot-attach pre-fold app stream capture for audio haptics --- .../companion/native/audio-helper-linux.mjs | 156 +++++++++++++----- 1 file changed, 115 insertions(+), 41 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index bcf606c..6981416 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -194,25 +194,18 @@ async function runRenderLoopbackHaptics(args) { const target = nodeProps(sink)['node.name']; const processor = new HapticsProcessor(readHapticsConfig(args)); + const appSource = { + processId: Number(argValue(args, '--haptics-app-process-id') ?? 0), + processPath: argValue(args, '--haptics-app-process-path'), + executableName: argValue(args, '--haptics-app-executable') + }; + const hasAppSource = appSource.processId > 0 || Boolean(appSource.processPath) + || Boolean(appSource.executableName); + // Optional capture pin: monitor a specific output device instead of // following the system default sink. const captureDevice = argValue(args, '--haptics-output-device'); - const record = spawn('pw-record', [ - '--raw', - '-P', '{ stream.capture.sink = true }', - ...(captureDevice ? ['--target', captureDevice] : []), - // Capture four discrete channels and let the processor use the fronts - // only. When the headset is plugged in, the default sink can be the - // bridge's own 4-channel device; any narrower capture goes through - // PipeWire's channel mixer, which folds the rear haptics channels we - // play back into the fronts (constant buzz / self-oscillation). With a - // matching 4ch format no mixing happens; stereo sinks upmix with - // silent rears, leaving the fronts intact either way. - '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', - '--channel-map', 'FL,FR,RL,RR', - '--latency', '256', - '-' - ], { stdio: ['ignore', 'pipe', 'pipe'] }); + const play = spawn('pw-play', [ '--raw', '--target', target, @@ -221,42 +214,123 @@ async function runRenderLoopbackHaptics(args) { '--latency', '256', '-' ], { stdio: ['pipe', 'ignore', 'pipe'] }); + play.stderr.on('data', (chunk) => process.stderr.write(chunk)); - // Report readiness on stream startup: a suspended default sink delivers no - // monitor frames until something plays, and the app only waits 8 s. - record.on('spawn', () => { - process.stderr.write('status: recording-started\n'); - }); - - let carry = Buffer.alloc(0); - record.stdout.on('data', (chunk) => { - let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; - const frameBytes = 4 * 4; // 4 channels x f32 - const usable = data.length - (data.length % frameBytes); - carry = data.subarray(usable); - if (usable === 0) { - return; - } - const input = new Float32Array(data.buffer, data.byteOffset, usable / 4); - const output = processor.process(input); - if (play.stdin.writable) { - play.stdin.write(Buffer.from(output.buffer, 0, output.byteLength)); - } - }); + let record = null; + let attachTimer = null; + let stopping = false; + let announcedRecording = false; const shutdown = (code, detail) => { + stopping = true; if (detail) { process.stderr.write(`${detail}\n`); } - record.kill(); + clearTimeout(attachTimer); + record?.kill(); play.kill(); process.exit(code); }; - record.stderr.on('data', (chunk) => process.stderr.write(chunk)); - play.stderr.on('data', (chunk) => process.stderr.write(chunk)); - record.on('exit', (code) => shutdown(code ?? 1, 'capture stream ended')); play.on('exit', (code) => shutdown(code ?? 1, 'playback stream ended')); + const pipeRecordToProcessor = (proc, stride) => { + let carry = Buffer.alloc(0); + proc.stdout.on('data', (chunk) => { + let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; + const frameBytes = stride * 4; // stride channels x f32 + const usable = data.length - (data.length % frameBytes); + carry = data.subarray(usable); + if (usable === 0) { + return; + } + const input = new Float32Array(data.buffer, data.byteOffset, usable / 4); + const output = processor.process(input); + if (play.stdin.writable) { + play.stdin.write(Buffer.from(output.buffer, 0, output.byteLength)); + } + }); + proc.stderr.on('data', (chunk) => process.stderr.write(chunk)); + proc.on('spawn', () => { + if (!announcedRecording) { + announcedRecording = true; + process.stderr.write('status: recording-started\n'); + } + }); + }; + + const startSinkMonitorCapture = () => { + record = spawn('pw-record', [ + '--raw', + '-P', '{ stream.capture.sink = true }', + ...(captureDevice ? ['--target', captureDevice] : []), + // Capture four discrete channels and let the processor use the fronts + // only. When the headset is plugged in, the default sink can be the + // bridge's own 4-channel device; any narrower capture goes through + // PipeWire's channel mixer, which folds the rear haptics channels we + // play back into the fronts (constant buzz / self-oscillation). With a + // matching 4ch format no mixing happens; stereo sinks upmix with + // silent rears, leaving the fronts intact either way. + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, 4); + record.on('exit', (code) => { + if (!stopping) { + shutdown(code ?? 1, 'capture stream ended'); + } + }); + }; + + // Pre-fold app capture: target the app's own output stream so discrete + // surround channels (especially LFE) survive even when the listening + // device is stereo. The node comes and goes with the game, so poll and + // re-attach instead of failing hard. + const APP_POLL_MS = 2000; + const pollForAppNode = async () => { + if (stopping) { + return; + } + let node = null; + try { + node = matchAppStreamNode(await pwDump(), appSource); + } catch (error) { + process.stderr.write(`app node poll failed: ${error.message}\n`); + } + if (!node) { + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + return; + } + const layout = nodeChannelLayout(node); + processor.setInputLayout(channelIndices(layout.position)); + record = spawn('pw-record', [ + '--raw', + '--target', `${node.id}`, + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, + '--channels', `${layout.channels}`, + '--channel-map', layout.position.join(','), + '--latency', '256', + '-' + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, layout.channels); + record.on('exit', () => { + // Game restarted its stream (level load, restart): go back to polling. + record = null; + if (!stopping) { + process.stderr.write('status: waiting-for-app\n'); + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + } + }); + }; + + if (hasAppSource) { + process.stderr.write('status: waiting-for-app\n'); + await pollForAppNode(); + } else { + startSinkMonitorCapture(); + } + const control = createInterface({ input: process.stdin }); control.on('line', (line) => { const parts = line.trim().split(/\s+/); From 35c82b6ef28bfb615972f6bb0f58939b757b3932 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 20:36:27 +0400 Subject: [PATCH 26/34] Target app streams by object.serial to avoid mic fallback --- .../companion/native/audio-helper-linux.mjs | 26 ++++++++++++------- .../src/main/audio-helper-linux.test.ts | 24 ++++++++++++++++- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index 6981416..6e556ed 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -304,15 +304,7 @@ async function runRenderLoopbackHaptics(args) { } const layout = nodeChannelLayout(node); processor.setInputLayout(channelIndices(layout.position)); - record = spawn('pw-record', [ - '--raw', - '--target', `${node.id}`, - '--format', 'f32', '--rate', `${SAMPLE_RATE}`, - '--channels', `${layout.channels}`, - '--channel-map', layout.position.join(','), - '--latency', '256', - '-' - ], { stdio: ['ignore', 'pipe', 'pipe'] }); + record = spawn('pw-record', appCaptureRecordArgs(node, layout), { stdio: ['ignore', 'pipe', 'pipe'] }); pipeRecordToProcessor(record, layout.channels); record.on('exit', () => { // Game restarted its stream (level load, restart): go back to polling. @@ -462,6 +454,22 @@ async function runSetDefaultRenderBridge() { }); } +export function appCaptureRecordArgs(node, layout) { + const serial = nodeProps(node)['object.serial']; + return [ + '--raw', + // Target the app's own output stream by serial: WirePlumber ignores a + // plain --target for playback streams and falls back to the + // default source (the microphone), which is silence for haptics. + '-P', `{ target.object = ${serial ?? node.id} }`, + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, + '--channels', `${layout.channels}`, + '--channel-map', layout.position.join(','), + '--latency', '256', + '-' + ]; +} + export function matchAppStreamNode(objects, { processId, executableName, processPath }) { const pathBasename = processPath ? processPath.split('/').pop() : null; const matches = objects.filter((object) => { diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index 7dca126..d0a43fd 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -117,7 +117,29 @@ describe('HapticsProcessor layouts', () => { }); }); -import { matchAppStreamNode } from '../../native/audio-helper-linux.mjs'; +import { appCaptureRecordArgs, matchAppStreamNode } from '../../native/audio-helper-linux.mjs'; + +describe('appCaptureRecordArgs', () => { + it('targets the app stream by object.serial, not by --target id (mic fallback)', () => { + const node = { id: 297, info: { props: { 'object.serial': 48540 } } }; + const args = appCaptureRecordArgs(node, { channels: 4, position: ['FL', 'FR', 'RL', 'RR'] }); + const pIndex = args.indexOf('-P'); + expect(pIndex).toBeGreaterThanOrEqual(0); + expect(args[pIndex + 1]).toBe('{ target.object = 48540 }'); + const channelsIndex = args.indexOf('--channels'); + expect(args[channelsIndex + 1]).toBe('4'); + const mapIndex = args.indexOf('--channel-map'); + expect(args[mapIndex + 1]).toBe('FL,FR,RL,RR'); + expect(args).not.toContain('--target'); + }); + + it('falls back to node.id when object.serial is missing', () => { + const node = { id: 297, info: { props: {} } }; + const args = appCaptureRecordArgs(node, { channels: 4, position: ['FL', 'FR', 'RL', 'RR'] }); + const pIndex = args.indexOf('-P'); + expect(args[pIndex + 1]).toBe('{ target.object = 297 }'); + }); +}); function streamNode(id: number, props: Record, state = 'running') { return { From f41b141e13c6609f45bceee9baf6677f4324bfae Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 21:18:00 +0400 Subject: [PATCH 27/34] Decouple haptics strength from sink volume --- .../companion/native/audio-helper-linux.mjs | 45 +++++++++- .../src/main/audio-helper-linux.test.ts | 90 ++++++++++++++++++- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index 6e556ed..e1c85f2 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -130,13 +130,34 @@ function envelopeCoefficient(milliseconds) { return Math.exp(-1 / (SAMPLE_RATE * (milliseconds / 1000))); } +// wpctl reports volume on the cubic user scale; PipeWire applies vol^3 to +// samples. Compensate post-limiter so haptics strength is independent of +// the listening volume; cap so a near-zero volume cannot amplify noise. +export function volumeCompensation(cubicVolume) { + if (!Number.isFinite(cubicVolume) || cubicVolume <= 0) { + return 1; + } + const linear = cubicVolume ** 3; + return Math.min(32, Math.max(1 / 32, 1 / linear)); +} + +export function parseWpctlVolume(stdout) { + const match = /Volume:\s*([0-9.]+)/.exec(stdout ?? ''); + return match ? Number(match[1]) : null; +} + export class HapticsProcessor { constructor(config) { this.envelope = 0; this.layout = { stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }; + this.outputCompensation = 1; this.setConfig(config); } + setOutputCompensation(compensation) { + this.outputCompensation = compensation; + } + setConfig({ gainPercent, bassFocus, response, attack, release }) { this.gain = Math.max(0, Math.min(400, gainPercent)) / 100; this.responseGain = RESPONSE_GAIN[response] ?? 1.0; @@ -172,8 +193,9 @@ export class HapticsProcessor { // noise floor so silence does not buzz the actuators. const gate = this.envelope < 0.003 ? 0 : 1; const drive = this.gain * this.responseGain * 4 * gate; - output[frame * 4 + 2] = Math.tanh(left * drive); - output[frame * 4 + 3] = Math.tanh(right * drive); + const comp = this.outputCompensation; + output[frame * 4 + 2] = Math.max(-1, Math.min(1, Math.tanh(left * drive) * comp)); + output[frame * 4 + 3] = Math.max(-1, Math.min(1, Math.tanh(right * drive) * comp)); } return output; } @@ -218,15 +240,34 @@ async function runRenderLoopbackHaptics(args) { let record = null; let attachTimer = null; + let volumeTimer = null; let stopping = false; let announcedRecording = false; + // Playback always goes to the bridge sink, whose cubic volume PipeWire + // applies to the haptic samples. Poll it and compensate so haptics + // strength stays independent of the user's listening volume. + const refreshVolumeCompensation = () => { + execFile('wpctl', ['get-volume', `${sink.id}`], (error, stdout) => { + if (error) { + return; // keep last compensation + } + const volume = parseWpctlVolume(stdout); + if (volume !== null) { + processor.setOutputCompensation(volumeCompensation(volume)); + } + }); + }; + refreshVolumeCompensation(); + volumeTimer = setInterval(refreshVolumeCompensation, 2000); + const shutdown = (code, detail) => { stopping = true; if (detail) { process.stderr.write(`${detail}\n`); } clearTimeout(attachTimer); + clearInterval(volumeTimer); record?.kill(); play.kill(); process.exit(code); diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index d0a43fd..1d4d352 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -117,7 +117,95 @@ describe('HapticsProcessor layouts', () => { }); }); -import { appCaptureRecordArgs, matchAppStreamNode } from '../../native/audio-helper-linux.mjs'; +import { appCaptureRecordArgs, matchAppStreamNode, parseWpctlVolume, volumeCompensation } from '../../native/audio-helper-linux.mjs'; + +describe('volumeCompensation', () => { + it('is a no-op at unity volume', () => { + expect(volumeCompensation(1)).toBe(1); + }); + + it('compensates for the cubic sink volume at a typical listening level', () => { + expect(volumeCompensation(0.55)).toBeCloseTo(1 / 0.55 ** 3, 2); + }); + + it('caps the boost at 32 for near-silent sinks', () => { + expect(volumeCompensation(0.1)).toBe(32); + }); + + it('returns 1 for zero, negative, or non-finite inputs', () => { + expect(volumeCompensation(0)).toBe(1); + expect(volumeCompensation(-0.5)).toBe(1); + expect(volumeCompensation(NaN)).toBe(1); + }); + + it('compensates downward when volume exceeds unity, with a 1/32 floor', () => { + expect(volumeCompensation(1.26)).toBeCloseTo(1 / 1.26 ** 3, 2); + expect(volumeCompensation(1.26)).toBeGreaterThanOrEqual(1 / 32); + }); +}); + +describe('parseWpctlVolume', () => { + it('parses a plain volume line', () => { + expect(parseWpctlVolume('Volume: 0.55\n')).toBe(0.55); + }); + + it('parses a muted volume line', () => { + expect(parseWpctlVolume('Volume: 1.00 [MUTED]\n')).toBe(1.00); + }); + + it('returns null for garbage, empty, or missing input', () => { + expect(parseWpctlVolume('nonsense')).toBeNull(); + expect(parseWpctlVolume('')).toBeNull(); + expect(parseWpctlVolume(undefined)).toBeNull(); + }); +}); + +describe('HapticsProcessor output compensation', () => { + function loudInput(frames: number) { + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + const s = Math.sin(2 * Math.PI * 40 * (f / 48000)) * 0.9; + input[f * 4] = s; + input[f * 4 + 1] = s; + } + return input; + } + + it('doubles nonzero samples at compensation 2 but clamps to unit magnitude', () => { + const frames = 256; + const input = loudInput(frames); + const base = makeProcessor().process(input); + const boosted = makeProcessor(); + boosted.setOutputCompensation(2); + const out = boosted.process(input); + for (let i = 0; i < out.length; i += 1) { + if (base[i] === 0) { + expect(out[i]).toBe(0); + } else { + expect(out[i]).toBeCloseTo(Math.max(-1, Math.min(1, base[i] * 2)), 6); + expect(Math.abs(out[i])).toBeLessThanOrEqual(1); + } + } + }); + + it('clips to exactly unit magnitude with a strong input and high compensation', () => { + const frames = 256; + const input = loudInput(frames); + const clipped = makeProcessor(); + clipped.setOutputCompensation(8); + const out = clipped.process(input); + const peak = Math.max(...Array.from(out).map((s) => Math.abs(s))); + expect(peak).toBe(1); + }); + + it('leaves output byte-identical to a fresh default processor at compensation 1', () => { + const frames = 256; + const input = loudInput(frames); + const withComp = makeProcessor(); + withComp.setOutputCompensation(1); + expect(Array.from(withComp.process(input))).toEqual(Array.from(makeProcessor().process(input))); + }); +}); describe('appCaptureRecordArgs', () => { it('targets the app stream by object.serial, not by --target id (mic fallback)', () => { From daf0283de6d63361e3d674106eb6e0f7d0426dca Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 21:32:07 +0400 Subject: [PATCH 28/34] Pin haptics playback stream at unity volume --- .../companion/native/audio-helper-linux.mjs | 26 +++++++++++++------ .../src/main/audio-helper-linux.test.ts | 23 +++++++++++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index e1c85f2..fc208a8 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -228,14 +228,7 @@ async function runRenderLoopbackHaptics(args) { // following the system default sink. const captureDevice = argValue(args, '--haptics-output-device'); - const play = spawn('pw-play', [ - '--raw', - '--target', target, - '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', - '--channel-map', 'FL,FR,RL,RR', - '--latency', '256', - '-' - ], { stdio: ['pipe', 'ignore', 'pipe'] }); + const play = spawn('pw-play', hapticsPlaybackArgs(target), { stdio: ['pipe', 'ignore', 'pipe'] }); play.stderr.on('data', (chunk) => process.stderr.write(chunk)); let record = null; @@ -495,6 +488,23 @@ async function runSetDefaultRenderBridge() { }); } +// The helper owns its output level (gain, limiter, sink-volume +// compensation), so pin the playback stream at unity and opt out of +// WirePlumber's stream-restore: a remembered mixer tweak on "pw-play" +// must not silently scale the haptics. +export function hapticsPlaybackArgs(target) { + return [ + '--raw', + '--target', target, + '--volume', '1', + '-P', '{ state.restore-props = false }', + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ]; +} + export function appCaptureRecordArgs(node, layout) { const serial = nodeProps(node)['object.serial']; return [ diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index 1d4d352..0fb4d4c 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { channelIndices, HapticsProcessor, nodeChannelLayout } from '../../native/audio-helper-linux.mjs'; +import { channelIndices, hapticsPlaybackArgs, HapticsProcessor, nodeChannelLayout } from '../../native/audio-helper-linux.mjs'; describe('audio-helper-linux exports', () => { it('imports without running main and exposes HapticsProcessor', () => { @@ -264,3 +264,24 @@ describe('matchAppStreamNode', () => { expect(matchAppStreamNode([music, sinkNode], { processId: 1234, executableName: 'game-bin', processPath: null })).toBeNull(); }); }); + +describe('hapticsPlaybackArgs', () => { + const hasPair = (args: string[], a: string, b: string) => { + for (let i = 0; i < args.length - 1; i += 1) { + if (args[i] === a && args[i + 1] === b) { + return true; + } + } + return false; + }; + + it('pins the playback stream at unity and opts out of stream-restore', () => { + const args = hapticsPlaybackArgs('sinkname'); + expect(hasPair(args, '--target', 'sinkname')).toBe(true); + expect(hasPair(args, '--volume', '1')).toBe(true); + expect(hasPair(args, '-P', '{ state.restore-props = false }')).toBe(true); + expect(hasPair(args, '--channels', '4')).toBe(true); + expect(hasPair(args, '--channel-map', 'FL,FR,RL,RR')).toBe(true); + expect(args[args.length - 1]).toBe('-'); + }); +}); From 34ecdd5b9224f8a9b5760073b311d5924b49307f Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 21:50:32 +0400 Subject: [PATCH 29/34] Add Volume Sync spec and implementation plan --- .../plans/2026-07-17-volume-sync.md | 91 +++++++++++++++++++ .../specs/2026-07-17-volume-sync-design.md | 69 ++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-volume-sync.md create mode 100644 docs/superpowers/specs/2026-07-17-volume-sync-design.md diff --git a/docs/superpowers/plans/2026-07-17-volume-sync.md b/docs/superpowers/plans/2026-07-17-volume-sync.md new file mode 100644 index 0000000..c4d875a --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-volume-sync.md @@ -0,0 +1,91 @@ +# Volume Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Per-feature "Volume Sync" toggles (Audio Haptics + HD Haptics) choosing whether haptics follow the listening volume; both default ON (historical behavior). + +**Architecture:** Audio Haptics sync gates the existing `volumeCompensation` inside the Linux helper (new CLI flag + 7th `haptics-config` field). HD Haptics sync-off runs a new helper mode `--volume-guard` that pins the bridge sink's channels 3–4 at unity via `pw-cli set-param`, spawned/killed by the Electron main process. UI: one toggle row per card following existing card toggle patterns. + +**Tech Stack:** Node ESM helper (.mjs), Electron main (TypeScript), React renderer, vitest. + +**Spec:** `docs/superpowers/specs/2026-07-17-volume-sync-design.md` + +## Global Constraints + +- Defaults: `audioReactiveHapticsVolumeSync: true`, `hapticsVolumeSync: true`. Missing/garbage stored values normalize to `true`. +- Sync ON = haptics follow volume (compensation off / no guard) — exactly pre-f41b141 behavior for the audio path. +- Helper stays a single file (`ds5-bridge/companion/native/audio-helper-linux.mjs`); Windows helper untouched. +- `haptics-config` stdin line grows to 7 fields: `haptics-config `; absent 7th field → sync ON. +- Guard pins ONLY channels 3–4 (indices 2,3) to 1.0; channels 1–2 must keep the user's value. +- Tests from `ds5-bridge/companion`; run full `npx vitest run src` before each commit. +- Commit locally only, never push, no Co-Authored-By/Generated-with trailers, never `git add -A`. + +--- + +### Task 1: Settings store + helper volume-sync gating + +**Files:** +- Modify: `ds5-bridge/companion/src/main/settings-store.ts` (add `audioReactiveHapticsVolumeSync: true` and `hapticsVolumeSync: true` to defaults near the other `audioReactiveHaptics*` keys; add boolean normalization following the store's existing boolean pattern) +- Modify: `ds5-bridge/companion/src/shared/types.ts` (the settings type carrying `audioReactiveHapticsEnabled` gains both booleans) +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` +- Test: `ds5-bridge/companion/src/main/settings-store.test.ts` (follow existing normalizer tests), `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` + +**Interfaces:** +- Produces: settings keys `audioReactiveHapticsVolumeSync`, `hapticsVolumeSync` (booleans, default true). Helper: `--haptics-volume-sync 1|0` CLI flag; `HapticsProcessor.setVolumeSync(boolean)`; 7-field `haptics-config` line; when sync ON the applied output compensation is exactly 1 regardless of polled volume. + +- [ ] **Step 1 (TDD):** settings-store tests: both keys default true; stored `false` survives; garbage (string/number/undefined) → true. Helper tests: `readHapticsConfig` includes `volumeSync` (flag `--haptics-volume-sync 0` → false, absent → true); a processor with `setOutputCompensation(6)` then `setVolumeSync(true)` produces output identical to compensation 1; `setVolumeSync(false)` restores the 6× (clamped) output; `haptics-config 100 balanced balanced balanced balanced 0` parsing accepted at 7 fields (extend the existing stdin-parse branch condition and test via exported pieces, matching how existing config parsing is tested). +- [ ] **Step 2:** Run targeted tests, verify RED. +- [ ] **Step 3:** Implement: store defaults + normalizers; helper — `readHapticsConfig` reads the flag; `HapticsProcessor` gains `this.volumeSync = true`, `setVolumeSync(v)`, and applies `const comp = this.volumeSync ? 1 : this.outputCompensation;` in `process()`; the stdin `haptics-config` handler passes `parts[6] === undefined ? true : parts[6] === '1'` to `setVolumeSync` and keeps working with 6 fields; `runRenderLoopbackHaptics` calls `processor.setVolumeSync(...)` from the CLI flag at startup (volume polling keeps running either way — cheap, and toggling OFF then applies instantly). +- [ ] **Step 4:** Targeted tests GREEN; full `npx vitest run src` passes. +- [ ] **Step 5:** Commit `Add Volume Sync gating to the Linux audio helper and settings store`. + +### Task 2: Engine plumbing (audio-helper.ts) + +**Files:** +- Modify: `ds5-bridge/companion/src/main/audio-helper.ts` (`SystemAudioHapticsConfig` gains `volumeSync: boolean`; `normalizeSystemAudioHapticsConfig` defaults it true; `startInternal` appends `'--haptics-volume-sync', config.volumeSync ? '1' : '0'`; `setConfig`'s stdin line becomes the 7-field form) +- Modify: whatever main-process call site builds `SystemAudioHapticsConfig` from settings (grep `gainPercent: settings.audioReactiveHapticsGainPercent` / `audioReactiveHapticsBassFocus`) — pass `volumeSync: settings.audioReactiveHapticsVolumeSync`. +- Test: `ds5-bridge/companion/src/main/audio-helper.test.ts` (follow existing arg/stdin assertions; update every existing fixture config to include `volumeSync`). + +**Interfaces:** +- Consumes: Task 1's settings keys and helper flag/stdin contract. +- Produces: engine always sends the flag and the 7-field config line. + +- [ ] **Step 1 (TDD):** extend existing audio-helper tests: spawn args include the flag both ways; `setConfig` line ends with ` 1`/` 0`. +- [ ] **Step 2:** RED. **Step 3:** implement. **Step 4:** GREEN + full suite. **Step 5:** Commit `Plumb Audio Haptics Volume Sync through the engine`. + +### Task 3: HD volume guard (helper mode + main lifecycle) + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs`: export +```js +// Desktop volume controls rewrite all four channels of the bridge sink; +// when HD Volume Sync is off the haptic pair (3-4) must stay at unity so +// game HD haptics don't fade with the listening volume. +export function pinnedChannelVolumes(current) { + if (!Array.isArray(current) || current.length < 4) { + return null; + } + if (current[2] === 1 && current[3] === 1) { + return null; + } + return [current[0], current[1], 1, 1, ...current.slice(4)]; +} +``` + plus `runVolumeGuard()`: every 2000 ms, `requireBridgeSink()` on first tick then re-find on failure; read `channelVolumes` from the sink node's `Props` param (pw-dump object: `info.params.Props?.[0]?.channelVolumes`); if `pinnedChannelVolumes` returns non-null, `execFile('pw-cli', ['set-param', `${sink.id}`, 'Props', `{ channelVolumes: [ ${vols.join(', ')} ] }`])`. Errors → stderr, retry next tick. `stop` stdin line / SIGTERM / SIGINT exit cleanly (mirror `runMonitorAudioSessions`). Dispatch in `main()` via `--volume-guard`. +- Modify: Electron main — a small `VolumeGuard` wrapper next to the existing helper-lifecycle code (grep `mic-keepalive-only` / `helperLaunch` in `src/main` for the established spawn pattern): start when settings say `hapticsVolumeSync === false` (on boot and on settings change), stop on toggle ON and app quit. Linux only (`process.platform === 'linux'`). +- Test: helper — `pinnedChannelVolumes` (short array → null, already pinned → null, `[0.3,0.3,0.3,0.3]` → `[0.3,0.3,1,1]`, 6ch preserved tail); main wrapper — start/stop decisions per settings (mock spawn, follow existing helper lifecycle tests). + +- [ ] **Step 1 (TDD)** → **Step 2 RED** → **Step 3 implement** → **Step 4 GREEN + full suite** → **Step 5:** Commit `Pin HD haptic channels at unity when Volume Sync is off`. + +### Task 4: UI toggles + +**Files:** +- Modify: `ds5-bridge/companion/src/renderer/App.tsx` — one "Volume Sync" toggle row on the Audio Haptics card (near the gain slider controls, pattern-match the card's existing toggle rows) bound to `audioReactiveHapticsVolumeSync`, and one on the HD Haptics card bound to `hapticsVolumeSync`. Label: `Volume Sync`; description text: `Haptics follow the listening volume`. Follow the exact markup/state/IPC-update pattern of a neighboring boolean toggle on the same card (do not invent new styles). +- Test: `ds5-bridge/companion/src/renderer/app-behavior.test.ts` if it covers card toggles (follow an existing toggle's test); otherwise rely on typecheck + suite. + +- [ ] **Step 1:** implement both rows. **Step 2:** full `npx vitest run src` + `tsc` build passes (`npm run build:app` compiles). **Step 3:** Commit `Add Volume Sync toggles to the haptics cards`. + +### Task 5: Verification + +- [ ] Full suite `npm run test:companion`; rebuild AppImage (`npm run installer:linux`), deploy to `~/Applications/OpenDS5.AppImage`. +- [ ] Hardware (user): Audio Haptics sync ON → rumble fades with volume knob; OFF → constant. HD card sync OFF → game haptics constant while ears follow the knob (guard re-pins within ~2 s of a volume change); ON → today's coupled behavior. diff --git a/docs/superpowers/specs/2026-07-17-volume-sync-design.md b/docs/superpowers/specs/2026-07-17-volume-sync-design.md new file mode 100644 index 0000000..ef30fbf --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-volume-sync-design.md @@ -0,0 +1,69 @@ +# Volume Sync — Design + +Date: 2026-07-17 +Branch: `feature/haptics-prefold-capture` (continues the pre-fold capture work) +Status: approved by user (defaults: both toggles ON) + +## Problem + +Haptics on Linux are delivered as audio through the bridge sink, so the +sink's volume scales them: game HD haptics (channels 3–4 of the game's own +stream) and the Audio Haptics helper output both fade with the listening +volume. Tonight's `volumeCompensation` work decoupled the Audio Haptics +path unconditionally — but the user sometimes *wants* volume-coupled +haptics. Make coupling a per-feature choice. + +## Feature: "Volume Sync" toggles + +Two independent toggles, both **default ON** (ON = today's historical +behavior: haptics follow the listening volume). + +1. **Audio Haptics card → Volume Sync** + - ON: helper output follows sink volume (output compensation disabled, + factor fixed at 1). + - OFF: helper compensates by the inverse sink volume (the mechanism + shipped in f41b141), making synthesized haptics volume-independent. + - Setting: `audioReactiveHapticsVolumeSync: boolean` (default true). + - Plumbing: new helper CLI flag `--haptics-volume-sync 1|0` and a 7th + field on the live `haptics-config` stdin line, so toggling applies + without restarting capture. + +2. **HD Haptics card → Volume Sync** + - ON: nothing runs; sink volume scales the game's haptic channels as + today. + - OFF: a volume guard pins the bridge sink's haptic channels (3–4) at + unity while channels 1–2 keep tracking the user's volume. Implemented + as a new helper mode `--volume-guard`: every 2 s read the bridge + sink's `Props.channelVolumes`; if channels 3–4 differ from 1.0, + rewrite them to 1.0 via `pw-cli set-param Props`, leaving + channels 1–2 untouched. Desktop volume changes rewrite all four + channels, so drift is corrected within one poll (brief dip accepted). + - Setting: `hapticsVolumeSync: boolean` (default true). + - Lifecycle: the app's main process spawns the guard while + `hapticsVolumeSync === false` (regardless of hapticsEnabled — classic + rumble-over-audio also benefits), kills it when toggled back ON or on + quit. Linux only; Windows is untouched. + +## UI + +One toggle row on each card, labeled "Volume Sync", subtitle +"Haptics follow the listening volume". Follows the cards' existing toggle +row pattern. No other UI. + +## Error handling + +- Guard: pw-cli/pw-dump failures log to stderr and retry next tick; sink + absent → keep polling (controller may reconnect). +- Helper: unknown/absent 7th config field defaults to sync ON (safe, + historical behavior). + +## Testing + +- Settings store: both booleans normalize (garbage → default true). +- Helper: volume-sync flag parsing; compensation factor forced to 1 when + sync ON; live toggle via 7-field haptics-config line; guard's + channelVolumes patch computation as an exported pure function + (`pinnedChannelVolumes(current) -> array | null` — null when already + pinned, no-op). +- Engine: config type/args/stdin line carry the new field. +- Hardware: toggle each and confirm feel changes with the volume knob. From 0830e923da0a722c2c1910f47fc4a63eef263eb3 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 21:57:33 +0400 Subject: [PATCH 30/34] Add Volume Sync gating to the Linux audio helper and settings store --- .../companion/native/audio-helper-linux.mjs | 25 +++++- .../src/main/audio-helper-linux.test.ts | 84 ++++++++++++++++++- .../companion/src/main/settings-store.test.ts | 30 +++++++ .../companion/src/main/settings-store.ts | 8 ++ ds5-bridge/companion/src/shared/types.ts | 2 + 5 files changed, 144 insertions(+), 5 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index fc208a8..53d9de6 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -151,6 +151,10 @@ export class HapticsProcessor { this.envelope = 0; this.layout = { stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }; this.outputCompensation = 1; + // Volume Sync (default ON): haptics follow the listening volume, so the + // sink-volume compensation is neutralized to unity. OFF holds haptics + // strength independent of the listening volume via outputCompensation. + this.volumeSync = true; this.setConfig(config); } @@ -158,6 +162,10 @@ export class HapticsProcessor { this.outputCompensation = compensation; } + setVolumeSync(enabled) { + this.volumeSync = enabled; + } + setConfig({ gainPercent, bassFocus, response, attack, release }) { this.gain = Math.max(0, Math.min(400, gainPercent)) / 100; this.responseGain = RESPONSE_GAIN[response] ?? 1.0; @@ -193,7 +201,7 @@ export class HapticsProcessor { // noise floor so silence does not buzz the actuators. const gate = this.envelope < 0.003 ? 0 : 1; const drive = this.gain * this.responseGain * 4 * gate; - const comp = this.outputCompensation; + const comp = this.volumeSync ? 1 : this.outputCompensation; output[frame * 4 + 2] = Math.max(-1, Math.min(1, Math.tanh(left * drive) * comp)); output[frame * 4 + 3] = Math.max(-1, Math.min(1, Math.tanh(right * drive) * comp)); } @@ -201,20 +209,27 @@ export class HapticsProcessor { } } -function readHapticsConfig(args) { +export function readHapticsConfig(args) { return { gainPercent: Number(argValue(args, '--haptics-gain') ?? 100), bassFocus: argValue(args, '--haptics-bass-focus') ?? 'balanced', response: argValue(args, '--haptics-response') ?? 'balanced', attack: argValue(args, '--haptics-attack') ?? 'balanced', - release: argValue(args, '--haptics-release') ?? 'balanced' + release: argValue(args, '--haptics-release') ?? 'balanced', + // Volume Sync defaults ON: only an explicit "0" disables it. + volumeSync: argValue(args, '--haptics-volume-sync') !== '0' }; } async function runRenderLoopbackHaptics(args) { const sink = await requireBridgeSink(); const target = nodeProps(sink)['node.name']; - const processor = new HapticsProcessor(readHapticsConfig(args)); + const config = readHapticsConfig(args); + const processor = new HapticsProcessor(config); + // Volume polling keeps running either way (cheap); this just decides + // whether the polled compensation is applied. Toggling OFF later via + // stdin then takes effect on the next processed block. + processor.setVolumeSync(config.volumeSync); const appSource = { processId: Number(argValue(args, '--haptics-app-process-id') ?? 0), @@ -368,6 +383,8 @@ async function runRenderLoopbackHaptics(args) { attack: parts[4], release: parts[5] }); + // 7th field is optional so 6-field lines keep working: absent → sync ON. + processor.setVolumeSync(parts[6] === undefined ? true : parts[6] === '1'); } else if (parts[0] === 'stop') { shutdown(0); } diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index 0fb4d4c..9c39b4d 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -117,7 +117,7 @@ describe('HapticsProcessor layouts', () => { }); }); -import { appCaptureRecordArgs, matchAppStreamNode, parseWpctlVolume, volumeCompensation } from '../../native/audio-helper-linux.mjs'; +import { appCaptureRecordArgs, matchAppStreamNode, parseWpctlVolume, readHapticsConfig, volumeCompensation } from '../../native/audio-helper-linux.mjs'; describe('volumeCompensation', () => { it('is a no-op at unity volume', () => { @@ -176,6 +176,7 @@ describe('HapticsProcessor output compensation', () => { const input = loudInput(frames); const base = makeProcessor().process(input); const boosted = makeProcessor(); + boosted.setVolumeSync(false); boosted.setOutputCompensation(2); const out = boosted.process(input); for (let i = 0; i < out.length; i += 1) { @@ -192,6 +193,7 @@ describe('HapticsProcessor output compensation', () => { const frames = 256; const input = loudInput(frames); const clipped = makeProcessor(); + clipped.setVolumeSync(false); clipped.setOutputCompensation(8); const out = clipped.process(input); const peak = Math.max(...Array.from(out).map((s) => Math.abs(s))); @@ -207,6 +209,86 @@ describe('HapticsProcessor output compensation', () => { }); }); +describe('readHapticsConfig volume sync flag', () => { + it('defaults volumeSync to true when the flag is absent', () => { + expect(readHapticsConfig([]).volumeSync).toBe(true); + expect(readHapticsConfig(['--haptics-gain', '120']).volumeSync).toBe(true); + }); + + it('reads volumeSync true when the flag is 1', () => { + expect(readHapticsConfig(['--haptics-volume-sync', '1']).volumeSync).toBe(true); + }); + + it('reads volumeSync false when the flag is 0', () => { + expect(readHapticsConfig(['--haptics-volume-sync', '0']).volumeSync).toBe(false); + }); +}); + +describe('HapticsProcessor volume sync gating', () => { + function loudInput(frames: number) { + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + const s = Math.sin(2 * Math.PI * 40 * (f / 48000)) * 0.9; + input[f * 4] = s; + input[f * 4 + 1] = s; + } + return input; + } + + it('applies compensation 1 when volume sync is ON regardless of polled compensation', () => { + const frames = 256; + const input = loudInput(frames); + const synced = makeProcessor(); + synced.setOutputCompensation(6); + synced.setVolumeSync(true); + const reference = makeProcessor(); + reference.setOutputCompensation(1); + expect(Array.from(synced.process(input))).toEqual(Array.from(reference.process(input))); + }); + + it('restores the compensated (clamped) output when volume sync is OFF', () => { + const frames = 256; + const input = loudInput(frames); + const off = makeProcessor(); + off.setVolumeSync(false); + off.setOutputCompensation(6); + const compensated = makeProcessor(); + compensated.setVolumeSync(false); + compensated.setOutputCompensation(6); + expect(Array.from(off.process(input))).toEqual(Array.from(compensated.process(input))); + + const base = makeProcessor(); + base.setVolumeSync(false); + base.setOutputCompensation(1); + const baseOut = base.process(input); + const offOut = makeProcessor(); + offOut.setVolumeSync(false); + offOut.setOutputCompensation(6); + const out = offOut.process(input); + let sawNonzero = false; + for (let i = 0; i < out.length; i += 1) { + if (baseOut[i] === 0) { + expect(out[i]).toBe(0); + } else { + sawNonzero = true; + expect(out[i]).toBeCloseTo(Math.max(-1, Math.min(1, baseOut[i] * 6)), 6); + } + } + expect(sawNonzero).toBe(true); + }); + + it('defaults volume sync ON so compensation has no effect on a fresh processor', () => { + const frames = 256; + const input = loudInput(frames); + const fresh = makeProcessor(); + fresh.setOutputCompensation(6); + const reference = makeProcessor(); + reference.setVolumeSync(false); + reference.setOutputCompensation(1); + expect(Array.from(fresh.process(input))).toEqual(Array.from(reference.process(input))); + }); +}); + describe('appCaptureRecordArgs', () => { it('targets the app stream by object.serial, not by --target id (mic fallback)', () => { const node = { id: 297, info: { props: { 'object.serial': 48540 } } }; diff --git a/ds5-bridge/companion/src/main/settings-store.test.ts b/ds5-bridge/companion/src/main/settings-store.test.ts index dfec56b..c83bed7 100644 --- a/ds5-bridge/companion/src/main/settings-store.test.ts +++ b/ds5-bridge/companion/src/main/settings-store.test.ts @@ -533,6 +533,36 @@ describe('SettingsStore', () => { expect(settings.controllerProfiles[1]?.settings.lightbarColor).toBe('#123456'); }); + it('defaults both volume-sync toggles to true', () => { + const settings = new SettingsStore(tempUserDataPath()).get(); + expect(settings.audioReactiveHapticsVolumeSync).toBe(true); + expect(settings.hapticsVolumeSync).toBe(true); + expect(DEFAULT_SETTINGS.audioReactiveHapticsVolumeSync).toBe(true); + expect(DEFAULT_SETTINGS.hapticsVolumeSync).toBe(true); + }); + + it('persists a stored false for the volume-sync toggles', () => { + const userDataPath = tempUserDataPath(); + writeFileSync(path.join(userDataPath, 'settings.json'), JSON.stringify({ + audioReactiveHapticsVolumeSync: false, + hapticsVolumeSync: false + }), 'utf8'); + const settings = new SettingsStore(userDataPath).get(); + expect(settings.audioReactiveHapticsVolumeSync).toBe(false); + expect(settings.hapticsVolumeSync).toBe(false); + }); + + it('coerces non-boolean volume-sync values back to true', () => { + const userDataPath = tempUserDataPath(); + writeFileSync(path.join(userDataPath, 'settings.json'), JSON.stringify({ + audioReactiveHapticsVolumeSync: 'nope', + hapticsVolumeSync: 0 + }), 'utf8'); + const settings = new SettingsStore(userDataPath).get(); + expect(settings.audioReactiveHapticsVolumeSync).toBe(true); + expect(settings.hapticsVolumeSync).toBe(true); + }); + it('persists the selected UI theme preset', () => { const userDataPath = tempUserDataPath(); const store = new SettingsStore(userDataPath); diff --git a/ds5-bridge/companion/src/main/settings-store.ts b/ds5-bridge/companion/src/main/settings-store.ts index c646ab4..1cf570f 100644 --- a/ds5-bridge/companion/src/main/settings-store.ts +++ b/ds5-bridge/companion/src/main/settings-store.ts @@ -170,6 +170,8 @@ export const DEFAULT_SETTINGS: CompanionSettings = { audioReactiveHapticsResponse: DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsResponse, audioReactiveHapticsAttack: DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsAttack, audioReactiveHapticsRelease: DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsRelease, + audioReactiveHapticsVolumeSync: true, + hapticsVolumeSync: true, lightbarEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.lightbarEnabled, lightbarColor: DEFAULT_CONTROLLER_PROFILE_SETTINGS.lightbarColor, lightbarBrightnessPercent: DEFAULT_CONTROLLER_PROFILE_SETTINGS.lightbarBrightnessPercent, @@ -956,6 +958,12 @@ function normalizeSettings(value: Partial | null | undefined) audioReactiveHapticsResponse: normalizeAudioReactiveHapticsResponse(value?.audioReactiveHapticsResponse), audioReactiveHapticsAttack: normalizeAudioReactiveHapticsAttack(value?.audioReactiveHapticsAttack), audioReactiveHapticsRelease: normalizeAudioReactiveHapticsRelease(value?.audioReactiveHapticsRelease), + audioReactiveHapticsVolumeSync: typeof value?.audioReactiveHapticsVolumeSync === 'boolean' + ? value.audioReactiveHapticsVolumeSync + : DEFAULT_SETTINGS.audioReactiveHapticsVolumeSync, + hapticsVolumeSync: typeof value?.hapticsVolumeSync === 'boolean' + ? value.hapticsVolumeSync + : DEFAULT_SETTINGS.hapticsVolumeSync, lightbarEnabled: typeof value?.lightbarEnabled === 'boolean' ? value.lightbarEnabled : DEFAULT_SETTINGS.lightbarEnabled, diff --git a/ds5-bridge/companion/src/shared/types.ts b/ds5-bridge/companion/src/shared/types.ts index 4de94fc..d115e7c 100644 --- a/ds5-bridge/companion/src/shared/types.ts +++ b/ds5-bridge/companion/src/shared/types.ts @@ -59,6 +59,8 @@ export interface CompanionSettings { audioReactiveHapticsResponse: AudioReactiveHapticsResponse; audioReactiveHapticsAttack: AudioReactiveHapticsAttack; audioReactiveHapticsRelease: AudioReactiveHapticsRelease; + audioReactiveHapticsVolumeSync: boolean; + hapticsVolumeSync: boolean; lightbarEnabled: boolean; lightbarColor: string; lightbarBrightnessPercent: number; From 952e19d5753f2ebc40972419800998e8e77ae161 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 22:01:31 +0400 Subject: [PATCH 31/34] Plumb Audio Haptics Volume Sync through the engine --- .../companion/src/main/audio-helper.test.ts | 45 ++++++++++++++++++- ds5-bridge/companion/src/main/audio-helper.ts | 13 ++++-- .../companion/src/main/bridge-service.ts | 3 +- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/ds5-bridge/companion/src/main/audio-helper.test.ts b/ds5-bridge/companion/src/main/audio-helper.test.ts index b0a844e..0a2f507 100644 --- a/ds5-bridge/companion/src/main/audio-helper.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper.test.ts @@ -80,7 +80,8 @@ describe('SystemAudioHapticsEngine app source', () => { bassFocus: 'punchy', response: 'strong', attack: 'fast', - release: 'smooth' + release: 'smooth', + volumeSync: true }, 'xbox'); const helper = childProcessMock.processes[0]!; @@ -97,6 +98,8 @@ describe('SystemAudioHapticsEngine app source', () => { expect(args).toContain('Game.exe'); expect(args).toContain('--bridge-persona'); expect(args).toContain('xbox'); + expect(args).toContain('--haptics-volume-sync'); + expect(args[args.indexOf('--haptics-volume-sync') + 1]).toBe('1'); expect(args).not.toContain('--device-name'); expect(args).not.toContain('DS5 Bridge'); expect(args).not.toContain('--stdout-only'); @@ -104,6 +107,46 @@ describe('SystemAudioHapticsEngine app source', () => { await engine.stop(); }); + it('passes the volume-sync flag as 0 when disabled and reconfigures with a 7-field line', async () => { + const engine = new SystemAudioHapticsEngine(); + const start = engine.start({ + source: 'system-audio', + gainPercent: 100, + bassFocus: 'balanced', + response: 'balanced', + attack: 'balanced', + release: 'balanced', + volumeSync: false + }, 'dualsense'); + + const helper = childProcessMock.processes[0]!; + helper.stderr.emit('data', Buffer.from('status: recording-started\n')); + await start; + + const args = childProcessMock.spawn.mock.calls[0]![1] as string[]; + expect(args).toContain('--haptics-volume-sync'); + expect(args[args.indexOf('--haptics-volume-sync') + 1]).toBe('0'); + + engine.setConfig({ + source: 'system-audio', + gainPercent: 100, + bassFocus: 'balanced', + response: 'balanced', + attack: 'balanced', + release: 'balanced', + volumeSync: false + }); + + const writes = (helper.stdin.write as ReturnType).mock.calls + .map((call) => call[0] as string) + .filter((line) => line.startsWith('haptics-config ')); + const lastLine = writes[writes.length - 1]!.trim(); + expect(lastLine).toBe('haptics-config 100 balanced balanced balanced balanced 0'); + expect(lastLine.split(' ')).toHaveLength(7); + + await engine.stop(); + }); + }); describe('bridge haptics test', () => { diff --git a/ds5-bridge/companion/src/main/audio-helper.ts b/ds5-bridge/companion/src/main/audio-helper.ts index d427f6c..70a67c0 100644 --- a/ds5-bridge/companion/src/main/audio-helper.ts +++ b/ds5-bridge/companion/src/main/audio-helper.ts @@ -21,6 +21,7 @@ export type SystemAudioHapticsConfig = { response: AudioReactiveHapticsResponse; attack: AudioReactiveHapticsAttack; release: AudioReactiveHapticsRelease; + volumeSync: boolean; }; export type DefaultRenderEndpointStatus = { @@ -97,7 +98,8 @@ export class SystemAudioHapticsEngine extends EventEmitter { bassFocus: 'balanced', response: 'balanced', attack: 'balanced', - release: 'balanced' + release: 'balanced', + volumeSync: true }; async start(config: SystemAudioHapticsConfig, hostPersonaMode: HostPersonaMode = 'dualsense'): Promise { @@ -138,7 +140,7 @@ export class SystemAudioHapticsEngine extends EventEmitter { setConfig(config: SystemAudioHapticsConfig): void { this.activeConfig = normalizeSystemAudioHapticsConfig(config); this.writeControlLine( - `haptics-config ${this.activeConfig.gainPercent} ${this.activeConfig.bassFocus} ${this.activeConfig.response} ${this.activeConfig.attack} ${this.activeConfig.release}` + `haptics-config ${this.activeConfig.gainPercent} ${this.activeConfig.bassFocus} ${this.activeConfig.response} ${this.activeConfig.attack} ${this.activeConfig.release} ${this.activeConfig.volumeSync ? '1' : '0'}` ); } @@ -206,7 +208,9 @@ export class SystemAudioHapticsEngine extends EventEmitter { '--haptics-attack', config.attack, '--haptics-release', - config.release + config.release, + '--haptics-volume-sync', + config.volumeSync ? '1' : '0' ]; const deviceSource = audioReactiveHapticsOutputDeviceSource(config.source); if (deviceSource) { @@ -459,7 +463,8 @@ function normalizeSystemAudioHapticsConfig(config: SystemAudioHapticsConfig): Sy : 'balanced', release: config.release === 'tight' || config.release === 'smooth' || config.release === 'long' ? config.release - : 'balanced' + : 'balanced', + volumeSync: config.volumeSync !== false }; } diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index c33b14d..e288ce5 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -2206,7 +2206,8 @@ export class BridgeService extends EventEmitter { bassFocus: settings.audioReactiveHapticsBassFocus, response: settings.audioReactiveHapticsResponse, attack: settings.audioReactiveHapticsAttack, - release: settings.audioReactiveHapticsRelease + release: settings.audioReactiveHapticsRelease, + volumeSync: settings.audioReactiveHapticsVolumeSync }; } From 800bd7de03068b610bc41b47d9c8fcfa78ba7268 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 23:23:51 +0400 Subject: [PATCH 32/34] Pin HD haptic channels at unity when Volume Sync is off --- .../companion/native/audio-helper-linux.mjs | 77 +++++++++++++++++++ .../src/main/audio-helper-linux.test.ts | 24 +++++- .../companion/src/main/audio-helper.test.ts | 36 ++++++++- ds5-bridge/companion/src/main/audio-helper.ts | 73 ++++++++++++++++++ .../companion/src/main/bridge-service.test.ts | 41 ++++++++++ .../companion/src/main/bridge-service.ts | 34 ++++++++ 6 files changed, 283 insertions(+), 2 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index 53d9de6..d65b821 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -624,6 +624,81 @@ async function runMonitorAudioSessions() { process.on('SIGTERM', stop); } +// Desktop volume controls rewrite all four channels of the bridge sink; +// when HD Volume Sync is off the haptic pair (3-4) must stay at unity so +// game HD haptics don't fade with the listening volume. +export function pinnedChannelVolumes(current) { + if (!Array.isArray(current) || current.length < 4) { + return null; + } + if (current[2] === 1 && current[3] === 1) { + return null; + } + return [current[0], current[1], 1, 1, ...current.slice(4)]; +} + +const VOLUME_GUARD_INTERVAL_MS = 2000; + +async function runVolumeGuard() { + let stopping = false; + // Hard-fail the first tick when the sink is missing (mirrors the other + // helper modes): the parent only starts the guard once the controller + // audio path is ready, so a missing sink at boot is a real setup error. + let sink = await requireBridgeSink(); + + const pinTick = async () => { + if (stopping) { + return; + } + try { + // Re-find the sink each tick so the guard survives the sink coming + // and going with the controller. channelVolumes change as the user + // adjusts volume, so always read them fresh from a live pw-dump. + const found = await findBridgeSink(); + if (!found) { + process.stderr.write('volume guard: bridge sink not found, retrying\n'); + return; + } + sink = found; + const vols = sink.info?.params?.Props?.[0]?.channelVolumes; + const pinned = pinnedChannelVolumes(vols); + if (!pinned) { + return; + } + await new Promise((resolve) => { + execFile('pw-cli', [ + 'set-param', `${sink.id}`, 'Props', + `{ channelVolumes: [ ${pinned.join(', ')} ] }` + ], (error) => { + if (error) { + process.stderr.write(`volume guard: set-param failed: ${error.message}\n`); + } + resolve(); + }); + }); + } catch (error) { + process.stderr.write(`volume guard tick failed: ${error.message}\n`); + } + }; + + await pinTick(); + const timer = setInterval(pinTick, VOLUME_GUARD_INTERVAL_MS); + const control = createInterface({ input: process.stdin }); + const stop = () => { + stopping = true; + clearInterval(timer); + process.exit(0); + }; + control.on('line', (line) => { + if (line.trim() === 'stop') { + stop(); + } + }); + control.on('close', stop); + process.on('SIGTERM', stop); + process.on('SIGINT', stop); +} + function runMicKeepalive() { // Microphone input is unsupported over the vds Bluetooth transport; stay // alive so the engine's lifecycle management works, but do nothing. @@ -647,6 +722,8 @@ async function main() { await runSetDefaultRenderBridge(); } else if (args.includes('--monitor-audio-sessions')) { await runMonitorAudioSessions(); + } else if (args.includes('--volume-guard')) { + await runVolumeGuard(); } else if (args.includes('--mic-keepalive-only')) { runMicKeepalive(); } else if (argValue(args, '--source') === 'render-loopback') { diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index 9c39b4d..c6b1ff3 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { channelIndices, hapticsPlaybackArgs, HapticsProcessor, nodeChannelLayout } from '../../native/audio-helper-linux.mjs'; +import { channelIndices, hapticsPlaybackArgs, HapticsProcessor, nodeChannelLayout, pinnedChannelVolumes } from '../../native/audio-helper-linux.mjs'; describe('audio-helper-linux exports', () => { it('imports without running main and exposes HapticsProcessor', () => { @@ -367,3 +367,25 @@ describe('hapticsPlaybackArgs', () => { expect(args[args.length - 1]).toBe('-'); }); }); + +describe('pinnedChannelVolumes', () => { + it('returns null for a non-array or too-short input', () => { + expect(pinnedChannelVolumes(undefined)).toBeNull(); + expect(pinnedChannelVolumes(null)).toBeNull(); + expect(pinnedChannelVolumes([0.3, 0.3, 0.3])).toBeNull(); + }); + + it('returns null when the haptic pair is already pinned at unity', () => { + expect(pinnedChannelVolumes([0.3, 0.3, 1, 1])).toBeNull(); + expect(pinnedChannelVolumes([1, 1, 1, 1])).toBeNull(); + }); + + it('pins the haptic pair to unity while keeping the speaker channels', () => { + expect(pinnedChannelVolumes([0.3, 0.3, 0.3, 0.3])).toEqual([0.3, 0.3, 1, 1]); + }); + + it('preserves any trailing channels beyond the first four', () => { + expect(pinnedChannelVolumes([0.3, 0.3, 0.3, 0.3, 0.5, 0.5])) + .toEqual([0.3, 0.3, 1, 1, 0.5, 0.5]); + }); +}); diff --git a/ds5-bridge/companion/src/main/audio-helper.test.ts b/ds5-bridge/companion/src/main/audio-helper.test.ts index 0a2f507..9adae0a 100644 --- a/ds5-bridge/companion/src/main/audio-helper.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper.test.ts @@ -55,7 +55,8 @@ import { AudioHapticsSessionMonitor, playBridgeHapticsTestPattern, playBridgeSpeakerTestTone, - SystemAudioHapticsEngine + SystemAudioHapticsEngine, + VolumeGuardEngine } from './audio-helper'; beforeEach(() => { @@ -193,6 +194,39 @@ describe('bridge speaker test', () => { }); }); +describe('VolumeGuardEngine', () => { + it('spawns the helper in volume-guard mode once and is idempotent', async () => { + const engine = new VolumeGuardEngine(); + await engine.start(); + await engine.start(); + + expect(childProcessMock.spawn).toHaveBeenCalledTimes(1); + const rawArgs = childProcessMock.spawn.mock.calls[0]![1] as string[]; + const args = process.platform === 'win32' ? rawArgs : rawArgs.slice(1); + expect(args).toEqual(['--volume-guard']); + expect(engine.isActive()).toBe(true); + + await engine.stop(); + }); + + it('stops the helper and becomes inactive', async () => { + const engine = new VolumeGuardEngine(); + await engine.start(); + const helper = childProcessMock.processes[0]!; + + await engine.stop(); + + expect(helper.kill).toHaveBeenCalled(); + expect(engine.isActive()).toBe(false); + }); + + it('stop is a no-op when never started', async () => { + const engine = new VolumeGuardEngine(); + await engine.stop(); + expect(childProcessMock.spawn).not.toHaveBeenCalled(); + }); +}); + describe('audio haptics session listing', () => { it('keeps one monitor helper alive and returns cached snapshots', async () => { const monitor = new AudioHapticsSessionMonitor(); diff --git a/ds5-bridge/companion/src/main/audio-helper.ts b/ds5-bridge/companion/src/main/audio-helper.ts index 70a67c0..4c6167c 100644 --- a/ds5-bridge/companion/src/main/audio-helper.ts +++ b/ds5-bridge/companion/src/main/audio-helper.ts @@ -1039,6 +1039,79 @@ export class MicKeepaliveEngine extends EventEmitter { } } +// Holds the bridge sink's haptic channels (3-4) at unity while HD Volume +// Sync is off. Desktop volume controls rewrite all four channels, so this +// re-pins the haptic pair without touching the speaker channels the user is +// adjusting. Linux only; a no-op mode elsewhere is never started. +export class VolumeGuardEngine extends EventEmitter { + private process: ChildProcess | null = null; + private starting: Promise | null = null; + + async start(): Promise { + if (this.process) { + return; + } + if (this.starting) { + return this.starting; + } + + this.starting = this.startInternal().finally(() => { + this.starting = null; + }); + return this.starting; + } + + async stop(): Promise { + const helper = this.process; + this.process = null; + if (!helper) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (!helper.killed) { + helper.kill('SIGKILL'); + } + resolve(); + }, 500); + + helper.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + + helper.stdin?.end(); + helper.kill(); + }); + } + + isActive(): boolean { + return this.process !== null; + } + + private async startInternal(): Promise { + const launch = helperLaunch(['--volume-guard']); + const helper = spawn(launch.command, launch.args, { + env: launch.env, + windowsHide: true, + stdio: ['pipe', 'ignore', 'pipe'] + }); + + this.process = helper; + helper.stderr.on('data', (chunk: Buffer) => { + this.emit('status', chunk.toString('utf8').trim()); + }); + helper.on('error', (error) => this.emit('error', error)); + helper.on('exit', (code, signal) => { + if (this.process === helper) { + this.process = null; + this.emit('status', `volume guard helper exited (${signal ?? code ?? 'unknown'})`); + } + }); + } +} + export function resolveAudioHelperPath(): string { const packagedCandidate = process.resourcesPath ? path.join(process.resourcesPath, HELPER_RELATIVE_PATH) : null; const devHelperRelativePath = process.platform === 'win32' diff --git a/ds5-bridge/companion/src/main/bridge-service.test.ts b/ds5-bridge/companion/src/main/bridge-service.test.ts index 7697f76..56229c8 100644 --- a/ds5-bridge/companion/src/main/bridge-service.test.ts +++ b/ds5-bridge/companion/src/main/bridge-service.test.ts @@ -1771,6 +1771,47 @@ describe('BridgeService', () => { await flushImmediate(); }); + it.runIf(process.platform === 'linux')( + 'starts the volume guard when HD Volume Sync is off and stops it when toggled on', + async () => { + const service = serviceFixture({ hapticsVolumeSync: false }); + const guard = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), isActive: () => false }; + (service as unknown as { volumeGuardEngine: typeof guard }).volumeGuardEngine = guard; + + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: true }); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + + await poll(service); + expect(guard.start).toHaveBeenCalledTimes(1); + expect(guard.stop).not.toHaveBeenCalled(); + + (service as unknown as { settingsStore: SettingsStore }).settingsStore.update({ hapticsVolumeSync: true }); + guard.start.mockClear(); + await poll(service); + expect(guard.stop).toHaveBeenCalled(); + expect(guard.start).not.toHaveBeenCalled(); + } + ); + + it.runIf(process.platform === 'linux')( + 'stops the volume guard when the controller audio path is not ready', + async () => { + const service = serviceFixture({ hapticsVolumeSync: false }); + const guard = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), isActive: () => false }; + (service as unknown as { volumeGuardEngine: typeof guard }).volumeGuardEngine = guard; + + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: false }); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + + await poll(service); + expect(guard.start).not.toHaveBeenCalled(); + } + ); + it('ignores controller mic mute events when mic pass-through is not armed', async () => { const service = serviceFixture({ duplexMicEnabled: true, diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index e288ce5..a45f7be 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -82,6 +82,7 @@ import { AudioHapticsSessionMonitor, MicKeepaliveEngine, SystemAudioHapticsEngine, + VolumeGuardEngine, listAudioOutputDevices, playBridgeHapticsTestPattern, playBridgeSpeakerTestTone, @@ -1378,6 +1379,7 @@ export class BridgeService extends EventEmitter { private readonly systemAudioHapticsEngine = new SystemAudioHapticsEngine(); private readonly audioHapticsSessionMonitor = new AudioHapticsSessionMonitor(); private readonly micKeepaliveEngine = new MicKeepaliveEngine(); + private readonly volumeGuardEngine = new VolumeGuardEngine(); private readonly hidDiscovery = new HidDiscoveryClient(); private audioHapticsSessionCache: { key: string; expiresAt: number; sessions: AudioHapticsSession[] } | null = null; private audioHapticsSessionListInFlight: Promise | null = null; @@ -1478,6 +1480,16 @@ export class BridgeService extends EventEmitter { } this.emitSnapshot(); }); + this.volumeGuardEngine.on('error', (error: Error) => { + this.appendAudioDebugLines([`[VolumeGuard] error: ${error.message}`]); + this.emitSnapshot(); + }); + this.volumeGuardEngine.on('status', (line: string) => { + if (line) { + this.appendAudioDebugLines([`[VolumeGuard] ${line}`]); + } + this.emitSnapshot(); + }); } private enqueueShortcutEvent(event: InputShortcutEvent): void { @@ -1567,6 +1579,7 @@ export class BridgeService extends EventEmitter { await this.systemAudioHapticsEngine.stop(); await this.stopAudioHapticsSessionPolling(); await this.micKeepaliveEngine.stop(); + await this.volumeGuardEngine.stop(); } async listAudioOutputDevices(): Promise { @@ -3555,6 +3568,26 @@ export class BridgeService extends EventEmitter { } } + // Pins the bridge sink's haptic channels at unity while HD Volume Sync is + // off (Linux only). Reconciled from the poll loop when controller audio is + // ready; a future settings-toggle setter should also call this for an + // immediate reconcile (as setDuplexMicEnabled does for mic keepalive). The + // guard is gated on controller readiness so the helper never hard-fails on + // a missing sink at boot and respawn-loops. + private async updateVolumeGuardEngine(controllerAudioReady: boolean): Promise { + try { + const settings = this.settingsStore.get(); + if (process.platform !== 'linux' || !controllerAudioReady || settings.hapticsVolumeSync) { + await this.volumeGuardEngine.stop(); + return; + } + await this.volumeGuardEngine.start(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.appendAudioDebugLines([`[VolumeGuard] error: ${message}`]); + } + } + private async pulseSystemAudioHaptics(): Promise { const settings = this.settingsStore.get(); if (!this.systemAudioHapticsDesired(settings)) { @@ -3699,6 +3732,7 @@ export class BridgeService extends EventEmitter { await this.restartSystemAudioHapticsAfterPersonaTransition(completedHostPersonaMode); } await this.updateMicKeepaliveEngine(status.controllerConnected); + await this.updateVolumeGuardEngine(this.controllerAudioReady(status)); await this.syncControllerPowerSavingState(settings); if (status.controllerConnected) { From 4ff2944f95a51cabb827d6a086867904a1e3ec70 Mon Sep 17 00:00:00 2001 From: lordvicky Date: Fri, 17 Jul 2026 23:35:35 +0400 Subject: [PATCH 33/34] Add Volume Sync toggles to the haptics cards --- .../companion/src/main/bridge-service.test.ts | 23 +++++++ .../companion/src/main/bridge-service.ts | 16 ++++- .../companion/src/main/ipc-contract.test.ts | 7 +++ ds5-bridge/companion/src/main/main.ts | 1 + ds5-bridge/companion/src/preload.ts | 3 + ds5-bridge/companion/src/renderer/App.tsx | 61 +++++++++++++++++++ ds5-bridge/companion/src/shared/protocol.ts | 1 + 7 files changed, 109 insertions(+), 3 deletions(-) diff --git a/ds5-bridge/companion/src/main/bridge-service.test.ts b/ds5-bridge/companion/src/main/bridge-service.test.ts index 56229c8..bde360e 100644 --- a/ds5-bridge/companion/src/main/bridge-service.test.ts +++ b/ds5-bridge/companion/src/main/bridge-service.test.ts @@ -1795,6 +1795,29 @@ describe('BridgeService', () => { } ); + it.runIf(process.platform === 'linux')( + 'setHapticsVolumeSync persists the setting and reconciles the volume guard immediately', + async () => { + const service = serviceFixture({ hapticsVolumeSync: false }); + const guard = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), isActive: () => false }; + (service as unknown as { volumeGuardEngine: typeof guard }).volumeGuardEngine = guard; + + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: true }); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + + await poll(service); + expect(guard.start).toHaveBeenCalledTimes(1); + + guard.start.mockClear(); + const snapshot = await service.setHapticsVolumeSync(true); + expect(snapshot.settings.hapticsVolumeSync).toBe(true); + expect(guard.stop).toHaveBeenCalled(); + expect(guard.start).not.toHaveBeenCalled(); + } + ); + it.runIf(process.platform === 'linux')( 'stops the volume guard when the controller audio path is not ready', async () => { diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index a45f7be..59a6300 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -609,7 +609,8 @@ function normalizeAudioReactiveHapticsConfig( bassFocus: normalizeAudioReactiveHapticsBassFocus(config.bassFocus ?? settings.audioReactiveHapticsBassFocus), response: normalizeAudioReactiveHapticsResponse(config.response ?? settings.audioReactiveHapticsResponse), attack: normalizeAudioReactiveHapticsAttack(config.attack ?? settings.audioReactiveHapticsAttack), - release: normalizeAudioReactiveHapticsRelease(config.release ?? settings.audioReactiveHapticsRelease) + release: normalizeAudioReactiveHapticsRelease(config.release ?? settings.audioReactiveHapticsRelease), + volumeSync: typeof config.volumeSync === 'boolean' ? config.volumeSync : settings.audioReactiveHapticsVolumeSync }; } @@ -2370,6 +2371,13 @@ export class BridgeService extends EventEmitter { return this.getSnapshot(); } + async setHapticsVolumeSync(enabled: boolean): Promise { + this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ hapticsVolumeSync: enabled })); + await this.updateVolumeGuardEngine(this.controllerAudioReady()); + this.emitSnapshot(); + return this.getSnapshot(); + } + async setClassicRumbleEnabled(enabled: boolean): Promise { const settings = { ...this.settingsStore.get(), classicRumbleEnabled: enabled }; await this.sendSettingCommand( @@ -2497,7 +2505,8 @@ export class BridgeService extends EventEmitter { audioReactiveHapticsBassFocus: normalized.bassFocus, audioReactiveHapticsResponse: normalized.response, audioReactiveHapticsAttack: normalized.attack, - audioReactiveHapticsRelease: normalized.release + audioReactiveHapticsRelease: normalized.release, + audioReactiveHapticsVolumeSync: normalized.volumeSync }; if (!this.audioReactiveHapticsSupported()) { throw new Error('Audio reactive haptics require updated bridge firmware.'); @@ -2532,7 +2541,8 @@ export class BridgeService extends EventEmitter { audioReactiveHapticsBassFocus: normalized.bassFocus, audioReactiveHapticsResponse: normalized.response, audioReactiveHapticsAttack: normalized.attack, - audioReactiveHapticsRelease: normalized.release + audioReactiveHapticsRelease: normalized.release, + audioReactiveHapticsVolumeSync: normalized.volumeSync })); await this.updateSystemAudioHapticsEngine(); this.emitSnapshot(); diff --git a/ds5-bridge/companion/src/main/ipc-contract.test.ts b/ds5-bridge/companion/src/main/ipc-contract.test.ts index 3b072fd..1551838 100644 --- a/ds5-bridge/companion/src/main/ipc-contract.test.ts +++ b/ds5-bridge/companion/src/main/ipc-contract.test.ts @@ -107,6 +107,13 @@ describe('IPC contract', () => { expect(mainSource).toContain('rawPowerState === 0x01 || rawPowerState === 0x02'); }); + it('exposes the HD haptics Volume Sync channel', () => { + expect(preloadSource).toContain("ipcRenderer.invoke('bridge:setHapticsVolumeSync', value)"); + expect(mainSource).toContain("ipcMain.handle('bridge:setHapticsVolumeSync'"); + expect(bridgeServiceSource).toContain('async setHapticsVolumeSync(enabled: boolean): Promise'); + expect(bridgeServiceSource).toContain('hapticsVolumeSync: enabled'); + }); + it('exposes trigger profile engine channels', () => { expect(preloadSource).toContain("ipcRenderer.invoke('bridge:listTriggerProfiles')"); expect(preloadSource).toContain("ipcRenderer.invoke('bridge:saveTriggerProfile', profile)"); diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index 62cd4f8..54bd28a 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -1356,6 +1356,7 @@ function registerIpc( ipcMain.handle('bridge:setFeedbackBoostEnabled', (_event, value: boolean) => ( service.setFeedbackBoostEnabled(value) )); + ipcMain.handle('bridge:setHapticsVolumeSync', (_event, value: boolean) => service.setHapticsVolumeSync(value)); ipcMain.handle('bridge:setHapticsBufferLength', (_event, value: number) => service.setHapticsBufferLength(value)); ipcMain.handle('bridge:setClassicRumbleGain', (_event, value: number) => service.setClassicRumbleGain(value)); ipcMain.handle('bridge:setClassicRumbleEnabled', (_event, value: boolean) => service.setClassicRumbleEnabled(value)); diff --git a/ds5-bridge/companion/src/preload.ts b/ds5-bridge/companion/src/preload.ts index 4f0ab0b..5cdd231 100644 --- a/ds5-bridge/companion/src/preload.ts +++ b/ds5-bridge/companion/src/preload.ts @@ -81,6 +81,9 @@ const api = { setFeedbackBoostEnabled: (value: boolean): Promise => ( ipcRenderer.invoke('bridge:setFeedbackBoostEnabled', value) ), + setHapticsVolumeSync: (value: boolean): Promise => ( + ipcRenderer.invoke('bridge:setHapticsVolumeSync', value) + ), setHapticsBufferLength: (value: number): Promise => ( ipcRenderer.invoke('bridge:setHapticsBufferLength', value) ), diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 1ef7efb..ae7f5cf 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -2981,6 +2981,7 @@ export function App() { const [classicRumbleCommitPending, setClassicRumbleCommitPending] = useState(false); const [classicRumbleV1CommitPending, setClassicRumbleV1CommitPending] = useState(false); const [feedbackBoostCommitPending, setFeedbackBoostCommitPending] = useState(false); + const [hapticsVolumeSyncCommitPending, setHapticsVolumeSyncCommitPending] = useState(false); const [speakerVolumeCommitPending, setSpeakerVolumeCommitPending] = useState(false); const [micVolumeCommitPending, setMicVolumeCommitPending] = useState(false); const [audioBufferLengthCommitPending, setAudioBufferLengthCommitPending] = useState(false); @@ -4028,6 +4029,8 @@ export function App() { const headsetOutputDetected = Boolean(audioStatus?.headsetPlugged); const controllerPowerSavingActive = controllerPowerSavingActiveFromSnapshot(snapshot); const feedbackBoostEnabled = Boolean(snapshot?.settings.feedbackBoostEnabled); + const hapticsVolumeSync = Boolean(snapshot?.settings.hapticsVolumeSync); + const audioReactiveHapticsVolumeSync = Boolean(snapshot?.settings.audioReactiveHapticsVolumeSync); const hapticsSliderMax = feedbackSliderMaxFromSnapshot(snapshot); const hapticsSliderTicks = feedbackSliderTicks(hapticsSliderMax); const percentSliderMax = controllerPowerSavingActive ? CONTROLLER_POWER_SAVING_CAP_PERCENT : 100; @@ -5716,6 +5719,30 @@ export function App() { })(); } + function toggleHapticsVolumeSync() { + if (!snapshot || hapticsVolumeSyncCommitPending) return; + + setHapticsVolumeSyncCommitPending(true); + void (async () => { + try { + const next = await window.bridge.setHapticsVolumeSync(!snapshot.settings.hapticsVolumeSync); + setSnapshot(next); + } catch { + const next = await window.bridge.getStatus(); + setSnapshot(next); + } finally { + setHapticsVolumeSyncCommitPending(false); + } + })(); + } + + function toggleAudioReactiveHapticsVolumeSync() { + if (!snapshot) return; + void commitAudioReactiveHapticsConfig({ + volumeSync: !snapshot.settings.audioReactiveHapticsVolumeSync + }); + } + function toggleSpeakerEnabled() { if (!snapshot) return; void runAction('speaker-enabled', () => window.bridge.setSpeakerEnabled(!snapshot.settings.speakerEnabled)); @@ -8017,6 +8044,23 @@ export function App() { /> +
+ Volume Sync + +
@@ -8186,6 +8230,23 @@ export function App() {
+
+ Volume Sync + +
{showClassicRumbleControl ? (
Date: Fri, 17 Jul 2026 23:44:50 +0400 Subject: [PATCH 34/34] Compute haptics compensation from per-channel sink volumes --- .../companion/native/audio-helper-linux.mjs | 85 +++++++++----- .../src/main/audio-helper-linux.test.ts | 111 ++++++++---------- .../companion/src/main/bridge-service.ts | 8 +- 3 files changed, 108 insertions(+), 96 deletions(-) diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index d65b821..ce395ef 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -130,20 +130,17 @@ function envelopeCoefficient(milliseconds) { return Math.exp(-1 / (SAMPLE_RATE * (milliseconds / 1000))); } -// wpctl reports volume on the cubic user scale; PipeWire applies vol^3 to -// samples. Compensate post-limiter so haptics strength is independent of -// the listening volume; cap so a near-zero volume cannot amplify noise. -export function volumeCompensation(cubicVolume) { - if (!Number.isFinite(cubicVolume) || cubicVolume <= 0) { - return 1; - } - const linear = cubicVolume ** 3; - return Math.min(32, Math.max(1 / 32, 1 / linear)); -} - -export function parseWpctlVolume(stdout) { - const match = /Volume:\s*([0-9.]+)/.exec(stdout ?? ''); - return match ? Number(match[1]) : null; +// The guard pins the haptic channels while the ear channels track the +// knob, so compensation must be computed from the sink's real per-channel +// volumes: desired scaling (ears when syncing, unity when not) over what +// the sink actually applies to the haptic pair. Correct in all four +// toggle states, guard running or not. +export function channelCompensation(earsLinear, hapticLinear, volumeSync) { + const desired = volumeSync + ? (Number.isFinite(earsLinear) && earsLinear > 0 ? earsLinear : 1) + : 1; + const actual = Number.isFinite(hapticLinear) && hapticLinear > 0 ? hapticLinear : 1; + return Math.min(32, Math.max(1 / 32, desired / actual)); } export class HapticsProcessor { @@ -151,9 +148,10 @@ export class HapticsProcessor { this.envelope = 0; this.layout = { stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }; this.outputCompensation = 1; - // Volume Sync (default ON): haptics follow the listening volume, so the - // sink-volume compensation is neutralized to unity. OFF holds haptics - // strength independent of the listening volume via outputCompensation. + // Volume Sync (default ON): haptics follow the listening volume. This is + // an input to the per-channel compensation math (see channelCompensation), + // which the poll reads via processor.volumeSync; process() always applies + // the resulting outputCompensation. this.volumeSync = true; this.setConfig(config); } @@ -201,7 +199,10 @@ export class HapticsProcessor { // noise floor so silence does not buzz the actuators. const gate = this.envelope < 0.003 ? 0 : 1; const drive = this.gain * this.responseGain * 4 * gate; - const comp = this.volumeSync ? 1 : this.outputCompensation; + // Compensation is always applied: the poll computes ears/haptic from + // the sink's real per-channel volumes (unity when no guard runs and + // sync ON, the ear volume when the guard pins the haptic pair). + const comp = this.outputCompensation; output[frame * 4 + 2] = Math.max(-1, Math.min(1, Math.tanh(left * drive) * comp)); output[frame * 4 + 3] = Math.max(-1, Math.min(1, Math.tanh(right * drive) * comp)); } @@ -226,9 +227,6 @@ async function runRenderLoopbackHaptics(args) { const target = nodeProps(sink)['node.name']; const config = readHapticsConfig(args); const processor = new HapticsProcessor(config); - // Volume polling keeps running either way (cheap); this just decides - // whether the polled compensation is applied. Toggling OFF later via - // stdin then takes effect on the next processed block. processor.setVolumeSync(config.volumeSync); const appSource = { @@ -252,19 +250,40 @@ async function runRenderLoopbackHaptics(args) { let stopping = false; let announcedRecording = false; - // Playback always goes to the bridge sink, whose cubic volume PipeWire - // applies to the haptic samples. Poll it and compensate so haptics - // strength stays independent of the user's listening volume. - const refreshVolumeCompensation = () => { - execFile('wpctl', ['get-volume', `${sink.id}`], (error, stdout) => { - if (error) { - return; // keep last compensation + // Playback always goes to the bridge sink. Its per-channel volumes + // (ears = channel 0, haptic = channel 2) drive the compensation: the + // volume guard may pin the haptic pair at unity while the ear channels + // track the knob, so a single scalar is not enough. Poll pw-dump, cache + // the channel volumes, and recompute so a live sync toggle applies at + // once instead of waiting for the next poll. + let lastChannelVolumes = null; + let lastCompError = null; + const applyCompensation = () => { + if (lastChannelVolumes) { + processor.setOutputCompensation( + channelCompensation(lastChannelVolumes[0], lastChannelVolumes[2], processor.volumeSync) + ); + } + }; + const refreshVolumeCompensation = async () => { + try { + const objects = await pwDump(); + const found = objects.find((object) => object.id === sink.id) + ?? objects.filter(isBridgeSink).find((s) => (nodeProps(s)['node.name'] ?? '').startsWith('alsa_output')) + ?? objects.filter(isBridgeSink)[0] + ?? null; + const cv = found?.info?.params?.Props?.[0]?.channelVolumes; + if (Array.isArray(cv) && cv.length >= 3) { + lastChannelVolumes = cv; + applyCompensation(); } - const volume = parseWpctlVolume(stdout); - if (volume !== null) { - processor.setOutputCompensation(volumeCompensation(volume)); + // channelVolumes unavailable: keep the last compensation. + } catch (error) { + if (error.message !== lastCompError) { + lastCompError = error.message; + process.stderr.write(`volume compensation poll failed: ${error.message}\n`); } - }); + } }; refreshVolumeCompensation(); volumeTimer = setInterval(refreshVolumeCompensation, 2000); @@ -385,6 +404,8 @@ async function runRenderLoopbackHaptics(args) { }); // 7th field is optional so 6-field lines keep working: absent → sync ON. processor.setVolumeSync(parts[6] === undefined ? true : parts[6] === '1'); + // Recompute from the cached channel volumes so the toggle applies now. + applyCompensation(); } else if (parts[0] === 'stop') { shutdown(0); } diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts index c6b1ff3..69a6a77 100644 --- a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -117,46 +117,47 @@ describe('HapticsProcessor layouts', () => { }); }); -import { appCaptureRecordArgs, matchAppStreamNode, parseWpctlVolume, readHapticsConfig, volumeCompensation } from '../../native/audio-helper-linux.mjs'; +import { appCaptureRecordArgs, channelCompensation, matchAppStreamNode, readHapticsConfig } from '../../native/audio-helper-linux.mjs'; -describe('volumeCompensation', () => { - it('is a no-op at unity volume', () => { - expect(volumeCompensation(1)).toBe(1); +describe('channelCompensation', () => { + it('is unity when ears and haptic match and sync is ON', () => { + expect(channelCompensation(1, 1, true)).toBe(1); }); - it('compensates for the cubic sink volume at a typical listening level', () => { - expect(volumeCompensation(0.55)).toBeCloseTo(1 / 0.55 ** 3, 2); + it('follows the knob when the guard pins the haptic pair and sync is ON', () => { + // ears track the volume (0.166), haptic pinned at unity by the guard. + expect(channelCompensation(0.166, 1, true)).toBeCloseTo(0.166, 6); }); - it('caps the boost at 32 for near-silent sinks', () => { - expect(volumeCompensation(0.1)).toBe(32); + it('is unity with no guard when sync is ON (ears == haptic)', () => { + expect(channelCompensation(0.166, 0.166, true)).toBeCloseTo(1, 6); }); - it('returns 1 for zero, negative, or non-finite inputs', () => { - expect(volumeCompensation(0)).toBe(1); - expect(volumeCompensation(-0.5)).toBe(1); - expect(volumeCompensation(NaN)).toBe(1); + it('boosts to 1/haptic with no guard when sync is OFF', () => { + // desired unity over haptic 0.166 -> ~6.02, holding strength constant. + expect(channelCompensation(0.166, 0.166, false)).toBeCloseTo(1 / 0.166, 4); }); - it('compensates downward when volume exceeds unity, with a 1/32 floor', () => { - expect(volumeCompensation(1.26)).toBeCloseTo(1 / 1.26 ** 3, 2); - expect(volumeCompensation(1.26)).toBeGreaterThanOrEqual(1 / 32); - }); -}); - -describe('parseWpctlVolume', () => { - it('parses a plain volume line', () => { - expect(parseWpctlVolume('Volume: 0.55\n')).toBe(0.55); + it('does not double-boost when the guard pins the pair and sync is OFF', () => { + // haptic pinned at unity, desired unity -> no compensation. + expect(channelCompensation(0.166, 1, false)).toBe(1); }); - it('parses a muted volume line', () => { - expect(parseWpctlVolume('Volume: 1.00 [MUTED]\n')).toBe(1.00); + it('treats garbage, zero, or non-finite inputs as unity', () => { + expect(channelCompensation(0, 1, true)).toBe(1); + expect(channelCompensation(-0.5, 1, true)).toBe(1); + expect(channelCompensation(NaN, 1, true)).toBe(1); + // A garbage haptic denominator falls back to actual = 1. + expect(channelCompensation(0.5, 0, true)).toBeCloseTo(0.5, 6); + expect(channelCompensation(0.5, NaN, true)).toBeCloseTo(0.5, 6); + expect(channelCompensation(NaN, NaN, false)).toBe(1); }); - it('returns null for garbage, empty, or missing input', () => { - expect(parseWpctlVolume('nonsense')).toBeNull(); - expect(parseWpctlVolume('')).toBeNull(); - expect(parseWpctlVolume(undefined)).toBeNull(); + it('clamps at 32 and 1/32', () => { + expect(channelCompensation(1, 0.001, true)).toBe(32); + expect(channelCompensation(1, 0.001, false)).toBe(32); + expect(channelCompensation(0.1, 1, true)).toBeCloseTo(0.1, 6); + expect(channelCompensation(1, 100, false)).toBe(1 / 32); }); }); @@ -176,7 +177,6 @@ describe('HapticsProcessor output compensation', () => { const input = loudInput(frames); const base = makeProcessor().process(input); const boosted = makeProcessor(); - boosted.setVolumeSync(false); boosted.setOutputCompensation(2); const out = boosted.process(input); for (let i = 0; i < out.length; i += 1) { @@ -193,7 +193,6 @@ describe('HapticsProcessor output compensation', () => { const frames = 256; const input = loudInput(frames); const clipped = makeProcessor(); - clipped.setVolumeSync(false); clipped.setOutputCompensation(8); const out = clipped.process(input); const peak = Math.max(...Array.from(out).map((s) => Math.abs(s))); @@ -224,7 +223,7 @@ describe('readHapticsConfig volume sync flag', () => { }); }); -describe('HapticsProcessor volume sync gating', () => { +describe('HapticsProcessor always-applied compensation', () => { function loudInput(frames: number) { const input = new Float32Array(frames * 4); for (let f = 0; f < frames; f += 1) { @@ -235,36 +234,39 @@ describe('HapticsProcessor volume sync gating', () => { return input; } - it('applies compensation 1 when volume sync is ON regardless of polled compensation', () => { + it('always applies the output compensation regardless of the volumeSync flag', () => { + // With per-channel compensation the poll folds volumeSync into the + // computed scaling, so process() must apply it in every toggle state. + const frames = 256; + const input = loudInput(frames); + const syncOn = makeProcessor(); + syncOn.setVolumeSync(true); + syncOn.setOutputCompensation(6); + const syncOff = makeProcessor(); + syncOff.setVolumeSync(false); + syncOff.setOutputCompensation(6); + // Both apply comp 6 identically; the flag no longer gates process(). + expect(Array.from(syncOn.process(input))).toEqual(Array.from(syncOff.process(input))); + }); + + it('is byte-identical to a fresh processor when compensation is unity (sync ON, no guard)', () => { const frames = 256; const input = loudInput(frames); const synced = makeProcessor(); - synced.setOutputCompensation(6); synced.setVolumeSync(true); - const reference = makeProcessor(); - reference.setOutputCompensation(1); - expect(Array.from(synced.process(input))).toEqual(Array.from(reference.process(input))); + synced.setOutputCompensation(channelCompensation(0.166, 0.166, true)); // ~1 + expect(Array.from(synced.process(input))).toEqual(Array.from(makeProcessor().process(input))); }); - it('restores the compensated (clamped) output when volume sync is OFF', () => { + it('applies the compensated (clamped) output for a nonzero compensation', () => { const frames = 256; const input = loudInput(frames); - const off = makeProcessor(); - off.setVolumeSync(false); - off.setOutputCompensation(6); - const compensated = makeProcessor(); - compensated.setVolumeSync(false); - compensated.setOutputCompensation(6); - expect(Array.from(off.process(input))).toEqual(Array.from(compensated.process(input))); - const base = makeProcessor(); - base.setVolumeSync(false); base.setOutputCompensation(1); const baseOut = base.process(input); - const offOut = makeProcessor(); - offOut.setVolumeSync(false); - offOut.setOutputCompensation(6); - const out = offOut.process(input); + const boosted = makeProcessor(); + boosted.setOutputCompensation(6); + const out = boosted.process(input); let sawNonzero = false; for (let i = 0; i < out.length; i += 1) { if (baseOut[i] === 0) { @@ -276,17 +278,6 @@ describe('HapticsProcessor volume sync gating', () => { } expect(sawNonzero).toBe(true); }); - - it('defaults volume sync ON so compensation has no effect on a fresh processor', () => { - const frames = 256; - const input = loudInput(frames); - const fresh = makeProcessor(); - fresh.setOutputCompensation(6); - const reference = makeProcessor(); - reference.setVolumeSync(false); - reference.setOutputCompensation(1); - expect(Array.from(fresh.process(input))).toEqual(Array.from(reference.process(input))); - }); }); describe('appCaptureRecordArgs', () => { diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 59a6300..0fed3a0 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -3580,10 +3580,10 @@ export class BridgeService extends EventEmitter { // Pins the bridge sink's haptic channels at unity while HD Volume Sync is // off (Linux only). Reconciled from the poll loop when controller audio is - // ready; a future settings-toggle setter should also call this for an - // immediate reconcile (as setDuplexMicEnabled does for mic keepalive). The - // guard is gated on controller readiness so the helper never hard-fails on - // a missing sink at boot and respawn-loops. + // ready, and immediately by setHapticsVolumeSync on toggle (as + // setDuplexMicEnabled does for mic keepalive). The guard is gated on + // controller readiness so the helper never hard-fails on a missing sink at + // boot and respawn-loops. private async updateVolumeGuardEngine(controllerAudioReady: boolean): Promise { try { const settings = this.settingsStore.get();