Skip to content

Commit

Permalink
[Update] Support Tundra Tracker with timing quirks
Browse files Browse the repository at this point in the history
Support the Tundra Tracker using microseconds instead of milliseconds
for the legacy, deprecated "triggerHapticPulse()" method.  This also
comes with an upper limit of around 4000 µs (4 ms), so the update loop
must run more frequently to stay within that maximum duration for a
single pulse.

Set Tundra Trackers back to a multiplier of 1.0, matching the behavior
of the Vive Trackers.

This might need replaced with support for the IVRInput system in place
of the deprecated triggerHapticPulse() function.

Warn if attempting to set a strength exceeding 100% (1.0).  It should
not happen as that should have no effect - the pulse duration lasts
longer than the delay before the next pulse.  However, multipliers
resulting in excess of 100% offer a way to adust for odd trackers
without needing to rebuild the released app on Windows.

Haptic Tundrakes, anyone? ...no?

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
  • Loading branch information
digitalf0x committed Jun 5, 2024
1 parent 0d5e8b7 commit e42c6ba
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 9 deletions.
7 changes: 3 additions & 4 deletions BridgeApp/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion BridgeApp/app_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))],
Expand Down
97 changes: 93 additions & 4 deletions BridgeApp/app_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,53 @@ def __init__(self, config: AppConfig, tracker: VRTracker, pulse_function, batter
self.strength: float = 0.0 # Should be treated as a value between 0 and 1
self.strength_delta: float = 0.0
self.last_str_set_time = time.time()

# If true, the warning for strength exceeding 1.0 (100%) has been shown
# Resets on changing strength
self.shown_strength_exceed_max = False

# 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
# avoid exceeding the maximum duration of one haptic pulse. 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 actual unit
self.hack_pulse_mult_to_ms = 0
# Hack: maximum allowed pulse duration in milliseconds
self.hack_pulse_limit_ms = 0
# 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)
Expand All @@ -36,17 +81,52 @@ def set_strength(self, strength):
self.strength_delta += abs(strength - self.strength)
self.strength = strength
self.last_str_set_time = time.time()
self.shown_strength_exceed_max = False

def run(self):
print(f"[VibrationManager] Thread started for {self.tracker.serial}")

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))
# Warn if strength exceeds 100%
if strength > 1 and not self.shown_strength_exceed_max:
print(f"[VibrationManager] Strength >100% ({round(strength * 100)}) for {self.tracker.serial}, multiplier too high?")
self.shown_strength_exceed_max = True
# Don't cap strength to 1.0 as some may rely on overriding
# the multiplier to adjust for different timescales, etc.

# 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

# Check if there's a queued force pulse
if start_time < self.hack_pulse_force_stop_time:
# Convert to milliseconds
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, cap the result.
# Exceeding the limit can result in sporadic vibration instead of
# a max strength (continuous) pulse.
if self.hack_pulse_limit_ms > 0:
pulse_length = min(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)
Expand Down Expand Up @@ -77,4 +157,13 @@ 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)
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))

0 comments on commit e42c6ba

Please sign in to comment.