Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 66 additions & 16 deletions src/haptics/authored_ir.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,19 @@ namespace haptics {
constexpr std::uint8_t source_discontinuity = 0x04;
constexpr auto legacy_emit_period = std::chrono::milliseconds(20);
constexpr auto legacy_watchdog_timeout = std::chrono::milliseconds(100);
constexpr float legacy_noise_gate = 0.015f;
constexpr float legacy_gate_open = 0.020f;
constexpr float legacy_gate_close = 0.010f;
constexpr float legacy_gate_hold_seconds = 0.060f;
constexpr float legacy_output_floor = 0.004f;
constexpr float legacy_low_band_trim = 1.15f;
constexpr float legacy_high_band_trim = 1.20f;
constexpr float legacy_transient_trim = 1.15f;
constexpr float legacy_low_makeup_gain = 1.35f;
constexpr float legacy_high_makeup_gain = 1.45f;
constexpr float legacy_low_attack_seconds = 0.012f;
constexpr float legacy_low_release_seconds = 0.040f;
constexpr float legacy_high_attack_seconds = 0.006f;
constexpr float legacy_high_release_seconds = 0.025f;

void
write_u16(std::uint8_t *p, std::uint16_t value) {
Expand Down Expand Up @@ -47,8 +59,26 @@ namespace haptics {
}

float
gated(float value) {
return std::clamp((value - legacy_noise_gate) / (1.0f - legacy_noise_gate), 0.0f, 1.0f);
shaped(float value, float makeup_gain, gate_state_t &gate, float duration_seconds) {
value = std::clamp(value, 0.0f, 1.0f);
if (value >= legacy_gate_open) {
gate.open = true;
gate.hold_seconds = legacy_gate_hold_seconds;
}
else if (gate.open) {
// Hysteresis only bridges short dips. Without the hold budget a level
// parked between the two thresholds -- which is exactly where residual
// out-of-band energy lands -- would keep the gate latched forever.
gate.hold_seconds -= duration_seconds;
if (value <= legacy_gate_close || gate.hold_seconds <= 0.0f) {
gate.open = false;
}
}
if (!gate.open) return 0.0f;

const auto gated = std::clamp(
(value - legacy_gate_close) / (1.0f - legacy_gate_close), 0.0f, 1.0f);
return std::tanh(makeup_gain * gated) / std::tanh(makeup_gain);
}

std::uint16_t
Expand Down Expand Up @@ -200,35 +230,52 @@ namespace haptics {
if (force_emit) {
_smoothed_low = 0.0f;
_smoothed_high = 0.0f;
_low_gate = {};
_high_gate = {};
_last_emit = {};
}

const bool must_stop = (frame->flags & AH_AUTHORED_FRAME_STREAM_END) != 0;
float low_target = 0.0f;
float high_target = 0.0f;
float low_energy = 0.0f;
float high_energy = 0.0f;
if ((frame->flags & AH_AUTHORED_FRAME_SILENT) == 0) {
// Trim gains compensate ERM motors' weak response to low drive levels;
// transients get less because peak amplitude already overshoots RMS.
for (const auto &lane : frame->lanes) {
const auto low = lane.rms_amplitude * std::sqrt(std::clamp(lane.low_band_ratio, 0.0f, 1.0f));
const auto high_band = lane.rms_amplitude * std::sqrt(std::clamp(1.0f - lane.low_band_ratio, 0.0f, 1.0f));
const auto transient = lane.peak_amplitude * lane.transient_strength;
low_target = std::max(low_target, gated(low * 1.35f));
high_target = std::max(high_target, gated(std::max(high_band * 1.45f, transient * 1.15f)));
low_energy = std::max(low_energy, low * legacy_low_band_trim);
high_energy = std::max(high_energy, std::max(
high_band * legacy_high_band_trim, transient * legacy_transient_trim));
Comment on lines 245 to +250
}
}

// Time-based attack/release prevents the mapping from depending on whether
// the sidecar happens to deliver 5 ms or larger PCM chunks. A zero-frame
// chunk must not freeze the smoother at its previous value.
// The heavier low motor keeps body slightly longer; the lighter high motor
// tracks texture and impacts quickly. Time-based coefficients keep this
// independent of the sidecar's chunk boundaries.
const auto duration_seconds = static_cast<float>(std::max(frame->source_frame_count, 1u)) / 48000.0f;
const auto smooth = [duration_seconds](float previous, float target) {
const auto tau = target > previous ? 0.010f : 0.035f;
if (must_stop) {
_low_gate = {};
_high_gate = {};
}
const auto low_target = must_stop ? 0.0f : shaped(
low_energy, legacy_low_makeup_gain, _low_gate, duration_seconds);
const auto high_target = must_stop ? 0.0f : shaped(
high_energy, legacy_high_makeup_gain, _high_gate, duration_seconds);

const auto smooth = [duration_seconds](float previous, float target,
float attack, float release) {
const auto tau = target > previous ? attack : release;
const auto alpha = 1.0f - std::exp(-duration_seconds / tau);
return previous + (target - previous) * alpha;
};
_smoothed_low = must_stop ? 0.0f : smooth(_smoothed_low, low_target);
_smoothed_high = must_stop ? 0.0f : smooth(_smoothed_high, high_target);
_smoothed_low = must_stop ? 0.0f : smooth(
_smoothed_low, low_target, legacy_low_attack_seconds, legacy_low_release_seconds);
_smoothed_high = must_stop ? 0.0f : smooth(
_smoothed_high, high_target, legacy_high_attack_seconds, legacy_high_release_seconds);
if (low_target <= 0.0f && _smoothed_low < legacy_output_floor) _smoothed_low = 0.0f;
if (high_target <= 0.0f && _smoothed_high < legacy_output_floor) _smoothed_high = 0.0f;

const auto low = rumble_u16(_smoothed_low);
const auto high = rumble_u16(_smoothed_high);
Expand All @@ -249,15 +296,18 @@ namespace haptics {

std::optional<legacy_rumble_t>
legacy_rumble_session_t::poll(std::chrono::steady_clock::time_point now) {
if (!_have_input || now - _last_input < legacy_watchdog_timeout ||
(_last_low == 0 && _last_high == 0)) {
if (!_have_input || now - _last_input < legacy_watchdog_timeout) {
return std::nullopt;
}
const bool had_output = _last_low != 0 || _last_high != 0;
_have_input = false;
_smoothed_low = 0.0f;
_smoothed_high = 0.0f;
_low_gate = {};
_high_gate = {};
_last_low = 0;
_last_high = 0;
if (!had_output) return std::nullopt;
_last_emit = now;
return legacy_rumble_t {_controller_id, 0, 0};
}
Expand Down
11 changes: 11 additions & 0 deletions src/haptics/authored_ir.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ namespace haptics {
std::uint16_t high_frequency;
};

/**
* Noise-gate state for one motor. The hold budget bounds how long hysteresis
* may keep the gate open without the input re-crossing the open threshold.
*/
struct gate_state_t {
bool open = false;
float hold_seconds = 0.0f;
};

/**
* Converts authored DualSense actuator PCM into the two-motor rumble format
* understood by legacy Moonlight clients.
Expand Down Expand Up @@ -97,6 +106,8 @@ namespace haptics {
std::uint16_t _last_high = 0;
float _smoothed_low = 0.0f;
float _smoothed_high = 0.0f;
gate_state_t _low_gate;
gate_state_t _high_gate;
bool _have_input = false;
};
} // namespace haptics
80 changes: 77 additions & 3 deletions tests/unit/test_authored_ir.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include <chrono>
#include <cstdint>
#include <cstring>
#include <optional>
#include <vector>

#include <gtest/gtest.h>
Expand All @@ -26,11 +27,11 @@ namespace {
}

std::vector<std::uint8_t>
sine_pcm(double frequency_hz, double amplitude) {
sine_pcm(double frequency_hz, double amplitude, std::size_t first_frame = 0) {
std::vector<std::int16_t> pcm(240 * 2);
for (std::size_t frame = 0; frame < 240; ++frame) {
const auto sample = static_cast<std::int16_t>(
std::sin(static_cast<double>(frame) * frequency_hz * 6.283185307 / 48000.0) * amplitude);
std::sin(static_cast<double>(first_frame + frame) * frequency_hz * 6.283185307 / 48000.0) * amplitude);
pcm[frame * 2] = sample;
pcm[frame * 2 + 1] = sample;
}
Expand Down Expand Up @@ -133,12 +134,85 @@ TEST(AuthoredDualSenseIr, LegacyFallbackProducesAndStopsRumble) {
EXPECT_FALSE(session.process(2, 0, 240, 6, 60000, silence, start + 60ms).has_value());
}

TEST(AuthoredDualSenseIr, LegacyFallbackSeparatesTactileBandsAndRejectsHiss) {
using namespace std::chrono_literals;
const auto measure = [](double frequency_hz, std::uint32_t chunks) {
haptics::legacy_rumble_session_t session;
std::optional<haptics::legacy_rumble_t> latest;
const auto start = std::chrono::steady_clock::time_point {1s};
for (std::uint32_t chunk = 0; chunk <= chunks; ++chunk) {
const auto pcm = sine_pcm(frequency_hz, 24000.0, chunk * 240U);
const auto output = session.process(
0, chunk == 0 ? 0x01 : 0, 240, chunk,
static_cast<std::uint64_t>(chunk) * 5000U, pcm,
start + std::chrono::milliseconds(chunk * 5U));
if (output) latest = output;
}
return latest;
};

const auto body = measure(120.0, 20);
const auto texture = measure(300.0, 20);
const auto hiss = measure(1000.0, 20);
ASSERT_TRUE(body.has_value());
ASSERT_TRUE(texture.has_value());
ASSERT_TRUE(hiss.has_value());
EXPECT_GT(body->low_frequency, texture->low_frequency);
EXPECT_GT(texture->high_frequency, body->high_frequency);
// Out-of-band hiss must reach exactly zero rather than merely a small value,
// otherwise the noise gate has latched open on the onset transient.
EXPECT_EQ(hiss->low_frequency, 0u);
EXPECT_EQ(hiss->high_frequency, 0u);

// Sustained residual stays gated no matter how long it runs.
const auto long_hiss = measure(1000.0, 400);
ASSERT_TRUE(long_hiss.has_value());
EXPECT_EQ(long_hiss->low_frequency, 0u);
EXPECT_EQ(long_hiss->high_frequency, 0u);
}

TEST(AuthoredDualSenseIr, LegacyFallbackGateReleasesAfterLoudOnset) {
using namespace std::chrono_literals;
haptics::legacy_rumble_session_t session;
ASSERT_TRUE(session.ready());
const auto start = std::chrono::steady_clock::time_point {1s};

// A loud in-band burst opens both gates.
const auto burst = sine_pcm(120.0, 32000.0);
const auto opened = session.process(3, 0x01, 240, 0, 0, burst, start);
ASSERT_TRUE(opened.has_value());
EXPECT_GT(opened->low_frequency, 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
EXPECT_GT(opened->high_frequency, 0);

// Hysteresis must not keep them open on a level parked between the open and
// close thresholds; the hold budget has to expire and mute the motors.
std::optional<haptics::legacy_rumble_t> latest;
std::optional<haptics::legacy_rumble_t> at_240ms;
for (std::uint32_t chunk = 1; chunk <= 60; ++chunk) {
const auto pcm = sine_pcm(1000.0, 24000.0, chunk * 240U);
const auto output = session.process(
3, 0, 240, chunk, static_cast<std::uint64_t>(chunk) * 5000U, pcm,
start + std::chrono::milliseconds(chunk * 5U));
if (output) latest = output;
if (chunk == 48) at_240ms = latest;
}
// Both motors reach zero by 200 ms: a 60 ms hold budget plus the low motor's
// 40 ms release tail. Checking at 240 ms bounds the budget instead of only
// proving that the gate eventually closes.
ASSERT_TRUE(at_240ms.has_value());
EXPECT_EQ(at_240ms->low_frequency, 0u);
EXPECT_EQ(at_240ms->high_frequency, 0u);
ASSERT_TRUE(latest.has_value());
EXPECT_EQ(latest->low_frequency, 0u);
EXPECT_EQ(latest->high_frequency, 0u);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

TEST(AuthoredDualSenseIr, LegacyFallbackWatchdogReleasesMotors) {
using namespace std::chrono_literals;
haptics::legacy_rumble_session_t session;
ASSERT_TRUE(session.ready());
const auto start = std::chrono::steady_clock::time_point {1s};
const auto pcm = sine_pcm(1000.0, 24000.0);
const auto pcm = sine_pcm(300.0, 24000.0);

const auto first = session.process(1, 0x01, 240, 1, 0, pcm, start);
ASSERT_TRUE(first.has_value());
Expand Down
Loading