audio: play DualSense haptics from vibration audio ports (#843)

* audio: play DualSense haptics from vibration audio ports

Games drive the DualSense's actuators through a vibration audio port, one channel per actuator.
These ports had no device, so their samples were dropped. Over USB the DualSense is a 4-channel
audio device whose back channels feed the actuators; play the port's samples there through SDL.

The device is looked up while the port plays, so the controller can be connected at any time. The
queue keeps about the latency of the regular audio path, with the playback rate nudged by up to 3%
when the port is fed off real time.

While haptic audio plays, hold a DualSense's rumble at zero: SDL disables audio haptics whenever
rumble is non-zero.

Add pad_haptics tests for the sample conversion, device-name matching and rate correction.

* Simplify DualSense audio haptics and restore idle rumble

---------

Co-authored-by: nmzik <Nmzik@mail.ru>
This commit is contained in:
Devran Cosmo Uenal 2026-09-26 22:37:27 +02:00 committed by GitHub
parent 81f312638f
commit a22a9d9348
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 588 additions and 3 deletions

View File

@ -477,6 +477,12 @@ add_executable(ngs2_sampler_tests EXCLUDE_FROM_ALL
target_link_libraries(ngs2_sampler_tests common fmt::fmt LibAtrac9)
target_include_directories(ngs2_sampler_tests PRIVATE ${inc_headers})
add_executable(pad_haptics_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/PadHapticsTests.cpp"
)
target_link_libraries(pad_haptics_tests common fmt::fmt SDL3::SDL3-static)
target_include_directories(pad_haptics_tests PRIVATE ${inc_headers})
add_executable(save_data_memory_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/SaveDataMemoryTests.cpp"
)
@ -569,6 +575,7 @@ if(BUILD_TESTING)
add_test(NAME sync_on_address COMMAND $<TARGET_FILE:sync_on_address_tests>)
add_test(NAME audio_out2_port COMMAND $<TARGET_FILE:audio_out2_port_tests>)
add_test(NAME ngs2_sampler COMMAND $<TARGET_FILE:ngs2_sampler_tests>)
add_test(NAME pad_haptics COMMAND $<TARGET_FILE:pad_haptics_tests>)
add_test(NAME save_data_memory COMMAND $<TARGET_FILE:save_data_memory_tests>)
add_test(NAME shader_recompiler_compute COMMAND $<TARGET_FILE:shader_recompiler_compute_tests>)
add_test(NAME shader_recompiler_alignbyte
@ -636,6 +643,7 @@ if(BUILD_TESTING)
virtual_memory_allocation_tests
audio_out2_port_tests
ngs2_sampler_tests
pad_haptics_tests
save_data_memory_tests
)
endif()

View File

@ -7,6 +7,8 @@
#include "common/threads.h"
#include "kernel/pthread.h"
#include "libs/audio_internal.h"
#include "libs/controller.h"
#include "libs/dualSenseHaptics.h"
#include "libs/errno.h"
#include "libs/libs.h"
@ -105,7 +107,8 @@ private:
int channels_num = 0;
int volume[12] = {};
SDL_AudioStream* stream = nullptr;
SDL_AudioStream* stream = nullptr;
Controller::DualSenseHaptics::Stream* haptics = nullptr;
};
struct PortIn {
@ -268,6 +271,8 @@ bool Audio::OpenSdlDevice(PortOut* port) {
void Audio::CloseSdlDevice(PortOut* port) {
EXIT_IF(port == nullptr);
Controller::DualSenseHaptics::Close(port->haptics);
port->haptics = nullptr;
if (port->stream != nullptr) {
SDL_DestroyAudioStream(port->stream);
@ -435,7 +440,9 @@ 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) {
if (type == AUDIO_OUT_PORT_TYPE_VIBRATION) {
port.haptics = Controller::DualSenseHaptics::Open(freq);
} else {
OpenSdlDevice(&port);
}
@ -547,7 +554,15 @@ 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()];
QueueSdlAudio(&port, params[i].data, blocking);
if (port.type == AUDIO_OUT_PORT_TYPE_VIBRATION) {
// Haptics never pace output; keep the stream alive against close and volume changes.
Common::LockGuard lock(m_mutex);
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 {
QueueSdlAudio(&port, params[i].data, blocking);
}
}
for (uint32_t i = 0; i < num; i++) {

View File

@ -8,6 +8,7 @@
#include "common/stringUtils.h"
#include "common/threads.h"
#include "kernel/pthread.h"
#include "libs/dualSenseHaptics.h"
#include "libs/errno.h"
#include "libs/libs.h"
#include "libs/padData.h"
@ -113,6 +114,7 @@ public:
void ReleaseHostPads();
void GetConnectionInfo(bool* flag, int* count);
void SetVibration(uint8_t large_motor, uint8_t small_motor);
int GetActiveControllerId();
void SetLightBar(uint8_t r, uint8_t g, uint8_t b);
bool SetTriggerEffect(const PadTriggerEffectParam& param);
void ReadState(ControllerState* state, bool* flag, int* count);
@ -518,6 +520,7 @@ void GameController::ResetInputState() {
void GameController::ReleaseHostPads() {
Common::LockGuard lock(m_mutex);
DualSenseHaptics::Shutdown();
std::vector<SDL_Gamepad*> pads;
for (const auto id: m_connected_ids) {
@ -551,6 +554,9 @@ void GameController::ReleaseHostPads() {
void GameController::SetVibration(uint8_t large_motor, uint8_t small_motor) {
Common::LockGuard lock(m_mutex);
if (DualSenseHaptics::SetVibration(m_active_id, large_motor, small_motor)) {
return;
}
if (m_active_id == HOST_INPUT_CONTROLLER_ID) {
return;
@ -568,6 +574,11 @@ void GameController::SetVibration(uint8_t large_motor, uint8_t small_motor) {
}
}
int GameController::GetActiveControllerId() {
Common::LockGuard lock(m_mutex);
return m_active_id;
}
void GameController::SetLightBar(uint8_t r, uint8_t g, uint8_t b) {
Common::LockGuard lock(m_mutex);
if (auto* pad = SDL_GetGamepadFromID(static_cast<SDL_JoystickID>(m_active_id));
@ -692,6 +703,10 @@ void ResetInputState() {
g_controller->ResetInputState();
}
int GetActiveControllerId() {
return g_controller != nullptr ? g_controller->GetActiveControllerId() : -1;
}
int KYTY_SYSV_ABI PadInit() {
PRINT_NAME();

View File

@ -73,6 +73,7 @@ void SetRightStick(int id, int x, int y);
void SetTouchPad(int id, int finger, bool down, float x, float y);
void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us);
void ResetInputState();
int GetActiveControllerId();
int KYTY_SYSV_ABI PadInit();
int KYTY_SYSV_ABI PadOpen(int user_id, int type, int index, const void* param);

View File

@ -0,0 +1,205 @@
#include "libs/dualSenseHaptics.h"
#include "common/logging/log.h"
#include "common/threads.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <vector>
namespace Libs::Controller::DualSenseHaptics {
struct Stream {
uint32_t freq = 0;
SDL_AudioStream* sdl = nullptr;
SDL_AudioDeviceID device = 0;
uint64_t next_check = 0;
std::vector<float> buffer;
};
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;
void ApplyRumble() { // 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;
(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);
}
SDL_UnlockJoysticks();
}
void SelectController(int controller) {
if (g_controller != controller) {
g_haptics_until = 0;
ApplyRumble();
g_controller = controller;
g_large_motor = g_small_motor = 0;
g_rumble_until = 0;
}
}
void SDLCALL UpdateRumble(void*, SDL_AudioStream*, int, int) {
Common::LockGuard lock(g_mutex);
if (g_haptics_until != 0 && SDL_GetTicks() >= g_haptics_until) {
g_haptics_until = 0;
ApplyRumble();
}
}
void CloseDevice(Stream* stream) {
if (stream->sdl != nullptr) {
// Destroy outside g_mutex: SDL waits for any running get callback.
SDL_DestroyAudioStream(stream->sdl);
stream->sdl = nullptr;
Common::LockGuard lock(g_mutex);
if (--g_playback_streams == 0 && g_haptics_until != 0) {
g_haptics_until = 0;
ApplyRumble();
}
}
}
SDL_AudioDeviceID FindDevice() {
int count = 0;
auto* devices = SDL_GetAudioPlaybackDevices(&count);
SDL_AudioDeviceID device = 0;
for (int i = 0; i < count && device == 0; 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) {
device = devices[i];
}
}
SDL_free(devices);
return device;
}
} // namespace
Stream* Open(uint32_t freq) {
if (freq == 0 || !SDL_InitSubSystem(SDL_INIT_AUDIO)) {
return nullptr;
}
return new Stream {.freq = freq};
}
void Close(Stream* stream) {
if (stream != nullptr) {
CloseDevice(stream);
delete stream;
SDL_QuitSubSystem(SDL_INIT_AUDIO);
}
}
void 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;
}
if (SDL_GetGamepadTypeForID(static_cast<SDL_JoystickID>(controller)) != SDL_GAMEPAD_TYPE_PS5) {
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());
}
}
}
}
if (stream->sdl == nullptr) {
return;
}
stream->buffer.assign(static_cast<size_t>(frames) * 4, 0.0f);
bool audible = false;
for (uint32_t frame = 0; frame < frames; frame++) {
for (uint32_t ch = 0; ch < 2; ch++) {
const auto src_ch = channels == 1 ? 0 : ch;
const auto index = static_cast<size_t>(frame) * channels + src_ch;
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;
audible |= std::fabs(value) > 1.0f / 1024;
}
}
auto queued = std::max(0, SDL_GetAudioStreamQueued(stream->sdl));
if (queued > static_cast<int>(stream->freq * FRAME_BYTES * 80 / 1000)) {
SDL_ClearAudioStream(stream->sdl);
queued = 0;
}
const int bytes = static_cast<int>(frames * FRAME_BYTES);
if (audible) {
Common::LockGuard lock(g_mutex);
if (SDL_GetGamepadTypeForID(static_cast<SDL_JoystickID>(controller)) !=
SDL_GAMEPAD_TYPE_PS5) {
return;
}
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));
if (starting) {
ApplyRumble();
}
}
if (!SDL_PutAudioStreamData(stream->sdl, stream->buffer.data(), bytes)) {
CloseDevice(stream);
}
}
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor) {
if (SDL_GetGamepadTypeForID(static_cast<SDL_JoystickID>(controller)) != SDL_GAMEPAD_TYPE_PS5) {
return false;
}
Common::LockGuard lock(g_mutex);
SelectController(controller);
g_large_motor = large_motor;
g_small_motor = small_motor;
g_rumble_until = SDL_GetTicks() + 0xffff;
ApplyRumble();
return true;
}
void Shutdown() {
Common::LockGuard lock(g_mutex);
g_rumble_until = 0;
SelectController(-1);
}
} // namespace Libs::Controller::DualSenseHaptics

View File

@ -0,0 +1,20 @@
#ifndef EMULATOR_INCLUDE_EMULATOR_DUALSENSE_HAPTICS_H_
#define EMULATOR_INCLUDE_EMULATOR_DUALSENSE_HAPTICS_H_
#include <cstdint>
namespace Libs::Controller::DualSenseHaptics {
struct Stream;
Stream* Open(uint32_t freq);
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 false for other gamepad types, which retain the normal rumble path.
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor);
void Shutdown();
} // namespace Libs::Controller::DualSenseHaptics
#endif // EMULATOR_INCLUDE_EMULATOR_DUALSENSE_HAPTICS_H_

321
tests/PadHapticsTests.cpp Normal file
View File

@ -0,0 +1,321 @@
// Exercise the production module with real SDL streams and fake USB endpoints.
#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <vector>
namespace {
void Check(bool condition, const char* text) {
if (!condition) {
std::fprintf(stderr, "PadHapticsTests: %s\n", text);
std::abort();
}
}
struct Device {
SDL_AudioDeviceID id;
const char* name;
int channels;
};
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 {};
} // namespace
namespace Fake {
Uint64 GetTicks() {
return now;
}
bool InitSubSystem(SDL_InitFlags flags) {
const bool result = SDL_InitSubSystem(flags);
if (result && (flags & SDL_INIT_AUDIO)) {
audio_refs++;
}
return result;
}
void QuitSubSystem(SDL_InitFlags flags) {
if (flags & SDL_INIT_AUDIO) {
audio_refs--;
}
SDL_QuitSubSystem(flags);
}
SDL_AudioDeviceID* GetAudioPlaybackDevices(int* count) {
*count = static_cast<int>(devices.size());
auto* ids = static_cast<SDL_AudioDeviceID*>(
SDL_malloc((devices.size() + 1) * sizeof(SDL_AudioDeviceID)));
for (size_t i = 0; i < devices.size(); i++) {
ids[i] = devices[i].id;
}
ids[devices.size()] = 0;
return ids;
}
const char* GetAudioDeviceName(SDL_AudioDeviceID id) {
for (const auto& device: devices) {
if (device.id == id) {
return device.name;
}
}
return nullptr;
}
bool GetAudioDeviceFormat(SDL_AudioDeviceID id, SDL_AudioSpec* spec, int*) {
if (id == 999) {
*spec = {SDL_AUDIO_F32, actual_channels, 48000};
return true;
}
for (const auto& device: devices) {
if (device.id == id) {
*spec = {SDL_AUDIO_F32, device.channels, 48000};
return true;
}
}
return false;
}
SDL_AudioStream* OpenAudioDeviceStream(SDL_AudioDeviceID id, const SDL_AudioSpec* spec,
SDL_AudioStreamCallback callback, void* userdata) {
opens++;
opened_device = id;
opened_spec = *spec;
if (fail_open) {
return nullptr;
}
auto* stream = SDL_CreateAudioStream(spec, spec);
Check(stream != nullptr && SDL_SetAudioStreamGetCallback(stream, callback, userdata),
"real SDL stream/callback creation failed");
streams.push_back(stream);
return stream;
}
bool ResumeAudioStreamDevice(SDL_AudioStream*) {
return !fail_resume;
}
void DestroyAudioStream(SDL_AudioStream* stream) {
if (stream == nullptr) {
return;
}
const auto it = std::find(streams.begin(), streams.end(), stream);
Check(it != streams.end(), "stream destroyed twice");
streams.erase(it);
SDL_DestroyAudioStream(stream);
}
SDL_AudioDeviceID GetAudioStreamDevice(SDL_AudioStream*) {
return 999;
}
SDL_GamepadType GetGamepadTypeForID(SDL_JoystickID id) {
return id == 1 ? 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;
}
bool RumbleGamepad(SDL_Gamepad*, Uint16 large, Uint16 small, Uint32 duration) {
rumble = {large, small, duration};
rumble_calls++;
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
#include "libs/dualSenseHaptics.cpp"
#undef SDL_GetTicks
#undef SDL_InitSubSystem
#undef SDL_QuitSubSystem
#undef SDL_GetAudioPlaybackDevices
#undef SDL_GetAudioDeviceName
#undef SDL_GetAudioDeviceFormat
#undef SDL_OpenAudioDeviceStream
#undef SDL_ResumeAudioStreamDevice
#undef SDL_DestroyAudioStream
#undef SDL_GetAudioStreamDevice
#undef SDL_GetGamepadTypeForID
#undef SDL_GetGamepadFromID
#undef SDL_RumbleGamepad
namespace {
namespace Haptics = Libs::Controller::DualSenseHaptics;
using Port = std::unique_ptr<Haptics::Stream, decltype(&Haptics::Close)>;
constexpr std::array<int, 2> unity {32768, 32768};
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;
opens = rumble_calls = 0;
fail_open = fail_resume = false;
actual_channels = 4;
}
~Fixture() {
Haptics::Shutdown();
Check(streams.empty() && audio_refs == 0, "stream or audio subsystem reference leaked");
}
};
Port Open() {
auto* stream = Haptics::Open(48000);
Check(stream != nullptr, "port open failed");
return Port(stream, Haptics::Close);
}
void Queue(const Port& port, const void* data, uint32_t frames = 2, uint32_t channels = 2,
bool is_float = true, const int* volume = unity.data()) {
Haptics::Queue(port.get(), 1, data, frames, channels, is_float, volume);
}
void Pull() {
std::array<float, 64> buffer {};
Check(!streams.empty() &&
SDL_GetAudioStreamData(streams.back(), buffer.data(), sizeof(buffer)) >= 0,
"audio device pull failed");
}
void ExpectPcm(std::array<float, 8> expected) {
std::array<float, 8> actual {};
Check(!streams.empty() && SDL_GetAudioStreamData(streams.back(), actual.data(),
sizeof(actual)) == sizeof(actual),
"wrong output byte count");
for (size_t i = 0; i < actual.size(); i++) {
Check(std::abs(actual[i] - expected[i]) < 1e-6f, "haptics mapping or volume is wrong");
}
}
void TestFormatsAndVolume() {
Fixture f;
auto port = Open();
const std::array<int, 2> volume {32768, 16384};
const std::array<float, 4> stereo {0.5f, -0.25f, 1.0f, 0.0f};
Queue(port, stereo.data(), 2, 2, true, volume.data());
Check(opened_device == 10 && opened_spec.channels == 4 && opened_spec.format == SDL_AUDIO_F32,
"did not select quad DualSense PCM");
ExpectPcm({0, 0, 0.5f, -0.125f, 0, 0, 1.0f, 0});
const std::array<int16_t, 2> mono {16384, -32768};
Queue(port, mono.data(), 2, 1, false, volume.data() + 1);
ExpectPcm({0, 0, 0.25f, 0.25f, 0, 0, -0.5f, -0.5f});
std::array<float, 24> multichannel {};
multichannel.fill(0.9f);
multichannel[0] = 0.1f;
multichannel[1] = 0.2f;
multichannel[12] = 0.3f;
multichannel[13] = 0.4f;
Queue(port, multichannel.data(), 2, 12);
ExpectPcm({0, 0, 0.1f, 0.2f, 0, 0, 0.3f, 0.4f});
}
void TestDiscoveryAndHotplug() {
Fixture f;
devices = {{10, "DualSense Wireless Controller", 2}, {20, "Other speakers", 4}};
auto port = Open();
Queue(port, pcm.data());
Check(streams.empty() && opens == 0, "stereo controller or unrelated speakers selected");
devices[0].channels = 4;
now += 2001;
Queue(port, pcm.data());
Check(streams.size() == 1 && opened_device == 10, "late USB connection was not discovered");
devices.clear();
now += 2001;
Queue(port, pcm.data());
Check(streams.empty(), "disconnected endpoint retained its stream");
}
void TestFailuresAndBoundedQueue() {
Fixture f;
auto port = Open();
std::array<float, 128> block {};
block.fill(0.5f);
fail_open = true;
Queue(port, block.data(), 64);
Check(streams.empty() && audio_refs == 1, "failed open leaked a stream/reference");
fail_open = false;
fail_resume = true;
now += 2001;
Queue(port, block.data(), 64);
Check(streams.empty() && audio_refs == 1, "failed resume leaked a stream/reference");
fail_resume = false;
actual_channels = 2;
now += 2001;
Queue(port, block.data(), 64);
Check(streams.empty(), "post-open stereo downgrade accepted");
actual_channels = 4;
now += 2001;
for (int i = 0; i < 70; i++) {
Queue(port, block.data(), 64);
}
constexpr int block_bytes = 64 * 4 * sizeof(float);
constexpr int max_queue = 48000 * 4 * sizeof(float) * 80 / 1000;
Check(streams.size() == 1 &&
SDL_GetAudioStreamQueued(streams.back()) <= max_queue + block_bytes,
"queue grows indefinitely when its clock stalls");
}
void TestRumbleLeaseAndDuration() {
Fixture f;
auto port = Open();
Check(Haptics::SetVibration(1, 100, 50), "set rumble failed");
Check(rumble.large == 100 * 0x101 && rumble.duration == 65535, "ordinary rumble changed");
Queue(port, pcm.data());
Check(rumble.large == 0 && rumble.small == 0, "rumble was not stopped before haptics");
now = 1100;
const std::array<float, 4> silence {};
Queue(port, silence.data());
now = 1249;
Pull();
Check(rumble.large == 0, "rumble resumed before lease expiration");
now = 1250;
Pull();
Check(rumble.large == 100 * 0x101 && rumble.small == 50 * 0x101 && rumble.duration == 65285,
"idle haptics did not restore remaining rumble duration");
now = 66500;
Queue(port, pcm.data());
now = 66751;
Pull();
Check(rumble.large == 0 && rumble.duration == 0, "expired rumble restarted after haptics");
Haptics::Shutdown();
const int calls = rumble_calls;
Pull();
Check(rumble_calls == calls, "audio pull touched rumble after shutdown");
}
void TestCloseRestoresRumble() {
Fixture f;
auto first = Open(), second = Open();
Haptics::SetVibration(1, 100, 50);
Queue(first, pcm.data());
Queue(second, pcm.data());
Check(rumble.large == 0, "haptics did not suppress rumble");
first.reset();
Check(rumble.large == 0, "closing one of two streams restored rumble early");
second.reset();
Check(rumble.large == 100 * 0x101 && rumble.small == 50 * 0x101,
"closing the final stream left rumble suppressed");
}
} // namespace
int main() {
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
TestFormatsAndVolume();
TestDiscoveryAndHotplug();
TestFailuresAndBoundedQueue();
TestRumbleLeaseAndDuration();
TestCloseRestoresRumble();
std::printf("PadHapticsTests: all cases passed\n");
}