Add --rumble-gain/--rumble-curve shaping to UART bridge

This commit is contained in:
Joey Yakimowich-Payne 2026-09-22 08:42:20 -06:00
commit 883eba193c
2 changed files with 55 additions and 3 deletions

View file

@ -198,17 +198,31 @@ def interactive_pairing(
return mappings
def shape_rumble(magnitude: float, gain: float, curve: float) -> float:
"""Map a decoded Switch magnitude (0-1) onto an ERM motor duty (0-1).
``curve`` is a power-law exponent applied before ``gain``; values below 1
lift the faint HD-rumble levels most Switch games use. Zero stays zero.
"""
magnitude = max(0.0, min(1.0, magnitude))
if magnitude == 0.0:
return 0.0
return min(1.0, (magnitude**curve) * gain)
def apply_rumble(
controller: sdl3.SDL_Gamepad,
low_frequency: float,
high_frequency: float,
gain: float = 1.0,
curve: float = 1.0,
) -> Tuple[bool, bool]:
"""Apply normalized low/high rumble magnitudes to an SDL controller.
Returns (motor active, SDL accepted the request).
"""
low = int(max(0.0, min(1.0, low_frequency)) * 0xFFFF)
high = int(max(0.0, min(1.0, high_frequency)) * 0xFFFF)
low = int(shape_rumble(low_frequency, gain, curve) * 0xFFFF)
high = int(shape_rumble(high_frequency, gain, curve) * 0xFFFF)
accepted = bool(sdl3.SDL_RumbleGamepad(controller, low, high, RUMBLE_DURATION_MS))
return low != 0 or high != 0, accepted
@ -768,6 +782,20 @@ def build_arg_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print every decoded rumble frame received from the Pico and whether SDL accepted it.",
)
parser.add_argument(
"--rumble-gain",
type=float,
default=1.0,
metavar="FACTOR",
help="Multiply decoded rumble before sending it to the controller (default: 1.0; clamped at full strength).",
)
parser.add_argument(
"--rumble-curve",
type=float,
default=1.0,
metavar="EXPONENT",
help="Power-law exponent applied to decoded rumble before --rumble-gain. Below 1.0 boosts faint HD rumble on ERM motors, e.g. 0.5 (default: 1.0, linear).",
)
parser.add_argument(
"--no-imu",
action="store_true",
@ -828,6 +856,8 @@ class BridgeConfig:
debug_rumble: bool = False
no_imu: bool = False
gyro_scale: float = 1.0
rumble_gain: float = 1.0
rumble_curve: float = 1.0
class DisplayIndexAllocator:
@ -921,6 +951,8 @@ def build_bridge_config(console: Console, args: argparse.Namespace) -> BridgeCon
debug_rumble=bool(args.debug_rumble),
no_imu=bool(args.no_imu),
gyro_scale=float(args.gyro_scale),
rumble_gain=float(args.rumble_gain),
rumble_curve=float(args.rumble_curve),
)
@ -1619,7 +1651,11 @@ def service_contexts(
if latest_rumble is not None:
# Apply only the freshest rumble command seen during this tick.
ctx.rumble_active, accepted = apply_rumble(
ctx.controller, latest_rumble[0], latest_rumble[1]
ctx.controller,
latest_rumble[0],
latest_rumble[1],
config.rumble_gain,
config.rumble_curve,
)
ctx.last_rumble_at = now
if config.debug_rumble and (
@ -1630,6 +1666,8 @@ def service_contexts(
print(
f"[RUMBLE idx={ctx.controller_index}] frame#{ctx.debug_rumble_frames} "
f"low={latest_rumble[0]:.3f} high={latest_rumble[1]:.3f} "
f"-> motor low={shape_rumble(latest_rumble[0], config.rumble_gain, config.rumble_curve):.3f} "
f"high={shape_rumble(latest_rumble[1], config.rumble_gain, config.rumble_curve):.3f} "
f"accepted={accepted}{error}"
)
elif (

View file

@ -61,6 +61,20 @@ def test_apply_rumble_maps_low_and_high_with_50ms_duration(
assert calls[-1] == (0, 0, 50)
def test_shape_rumble_curve_and_gain_boost_faint_levels_but_keep_silence() -> None:
# Linear defaults are the identity.
assert bridge.shape_rumble(0.3, 1.0, 1.0) == pytest.approx(0.3)
# Zero must never become a nonzero idle buzz, whatever the shaping.
assert bridge.shape_rumble(0.0, 4.0, 0.5) == 0.0
# Typical Switch HD levels (~0.03) are lifted into the ERM motor's usable range.
boosted = bridge.shape_rumble(0.031, 1.0, 0.5)
assert 0.15 < boosted < 0.2
# Gain saturates instead of wrapping.
assert bridge.shape_rumble(0.467, 4.0, 1.0) == 1.0
# Shaping is monotonic so louder input never rumbles softer.
assert bridge.shape_rumble(0.1, 2.0, 0.5) < bridge.shape_rumble(0.3, 2.0, 0.5)
def test_repeated_constant_rumble_stays_active_until_idle_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None: