fix: reduce IMU latency and refine native rumble response

This commit is contained in:
Joey Yakimowich-Payne 2026-09-05 14:36:36 -06:00
commit 65e635dc2b
9 changed files with 257 additions and 73 deletions

View file

@ -42,7 +42,11 @@ std::vector<uint8_t> render(SwitchHdRumbleSynth& synth, uint64_t first,
}
double wave(double cycles, uint16_t amplitude = 32768) {
return 95.25 * std::sin(kTau * cycles) * amplitude / 32768;
return 127.0 * std::sin(kTau * cycles) * std::pow(amplitude / 32768.0, 0.8);
}
double feedback_wave(double cycles, uint16_t amplitude = 32768) {
return 63.5 * std::sin(kTau * cycles) * amplitude / 32768;
}
template <typename Function>
@ -110,7 +114,7 @@ void test_physical_frequency_and_channels() {
}
}
expect(std::abs(peak_frequency - frequency) <= 0.5 &&
peak_amplitude > 92 && peak_amplitude < 99,
peak_amplitude > 123 && peak_amplitude < 132,
"DFT peak matches physical frequency including extreme indices");
}
}
@ -124,10 +128,10 @@ void test_linear_mix_headroom() {
frame.actuators[1] = one_side(1, state(64, 32768, 64, 32768)).actuators[1];
synth.push(frame, 0);
const auto pcm = render(synth, 0, 150);
expect_wave(pcm, 0, [](size_t n) { return 2 * wave(160.0 * n / 3000) / 1.5; },
expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * n / 3000); },
"coherent full-scale bands use headroom without waveform clipping");
expect_wave(pcm, 1, [](size_t n) {
return (wave(160.0 * n / 3000) + wave(320.0 * n / 3000)) / 1.5;
return (wave(160.0 * n / 3000) + wave(320.0 * n / 3000)) / 2;
}, "full-scale two-band balance is preserved by the joint gain ceiling");
int sum = 0;
for (size_t n = 0; n < pcm.size() / 2; ++n) {
@ -141,7 +145,15 @@ void test_linear_mix_headroom() {
expect_wave(render(synth, 0, 150), 0, [](size_t n) {
return 127.0 * (2 * std::sin(kTau * 160.0 * n / 3000) +
std::sin(kTau * 320.0 * n / 3000)) / 3;
}, "headroom-limited boost retains a 2:1 band amplitude ratio");
}, "joint limiting preserves the two-band amplitude ratio");
synth.reset(0);
synth.push(one_side(0, state(64, 8192, 64, 4096)), 0);
expect_wave(render(synth, 0, 150), 0, [](size_t n) {
const double peak = 127.0 * std::pow(0.375, 0.8);
return peak * (2 * std::sin(kTau * 160.0 * n / 3000) +
std::sin(kTau * 320.0 * n / 3000)) / 3;
}, "quiet-effect curve preserves band balance instead of independently boosting voices");
synth.reset(0);
synth.push(one_side(0, state(127, 0, 127, 0)), 0);
@ -235,14 +247,14 @@ void test_feedback_returns_to_live_host() {
auto pcm = render(synth, 0, 64);
expect_wave(pcm, 0, [](size_t n) {
if (n >= 16 && n < 30) {
return wave(320.0 * n / 3000) / 1.5;
return feedback_wave(320.0 * n / 3000);
}
const double cycles = n < 24 ? n * 160.0 / 3000
: (24 * 160.0 + (n - 24) * 320.0) / 3000;
return wave(cycles, n < 24 ? 32768 : 16384);
}, "partial feedback expiry returns to live host state and host phase");
expect_wave(pcm, 1, [](size_t n) {
return n >= 16 && n < 30 ? wave(320.0 * n / 3000) / 1.5 : 0;
return n >= 16 && n < 30 ? feedback_wave(320.0 * n / 3000) : 0;
}, "feedback overrides both sides only for its actual duration");
synth.reset(0);
@ -254,10 +266,10 @@ void test_feedback_returns_to_live_host() {
pcm = render(synth, 0, 60);
expect_wave(pcm, 0, [](size_t n) {
if (n < 12) {
return wave(160.0 * n / 3000, static_cast<uint16_t>((128u * 32768 + 127) / 255)) / 1.5;
return feedback_wave(160.0 * n / 3000, static_cast<uint16_t>((128u * 32768 + 127) / 255));
}
const bool feedback = n >= 24 && n < 36;
return wave((feedback ? 320.0 : 160.0) * n / 3000) / (feedback ? 1.5 : 1);
return feedback ? feedback_wave(320.0 * n / 3000) : wave(160.0 * n / 3000);
}, "zero magnitudes and zero duration cancel override without cancelling host");
synth.reset(0);
@ -265,7 +277,7 @@ void test_feedback_returns_to_live_host() {
synth.feedback(0, 80000, 0, 255);
pcm = render(synth, 0, 270);
expect_wave(pcm, 0, [](size_t n) {
return n < 240 ? wave(320.0 * n / 3000) / 1.5 : 0;
return n < 240 ? feedback_wave(320.0 * n / 3000) : 0;
}, "feedback expiry cannot resurrect an expired host effect");
}
@ -301,7 +313,7 @@ void test_late_commands_and_clock_rollover() {
pcm = render(synth, 0, 30);
expect_wave(pcm, 0, [](size_t n) {
if (n >= 12 && n < 15) {
return wave(320.0 * n / 3000) / 1.5;
return feedback_wave(320.0 * n / 3000);
}
return wave(160.0 * n / 3000, n < 9 ? 32768 : 16384);
}, "64-bit microsecond clock rollover preserves order and duration");
@ -330,7 +342,7 @@ void test_stall_and_overflow() {
"fresh effect after giant stall is accepted");
const auto fresh = render(skipped, far + 64, 150);
const double fresh_amplitude = spectral_amplitude(fresh, 0, 160);
expect(fresh_amplitude > 93 && fresh_amplitude < 98,
expect(fresh_amplitude > 124 && fresh_amplitude < 131,
"fresh 160 Hz effect resumes at full band amplitude after giant stall");
expect_wave(fresh, 1, [](size_t) { return 0; },
"resuming after stall does not activate the other actuator");

View file

@ -714,6 +714,102 @@ void test_uart_parser_is_pure() {
"failed UART parse modified its output reference");
}
void test_motion_backpressure_retries_without_advancing_state() {
for (bool rejected_transfer : {false, true}) {
initialize_contexts();
send_feature(0, TOGGLE_IMU, 2);
send_feature(1, TOGGLE_IMU, 2);
now_ms = 6;
switch_pro_task(0);
switch_pro_task(1);
ControllerState moving{};
moving.motion_sample_count = 1;
moving.motion_samples[0] = {100, 200, 300, 20000, 0, 0};
switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
switch_pro_set_input(1, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
const auto before = get_current_report(0, "missing pre-send report");
hid_ready[0] = rejected_transfer;
hid_report_succeeds[0] = !rejected_transfer;
now_ms = 21;
expect(!switch_pro_task(0), "blocked motion transfer was counted as sent");
const auto blocked = get_current_report(0, "missing blocked report");
expect(blocked.timestamp == before.timestamp &&
std::memcmp(blocked.imuData, before.imuData, sizeof(blocked.imuData)) == 0,
"blocked motion advanced timestamp or quaternion payload");
hid_ready[0] = true;
hid_report_succeeds[0] = true;
now_ms = 22;
expect(switch_pro_task(0), "overdue motion waited another 15 ms after USB became ready");
expect(switch_pro_task(1), "reference motion did not send");
const auto recovered = copy_switch_report(latest_regular_report(0));
const auto reference = copy_switch_report(latest_regular_report(1));
expect(recovered.timestamp == reference.timestamp &&
std::memcmp(recovered.imuData, reference.imuData, sizeof(reference.imuData)) == 0,
"retry integrated an unsent quaternion sample twice");
now_ms = 23;
expect(!switch_pro_task(0), "retry recovery emitted motion faster than its 15 ms cadence");
}
}
void test_control_replies_do_not_postpone_motion() {
initialize_contexts();
send_feature(0, TOGGLE_IMU, 1);
now_ms = 6;
switch_pro_task(0);
ControllerState moving{};
moving.motion_sample_count = 1;
moving.motion_samples[0].gyro_z = 1000;
switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 21;
expect(switch_pro_task(0), "initial motion report missing");
send_feature(0, GET_CONTROLLER_STATE, 0);
now_ms = 35;
expect(!switch_pro_task(0), "control response counted as motion");
moving.motion_samples[0].gyro_z = 2000;
switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
now_ms = 36;
expect(switch_pro_task(0), "control response postponed the independent motion deadline");
const auto current = copy_switch_report(latest_regular_report(0));
expect(read_int16_le(current.imuData + 10) == 2000 && current.timestamp == 6,
"overdue motion did not use the freshest input");
initialize_contexts();
now_ms = 15;
send_feature(0, GET_CONTROLLER_STATE, 0);
switch_pro_task(0);
now_ms = 16;
send_feature(0, GET_CONTROLLER_STATE, 0);
expect(switch_pro_task(0), "successive control replies starved overdue motion");
now_ms = 22;
expect(!switch_pro_task(0), "pending control response counted as motion");
expect(sent_reports[sent_report_count - 1].data[0] == REPORT_OUTPUT_21,
"motion fairness discarded the pending control reply");
}
void test_motion_cadence_survives_usb_poll_quantization() {
initialize_contexts();
for (now_ms = 1; now_ms <= 120; ++now_ms) {
hid_ready[0] = now_ms % 8 == 0;
switch_pro_task(0);
}
expect(reports_for_instance(0) == 8,
"8 ms USB polling stretched the 15 ms motion clock");
const auto report = copy_switch_report(latest_regular_report(0));
expect(report.timestamp == 24, "motion clock did not represent 24 samples in 120 ms");
hid_ready[0] = true;
now_ms = 121;
expect(!switch_pro_task(0), "motion sent an extra unscheduled sample group");
now_ms = 300;
expect(switch_pro_task(0), "motion did not recover after a long USB stall");
const auto recovered = copy_switch_report(latest_regular_report(0));
expect(recovered.timestamp == 60, "motion timer did not skip missing periods");
expect(!switch_pro_task(0), "motion replayed a stale catch-up burst");
}
} // namespace
extern "C" absolute_time_t get_absolute_time(void) {
@ -765,6 +861,9 @@ int main() {
test_protocol_neutral_trigger_threshold();
test_custom_trigger_thresholds_are_isolated();
test_uart_parser_is_pure();
test_motion_backpressure_retries_without_advancing_state();
test_control_replies_do_not_postpone_motion();
test_motion_cadence_survives_usb_poll_quantization();
if (failures != 0) {
std::cerr << failures << " driver context test(s) failed\n";
return 1;

View file

@ -2343,7 +2343,8 @@ def test_haptics_arming_waits_for_firmware_not_usb_ack(
"lookback_us": pytest.approx(21333.333333333),
"command_window_us": 8000,
"watchdog_us": 50000,
"host_gain": 1.5,
"band_gains": {"low": 2.0, "high": 2.0},
"response_exponent": 0.8,
}
assert row["first_tone_submission_delay_us"] is None
else: