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 (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
  • Loading branch information
digitalf0x committed Jul 8, 2024
1 parent 0d5e8b7 commit 1a6749f
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 8 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
107 changes: 104 additions & 3 deletions BridgeApp/app_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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))

0 comments on commit 1a6749f

Please sign in to comment.