audio: play pad speaker ports on the DualSense speaker (#868)

* audio: play pad speaker ports on the DualSense speaker

Route the right channel to the speaker with the audio-control values the Linux hid-playstation
driver uses without headphones, and fall back to the main output while no DualSense audio device
is active. Also stop the previous DualSense's rumble when another DualSense becomes active.

* audio: harden DualSense speaker routing and fallback

---------

Co-authored-by: nmzik <Nmzik@mail.ru>
This commit is contained in:
Devran Cosmo Uenal 2026-09-28 18:00:58 +02:00 committed by GitHub
parent a107d1e333
commit 22ff4693c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 500 additions and 106 deletions

View File

@ -113,14 +113,14 @@ jobs:
shell: cmd
run: |
cmake --build _Build/windows --target launcher ^
audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
resource_tracking_tests scalar_provenance_tests ^
resource_materialization_tests shader_vertex_metadata_tests --parallel
- name: Test
shell: cmd
run: |
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$"
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$"
- name: Install
shell: cmd
@ -201,7 +201,7 @@ jobs:
shell: bash
run: |
cmake --build _Build/macos \
--target launcher audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
--target launcher audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests \
--parallel
@ -210,7 +210,7 @@ jobs:
shell: bash
run: |
ctest --test-dir _Build/macos --output-on-failure \
-R '^(audio_out2_port|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$'
- name: Install
shell: bash
@ -384,7 +384,7 @@ jobs:
run: |
cmake --build _Build/linux \
--target launcher page_manager_tests memory_tracker_tests \
audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests \
--parallel
@ -393,7 +393,7 @@ jobs:
shell: bash
run: |
ctest --test-dir _Build/linux --output-on-failure \
-R '^(audio_out2_port|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$'
- name: Install
shell: bash

View File

@ -32,6 +32,7 @@ constexpr int AUDIO_OUT_PORT_TYPE_AUDIO3D = 126;
constexpr int AUDIO_OUT_PORT_TYPE_AUX = 127;
constexpr uint32_t AUDIO_OUT_PARAM_FORMAT_MASK = 0x000000ffu;
constexpr uint64_t AUDIO_OUT_TARGET_LATENCY_US = 40000;
constexpr int AUDIO_IN_SILENT_STATE_DEVICE_NONE = 0x1;
constexpr uint32_t AUDIO_IN_GRAIN_MAX_ASYNC = 384;
@ -368,11 +369,11 @@ bool Audio::QueueSdlAudio(PortOut* port, const void* data, bool blocking) {
uint32_t min_queued_size = 0;
if (blocking) {
constexpr uint64_t target_latency_us = 40000;
const auto buffer_us = port->freq != 0 ? (1000000ULL * port->samples_num) / port->freq : 0;
const auto buffers =
buffer_us != 0 ? static_cast<uint32_t>((target_latency_us + buffer_us - 1) / buffer_us)
: 2u;
buffer_us != 0
? static_cast<uint32_t>((AUDIO_OUT_TARGET_LATENCY_US + buffer_us - 1) / buffer_us)
: 2u;
min_queued_size = prepared_size * std::clamp(buffers, 2u, 16u);
const auto wait_start = LibKernel::KernelGetProcessTime();
auto queued = SDL_GetAudioStreamQueued(port->stream);
@ -440,11 +441,14 @@ Audio::Id Audio::AudioOutOpen(int type, uint32_t samples_num, uint32_t freq, For
port.volume[i] = 32768;
}
if (type == AUDIO_OUT_PORT_TYPE_VIBRATION) {
port.haptics = Controller::DualSenseHaptics::Open(freq);
} else {
// Pad speaker ports keep the main output too; it plays them whenever no DualSense does.
if (type != AUDIO_OUT_PORT_TYPE_VIBRATION) {
OpenSdlDevice(&port);
}
if (type == AUDIO_OUT_PORT_TYPE_VIBRATION || type == AUDIO_OUT_PORT_TYPE_PADSPK) {
port.haptics =
Controller::DualSenseHaptics::Open(freq, type == AUDIO_OUT_PORT_TYPE_PADSPK);
}
return Id::Create(id);
}
@ -554,14 +558,22 @@ uint32_t Audio::AudioOutOutputs(OutputParam* params, uint32_t num, bool blocking
for (uint32_t i = 0; i < num; i++) {
auto& port = m_out_ports[params[i].handle.GetId()];
if (port.type == AUDIO_OUT_PORT_TYPE_VIBRATION) {
// Haptics never pace output; keep the stream alive against close and volume changes.
uint64_t controller_queued_us = 0;
if (port.haptics != nullptr) {
// Keep the stream alive against close and volume changes.
Common::LockGuard lock(m_mutex);
Controller::DualSenseHaptics::Queue(
controller_queued_us = Controller::DualSenseHaptics::Queue(
port.haptics, Controller::GetActiveControllerId(), params[i].data, port.samples_num,
static_cast<uint32_t>(port.channels_num), FormatIsFloat(port.format), port.volume);
} else {
}
if (controller_queued_us == 0) {
// No DualSense took it (e.g. it was unplugged); a pad speaker port plays on the main
// output instead, and a vibration port has none.
QueueSdlAudio(&port, params[i].data, blocking);
} else if (blocking && port.type == AUDIO_OUT_PORT_TYPE_PADSPK &&
controller_queued_us > AUDIO_OUT_TARGET_LATENCY_US) {
// Vibration never paces output; the speaker is audible, so pace it like other ports.
Common::Thread::SleepMicro(controller_queued_us - AUDIO_OUT_TARGET_LATENCY_US);
}
}

View File

@ -5,13 +5,17 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <map>
#include <vector>
namespace Libs::Controller::DualSenseHaptics {
struct Stream {
uint32_t freq = 0;
bool speaker = false;
int controller = -1;
SDL_AudioStream* sdl = nullptr;
SDL_AudioDeviceID device = 0;
uint64_t next_check = 0;
@ -20,19 +24,26 @@ struct Stream {
namespace {
constexpr int FRAME_BYTES = 4 * sizeof(float);
Common::Mutex g_mutex;
int g_controller = -1;
int g_playback_streams = 0;
uint8_t g_large_motor = 0, g_small_motor = 0;
uint64_t g_rumble_until = 0, g_haptics_until = 0;
constexpr int FRAME_BYTES = 4 * sizeof(float);
Common::Mutex g_mutex;
int g_controller = -1;
int g_haptics_streams = 0;
uint8_t g_large_motor = 0, g_small_motor = 0;
uint64_t g_rumble_until = 0, g_haptics_until = 0;
int g_speaker_controller = -1;
std::map<int, int> g_speaker_streams;
void ApplyRumble() { // Caller holds g_mutex; no calls into Audio or Controller.
void ApplyRumble(bool force = false) { // Caller holds g_mutex; no calls into Audio or Controller.
SDL_LockJoysticks();
if (auto* pad = SDL_GetGamepadFromID(static_cast<SDL_JoystickID>(g_controller));
pad != nullptr) {
const auto now = SDL_GetTicks();
const bool muted = g_haptics_until > now || g_rumble_until <= now;
if (force && !muted) {
// SDL caches motor strengths. Force a report after raw speaker routing changed the
// controller's motor mode without changing that cache.
(void)SDL_RumbleGamepad(pad, 0, 0, 0);
}
(void)SDL_RumbleGamepad(
pad, muted ? 0 : g_large_motor * 0x101U, muted ? 0 : g_small_motor * 0x101U,
g_rumble_until > now ? static_cast<uint32_t>(g_rumble_until - now) : 0);
@ -42,14 +53,52 @@ void ApplyRumble() { // Caller holds g_mutex; no calls into Audio or Controller.
void SelectController(int controller) {
if (g_controller != controller) {
g_haptics_until = 0;
g_haptics_until = g_rumble_until = 0;
ApplyRumble();
g_controller = controller;
g_large_motor = g_small_motor = 0;
g_rumble_until = 0;
}
}
bool RouteSpeaker(int controller, bool speaker) { // Caller holds g_mutex.
// Route the right channel to the speaker and mute the headphones with the values Linux uses
// without headphones (output report 0x02). Plugged-in headphones are not checked.
std::array<uint8_t, 38> report {};
report[0] = 0x80; // Audio control valid
if (speaker) {
report[0] |= 0x20; // Speaker volume valid
report[1] = 0x80; // Audio control 2 valid
report[5] = 0x64; // Speaker volume
report[7] = 0x30; // Output path: right channel to the speaker, headphones muted
report[37] = 0x02; // Speaker preamp +6 dB
}
bool routed = false;
SDL_LockJoysticks();
if (auto* pad = SDL_GetGamepadFromID(static_cast<SDL_JoystickID>(controller)); pad != nullptr) {
routed = SDL_SendGamepadEffect(pad, report.data(), static_cast<int>(report.size()));
}
SDL_UnlockJoysticks();
if (routed && controller == g_controller) {
ApplyRumble(true);
}
return routed;
}
bool SelectSpeaker(int controller) { // Caller holds g_mutex; -1 restores headphone routing.
if (g_speaker_controller == controller) {
return true;
}
if (g_speaker_controller != -1) {
RouteSpeaker(g_speaker_controller, false);
}
g_speaker_controller = -1;
if (controller != -1 && !RouteSpeaker(controller, true)) {
return false;
}
g_speaker_controller = controller;
return true;
}
void SDLCALL UpdateRumble(void*, SDL_AudioStream*, int, int) {
Common::LockGuard lock(g_mutex);
if (g_haptics_until != 0 && SDL_GetTicks() >= g_haptics_until) {
@ -64,22 +113,55 @@ void CloseDevice(Stream* stream) {
SDL_DestroyAudioStream(stream->sdl);
stream->sdl = nullptr;
Common::LockGuard lock(g_mutex);
if (--g_playback_streams == 0 && g_haptics_until != 0) {
if (!stream->speaker && --g_haptics_streams == 0 && g_haptics_until != 0) {
g_haptics_until = 0;
ApplyRumble();
}
// Route back to the headphones once no speaker port plays on the controller.
if (stream->speaker && --g_speaker_streams.at(stream->controller) == 0) {
g_speaker_streams.erase(stream->controller);
if (g_speaker_controller == stream->controller) {
SelectSpeaker(-1);
}
}
}
}
bool CanUseDevice(int controller) {
// SDL exposes no portable association between a gamepad and its USB audio endpoint.
// Require one wired DualSense; guessing with multiple pads could play on the wrong one.
SDL_LockJoysticks();
auto* pad = SDL_GetGamepadFromID(static_cast<SDL_JoystickID>(controller));
bool usable =
pad != nullptr && SDL_GetGamepadConnectionState(pad) == SDL_JOYSTICK_CONNECTION_WIRED;
int count = 0;
auto* pads = SDL_GetGamepads(&count);
int dualsense_count = 0;
bool found = false;
for (int i = 0; i < count; i++) {
if (SDL_GetGamepadTypeForID(pads[i]) == SDL_GAMEPAD_TYPE_PS5) {
dualsense_count++;
found |= pads[i] == static_cast<SDL_JoystickID>(controller);
}
}
SDL_free(pads);
SDL_UnlockJoysticks();
return usable && found && dualsense_count == 1;
}
SDL_AudioDeviceID FindDevice() {
int count = 0;
auto* devices = SDL_GetAudioPlaybackDevices(&count);
SDL_AudioDeviceID device = 0;
for (int i = 0; i < count && device == 0; i++) {
for (int i = 0; i < count; i++) {
const char* name = SDL_GetAudioDeviceName(devices[i]);
SDL_AudioSpec spec {};
if (name != nullptr && SDL_strcasestr(name, "DualSense") != nullptr &&
SDL_GetAudioDeviceFormat(devices[i], &spec, nullptr) && spec.channels == 4) {
if (device != 0) {
device = 0; // Multiple matching endpoints cannot be associated reliably either.
break;
}
device = devices[i];
}
}
@ -87,13 +169,59 @@ SDL_AudioDeviceID FindDevice() {
return device;
}
void RefreshDevice(Stream* stream, int controller) {
if (stream->controller != controller) {
CloseDevice(stream);
stream->controller = controller;
stream->next_check = 0;
}
const auto now = SDL_GetTicks();
if (now < stream->next_check) {
return;
}
stream->next_check = now + 2000;
const auto device = FindDevice();
if (stream->device == device && stream->sdl != nullptr) {
return;
}
CloseDevice(stream);
stream->device = device;
if (device == 0) {
return;
}
const SDL_AudioSpec desired {SDL_AUDIO_F32, 4, static_cast<int>(stream->freq)};
auto* sdl = SDL_OpenAudioDeviceStream(device, &desired, UpdateRumble, nullptr);
SDL_AudioSpec actual {};
bool ready = sdl != nullptr &&
SDL_GetAudioDeviceFormat(SDL_GetAudioStreamDevice(sdl), &actual, nullptr) &&
actual.channels == 4 && SDL_ResumeAudioStreamDevice(sdl);
if (ready) {
Common::LockGuard lock(g_mutex);
if (stream->speaker) {
ready = SelectSpeaker(controller);
if (ready) {
g_speaker_streams[controller]++;
}
} else {
g_haptics_streams++;
}
}
if (ready) {
stream->sdl = sdl;
LOGF("DualSenseHaptics: playing on '%s'\n", SDL_GetAudioDeviceName(device));
} else {
SDL_DestroyAudioStream(sdl);
LOGF("DualSenseHaptics: cannot open quad output: %s\n", SDL_GetError());
}
}
} // namespace
Stream* Open(uint32_t freq) {
Stream* Open(uint32_t freq, bool speaker) {
if (freq == 0 || !SDL_InitSubSystem(SDL_INIT_AUDIO)) {
return nullptr;
}
return new Stream {.freq = freq};
return new Stream {.freq = freq, .speaker = speaker};
}
void Close(Stream* stream) {
@ -104,44 +232,30 @@ void Close(Stream* stream) {
}
}
void Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels,
bool is_float, const int* volume) {
uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels,
bool is_float, const int* volume) {
if (stream == nullptr || data == nullptr || frames == 0 || channels == 0 || volume == nullptr) {
return;
return 0;
}
if (SDL_GetGamepadTypeForID(static_cast<SDL_JoystickID>(controller)) != SDL_GAMEPAD_TYPE_PS5) {
if (!CanUseDevice(controller)) {
CloseDevice(stream);
return;
}
const auto now = SDL_GetTicks();
if (now >= stream->next_check) {
stream->next_check = now + 2000;
const auto device = FindDevice();
if (stream->device != device || stream->sdl == nullptr) {
CloseDevice(stream);
stream->device = device;
if (device != 0) {
const SDL_AudioSpec desired {SDL_AUDIO_F32, 4, static_cast<int>(stream->freq)};
auto* sdl = SDL_OpenAudioDeviceStream(device, &desired, UpdateRumble, nullptr);
SDL_AudioSpec actual {};
if (sdl != nullptr &&
SDL_GetAudioDeviceFormat(SDL_GetAudioStreamDevice(sdl), &actual, nullptr) &&
actual.channels == 4 && SDL_ResumeAudioStreamDevice(sdl)) {
stream->sdl = sdl;
{
Common::LockGuard lock(g_mutex);
g_playback_streams++;
}
LOGF("DualSenseHaptics: playing on '%s'\n", SDL_GetAudioDeviceName(device));
} else {
SDL_DestroyAudioStream(sdl);
LOGF("DualSenseHaptics: cannot open quad output: %s\n", SDL_GetError());
}
}
}
stream->next_check = 0; // Look for the device as soon as a DualSense is active again.
return 0;
}
RefreshDevice(stream, controller);
if (stream->sdl == nullptr) {
return;
return 0;
}
if (stream->speaker) {
bool routed;
{
Common::LockGuard lock(g_mutex);
routed = SelectSpeaker(controller);
}
if (!routed) {
CloseDevice(stream);
return 0;
}
}
stream->buffer.assign(static_cast<size_t>(frames) * 4, 0.0f);
bool audible = false;
@ -152,7 +266,8 @@ void Queue(Stream* stream, int controller, const void* data, uint32_t frames, ui
float value = is_float ? static_cast<const float*>(data)[index]
: static_cast<const int16_t*>(data)[index] / 32768.0f;
value *= volume[src_ch] / 32768.0f;
stream->buffer[static_cast<size_t>(frame) * 4 + ch + 2] = value;
// The speaker plays the right front channel; the actuators play the back ones.
stream->buffer[static_cast<size_t>(frame) * 4 + ch + (stream->speaker ? 0 : 2)] = value;
audible |= std::fabs(value) > 1.0f / 1024;
}
}
@ -161,26 +276,29 @@ void Queue(Stream* stream, int controller, const void* data, uint32_t frames, ui
SDL_ClearAudioStream(stream->sdl);
queued = 0;
}
const int bytes = static_cast<int>(frames * FRAME_BYTES);
if (audible) {
const int bytes = static_cast<int>(frames * FRAME_BYTES);
const auto queued_us =
static_cast<uint64_t>(queued + bytes) * 1000000 / (stream->freq * FRAME_BYTES);
if (audible && !stream->speaker) {
Common::LockGuard lock(g_mutex);
if (SDL_GetGamepadTypeForID(static_cast<SDL_JoystickID>(controller)) !=
SDL_GAMEPAD_TYPE_PS5) {
return;
return 0;
}
SelectController(controller);
const auto playback_now = SDL_GetTicks();
const bool starting = g_haptics_until <= playback_now;
const auto duration = (queued + bytes) * 1000ULL / (stream->freq * FRAME_BYTES);
g_haptics_until =
std::max(g_haptics_until, playback_now + std::max<uint64_t>(duration, 250));
std::max(g_haptics_until, playback_now + std::max<uint64_t>(queued_us / 1000, 250));
if (starting) {
ApplyRumble();
}
}
if (!SDL_PutAudioStreamData(stream->sdl, stream->buffer.data(), bytes)) {
CloseDevice(stream);
return 0;
}
return queued_us;
}
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor) {
@ -198,8 +316,8 @@ bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor) {
void Shutdown() {
Common::LockGuard lock(g_mutex);
g_rumble_until = 0;
SelectController(-1);
SelectSpeaker(-1);
}
} // namespace Libs::Controller::DualSenseHaptics

View File

@ -7,12 +7,18 @@ namespace Libs::Controller::DualSenseHaptics {
struct Stream;
Stream* Open(uint32_t freq);
// A speaker stream plays a pad speaker port and routes the speaker to it; the others play vibration
// ports on the actuators.
Stream* Open(uint32_t freq, bool speaker);
void Close(Stream* stream);
void Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels,
bool is_float, const int* volume);
// Returns how long the queued audio still plays, in microseconds, or 0 when no DualSense took it
// (the active pad has no usable USB audio device or its speaker cannot be routed).
// Multiple DualSenses or matching audio endpoints are ambiguous and are not selected.
uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels,
bool is_float, const int* volume);
// Returns false for other gamepad types, which retain the normal rumble path.
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor);
// Also restores the headphone routing.
void Shutdown();
} // namespace Libs::Controller::DualSenseHaptics

View File

@ -24,15 +24,23 @@ struct Rumble {
Uint16 large, small;
Uint32 duration;
};
std::vector<Device> devices;
std::vector<SDL_AudioStream*> streams;
Rumble rumble {};
Uint64 now = 1000;
int audio_refs = 0, opens = 0, rumble_calls = 0;
bool fail_open = false, fail_resume = false;
int actual_channels = 4;
SDL_AudioDeviceID opened_device = 0;
SDL_AudioSpec opened_spec {};
std::vector<Device> devices;
std::vector<SDL_AudioStream*> streams;
Rumble rumble {};
Uint64 now = 1000;
int audio_refs = 0, opens = 0, rumble_calls = 0;
bool fail_open = false, fail_resume = false;
int actual_channels = 4;
SDL_AudioDeviceID opened_device = 0;
SDL_AudioSpec opened_spec {};
std::array<Rumble, 4> pad_rumble {};
std::array<std::vector<uint8_t>, 4> effects;
std::vector<SDL_JoystickID> connected_pads {1};
std::array<Rumble, 4> cached_rumble {};
bool fail_effect = false, wireless = false;
SDL_AudioStream* default_stream = nullptr;
bool fail_default_resume = false;
int active_controller = 1;
} // namespace
namespace Fake {
@ -95,10 +103,13 @@ SDL_AudioStream* OpenAudioDeviceStream(SDL_AudioDeviceID id, const SDL_AudioSpec
Check(stream != nullptr && SDL_SetAudioStreamGetCallback(stream, callback, userdata),
"real SDL stream/callback creation failed");
streams.push_back(stream);
if (id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) {
default_stream = stream;
}
return stream;
}
bool ResumeAudioStreamDevice(SDL_AudioStream*) {
return !fail_resume;
bool ResumeAudioStreamDevice(SDL_AudioStream* stream) {
return !fail_resume && !(stream == default_stream && fail_default_resume);
}
void DestroyAudioStream(SDL_AudioStream* stream) {
if (stream == nullptr) {
@ -107,38 +118,79 @@ void DestroyAudioStream(SDL_AudioStream* stream) {
const auto it = std::find(streams.begin(), streams.end(), stream);
Check(it != streams.end(), "stream destroyed twice");
streams.erase(it);
if (stream == default_stream) {
default_stream = nullptr;
}
SDL_DestroyAudioStream(stream);
}
SDL_AudioDeviceID GetAudioStreamDevice(SDL_AudioStream*) {
return 999;
}
// Pads 1 and 3 are DualSenses.
SDL_GamepadType GetGamepadTypeForID(SDL_JoystickID id) {
return id == 1 ? SDL_GAMEPAD_TYPE_PS5 : SDL_GAMEPAD_TYPE_UNKNOWN;
return id == 1 || id == 3 ? SDL_GAMEPAD_TYPE_PS5 : SDL_GAMEPAD_TYPE_UNKNOWN;
}
SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) {
return id == 1 ? reinterpret_cast<SDL_Gamepad*>(static_cast<uintptr_t>(id)) : nullptr;
return id == 1 || id == 3 ? reinterpret_cast<SDL_Gamepad*>(static_cast<uintptr_t>(id))
: nullptr;
}
bool RumbleGamepad(SDL_Gamepad*, Uint16 large, Uint16 small, Uint32 duration) {
rumble = {large, small, duration};
SDL_JoystickID* GetGamepads(int* count) {
*count = static_cast<int>(connected_pads.size());
auto* ids = static_cast<SDL_JoystickID*>(
SDL_malloc((connected_pads.size() + 1) * sizeof(SDL_JoystickID)));
std::copy(connected_pads.begin(), connected_pads.end(), ids);
ids[connected_pads.size()] = 0;
return ids;
}
SDL_JoystickConnectionState GetGamepadConnectionState(SDL_Gamepad*) {
return wireless ? SDL_JOYSTICK_CONNECTION_WIRELESS : SDL_JOYSTICK_CONNECTION_WIRED;
}
bool RumbleGamepad(SDL_Gamepad* pad, Uint16 large, Uint16 small, Uint32 duration) {
const auto id = reinterpret_cast<uintptr_t>(pad);
// SDL only sends a new report when the strengths change.
if (large != cached_rumble[id].large || small != cached_rumble[id].small) {
pad_rumble[id] = {large, small, duration};
}
cached_rumble[id] = {large, small, duration};
rumble = pad_rumble[id];
rumble.duration = duration;
rumble_calls++;
return true;
}
bool SendGamepadEffect(SDL_Gamepad* pad, const void* data, int size) {
if (fail_effect) {
return false;
}
effects[reinterpret_cast<uintptr_t>(pad)].assign(static_cast<const uint8_t*>(data),
static_cast<const uint8_t*>(data) + size);
// Raw audio-routing reports clear the emulated-rumble mode on the controller,
// without updating SDL's cached motor strengths.
if ((static_cast<const uint8_t*>(data)[0] & 3) == 0) {
pad_rumble[reinterpret_cast<uintptr_t>(pad)] = {};
rumble = {};
}
return true;
}
} // namespace Fake
#define SDL_GetTicks Fake::GetTicks
#define SDL_InitSubSystem Fake::InitSubSystem
#define SDL_QuitSubSystem Fake::QuitSubSystem
#define SDL_GetAudioPlaybackDevices Fake::GetAudioPlaybackDevices
#define SDL_GetAudioDeviceName Fake::GetAudioDeviceName
#define SDL_GetAudioDeviceFormat Fake::GetAudioDeviceFormat
#define SDL_OpenAudioDeviceStream Fake::OpenAudioDeviceStream
#define SDL_ResumeAudioStreamDevice Fake::ResumeAudioStreamDevice
#define SDL_DestroyAudioStream Fake::DestroyAudioStream
#define SDL_GetAudioStreamDevice Fake::GetAudioStreamDevice
#define SDL_GetGamepadTypeForID Fake::GetGamepadTypeForID
#define SDL_GetGamepadFromID Fake::GetGamepadFromID
#define SDL_RumbleGamepad Fake::RumbleGamepad
#define SDL_GetTicks Fake::GetTicks
#define SDL_InitSubSystem Fake::InitSubSystem
#define SDL_QuitSubSystem Fake::QuitSubSystem
#define SDL_GetAudioPlaybackDevices Fake::GetAudioPlaybackDevices
#define SDL_GetAudioDeviceName Fake::GetAudioDeviceName
#define SDL_GetAudioDeviceFormat Fake::GetAudioDeviceFormat
#define SDL_OpenAudioDeviceStream Fake::OpenAudioDeviceStream
#define SDL_ResumeAudioStreamDevice Fake::ResumeAudioStreamDevice
#define SDL_DestroyAudioStream Fake::DestroyAudioStream
#define SDL_GetAudioStreamDevice Fake::GetAudioStreamDevice
#define SDL_GetGamepadTypeForID Fake::GetGamepadTypeForID
#define SDL_GetGamepadFromID Fake::GetGamepadFromID
#define SDL_RumbleGamepad Fake::RumbleGamepad
#define SDL_SendGamepadEffect Fake::SendGamepadEffect
#define SDL_GetGamepads Fake::GetGamepads
#define SDL_GetGamepadConnectionState Fake::GetGamepadConnectionState
#include "libs/dualSenseHaptics.cpp"
#include "libs/audio.cpp"
#undef SDL_GetTicks
#undef SDL_InitSubSystem
#undef SDL_QuitSubSystem
@ -152,6 +204,27 @@ bool RumbleGamepad(SDL_Gamepad*, Uint16 large, Uint16 small, Uint32 duration) {
#undef SDL_GetGamepadTypeForID
#undef SDL_GetGamepadFromID
#undef SDL_RumbleGamepad
#undef SDL_SendGamepadEffect
#undef SDL_GetGamepads
#undef SDL_GetGamepadConnectionState
namespace Libs::Controller {
int GetActiveControllerId() {
return active_controller;
}
} // namespace Libs::Controller
namespace Libs::LibKernel {
uint64_t KYTY_SYSV_ABI KernelGetProcessTime() {
return now * 1000;
}
} // namespace Libs::LibKernel
namespace Loader::Timer {
double GetTimeMs() {
return static_cast<double>(now);
}
} // namespace Loader::Timer
namespace {
namespace Haptics = Libs::Controller::DualSenseHaptics;
@ -161,9 +234,17 @@ constexpr std::array<float, 4> pcm {0.5f, 0.5f, 0.5f, 0.5f};
struct Fixture {
Fixture() {
devices = {{10, "Speakers (DualSense Wireless Controller)", 4}};
rumble = {};
now = 1000;
devices = {{10, "Speakers (DualSense Wireless Controller)", 4}};
rumble = {};
pad_rumble = {};
cached_rumble = {};
effects = {};
connected_pads = {1};
fail_effect = wireless = false;
default_stream = nullptr;
fail_default_resume = false;
active_controller = 1;
now = 1000;
opens = rumble_calls = 0;
fail_open = fail_resume = false;
actual_channels = 4;
@ -174,8 +255,8 @@ struct Fixture {
}
};
Port Open() {
auto* stream = Haptics::Open(48000);
Port Open(bool speaker = false) {
auto* stream = Haptics::Open(48000, speaker);
Check(stream != nullptr, "port open failed");
return Port(stream, Haptics::Close);
}
@ -237,6 +318,71 @@ void TestDiscoveryAndHotplug() {
Check(streams.empty(), "disconnected endpoint retained its stream");
}
void TestSpeaker() {
Fixture f;
devices = {{10, "DualSense Wireless Controller", 2}};
auto speaker = Open(true);
Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) == 0 &&
streams.empty(),
"speaker played without a quad DualSense device");
devices = {{10, "Speakers (DualSense Wireless Controller)", 4}};
now += 2001;
Check(Haptics::SetVibration(1, 100, 50), "set rumble failed");
const std::array<float, 4> stereo {0.5f, -0.25f, 1.0f, 0.0f};
Queue(speaker, stereo.data());
ExpectPcm({0.5f, -0.25f, 0, 0, 1.0f, 0, 0, 0});
Check(rumble.large == 100 * 0x101, "speaker audio stopped the rumble");
const auto routed = effects[1];
Check(routed.size() == 38 && routed[0] == 0xa0 && routed[1] == 0x80 && routed[5] == 0x64 &&
routed[7] == 0x30 && routed[37] == 0x02,
"speaker not routed like the Linux driver");
Check(Haptics::Queue(speaker.get(), 1, stereo.data(), 2, 2, true, unity.data()) ==
2 * 1000000 / 48000,
"queued speaker playback time is wrong");
auto other = Open(true);
connected_pads = {3};
Haptics::Queue(other.get(), 3, pcm.data(), 2, 2, true, unity.data());
Check(effects[1][0] == 0x80 && effects[1][1] == 0 && effects[1][7] == 0 && effects[3] == routed,
"switching pads left the old speaker routed");
speaker.reset();
Check(effects[3] == routed, "closing one speaker port unrouted the other");
other.reset();
Check(effects[3][0] == 0x80 && effects[3][7] == 0,
"closing the last speaker port left it routed");
auto last = Open(true);
Haptics::Queue(last.get(), 3, pcm.data(), 2, 2, true, unity.data());
Check(effects[3] == routed, "reopened speaker port was not routed");
Haptics::Shutdown();
Check(effects[3][0] == 0x80 && effects[3][7] == 0, "shutdown left the speaker routed");
}
void TestSpeakerUnplug() {
Fixture f;
auto speaker = Open(true);
Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) != 0,
"speaker did not play on the DualSense");
const auto routed = effects[1];
// Unplugged: another controller becomes active, and the port must fall back
// to the main output.
Check(Haptics::Queue(speaker.get(), 2, pcm.data(), 2, 2, true, unity.data()) == 0 &&
streams.empty(),
"unplugged pad kept the speaker port");
Check(effects[1][0] == 0x80 && effects[1][7] == 0, "unplugging left the speaker routed");
// Plugged back in: the port returns to the controller at once.
effects = {};
Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) != 0 &&
streams.size() == 1 && effects[1] == routed,
"replugged pad did not take the speaker back");
// The audio endpoint vanishes while a DualSense stays active.
devices.clear();
now += 2001;
Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) == 0 &&
streams.empty(),
"lost audio endpoint kept the speaker port");
Check(effects[1][0] == 0x80 && effects[1][7] == 0,
"lost audio endpoint left the speaker routed");
}
void TestFailuresAndBoundedQueue() {
Fixture f;
auto port = Open();
@ -295,9 +441,23 @@ void TestRumbleLeaseAndDuration() {
Check(rumble_calls == calls, "audio pull touched rumble after shutdown");
}
void TestSwitchStopsOldRumble() {
Fixture f;
auto port = Open();
Haptics::SetVibration(1, 100, 50);
Queue(port, pcm.data());
Check(pad_rumble[1].large == 0, "haptics did not suppress rumble");
Haptics::SetVibration(3, 10, 10);
Check(pad_rumble[1].large == 0 && pad_rumble[1].small == 0,
"switching controllers restarted the old pad's rumble");
Check(pad_rumble[3].large == 10 * 0x101, "new pad did not rumble");
}
void TestCloseRestoresRumble() {
Fixture f;
auto first = Open(), second = Open();
auto speaker = Open(true);
Queue(speaker, pcm.data());
auto first = Open(), second = Open();
Haptics::SetVibration(1, 100, 50);
Queue(first, pcm.data());
Queue(second, pcm.data());
@ -308,14 +468,112 @@ void TestCloseRestoresRumble() {
Check(rumble.large == 100 * 0x101 && rumble.small == 50 * 0x101,
"closing the final stream left rumble suppressed");
}
void TestSpeakerSwitchAndFailures() {
Fixture f;
auto speaker = Open(true);
auto queue = [&](int controller) {
return Haptics::Queue(speaker.get(), controller, pcm.data(), 2, 2, true, unity.data());
};
fail_effect = true;
Check(queue(1) == 0 && streams.empty(), "failed speaker routing suppressed fallback");
fail_effect = false;
now += 2001;
Check(queue(1) != 0, "speaker did not recover after routing failed");
connected_pads = {3};
devices = {{20, "Speakers (DualSense Wireless Controller)", 4}};
Check(queue(3) != 0 && opened_device == 20 && effects[1][7] == 0 && effects[3][7] == 0x30,
"persistent speaker port did not follow the new controller immediately");
// An idle stream on the old pad must not keep the new pad routed after its
// last port closes.
connected_pads = {1};
auto other = Open(true);
Check(Haptics::Queue(other.get(), 1, pcm.data(), 2, 2, true, unity.data()) != 0,
"second speaker port failed");
other.reset();
Check(effects[1][7] == 0, "old controller stream kept the new speaker routed");
}
void TestSpeakerRequiresUnambiguousUsbDevice() {
Fixture f;
auto speaker = Open(true);
auto queue = [&] {
return Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data());
};
wireless = true;
Check(queue() == 0 && streams.empty(), "Bluetooth controller stole another pad's USB audio");
wireless = false;
connected_pads = {1, 3};
Check(queue() == 0 && streams.empty(), "multiple DualSenses chose an arbitrary controller");
connected_pads = {1};
devices.push_back({20, "DualSense Wireless Controller", 4});
Check(queue() == 0 && streams.empty(), "multiple audio endpoints chose an arbitrary device");
devices.pop_back();
now += 2001;
Check(queue() != 0, "unambiguous USB device was not recovered");
wireless = true;
Check(queue() == 0 && streams.empty() && effects[1][7] == 0,
"USB to Bluetooth change retained the USB speaker");
}
void TestAudioSpeakerFallback() {
Fixture f;
Libs::Audio::Audio audio;
active_controller = 2; // A different controller is active while a USB DualSense is attached.
const auto port = audio.AudioOutOpen(4, 2, 48000, Libs::Audio::Audio::Format::FloatStereo);
Check(port.IsValid() && default_stream != nullptr, "pad speaker fallback did not open");
Libs::Audio::Audio::OutputParam output {port, pcm.data()};
Check(audio.AudioOutOutputs(&output, 1, false) == 2, "pad speaker output count is wrong");
Check(streams.size() == 1 && opens == 1 && effects[1].empty(),
"another controller's pad speaker opened or routed the DualSense");
std::array<float, 4> actual {};
Check(SDL_GetAudioStreamData(default_stream, actual.data(), sizeof(actual)) == sizeof(actual) &&
actual == pcm,
"pad speaker did not fall back to the main output");
}
void TestAudioSpeakerRouting() {
Fixture f;
Libs::Audio::Audio audio;
const auto port = audio.AudioOutOpen(4, 2, 48000, Libs::Audio::Audio::Format::FloatStereo);
Check(port.IsValid() && default_stream != nullptr, "pad speaker port did not open");
Libs::Audio::Audio::OutputParam output {port, pcm.data()};
audio.AudioOutOutputs(&output, 1, false);
Check(streams.size() == 2 && opened_device == 10 &&
SDL_GetAudioStreamQueued(default_stream) == 0,
"successful DualSense playback also queued audio on the main output");
ExpectPcm({0.5f, 0.5f, 0, 0, 0.5f, 0.5f, 0, 0});
}
void TestAudioDefaultResumeFailure() {
Fixture f;
Libs::Audio::Audio audio;
fail_default_resume = true;
const auto port = audio.AudioOutOpen(4, 2, 48000, Libs::Audio::Audio::Format::FloatStereo);
Check(port.IsValid() && opens == 1 && default_stream == nullptr,
"default-output resume failure was not exercised");
Libs::Audio::Audio::OutputParam output {port, pcm.data()};
audio.AudioOutOutputs(&output, 1, false);
Check(streams.size() == 1 && opened_device == 10,
"default-output resume failure disabled the DualSense speaker");
ExpectPcm({0.5f, 0.5f, 0, 0, 0.5f, 0.5f, 0, 0});
}
} // namespace
int main() {
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
TestAudioSpeakerFallback();
TestAudioSpeakerRouting();
TestAudioDefaultResumeFailure();
TestFormatsAndVolume();
TestDiscoveryAndHotplug();
TestSpeaker();
TestSpeakerUnplug();
TestSpeakerSwitchAndFailures();
TestSpeakerRequiresUnambiguousUsbDevice();
TestFailuresAndBoundedQueue();
TestRumbleLeaseAndDuration();
TestSwitchStopsOldRumble();
TestCloseRestoresRumble();
std::printf("PadHapticsTests: all cases passed\n");
}