From d3de27c1e791266ff12a23543709b01a1ac5069a Mon Sep 17 00:00:00 2001 From: lordvicky Date: Thu, 16 Jul 2026 21:25:55 +0400 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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" } } }