From 883eba193cbc9a20aa71cc9d17fd723dc0bf0d6d Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 22 Sep 2026 08:42:20 -0600 Subject: [PATCH] Add --rumble-gain/--rumble-curve shaping to UART bridge --- .../controller_uart_bridge.py | 44 +++++++++++++++++-- tests/test_uart_rumble.py | 14 ++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index aaee4eb..ae03fb6 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -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 ( diff --git a/tests/test_uart_rumble.py b/tests/test_uart_rumble.py index e5bb475..f183eb7 100644 --- a/tests/test_uart_rumble.py +++ b/tests/test_uart_rumble.py @@ -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: