A maintained fork of webhid-ds4 and a high-level, ESM-first browser API for
Sony DualShock 4 controllers, built on the experimental
WebHID API. It provides
controller input, motion and touchpad data, battery information, lightbar
control, and rumble over USB and Bluetooth.
npm install @hcodes/webhid-ds4
import { DualShock4 } from '@hcodes/webhid-ds4'
Add a connect button and an element for displaying the controller state:
<button id="connectButton" type="button">Connect controller</button>
<pre id="controllerState"></pre>
Then request the controller from the button handler. connect() resolves to
false when the device picker is cancelled and rejects when access or opening
the selected device fails.
import { DualShock4 } from '@hcodes/webhid-ds4'
const connectButton = document.querySelector('#connectButton')
const stateOutput = document.querySelector('#controllerState')
if (!connectButton || !stateOutput) {
throw new Error('The controller UI is missing')
}
if (!navigator.hid || typeof navigator.hid.requestDevice !== 'function') {
connectButton.disabled = true
stateOutput.textContent = 'WebHID is not available in this browser or context.'
} else {
connectButton.addEventListener('click', async () => {
try {
const controller = new DualShock4()
if (!(await controller.connect())) return
function renderState () {
const { axes, buttons, batteryCapacity, batteryStatus } = controller.state
stateOutput.textContent = JSON.stringify({
leftStick: [axes.leftStickX, axes.leftStickY],
rightStick: [axes.rightStickX, axes.rightStickY],
crossPressed: buttons.cross,
batteryCapacity,
batteryStatus
}, null, 2)
requestAnimationFrame(renderState)
}
renderState()
} catch (error) {
console.error('Could not connect the DualShock 4 controller:', error)
}
})
}
After a successful connection, firmwareInfo contains metadata read from the
controller's feature report 0xA3:
controller.addEventListener('clonecheck', ({ detail }) => {
console.log('Authentication result:', detail.isClone) // false / true / null
})
if (await controller.connect()) {
console.log(controller.firmwareInfo)
// {
// buildDate: 'Aug 3 2013',
// buildTime: '07:01:12',
// hardwareVersion: 0xA000,
// hardwareVersionHex: '0xA000',
// boardModel: 'JDM-050',
// firmwareVersion: 0x0100,
// firmwareVersionHex: '0x0100'
// }
console.log(controller.isClone) // Usually null while authentication is pending.
}
The same firmware report is supported over USB and Bluetooth and times out after
one second. Authentication runs in the background, independently of the firmware
result, with a total 30-second deadline. connect() and the connect event do not
wait for authentication; firmware reading adds at most one second. Call
await controller.readFirmwareInfo() to refresh it. The method returns the
updated object, or null when a third-party controller does not implement the
report or returns malformed data. Reading firmware information therefore does
not prevent an otherwise compatible controller from connecting.
The authentication deadline is based on the approximately 30-second Bluetooth exchange captured in the protocol description. This is an application timeout, not a protocol-mandated minimum duration; a successful check completes as soon as the response is verified.
isClone uses the same cryptographic check on USB and Bluetooth: a fresh random
256-byte challenge is sent through feature report 0xF0, 0xF2 is polled for
readiness, and the response is read through 0xF1. Web Crypto verifies both the
controller's certificate against the pinned Sony Jedi CA and its RSA-PSS/SHA-256
signature of the challenge. false means both signatures verified; true
means a signature failed. null means unknown: before checking, after disconnect,
when authentication reports or Web Crypto are unavailable, or on communication,
packet-integrity or timeout errors. Handle null as "unknown" in your UI.
The check starts automatically during connect() and readFirmwareInfo();
neither method waits for it. isClone is reset to null while checking, then
updated before a clonecheck event with { device, isClone } is emitted.
The read-only isCloneChecking getter is true while authentication is running,
and false before it starts, after completion (including an unknown result),
or immediately after cancellation. It is already false inside clonecheck
listeners. Use it to distinguish a pending check from an unknown result:
function authenticityStatus () {
if (controller.isCloneChecking) return 'Checking…'
if (controller.isClone === null) return 'Unknown'
return controller.isClone ? 'Signature verification failed' : 'Signatures verified'
}
controller.addEventListener('clonecheck', () => console.log(authenticityStatus()))
await controller.connect()
console.log(authenticityStatus())
Subscribe before connecting or refreshing so fast results are not missed. Concurrent refreshes share an authentication exchange. Manual disconnect, device loss and device replacement cancel it; stale completions emit no event and cannot update the current controller. An invalid signature does not prevent connecting or using the controller. This verifies possession of Sony-certified keys: a replica using copied genuine keys can pass. Key revocation checking is not implemented.
Hardware and firmware versions are raw 16-bit values supplied by the controller. The hexadecimal properties preserve the four-digit notation used by low-level controller tools and drivers. They are deliberately not converted to semantic versions: Sony does not publish a DualShock 4 controller-firmware release catalog that establishes such a mapping.
The state object is updated when the library receives a supported controller
input report. Its main properties are:
| Property | Description |
|---|---|
interface |
none, usb, or bt; detected after the first supported input report |
batteryCapacity |
Estimated capacity from 0 to 100, or null when unavailable |
batteryStatus |
discharging, charging, full, error, or unknown |
cableConnected |
Whether the controller reports a cable connected, independently of USB/Bluetooth transport |
externalDeviceConnected |
Whether the controller reports a device connected to its EXT accessory port |
headphonesConnected |
Whether the controller reports headphones connected to its 3.5 mm jack |
microphoneConnected |
Whether the controller reports a microphone connected to its 3.5 mm jack |
axes |
Normalized sticks and triggers plus raw motion sensor values |
buttons |
Face, shoulder, D-pad, stick, PS, and touchpad buttons |
touchpad.touches |
Current touch contacts and their coordinates |
touchpad.frames |
All touch frames from the most recent full input report, in reported order |
touchpad.frameCounter |
Raw counter of that report's last touch frame, 0-255, or null when no frames are available |
sensorTimestamp |
Raw controller sensor timestamp, 0-65535, or null before a full input report |
reportCounter |
Raw input report counter, 0-63, or null before the first input report |
timestamp |
Browser event timestamp of the most recent input report, in milliseconds |
cableConnected, externalDeviceConnected, headphonesConnected and
microphoneConnected are booleans
read from full USB and Bluetooth HID input reports. All default to false and
reset to false when the controller disconnects. Basic Bluetooth reports contain
no cable or accessory status and preserve the last values. The headphone and microphone
flags describe the controller's jack detection; browser audio routing, permissions
and capture are checked separately through controller.audio.headphones and
controller.audio.microphone.
externalDeviceConnected exposes the raw EXT flag from the controller's
input status byte.
It does not identify the accessory or indicate an audio device connected to the OS.
sensorTimestamp preserves the controller's raw 16-bit timer and wraps from
65535 to 0; it is not converted to milliseconds. reportCounter preserves the
6-bit counter and wraps from 63 to 0. Both reset to null on disconnect.
Basic Bluetooth reports update reportCounter but preserve the last
sensorTimestamp, since they contain no sensor data.
Each touchpad.frames entry has { frameCounter, touches }, including frames
with no active contacts. USB reports contain up to three frames and Bluetooth
reports up to four, as described in the Linux DualShock 4 report structs.
The array is replaced by each full report; it is not accumulated gesture history.
Counters are raw 8-bit values and wrap from 255 to 0; frames are not reordered or
deduplicated. touchpad.touches remains the active contacts from the last frame,
with an independent copy so changes to it do not modify stored frames.
When a full report has no frames, both arrays are empty and frameCounter is
null. Basic Bluetooth reports preserve these fields; disconnect resets them.
for (const { frameCounter, touches } of controller.state.touchpad.frames) {
console.log(frameCounter, touches)
}
The asynchronous lightbar and rumble methods can be called immediately after
connect() succeeds. Until the first supported input report identifies USB or
Bluetooth, output is deferred. Multiple early updates are combined, and their
promises resolve after the latest lightbar and rumble state is sent using the
correct report format:
await controller.lightbar.setColorRGB(170, 255, 0)
// Alternatively, use HSL values in the 0-1 range.
await controller.lightbar.setColorHSL(0.22, 1, 0.5)
await controller.rumble.setRumbleIntensity(64, 192)
Hardware lightbar blinking runs in the controller without JavaScript timers:
await controller.lightbar.setBlink(500, 250) // 500 ms bright, 250 ms dark.
await controller.lightbar.setBlink(300) // Equal bright/dark intervals.
console.log(controller.lightbar.blinkOn, controller.lightbar.blinkOff) // 300, 300
await controller.lightbar.stopBlink() // Steady illumination; RGB is preserved.
Intervals are milliseconds, clamped to 0-2550 and rounded down to 10 ms units,
matching the Linux DualShock 4 blink implementation.
NaN and infinities are rejected without changing either interval. stopBlink()
sends both intervals as zero with the blink update flag set. These methods share
the ordered output queue with color, rumble and configured audio levels, including
deferred writes before USB/Bluetooth detection. They reject on send failure or
disconnect. No blink flag is sent until an explicit blink/stop request.
blinkOn/blinkOff are read-only cached requested intervals, initially null;
they do not read hardware state. Configured intervals remain in subsequent output
reports and across reconnection until changed; changing color preserves them.
Close the WebHID session when the controller is no longer needed. The method is safe to call more than once and does not revoke the browser's permission to use the device:
await controller.disconnect()
A successful disconnection stops rumble, clears the current controller state,
and rejects output still waiting for transport detection with an AbortError.
If the browser fails to close a device that remains open, the active session is
restored and disconnect() rejects so it can be retried. The same DualShock4
instance can be connected again later.
Each DualShock4 instance is an EventTarget with typed connect,
disconnect and clonecheck events. Subscribe before calling connect():
controller.addEventListener('connect', ({ detail }) => {
console.log('Connected:', detail.device.productName)
console.log('Firmware:', controller.firmwareInfo)
})
controller.addEventListener('disconnect', ({ detail }) => {
console.log('Disconnected:', detail.device.productName, detail.reason)
// reason is 'manual' or 'device-lost'; controller state is already cleared.
})
connect fires once after the session opens and firmware detection finishes,
including when firmware information is unavailable. It does not wait for the
first input report to identify USB or Bluetooth, or for background authentication.clonecheck reports background authentication completion with { device, isClone }.
The field is already updated when listeners run. Cancelled or stale checks
emit no completion event.disconnect fires once when an established session ends through disconnect()
(manual) or WebHID reports device loss (device-lost). The event retains the
previous HIDDevice in detail.device, while controller.device is cleared.connect() with AbortError, without
emitting either event.close() that leaves the device open does not emit disconnect.
Device loss aborts pending output with AbortError; late operations and input
reports from that session cannot update a new session.Use removeEventListener(), { once: true }, or { signal } to manage
subscriptions. Reconnection remains explicit via connect(); native WebHID
connect events do not automatically open a controller session.
Subscribe on the controller to detect hardware connected to its jack or EXT port:
| Accessory | Connected event | Disconnected event |
|---|---|---|
| Headphones | headphonesconnect |
headphonesdisconnect |
| Microphone | microphoneconnect |
microphonedisconnect |
| EXT device | externaldeviceconnect |
externaldevicedisconnect |
Each event has detail: { device, initial }, where device is the controller's
HIDDevice. The first valid full input report emits a connected event for each
accessory already attached, with initial: true. Absent accessories produce no
initial disconnected event. Subsequent flag changes emit the matching event
with initial: false; unchanged reports produce no duplicates. All controller
state fields are updated before listeners run.
const events = [
'headphonesconnect', 'headphonesdisconnect',
'microphoneconnect', 'microphonedisconnect',
'externaldeviceconnect', 'externaldevicedisconnect'
]
for (const name of events) {
controller.addEventListener(name, ({ detail }) => {
console.log(name, detail.device.productName, { initial: detail.initial })
})
}
await controller.connect()
Subscribe before connect() to observe initial detection; full input reports
can arrive while firmware initialization is still running. Late subscribers do
not receive past events and should read controller.state.headphonesConnected,
microphoneConnected and externalDeviceConnected for the current snapshot.
Before the first full report, these flags retain their default false values.
Manual disconnect, controller loss and replacement reset the flags without accessory disconnected events. Reconnection begins a fresh initial detection. Basic Bluetooth input reports have no accessory status; they preserve the flags and emit no accessory events. Corrupt or stale reports are ignored. These events describe the controller's hardware flags, independently of OS audio discovery, headphone playback or microphone capture.
controller.audio.speaker controls the built-in mono speaker's hardware volume.
controller.audio.headphones controls the left and right headphone volume bytes,
as well as the browser playback API described below. Volume control uses WebHID
and requires no browser audio output selection or microphone permission.
controller.audio.microphone.volume controls the headset microphone's hardware
input gain, independently of browser capture.
await controller.connect()
await controller.audio.speaker.setVolume(60)
await controller.audio.headphones.setVolume(67) // Same value for both channels.
await controller.audio.headphones.setVolume(50, 70) // Independent left/right levels.
await controller.audio.microphone.setVolume(64) // Headset microphone hardware gain.
console.log(controller.audio.speaker.volume) // 60
console.log(controller.audio.headphones.volumeLeft) // 50
console.log(controller.audio.headphones.volumeRight) // 70
console.log(controller.audio.microphone.volume) // 64
// Property setters send updates asynchronously, like lightbar/rumble setters.
controller.audio.speaker.volume = 40
controller.audio.headphones.volumeLeft = 55
controller.audio.headphones.volumeRight = 65
controller.audio.microphone.volume = 32
Values are raw HID bytes from 0 to 255, not percentages or a calibrated loudness
scale. Finite values are clamped to this range and rounded to the nearest integer;
NaN and infinities are rejected. Prefer await setVolume(...) to handle output
failures; property setters report asynchronous failures through console.error.
Updates requested after connect() but before transport detection are combined
with pending rumble/lightbar updates and sent once USB or Bluetooth is identified.
Volume getters return the last values requested by this library. The
documented HID reports do not provide a confirmed volume readback. Getters return
null before configuration and after endpoint reset or controller disconnect.
They do not detect changes made by the OS or another application and remain at
the requested values if sending fails. An unset channel is omitted from the HID
update flags, so configuring one channel does not mute another. Resetting the
cache does not mute hardware or restore its previous volume.
Reset or a new controller session rejects pending volume requests with
AbortError; unrelated pending rumble/lightbar updates are preserved.
Stopping microphone capture preserves its configured hardware gain; resetting
the microphone clears the gain cache as well as capture resources.
These controls are optional. Leave them unset when system audio settings provide the desired controls. Once configured, cached levels are included in subsequent HID output reports and may overwrite changes made by the OS or another app.
Volume bytes and individual channel flags follow the DS4 Bluetooth report layout and the DS4Windows volume flag mapping. Audio routing flags are not changed; microphone gain is updated only when explicitly configured. A successful HID send does not confirm audible playback or hardware acceptance. Built-in speaker audio streaming is not implemented, and volume writes do not enable an OS audio device over ordinary Bluetooth. The headphone playback requirements below still apply.
controller.audio.headphones controls stereo playback through the controller's
3.5 mm headphone jack. The DualShock4Headphones class is a separate endpoint
inside DualShock4Audio; controller.audio.speaker exposes mono-speaker volume,
while built-in speaker audio streaming is not implemented.
Headphone audio uses an OS audio output through Web Audio, independently of HID
rumble/lightbar reports. It plays audio supplied by this page; it does not capture
system audio or change the system default output. No audio permission is requested
by connect() or checkSupport().
After connecting the controller:
const headphones = controller.audio.headphones
const support = await headphones.checkSupport()
console.log(support.supported, support.reason, support.connection)
console.log(support.outputs, support.requiresAdapter)
| Field | Meaning |
|---|---|
supported |
true: a concrete output was explicitly selected, routed successfully and is still enumerated; false: the browser/session or standard hardware connection cannot provide this path; null: more information, permission or selection is needed |
reason |
Machine-readable diagnostic listed below |
outputs |
Label-matched candidates, plus the selected output; never proof of association with this HID controller |
connection |
usb, bluetooth, sony-adapter, or unknown before transport detection |
requiresAdapter |
Model-based recommendation: true for the standard v1 path and v2 over ordinary Bluetooth; false for v2 over USB, the Sony adapter or successful explicit routing; null for unknown hardware/transport. false does not prove that an audio output exists |
Common reasons are selection-required, permission-or-device-unavailable,
permission-denied, output-unavailable, v1-usb-audio-unavailable,
bluetooth-audio-unavailable, insecure-context, api-unavailable,
controller-disconnected, enumeration-failed, routing-failed, and ready.
Only supported === true enables playback. A permission failure does not prove
that the hardware lacks audio support. An empty or incomplete device list stays
unknown because the browser can hide devices and labels before permission.
The expected name is Wireless Controller, or DUALSHOCK®4 USB Wireless Adaptor
when using the Sony adapter. Headphones and microphones share the same
case-insensitive name check, including OS prefixes/suffixes and the adapter name
without ® (DUALSHOCK 4 USB Wireless Adaptor). DualSense and multiple DS4 controllers
can have the same name. Browser media devices do not expose USB VID/PID or a
reliable HID-to-audio mapping, so the user must choose the correct headphone
output. A custom-labelled output can also be selected explicitly, including one
provided by an external driver.
Use separate UI actions to grant access, select an output, and play sound:
// Within an access button's click handler:
const outputs = await headphones.requestOutput()
// Populate a chooser with outputs: display label, use deviceId as the value.
// Within the chooser's change handler, using the user's selected value:
await headphones.setOutput(selectedDeviceId)
console.log((await headphones.checkSupport()).supported) // true if routed
// Within a play button click handler, pass a live MediaStream:
// e.g. a WebRTC stream, or a Web Audio MediaStreamDestination.stream.
await headphones.play(stream)
headphones.stop()
Handle rejections from these async methods in the application's UI. The demo
includes complete controls, error handling, and its own live oscillator test
stream passed to play(). The bundled example.mp3 plays through a media element
connected to a MediaStreamDestination without reading the whole file into an
ArrayBuffer. Test-tone generation and media-source management belong to the demo.
The demo automatically routes a single matching controller output on connection,
after output access is granted, or when Check support is pressed. Multiple matches
require manual selection, and an existing selection is preserved.
The demo lists only controller-matched outputs. If none are visible, it hides the
selector and displays connection guidance.
requestOutput() must be called from a user action. If supported, it opens
selectAudioOutput() and returns the chosen concrete output as an array.
Otherwise it requests getUserMedia({ audio: true }), stops every capture track
immediately, and returns all visible concrete audio outputs for manual choice.
It does not record or retain microphone audio. A native picker refusal does not
trigger a second microphone permission prompt.enumerateDevices() itself does not require a click, but its list is subject
to permissions. The native picker grants the selected device; the microphone
fallback exposes the broader device list. OS-disabled or browser-blocked
devices may still be absent.setOutput(deviceId) verifies routing with AudioContext.setSinkId() without
playing sound. Empty, default, and communications IDs are rejected to avoid
following a changing system default. A failed routing/selection attempt clears
the old route. HID functionality remains available.play(MediaStream) connects a live stream to the selected headphone output
using createMediaStreamSource(), replacing the previous source. It resolves
when the stream is connected, not when it ends. Supply exactly one live audio
track, which may carry mono or stereo audio. Mix multiple tracks upstream using
Web Audio if needed. The library never accumulates or decodes an entire file.
Call from a click to satisfy autoplay restrictions. A producer AudioContext may
also need to be resumed by the application.stop() disconnects the source and cancels pending resume, preserving the
route. The caller owns the MediaStream and its tracks: stop(), reset(),
output changes and controller disconnection never call track.stop(). Stop
upstream playback/capture separately when the application no longer needs it. reset() disconnects playback, clears routing and releases device listeners;
the endpoint can be used again after an explicit selection.Listen for endpoint changes and recheck support rather than caching it forever:
headphones.addEventListener('change', async () => {
const support = await headphones.checkSupport()
// Refresh status and disable playback unless support.supported === true.
})
change also fires after routing/permission changes and reset. A successful
check proves that the browser accepted the route, not that headphones are
inserted, unmuted or audible. Use the demo stereo test for physical confirmation.
| Controller / connection | Standard headphone audio path |
|---|---|
DS4 v1, CUH-ZCT1, USB cable (054C:05C4) |
No USB Audio Class interface; use the Sony wireless adapter |
DS4 v2, CUH-ZCT2, USB cable (054C:09CC) |
USB headphone audio, provided the OS exposes and enables the output |
| DS4 v1, ordinary Bluetooth | Standard PC connection does not expose headphone audio; the library recommends the Sony adapter |
DS4 v2, CUH-ZCT2, Bluetooth (054C:09CC) |
No headphone audio over standard Bluetooth; use the Sony USB wireless receiver or a USB cable |
Sony DUALSHOCK®4 USB Wireless Adaptor, CUH-ZWA1 (054C:0BA0) |
Wireless headphone audio through the adapter's OS audio output |
| Third-party controllers, bridges and virtual devices | Unknown until the user selects and verifies an actual output |
The required “stick” for the standard v1 PC path is the Sony DUALSHOCK®4 USB Wireless Adaptor CUH-ZWA1, not an ordinary Bluetooth dongle. Its HID ID is already recognized by this library. Adapter recognition alone does not prove that audio is enabled or that the headphones are attached.
For v2, USB returns requiresAdapter: false. Ordinary Bluetooth returns
supported: false, reason: 'bluetooth-audio-unavailable' and
requiresAdapter: true, just as for v1. Wireless headphone audio needs the Sony
CUH-ZWA1 USB receiver; an ordinary Bluetooth dongle is not a substitute. A v2
controller can alternatively use a USB cable. The demo does not automatically
select a label-matched output when this unsupported transport is detected.
This is a limitation of the standard audio path, not a claim that Bluetooth audio is technically impossible. Native experimental streamers and custom drivers can implement a separate transport; this library does not implement that protocol. Explicit selection of a working OS output overrides the standard hardware hint.
Sources: Sony adapter announcement, Sony Bluetooth limitations, v1/v2 USB hardware probes, experimental DS4 Bluetooth audio. The device-discovery approach follows dualsense-ts/audio.ts; browser routing and permissions follow Chrome's Web Audio guidance and the Audio Output Devices specification.
The endpoint requires HTTPS/localhost and a browser with AudioContext.setSinkId
(Chrome 110+ introduced it; feature detection is performed at runtime). Embedded
pages may also need speaker-selection and microphone Permissions Policy
allowances from their parent. To verify the hardware, use the demo's left/right
test and confirm that each tone is audible in the corresponding headphone.
controller.audio.microphone captures the microphone of a headset plugged into
the controller's 3.5 mm jack. It uses the OS audio input through
getUserMedia(), independently of WebHID and headphone playback. No microphone
permission is requested by controller connection or passive support checks.
Use separate UI actions to grant access, select an input and start capture:
const microphone = controller.audio.microphone
// Passive check: no permission prompt or capture.
const support = await microphone.checkSupport()
console.log(support.supported, support.reason, support.inputs)
// In an access button click handler; populate a chooser with the returned inputs.
const inputs = await microphone.requestInput()
// In the chooser's change handler, using the user's selected concrete deviceId.
await microphone.setInput(selectedDeviceId)
// In a start button click handler. Handle errors from every async method in your UI.
const stream = await microphone.start()
// Use stream with WebRTC, Web Audio, or MediaRecorder as needed.
// Stops the capture tracks and cancels pending work; keeps the selected input.
microphone.stop()
// Also clears selection and releases device listeners.
microphone.reset()
Look for Wireless Controller or DUALSHOCK®4 USB Wireless Adaptor in the input
labels (case-insensitive, including OS labels such as Microphone (Wireless Controller)
or Microphone (DUALSHOCK®4 USB Wireless Adaptor)). The demo lists only these
microphone inputs and hides the selector when none are visible.
Labels are only discovery hints: the browser cannot reliably associate a media
input with a specific HID controller. Select and test the correct input,
especially with multiple controllers. requestInput() returns all concrete
audio inputs, including custom-labelled devices; checkSupport().inputs lists
controller label matches plus the selected input. Empty, default and
communications IDs are rejected. start() uses an exact deviceId constraint
and verifies the returned track's device ID, so it cannot silently fall back to
the laptop microphone.
requestInput() briefly captures the browser's default microphone to reveal
device names and immediately stops every temporary track, including late
permission responses after cancellation. It never selects an input or retains
the temporary stream. It stops any previous endpoint capture.setInput() only selects a visible input; it does not request permission or
capture. Changing selection stops previous capture. A failed selection clears
the old input. Custom inputs can be selected explicitly to use alternative drivers.start() returns a live MediaStream with one audio track, replacing previous
capture. The library does not record, upload or play it. microphone.stream
exposes the active stream, or null after cleanup. Browser audio processing
defaults apply; applications can use the track's applyConstraints() if needed.stop(), reset(),
changing inputs and controller disconnection call track.stop(), even if that
stream is being consumed elsewhere. Stop application-created clones separately.
Removal of the selected audio input or an ended event also clears capture.
Calling stop() cancels pending selection, permission and capture requests;
the browser permission dialog itself may remain open, but a late stream is stopped.change after selection, capture, stop, reset, permission
results and audio device changes. Read microphone.stream and call
checkSupport() again to refresh the UI. If an application directly calls
track.stop(), which does not emit ended, a subsequent support check detects
the stopped track; prefer microphone.stop() for immediate notification.Support uses the same connection and requiresAdapter hardware hints as
headphones. supported: true means the selected input has live capture and is
still enumerated; it does not prove that the headset is inserted, unmuted or
producing audible sound. A selected but stopped microphone returns
supported: null, reason: 'capture-required'. Other reasons include
selection-required, permission-or-device-unavailable, permission-denied,
input-unavailable, enumeration-failed, capture-failed, insecure-context,
api-unavailable, controller-disconnected, v1-usb-audio-unavailable and
bluetooth-audio-unavailable. Permission failures remain unknown rather than
claiming unsupported hardware.
The standard PC connection requires the OS to expose a headset audio input: use USB with DS4 v2, or the Sony DUALSHOCK®4 USB Wireless Adaptor (CUH-ZWA1) with v1/v2. Ordinary Bluetooth does not expose the headset audio jack; DS4 v1 does not provide the USB Audio Class path. A successfully captured explicit input overrides these hardware hints, as with headphone routing. See the hardware sources above and the Media Capture and Streams specification for device selection and capture behavior.
Microphone capture requires HTTPS/localhost, getUserMedia() and
enumerateDevices(); it does not require AudioContext.setSinkId(). Embedded
pages may need a microphone Permissions Policy allowance. The demo additionally
uses Web Audio for its level meter: allow access, select the controller input,
press Start microphone, speak into the headset, then press Stop microphone.
The meter does not play your voice back or save a recording.
The device picker currently recognizes these vendor and product IDs:
| Vendor | Product ID | Device / model |
|---|---|---|
Sony (0x054C) |
0x05C4 |
DUALSHOCK 4 (CUH-ZCT1) |
Sony (0x054C) |
0x09CC |
DUALSHOCK 4 v2 (CUH-ZCT2) |
Sony (0x054C) |
0x0BA0 |
DUALSHOCK®4 USB Wireless Adaptor (CUH-ZWA1) |
Sony VID (0x054C) |
0x05C5 |
Strike Pack FPS Dominator (no CUH model) |
Razer (0x1532) |
0x1000, 0x1007, 0x1004, 0x1009 |
Raiju family |
Nacon (0x146B) |
0x0D01, 0x0D02, 0x0D08 |
Revolution family |
| Other third-party devices | 0x0F0D:0x00EE, 0x7545:0x0104, 0x2E95:0x7725, 0x11C0:0x4001, 0x0C12:0x57AB, 0x0C12:0x0E16, 0x0F0D:0x0084 |
Compatibility IDs |
An ID in this list means that the browser picker allows the device to be selected; it does not guarantee full report compatibility. The upstream project was hardware-tested with a CUH-ZCT2U. Other revisions and third-party controllers may behave differently, so hardware verification reports are welcome.
DualShock4 instance always opens the device picker. Previously
granted devices can be discovered directly with
navigator.hid.getDevices().firmwareInfo can identify the raw version and known board model reported by
a controller, but it cannot determine whether that version is a latest Sony
release. isClone verifies Sony-certified keys; it cannot identify replicas
using copied genuine keys or check whether a key has been revoked.npm ci
npm run lint
npm test
npm run build
npm run build-docs
npm run build creates the ESM bundle and TypeScript declarations in dist.npm run lint checks TypeScript, JavaScript, and Vue files with ESLint.
Use npm run lint:fix to apply automatic fixes.npm run build-docs creates the demo and API reference in dist-pages.Originally created by TheBITLINK as
webhid-ds4. This fork is
maintained by hcodes.