gui: Support for DualSense haptics and trigger effects

Haptics with PulseAudio does not seem to be working properly, so using
Pipewire as a backend is recommended (and picked by default, if
available via an SDL hint).
This commit is contained in:
Johannes Baiter 2022-11-01 10:37:20 +01:00 committed by Florian Märkl
commit c2f0932670
12 changed files with 343 additions and 71 deletions

View file

@ -67,12 +67,12 @@ if(CHIAKI_ENABLE_CLI)
endif()
target_link_libraries(chiaki Qt5::Core Qt5::Widgets Qt5::Gui Qt5::Concurrent Qt5::Multimedia Qt5::OpenGL Qt5::Svg)
target_link_libraries(chiaki SDL2::SDL2)
if(APPLE)
target_link_libraries(chiaki Qt5::MacExtras)
target_compile_definitions(chiaki PRIVATE CHIAKI_GUI_ENABLE_QT_MACEXTRAS)
endif()
if(CHIAKI_GUI_ENABLE_SDL_GAMECONTROLLER)
target_link_libraries(chiaki SDL2::SDL2)
target_compile_definitions(chiaki PRIVATE CHIAKI_GUI_ENABLE_SDL_GAMECONTROLLER)
endif()
if(CHIAKI_ENABLE_SETSU)

View file

@ -77,6 +77,7 @@ class Controller : public QObject
int id;
ChiakiOrientationTracker orientation_tracker;
ChiakiControllerState state;
bool is_dualsense;
#ifdef CHIAKI_GUI_ENABLE_SDL_GAMECONTROLLER
QMap<QPair<Sint64, Sint64>, uint8_t> touch_ids;
@ -91,9 +92,43 @@ class Controller : public QObject
QString GetName();
ChiakiControllerState GetState();
void SetRumble(uint8_t left, uint8_t right);
void SetTriggerEffects(uint8_t type_left, const uint8_t *data_left, uint8_t type_right, const uint8_t *data_right);
bool IsDualSense();
signals:
void StateChanged();
};
/* PS5 trigger effect documentation:
https://controllers.fandom.com/wiki/Sony_DualSense#FFB_Trigger_Modes
Taken from SDL2, licensed under the zlib license,
Copyright (C) 1997-2022 Sam Lantinga <slouken@libsdl.org>
https://github.com/libsdl-org/SDL/blob/release-2.24.1/test/testgamecontroller.c#L263-L289
*/
typedef struct
{
Uint8 ucEnableBits1; /* 0 */
Uint8 ucEnableBits2; /* 1 */
Uint8 ucRumbleRight; /* 2 */
Uint8 ucRumbleLeft; /* 3 */
Uint8 ucHeadphoneVolume; /* 4 */
Uint8 ucSpeakerVolume; /* 5 */
Uint8 ucMicrophoneVolume; /* 6 */
Uint8 ucAudioEnableBits; /* 7 */
Uint8 ucMicLightMode; /* 8 */
Uint8 ucAudioMuteBits; /* 9 */
Uint8 rgucRightTriggerEffect[11]; /* 10 */
Uint8 rgucLeftTriggerEffect[11]; /* 21 */
Uint8 rgucUnknown1[6]; /* 32 */
Uint8 ucLedFlags; /* 38 */
Uint8 rgucUnknown2[2]; /* 39 */
Uint8 ucLedAnim; /* 41 */
Uint8 ucLedBrightness; /* 42 */
Uint8 ucPadLights; /* 43 */
Uint8 ucLedRed; /* 44 */
Uint8 ucLedGreen; /* 45 */
Uint8 ucLedBlue; /* 46 */
} DS5EffectsState_t;
#endif // CHIAKI_CONTROLLERMANAGER_H

View file

@ -63,6 +63,9 @@ class Settings : public QObject
void SetLogVerbose(bool enabled) { settings.setValue("settings/log_verbose", enabled); }
uint32_t GetLogLevelMask();
bool GetDualSenseEnabled() const { return settings.value("settings/dualsense_enabled", false).toBool(); }
void SetDualSenseEnabled(bool enabled) { settings.setValue("settings/dualsense_enabled", enabled); }
ChiakiVideoResolutionPreset GetResolution() const;
void SetResolution(ChiakiVideoResolutionPreset resolution);

View file

@ -20,6 +20,7 @@ class SettingsDialog : public QDialog
QCheckBox *log_verbose_check_box;
QComboBox *disconnect_action_combo_box;
QCheckBox *dualsense_check_box;
QComboBox *resolution_combo_box;
QComboBox *fps_combo_box;
@ -37,6 +38,7 @@ class SettingsDialog : public QDialog
private slots:
void LogVerboseChanged();
void DualSenseChanged();
void DisconnectActionSelected();
void ResolutionSelected();

View file

@ -56,6 +56,7 @@ struct StreamSessionConnectInfo
bool fullscreen;
TransformMode transform_mode;
bool enable_keyboard;
bool enable_dualsense;
StreamSessionConnectInfo(
Settings *settings,
@ -101,17 +102,23 @@ class StreamSession : public QObject
unsigned int audio_buffer_size;
QAudioOutput *audio_output;
QIODevice *audio_io;
SDL_AudioDeviceID haptics_output;
uint8_t *haptics_resampler_buf;
QMap<Qt::Key, int> key_map;
void PushAudioFrame(int16_t *buf, size_t samples_count);
void PushHapticsFrame(uint8_t *buf, size_t buf_size);
#if CHIAKI_GUI_ENABLE_SETSU
void HandleSetsuEvent(SetsuEvent *event);
#endif
private slots:
void InitAudio(unsigned int channels, unsigned int rate);
void InitHaptics();
void Event(ChiakiEvent *event);
void DisconnectHaptics();
void ConnectHaptics();
public:
explicit StreamSession(const StreamSessionConnectInfo &connect_info, QObject *parent = nullptr);

View file

@ -68,6 +68,12 @@ static QSet<QString> chiaki_motion_controller_guids({
"030000008f0e00001431000000000000",
});
static QSet<QPair<int16_t, int16_t>> chiaki_dualsense_controller_ids({
// in format (vendor id, product id)
QPair<int16_t, int16_t>(0x054c, 0x0ce6), // DualSense controller
QPair<int16_t, int16_t>(0x054c, 0x0df2), // DualSense Edge controller
});
static ControllerManager *instance = nullptr;
#define UPDATE_INTERVAL_MS 4
@ -84,6 +90,15 @@ ControllerManager::ControllerManager(QObject *parent)
{
#ifdef CHIAKI_GUI_ENABLE_SDL_GAMECONTROLLER
SDL_SetMainReady();
#ifdef SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1");
#endif
#ifdef SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1");
#endif
#ifdef SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
#endif
if(SDL_Init(SDL_INIT_GAMECONTROLLER) < 0)
{
const char *err = SDL_GetError();
@ -225,7 +240,8 @@ void ControllerManager::ControllerClosed(Controller *controller)
open_controllers.remove(controller->GetDeviceID());
}
Controller::Controller(int device_id, ControllerManager *manager) : QObject(manager)
Controller::Controller(int device_id, ControllerManager *manager)
: QObject(manager), is_dualsense(false)
{
this->id = device_id;
this->manager = manager;
@ -244,8 +260,10 @@ Controller::Controller(int device_id, ControllerManager *manager) : QObject(mana
SDL_GameControllerSetSensorEnabled(controller, SDL_SENSOR_ACCEL, SDL_TRUE);
if(SDL_GameControllerHasSensor(controller, SDL_SENSOR_GYRO))
SDL_GameControllerSetSensorEnabled(controller, SDL_SENSOR_GYRO, SDL_TRUE);
break;
#endif
auto controller_id = QPair<int16_t, int16_t>(SDL_GameControllerGetVendor(controller), SDL_GameControllerGetProduct(controller));
is_dualsense = chiaki_dualsense_controller_ids.contains(controller_id);
break;
}
}
#endif
@ -255,7 +273,12 @@ Controller::~Controller()
{
#ifdef CHIAKI_GUI_ENABLE_SDL_GAMECONTROLLER
if(controller)
{
// Clear trigger effects, SDL doesn't do it automatically
const uint8_t clear_effect[10] = { 0 };
this->SetTriggerEffects(0x05, clear_effect, 0x05, clear_effect);
SDL_GameControllerClose(controller);
}
#endif
manager->ControllerClosed(this);
}
@ -487,3 +510,28 @@ void Controller::SetRumble(uint8_t left, uint8_t right)
SDL_GameControllerRumble(controller, (uint16_t)left << 8, (uint16_t)right << 8, 5000);
#endif
}
void Controller::SetTriggerEffects(uint8_t type_left, const uint8_t *data_left, uint8_t type_right, const uint8_t *data_right)
{
#if defined(CHIAKI_GUI_ENABLE_SDL_GAMECONTROLLER) && SDL_VERSION_ATLEAST(2, 0, 16)
if(!is_dualsense || !controller)
return;
DS5EffectsState_t state;
SDL_zero(state);
state.ucEnableBits1 |= (0x04 /* left trigger */ | 0x08 /* right trigger */);
state.rgucLeftTriggerEffect[0] = type_left;
SDL_memcpy(state.rgucLeftTriggerEffect + 1, data_left, 10);
state.rgucRightTriggerEffect[0] = type_right;
SDL_memcpy(state.rgucRightTriggerEffect + 1, data_right, 10);
SDL_GameControllerSendEffect(controller, &state, sizeof(state));
#endif
}
bool Controller::IsDualSense()
{
#ifdef CHIAKI_GUI_ENABLE_SDL_GAMECONTROLLER
if(!controller)
return false;
return is_dualsense;
#endif
}

View file

@ -69,6 +69,11 @@ SettingsDialog::SettingsDialog(Settings *settings, QWidget *parent) : QDialog(pa
log_verbose_check_box->setChecked(settings->GetLogVerbose());
connect(log_verbose_check_box, &QCheckBox::stateChanged, this, &SettingsDialog::LogVerboseChanged);
dualsense_check_box = new QCheckBox(this);
general_layout->addRow(tr("Extended DualSense Support:\nEnable haptics and adaptive triggers\nfor attached DualSense controllers.\nThis is currently experimental."), dualsense_check_box);
dualsense_check_box->setChecked(settings->GetDualSenseEnabled());
connect(dualsense_check_box, &QCheckBox::stateChanged, this, &SettingsDialog::DualSenseChanged);
auto log_directory_label = new QLineEdit(GetLogBaseDir(), this);
log_directory_label->setReadOnly(true);
general_layout->addRow(tr("Log Directory:"), log_directory_label);
@ -322,6 +327,11 @@ void SettingsDialog::LogVerboseChanged()
settings->SetLogVerbose(log_verbose_check_box->isChecked());
}
void SettingsDialog::DualSenseChanged()
{
settings->SetDualSenseEnabled(dualsense_check_box->isChecked());
}
void SettingsDialog::FPSSelected()
{
settings->SetFPS((ChiakiVideoFPSPreset)fps_combo_box->currentData().toInt());

View file

@ -14,6 +14,12 @@
#define SETSU_UPDATE_INTERVAL_MS 4
#ifdef Q_OS_LINUX
#define DUALSENSE_AUDIO_DEVICE_NEEDLE "DualSense"
#else
#define DUALSENSE_AUDIO_DEVICE_NEEDLE "Wireless Controller"
#endif
StreamSessionConnectInfo::StreamSessionConnectInfo(
Settings *settings,
ChiakiTarget target,
@ -39,10 +45,12 @@ StreamSessionConnectInfo::StreamSessionConnectInfo(
this->fullscreen = fullscreen;
this->transform_mode = transform_mode;
this->enable_keyboard = false; // TODO: from settings
this->enable_dualsense = settings->GetDualSenseEnabled();
}
static void AudioSettingsCb(uint32_t channels, uint32_t rate, void *user);
static void AudioFrameCb(int16_t *buf, size_t samples_count, void *user);
static void HapticsFrameCb(uint8_t *buf, size_t buf_size, void *user);
static void EventCb(ChiakiEvent *event, void *user);
#if CHIAKI_GUI_ENABLE_SETSU
static void SessionSetsuCb(SetsuEvent *event, void *user);
@ -57,7 +65,9 @@ StreamSession::StreamSession(const StreamSessionConnectInfo &connect_info, QObje
pi_decoder(nullptr),
#endif
audio_output(nullptr),
audio_io(nullptr)
audio_io(nullptr),
haptics_output(0),
haptics_resampler_buf(nullptr)
{
connected = false;
ChiakiErrorCode err;
@ -116,6 +126,7 @@ StreamSession::StreamSession(const StreamSessionConnectInfo &connect_info, QObje
chiaki_connect_info.video_profile = connect_info.video_profile;
chiaki_connect_info.video_profile_auto_downgrade = true;
chiaki_connect_info.enable_keyboard = false;
chiaki_connect_info.enable_dualsense = connect_info.enable_dualsense;
#if CHIAKI_LIB_ENABLE_PI_DECODER
if(connect_info.decoder == Decoder::Pi && chiaki_connect_info.video_profile.codec != CHIAKI_CODEC_H264)
@ -144,6 +155,14 @@ StreamSession::StreamSession(const StreamSessionConnectInfo &connect_info, QObje
chiaki_opus_decoder_get_sink(&opus_decoder, &audio_sink);
chiaki_session_set_audio_sink(&session, &audio_sink);
if(connect_info.enable_dualsense)
{
ChiakiAudioSink haptics_sink;
haptics_sink.user = this;
haptics_sink.frame_cb = HapticsFrameCb;
chiaki_session_set_haptics_sink(&session, &haptics_sink);
}
#if CHIAKI_LIB_ENABLE_PI_DECODER
if(pi_decoder)
chiaki_session_set_video_sample_cb(&session, chiaki_pi_decoder_video_sample_cb, pi_decoder);
@ -181,6 +200,10 @@ StreamSession::StreamSession(const StreamSessionConnectInfo &connect_info, QObje
#endif
key_map = connect_info.key_map;
if(connect_info.enable_dualsense)
{
InitHaptics();
}
UpdateGamepads();
}
@ -208,6 +231,16 @@ StreamSession::~StreamSession()
chiaki_ffmpeg_decoder_fini(ffmpeg_decoder);
delete ffmpeg_decoder;
}
if(haptics_output > 0)
{
SDL_CloseAudioDevice(haptics_output);
haptics_output = 0;
}
if(haptics_resampler_buf)
{
free(haptics_resampler_buf);
haptics_resampler_buf = nullptr;
}
}
void StreamSession::Start()
@ -312,6 +345,8 @@ void StreamSession::UpdateGamepads()
{
CHIAKI_LOGI(log.GetChiakiLog(), "Controller %d disconnected", controller->GetDeviceID());
controllers.remove(controller_id);
if(controller->IsDualSense())
DisconnectHaptics();
delete controller;
}
}
@ -330,6 +365,11 @@ void StreamSession::UpdateGamepads()
CHIAKI_LOGI(log.GetChiakiLog(), "Controller %d opened: \"%s\"", controller_id, controller->GetName().toLocal8Bit().constData());
connect(controller, &Controller::StateChanged, this, &StreamSession::SendFeedbackState);
controllers[controller_id] = controller;
if(controller->IsDualSense())
{
// Connect haptics audio device with a delay to give the sound system time to set up
QTimer::singleShot(1000, this, &StreamSession::ConnectHaptics);
}
}
}
@ -388,6 +428,82 @@ void StreamSession::InitAudio(unsigned int channels, unsigned int rate)
channels, rate, audio_output->bufferSize());
}
void StreamSession::InitHaptics()
{
haptics_output = 0;
haptics_resampler_buf = nullptr;
#ifdef Q_OS_LINUX
// Haptics work most reliably with Pipewire, so try to use that if available
SDL_SetHint("SDL_AUDIODRIVER", "pipewire");
#endif
if(SDL_Init(SDL_INIT_AUDIO) < 0)
{
CHIAKI_LOGE(log.GetChiakiLog(), "Could not initialize SDL Audio for haptics output: %s", SDL_GetError());
return;
}
#ifdef Q_OS_LINUX
if(!strstr(SDL_GetCurrentAudioDriver(), "pipewire"))
{
CHIAKI_LOGW(
log.GetChiakiLog(),
"Haptics output is not using Pipewire, this may not work reliably. (was: '%s')",
SDL_GetCurrentAudioDriver());
}
#endif
SDL_AudioCVT cvt;
SDL_BuildAudioCVT(&cvt, AUDIO_S16LSB, 4, 3000, AUDIO_S16LSB, 4, 48000);
cvt.len = 240; // 10 16bit stereo samples
haptics_resampler_buf = (uint8_t*) calloc(cvt.len * cvt.len_mult, sizeof(uint8_t));
}
void StreamSession::DisconnectHaptics()
{
if(this->haptics_output > 0)
{
SDL_CloseAudioDevice(haptics_output);
this->haptics_output = 0;
}
}
void StreamSession::ConnectHaptics()
{
if(this->haptics_output > 0)
{
CHIAKI_LOGW(this->log.GetChiakiLog(), "Haptics already connected to an attached DualSense controller, ignoring additional controllers.");
return;
}
SDL_AudioSpec want, have;
SDL_zero(want);
want.freq = 48000;
want.format = AUDIO_S16LSB;
want.channels = 4;
want.samples = 480; // 10ms buffer
want.callback = NULL;
const char *device_name = nullptr;
for(int i=0; i < SDL_GetNumAudioDevices(0); i++)
{
device_name = SDL_GetAudioDeviceName(i, 0);
if(!device_name || !strstr(device_name, DUALSENSE_AUDIO_DEVICE_NEEDLE))
continue;
haptics_output = SDL_OpenAudioDevice(device_name, 0, &want, &have, 0);
if(haptics_output == 0)
{
CHIAKI_LOGE(log.GetChiakiLog(), "Could not open SDL Audio Device %s for haptics output: %s", device_name, SDL_GetError());
continue;
}
SDL_PauseAudioDevice(haptics_output, 0);
CHIAKI_LOGI(log.GetChiakiLog(), "Haptics Audio Device '%s' opened with %d channels @ %d Hz, buffer size %u (driver=%s)", device_name, have.channels, have.freq, have.size, SDL_GetCurrentAudioDriver());
return;
}
CHIAKI_LOGW(log.GetChiakiLog(), "DualSense features were enabled and a DualSense is connected, but could not find the DualSense audio device!");
return;
}
void StreamSession::PushAudioFrame(int16_t *buf, size_t samples_count)
{
if(!audio_io)
@ -395,6 +511,35 @@ void StreamSession::PushAudioFrame(int16_t *buf, size_t samples_count)
audio_io->write((const char *)buf, static_cast<qint64>(samples_count * 2 * 2));
}
void StreamSession::PushHapticsFrame(uint8_t *buf, size_t buf_size)
{
if(haptics_output == 0)
return;
SDL_AudioCVT cvt;
// Haptics samples are coming in at 3KHZ, but the DualSense expects 48KHZ
SDL_BuildAudioCVT(&cvt, AUDIO_S16LSB, 4, 3000, AUDIO_S16LSB, 4, 48000);
cvt.len = buf_size * 2;
cvt.buf = haptics_resampler_buf;
// Remix to 4 channels
for (int i=0; i < buf_size; i+=4)
{
SDL_memset(haptics_resampler_buf + i * 2, 0, 4);
SDL_memcpy(haptics_resampler_buf + (i * 2) + 4, buf + i, 4);
}
// Resample to 48kHZ
if(SDL_ConvertAudio(&cvt) != 0)
{
CHIAKI_LOGE(log.GetChiakiLog(), "Failed to resample haptics audio: %s", SDL_GetError());
return;
}
if(SDL_QueueAudio(haptics_output, cvt.buf, cvt.len_cvt) < 0)
{
CHIAKI_LOGE(log.GetChiakiLog(), "Failed to submit haptics audio to device: %s", SDL_GetError());
return;
}
}
void StreamSession::Event(ChiakiEvent *event)
{
switch(event->type)
@ -418,6 +563,19 @@ void StreamSession::Event(ChiakiEvent *event)
});
break;
}
case CHIAKI_EVENT_TRIGGER_EFFECTS: {
uint8_t type_left = event->trigger_effects.type_left;
uint8_t data_left[10];
memcpy(data_left, event->trigger_effects.left, 10);
uint8_t data_right[10];
memcpy(data_right, event->trigger_effects.right, 10);
uint8_t type_right = event->trigger_effects.type_right;
QMetaObject::invokeMethod(this, [this, type_left, data_left, type_right, data_right]() {
for(auto controller : controllers)
controller->SetTriggerEffects(type_left, data_left, type_right, data_right);
});
break;
}
default:
break;
}
@ -544,6 +702,7 @@ class StreamSessionPrivate
}
static void PushAudioFrame(StreamSession *session, int16_t *buf, size_t samples_count) { session->PushAudioFrame(buf, samples_count); }
static void PushHapticsFrame(StreamSession *session, uint8_t *buf, size_t buf_size) { session->PushHapticsFrame(buf, buf_size); }
static void Event(StreamSession *session, ChiakiEvent *event) { session->Event(event); }
#if CHIAKI_GUI_ENABLE_SETSU
static void HandleSetsuEvent(StreamSession *session, SetsuEvent *event) { session->HandleSetsuEvent(event); }
@ -563,6 +722,12 @@ static void AudioFrameCb(int16_t *buf, size_t samples_count, void *user)
StreamSessionPrivate::PushAudioFrame(session, buf, samples_count);
}
static void HapticsFrameCb(uint8_t *buf, size_t buf_size, void *user)
{
auto session = reinterpret_cast<StreamSession *>(user);
StreamSessionPrivate::PushHapticsFrame(session, buf, buf_size);
}
static void EventCb(ChiakiEvent *event, void *user)
{
auto session = reinterpret_cast<StreamSession *>(user);