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
160 changes: 143 additions & 17 deletions src/haptics/authored_ir.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
*/
#include "authored_ir.h"

#include <algorithm>
#include <bit>
#include <cmath>
#include <cstring>
#include <limits>

#include <moonlight_haptics/authored_haptics.h>

Expand All @@ -14,8 +17,9 @@ namespace haptics {
constexpr std::uint8_t source_stream_start = 0x01;
constexpr std::uint8_t source_stream_end = 0x02;
constexpr std::uint8_t source_discontinuity = 0x04;
constexpr std::uint8_t ir_stream_end = 0x04;
constexpr std::uint8_t ir_silent = 0x08;
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;

void
write_u16(std::uint8_t *p, std::uint16_t value) {
Expand All @@ -41,6 +45,17 @@ namespace haptics {
write_float(std::uint8_t *p, float value) {
write_u32(p, std::bit_cast<std::uint32_t>(value));
}

float
gated(float value) {
return std::clamp((value - legacy_noise_gate) / (1.0f - legacy_noise_gate), 0.0f, 1.0f);
}

std::uint16_t
rumble_u16(float value) {
return static_cast<std::uint16_t>(std::lround(
std::clamp(value, 0.0f, 1.0f) * std::numeric_limits<std::uint16_t>::max()));
}
} // namespace

void
Expand All @@ -64,10 +79,9 @@ namespace haptics {
return _engine != nullptr;
}

std::optional<authored_ir_v2_wire_t>
authored_ir_session_t::process(std::uint16_t controller_id, std::uint8_t source_flags,
std::uint16_t frame_count, std::uint32_t sequence,
std::uint64_t presentation_time_us,
std::optional<authored_frame_t>
authored_ir_session_t::analyze(std::uint8_t source_flags, std::uint16_t frame_count,
std::uint32_t sequence, std::uint64_t presentation_time_us,
std::span<const std::uint8_t> pcm) {
const auto expected_pcm_size = static_cast<std::size_t>(frame_count) * 4;
if (!_engine || frame_count > 240 || pcm.size() != expected_pcm_size) {
Expand Down Expand Up @@ -97,24 +111,51 @@ namespace haptics {
return std::nullopt;
}

authored_ir_v2_wire_t wire {};
wire[0] = 2;
write_u16(wire.data() + 2, authored_ir_v2_wire_size);
write_u16(wire.data() + 4, controller_id);
write_u32(wire.data() + 8, sequence);
write_u64(wire.data() + 12, presentation_time_us);
write_u32(wire.data() + 20, frame_count);
if (output_count == 0 && (source_flags & source_stream_end)) {
// An empty stream has no analyzable frame, but the transport must still
// stop the client's actuator and clear its renderer state.
wire[1] = ir_stream_end | ir_silent;
return wire;
return authored_frame_t {
.flags = AH_AUTHORED_FRAME_STREAM_END | AH_AUTHORED_FRAME_SILENT,
.timestamp_us = presentation_time_us,
.source_sequence_number = sequence,
.source_frame_count = frame_count,
};
}
if (output_count != 1) {
return std::nullopt;
}

const auto &frame = output[0];
authored_frame_t result {
.flags = frame.flags,
.timestamp_us = frame.timestamp_us,
.source_sequence_number = frame.source_sequence_number,
.source_frame_count = frame.source_frame_count,
.lane_correlation = frame.lane_correlation,
};
for (std::size_t lane = 0; lane < result.lanes.size(); ++lane) {
result.lanes[lane] = {
.rms_amplitude = frame.lanes[lane].rms_amplitude,
.peak_amplitude = frame.lanes[lane].peak_amplitude,
.transient_strength = frame.lanes[lane].transient_strength,
.low_band_ratio = frame.lanes[lane].low_band_ratio,
.zero_crossing_rate_hz = frame.lanes[lane].zero_crossing_rate_hz,
};
}
return result;
}

std::optional<authored_ir_v2_wire_t>
authored_ir_session_t::process(std::uint16_t controller_id, std::uint8_t source_flags,
std::uint16_t frame_count, std::uint32_t sequence,
std::uint64_t presentation_time_us,
std::span<const std::uint8_t> pcm) {
const auto analyzed = analyze(source_flags, frame_count, sequence, presentation_time_us, pcm);
if (!analyzed) return std::nullopt;

authored_ir_v2_wire_t wire {};
wire[0] = 2;
write_u16(wire.data() + 2, authored_ir_v2_wire_size);
write_u16(wire.data() + 4, controller_id);
const auto &frame = *analyzed;
if (frame.flags & AH_AUTHORED_FRAME_DISCONTINUITY) wire[1] |= 0x01;
if (frame.flags & AH_AUTHORED_FRAME_PARTIAL) wire[1] |= 0x02;
if (frame.flags & AH_AUTHORED_FRAME_STREAM_END) wire[1] |= 0x04;
Expand All @@ -135,4 +176,89 @@ namespace haptics {
write_u32(wire.data() + 68, 0);
return wire;
}

bool
legacy_rumble_session_t::ready() const noexcept {
return _analyzer.ready();
}

std::optional<legacy_rumble_t>
legacy_rumble_session_t::process(std::uint16_t controller_id, std::uint8_t source_flags,
std::uint16_t frame_count, std::uint32_t sequence,
std::uint64_t presentation_time_us,
std::span<const std::uint8_t> pcm,
std::chrono::steady_clock::time_point now) {
const auto frame = _analyzer.analyze(
source_flags, frame_count, sequence, presentation_time_us, pcm);
if (!frame) return std::nullopt;

_controller_id = controller_id;
_last_input = now;
_have_input = true;

const bool force_emit = (source_flags & (source_stream_start | source_discontinuity)) != 0;
if (force_emit) {
_smoothed_low = 0.0f;
_smoothed_high = 0.0f;
_last_emit = {};
}

const bool must_stop = (frame->flags & AH_AUTHORED_FRAME_STREAM_END) != 0;
float low_target = 0.0f;
float high_target = 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)));
}
}

// 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.
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;
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);

const auto low = rumble_u16(_smoothed_low);
const auto high = rumble_u16(_smoothed_high);
// The 20 ms rate limit is the only emission gate. Held silence additionally
// stays quiet instead of re-sending zero rumble at 50 Hz.
const bool silent_hold = low == 0 && high == 0 && _last_low == 0 && _last_high == 0;
if (!must_stop && !force_emit &&
(silent_hold ||
(_last_emit != std::chrono::steady_clock::time_point {} && now - _last_emit < legacy_emit_period))) {
return std::nullopt;
}

_last_emit = now;
_last_low = low;
_last_high = high;
return legacy_rumble_t {controller_id, low, high};
}

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)) {
return std::nullopt;
}
_have_input = false;
_smoothed_low = 0.0f;
_smoothed_high = 0.0f;
_last_low = 0;
_last_high = 0;
_last_emit = now;
return legacy_rumble_t {_controller_id, 0, 0};
}
} // namespace haptics
59 changes: 59 additions & 0 deletions src/haptics/authored_ir.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#pragma once

#include <array>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <memory>
Expand All @@ -17,6 +18,23 @@ namespace haptics {
constexpr std::size_t authored_ir_v2_wire_size = 72;
using authored_ir_v2_wire_t = std::array<std::uint8_t, authored_ir_v2_wire_size>;

struct authored_lane_t {
float rms_amplitude;
float peak_amplitude;
float transient_strength;
float low_band_ratio;
float zero_crossing_rate_hz;
};

struct authored_frame_t {
std::uint32_t flags;
std::uint64_t timestamp_us;
std::uint32_t source_sequence_number;
std::uint32_t source_frame_count;
std::array<authored_lane_t, 2> lanes;
float lane_correlation;
};

class authored_ir_session_t {
public:
authored_ir_session_t();
Expand All @@ -28,6 +46,11 @@ namespace haptics {
bool
ready() const noexcept;

std::optional<authored_frame_t>
analyze(std::uint8_t source_flags, std::uint16_t frame_count,
std::uint32_t sequence, std::uint64_t presentation_time_us,
std::span<const std::uint8_t> pcm);

std::optional<authored_ir_v2_wire_t>
process(std::uint16_t controller_id, std::uint8_t source_flags,
std::uint16_t frame_count, std::uint32_t sequence,
Expand All @@ -40,4 +63,40 @@ namespace haptics {
};
std::unique_ptr<AhAuthoredEngine, engine_deleter_t> _engine;
};

struct legacy_rumble_t {
std::uint16_t controller_id;
std::uint16_t low_frequency;
std::uint16_t high_frequency;
};

/**
* Converts authored DualSense actuator PCM into the two-motor rumble format
* understood by legacy Moonlight clients.
*/
class legacy_rumble_session_t {
public:
bool
ready() const noexcept;

std::optional<legacy_rumble_t>
process(std::uint16_t controller_id, std::uint8_t source_flags,
std::uint16_t frame_count, std::uint32_t sequence,
std::uint64_t presentation_time_us, std::span<const std::uint8_t> pcm,
std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now());

std::optional<legacy_rumble_t>
poll(std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now());

private:
authored_ir_session_t _analyzer;
std::chrono::steady_clock::time_point _last_input {};
std::chrono::steady_clock::time_point _last_emit {};
std::uint16_t _controller_id = 0;
std::uint16_t _last_low = 0;
std::uint16_t _last_high = 0;
float _smoothed_low = 0.0f;
float _smoothed_high = 0.0f;
bool _have_input = false;
};
} // namespace haptics
Loading
Loading