From 1a6749f00a5f893feb03fc3ec0252e672e2679f9 Mon Sep 17 00:00:00 2001 From: digitalfox Date: Sun, 24 Mar 2024 21:37:33 -0400 Subject: [PATCH] [Update] Support Tundra Tracker with timing quirks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support the Tundra Tracker (Haptic Tundrakes?) via… 1. Microseconds instead of milliseconds for "triggerHapticPulse()" Scale up from milliseconds via a time-based multiplier for the legacy, deprecated "triggerHapticPulse()" method. Set Tundra Trackers back to a multiplier of 1.0 elsewhere, matching the behavior of Vive Trackers. This could be replaced with support for the IVRInput system instead of using the deprecated triggerHapticPulse() function. 2. Limit pulse length to 4000 µs / 4 ms Tundra Trackers have a limit of around 4000 µs (4 ms) per pulse, so the update loop must run more frequently to stay within that maximum duration for a single pulse. Queue a time-based forced pulse and warn once if attempting to send a haptic pulse exceeding the max pulse length for the tracker (if set). Usually this shouldn't happen - strength values of up to 100% would result in pulses scaled up to the pulse limit. However, there are two notable cases: A. "Identify" button triggers a 500 ms pulse B. Velocity calculation can result in a strength that exceeds 1.0 This should (hopefully) match the Vive Tracker behavior. NOTE: Velocity calculation currently appears to do almost nothing with Tundra Trackers unless the multiplier is set to around 50, which then breaks normal Proximity and Identify-button haptics pulses. Tested setup: * 4x pancake vibration motors Thinner than LRAs shipped with Tundra IO Boards, fits in stock base * 4x Tundra Trackers Left/right foot, waist, chest * Default multiplier, default strengths Also tested Velocity strength set to 60% - 100% * Proximity and Velocity set to "Linear" pattern "Throb" works too, but feels a bit weak --- BridgeApp/app_config.py | 7 ++- BridgeApp/app_gui.py | 2 +- BridgeApp/app_runner.py | 107 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/BridgeApp/app_config.py b/BridgeApp/app_config.py index 47337db..a1c445e 100644 --- a/BridgeApp/app_config.py +++ b/BridgeApp/app_config.py @@ -21,12 +21,11 @@ def __init__(self, index: int, model: str, serial: str): @staticmethod def get_multiplier(model: str): - if model.startswith("Tundra"): - return 100.0 if model.startswith("VIVE Controller"): return 100.0 - else: - return 1.0 + + # Vive Tracker, Tundra Tracker, etc + return 1.0 # This is a definition class for storing user settings per tracker diff --git a/BridgeApp/app_gui.py b/BridgeApp/app_gui.py index 593eb14..41e4101 100644 --- a/BridgeApp/app_gui.py +++ b/BridgeApp/app_gui.py @@ -107,7 +107,7 @@ def tracker_row(self, tracker_id, tracker_serial, tracker_model): vib_multiplier = dev_config.multiplier_override battery_threshold = dev_config.battery_threshold - multiplier_tooltip = "1.0 for Vive trackers\n150 for Tundra trackers\n200 for Vive Wand\n400 for Index c." + multiplier_tooltip = "Additional strength multiplier\nCompensates for different trackers\n1.0 for default (Vive/Tundra Tracker)\n200 for Vive Wand\n400 for Index c." print(f"[GUI] Adding tracker: {string}") layout = [[sg.Text(string, pad=(0, 0))], diff --git a/BridgeApp/app_runner.py b/BridgeApp/app_runner.py index ff3925e..26b3c74 100644 --- a/BridgeApp/app_runner.py +++ b/BridgeApp/app_runner.py @@ -22,7 +22,56 @@ def __init__(self, config: AppConfig, tracker: VRTracker, pulse_function, batter self.strength_delta: float = 0.0 self.last_str_set_time = time.time() + # Some devices (e.g. Tundra Trackers) use microseconds instead of + # milliseconds for the legacy triggerHapticPulse() function, but then + # limit you to around 3999µs per pulse. + # + # TODO: That number is probably not exact; check with an oscilloscope. + # + # See https://steamcommunity.com/app/358720/discussions/0/405693392914144440/ + # + # In these situations, the update loop needs to run much faster to + # try to avoid exceeding the maximum duration of one haptic pulse. + # Exceeding 100% strength requires extending a pulse into the next loop + # iteration. Any manual pulses also need to be spread out over time to + # not exceed the limit. + # + # TODO: Try switching from "triggerHapticPulse()" to IVRInput + # triggerHapticPulse() is deprecated as per Valve: + # * https://github.com/ValveSoftware/openvr/blob/v2.2.3/headers/openvr.h#L2431-L2433 + # * https://github.com/ValveSoftware/openvr/blob/v2.2.3/headers/openvr.h#L5216-L5218 + # + # This might be specific to Tundra Trackers vs. Vive Trackers, as + # Tundra ships their IO Expansion board with a LRA/haptic actuator + + # Assume trackers that behave as expected self.interval_ms = 50 # millis + + # HACK: multiplier to convert from milliseconds to the device time unit + self.hack_pulse_mult_to_ms = 0 + # HACK: maximum allowed pulse duration in milliseconds + # Exceeding the limit likely results in sporadic or glitchy vibration + # instead of a max strength (as continuous as possible) pulse. + self.hack_pulse_limit_ms = 0 + # HACK: if true, the warning for a pulse exceeding the pulse limit has + # been shown. Only shown once to avoid console spam. + self.hack_pulse_limit_exceeded = False + # HACK: when to stop a queued force pulse + self.hack_pulse_force_stop_time = 0 + + # Special-case as needed + if self.tracker.model.startswith("Tundra"): + # Tundra Tracker + # Works in microseconds + self.hack_pulse_mult_to_ms = 1 / 1000 + # Has a limit of roughly 4000 microseconds + self.hack_pulse_limit_ms = 4000 * self.hack_pulse_mult_to_ms + + if self.hack_pulse_limit_ms > 0: + # Apply pulse duration hack/workaround + self.interval_ms = self.hack_pulse_limit_ms + print(f"[VibrationManager] Using {self.interval_ms} ms pulse limit workaround for {self.tracker.serial}") + self.interval_s = self.interval_ms / 1000 # seconds self.vp = VibrationPattern(self.config) @@ -43,10 +92,51 @@ def run(self): while True: start_time = time.time() + pulse_length = 0 + strength = self.calculate_strength(start_time) - # So we pulse every 50 ms that means a 50 ms pulse would be 100% if strength > 0: - self.pulse_function(self.tracker.index, int(strength * self.interval_ms)) + # So we pulse every self.interval_ms (e.g. 50) ms. That means a + # self.interval_ms (50/etc) ms pulse would be 100%. + pulse_length = strength * self.interval_ms + # Don't cap strength to 1.0 as velocity calculation can easily + # exceed 1. Also, some may rely on overriding the multiplier + # to adjust for different timescales, etc. + + # Check if there's a queued force pulse + if start_time < self.hack_pulse_force_stop_time: + # Convert to milliseconds + # (Don't cap this so if a pulse limit is specified, the + # leftover pulse carries over.) + force_pulse_duration = (self.hack_pulse_force_stop_time - start_time) * 1000 + # Pick the biggest number for pulse_length + pulse_length = max(pulse_length, force_pulse_duration) + + # If a maximum pulse length is specified... + if self.hack_pulse_limit_ms > 0: + # ...and we exceed it... + if pulse_length > self.hack_pulse_limit_ms: + if not self.hack_pulse_limit_exceeded: + print(f"[VibrationManager] {round(pulse_length, 2)} ms pulse exceeds {self.interval_ms} ms limit for {self.tracker.serial}, extending next pulse [this warning won't repeat]") + self.hack_pulse_limit_exceeded = True + + # Carry over any excess to the next loop iteration by + # queuing it as a forced pulse. Don't subtract anything as + # this pulse's time.sleep() also reduces the next pulse. + self.force_pulse(pulse_length) + # Do a max length pulse now + pulse_length = self.hack_pulse_limit_ms + + # Convert to target unit of time if necessary + if self.hack_pulse_mult_to_ms: + pulse_length = pulse_length / self.hack_pulse_mult_to_ms + + # Convert to integer (after all else for max precision) + pulse_length = int(pulse_length) + + # Trigger pulse if nonzero length requested + if pulse_length > 0: + self.pulse_function(self.tracker.index, pulse_length) sleep = max(self.interval_s - (time.time() - start_time), 0.0) time.sleep(sleep) @@ -77,4 +167,15 @@ def apply_multiplier(self, strength): * self.config.get_tracker_config(self.tracker.serial).multiplier_override) def force_pulse(self, length): - self.pulse_function(self.tracker.index, int(length * self.tracker.pulse_multiplier)) + if self.hack_pulse_limit_ms > 0: + # Add the pulse length in milliseconds to the current time in + # seconds, determining the new target time to stop + self.hack_pulse_force_stop_time = time.time() + (length / 1000) + # NOTE: This is also used by run() to handle lengths that exceed + # the maximum. + else: + # Convert to target unit of time if necessary + if self.hack_pulse_mult_to_ms: + length = length / self.hack_pulse_mult_to_ms + # Trigger haptic pulse + self.pulse_function(self.tracker.index, int(length * self.tracker.pulse_multiplier))