Wooting Analog Keyboards

TachyPy integrates with TachyWooting, a hardware toolbox for Wooting analog keyboards (analog pressure acquisition, hierarchical HDF5 logging, light-press / release readiness checks). The hardware toolbox is usable on its own; this page documents only what becomes available inside TachyPy once the integration is installed — chiefly on-screen visual pressure feedback. For the full keyboard/logging reference, see TachyWooting’s own documentation.

Installation

The integration ships as an optional extra. It pulls tachywooting and exposes the keyboard through the top-level tachypy namespace:

pip install "tachypy[wooting]"

(See First-time setup below for the one-time native/permissions step.)

One import surface

The enriched WOOTING_ACQUISITION — the hardware acquisition class plus TachyPy visual feedback — is available straight from the top-level package:

from tachypy import WOOTING_ACQUISITION  # keyboard + visual feedback

You never import tachywooting directly. WOOTING_ACQUISITION is the only keyboard symbol exposed at the top level (it is the common entry point and its name is unambiguous). The rest of the keyboard surface — the helpers (convert_char_to_keycode, load_trial, load_session, trial_to_dataframe, visualize, visualize_all_keys) and the low-level CFFI handles lib and ffi — lives under tachypy.wooting:

from tachypy.wooting import visualize, load_trial, convert_char_to_keycode

How the enrichment works

WOOTING_ACQUISITION is enriched in tachypy/wooting/__init__.py: it is a thin subclass that combines TachyWooting’s hardware acquisition class with VisualPressureFeedbackMixin. The mixin is what adds the wait_light_press_visual method — nothing else changes:

from tachywooting import WOOTING_ACQUISITION as _BaseAcquisition
from tachypy.feedback import VisualPressureFeedbackMixin

class WOOTING_ACQUISITION(_BaseAcquisition, VisualPressureFeedbackMixin):
    """Hardware acquisition + logging (base) + TachyPy visual feedback (mixin)."""

This keeps the hardware package (TachyWooting) completely free of TachyPy — the visual method is grafted on here, on TachyPy’s side. Because the mixin only relies on the PressureSource contract (reading pressures plus the light-press thresholds), the very same pattern enriches any future analog keyboard: subclass its base acquisition class and mix in VisualPressureFeedbackMixin.

First-time setup

The first time you create a WOOTING_ACQUISITION, TachyPy builds the native interface automatically if it is missing (this needs only a C compiler, no admin rights). The Wooting SDK plugins and input permissions, however, require a one-time privileged step:

wooting-build-interface   # installs SDK plugins + permissions (needs admin)

If the keyboard is not detected, the error message tells you exactly to run this command — you do not have to remember it.

Basic keyboard use

The enriched class behaves exactly like the hardware acquisition class for acquisition and logging:

from tachypy import WOOTING_ACQUISITION

acq = WOOTING_ACQUISITION(threshold=0.8)
acq.initialize_keyboard(verbose=True)
try:
    # Instantaneous analog pressure (0.0–1.0) of one or more keys
    print(acq.read_pressure("C"))
    print(acq.read_pressures(["C", "Z"]))

    # Block until keys are held in the light-press range (no display needed)
    acq.wait_keys_light_press(target_keys=["C", "Z"], quit_key="Q")

    # Record an analog trial and write it to an HDF5 shard
    acq.setup_logging(name="tracking", path="logs", int_analog=2)
    trial = acq.acquire_analog_values(target_keys=["C", "Z"])
finally:
    acq.uninitialize_keyboard()

Visual pressure feedback

Note

Why this matters. The whole point of an analog keyboard is to record the continuous pressure trajectory of a response, not just a binary press — and that only works if the participant’s fingers are already resting on the keys when the trial begins. Requiring a stable light press before each trial:

  • guarantees the fingers are on the keys, so the full movement is captured from its earliest, lowest-pressure samples;

  • guides them to the right resting pressure — high enough that softer presses still fall inside the trackable range, yet low enough to leave headroom for the fuller press that follows.

The on-screen feedback turns this abstract requirement into something the participant can simply aim for.

wait_light_press_visual shows an interactive fixation cross while waiting for two keys to stay within the light-press interval for hold_seconds. It needs a TachyPy Screen; pass a ResponseHandler to let the participant quit early:

from tachypy import Screen, ResponseHandler, FixationCross
from tachypy import WOOTING_ACQUISITION

acq = WOOTING_ACQUISITION(threshold=0.8, min_pressure_start=0.33, max_pressure_start=0.66)
acq.initialize_keyboard()

screen = Screen(fullscreen=False)
rh = ResponseHandler(screen=screen)
fixation = FixationCross(center=(screen.width // 2, screen.height // 2),
                         half_width=18, half_height=18, thickness=8, color=(0, 0, 0))

ready = acq.wait_light_press_visual(
    target_keys=["c", "z"],
    screen=screen,
    response_handler=rh,
    fixation_cross=fixation,   # geometry + color copied automatically
    show_pressure_text=True,
    show_goal_markers=True,
)

The horizontal bar grows and shrinks with pressure, turns toward the target color as the hold completes, and (optionally) shows live pressure values for keys that fall outside the acceptable interval.

Interactive fixation cross with real-time pressure feedback

Logging and a full experiment loop

Acquisition trials are logged to HDF5. Logging is opt-in and follows a simple lifecycle:

  1. setup_logging(name, path, int_analog) enables logging. int_analog=2 stores analog pressure in [0, 1]; int_analog=1 stores integer values in [0, 255].

  2. Every acquire_analog_values() call writes one trial shard to a staging directory, so a crash mid-experiment never loses completed trials.

  3. uninitialize_keyboard() merges all shards into the final <name>.hdf5 and releases the SDK. Always call it at the end (a try/finally is the safest pattern).

Each values dataset stores three columns — position, time_from_onset and time_abs — under a per-trial, per-key hierarchy:

/trials/0001/keys/0004/values

Beyond the three columns, each trial also carries metadata as HDF5 group attributes, surfaced under the "_attrs" key by load_trial():

Attribute

Type

Meaning

backend

str

Readout backend used for the trial ("read_analog" or "read_full_buffer").

threshold

float

Actuation threshold (0–1) that defined the response on this trial.

threshold_time

float

Seconds from trial_start_ns (onset) to the threshold crossing.

threshold_key

int

HID keycode of the key that crossed the threshold first.

trial_start_perf_ns

int

Onset reference timestamp (perf_counter_ns) used to compute time_from_onset.

trial_start_clock

str

Clock the onset was supplied on ("perf" or "mono").

The threshold-related attributes (threshold_time, threshold_key) are absent when the threshold was never reached during the trial.

A minimal but complete experiment that ties the lifecycle together:

from tachypy import FixationCross, ResponseHandler, Screen, WOOTING_ACQUISITION
from tachypy.wooting import convert_char_to_keycode

YES, NO = "z", "c"

acq = WOOTING_ACQUISITION(threshold=0.8, min_pressure_start=0.33, max_pressure_start=0.66)
acq.initialize_keyboard()                       # builds the native interface on first use
acq.setup_logging(name="participant_01", path="logs", int_analog=2)

screen = Screen(fullscreen=False)
rh = ResponseHandler(screen=screen)
fixation = FixationCross(center=(screen.width // 2, screen.height // 2),
                         half_width=18, half_height=18, thickness=8, color=(0, 0, 0))

try:
    for trial in range(1, 21):
        # 1) readiness: wait for both fingers to rest lightly on the keys
        if not acq.wait_light_press_visual([YES, NO], screen=screen,
                                           response_handler=rh, fixation_cross=fixation):
            break                               # participant pressed a quit key (Esc, etc.)

        # 2) present your stimulus, then time-lock the trial to the flip
        screen.fill((128, 128, 128))
        # ... draw your stimulus here ...
        onset = screen.flip()                   # flip() returns the post-swap timestamp = onset

        # 3) acquire the response trajectory (writes one HDF5 shard)
        hier = acq.acquire_analog_values([YES, NO], trial_start_ns=onset, trial_start_clock="mono")
        response = acq.get_response_key(hier, [YES, NO])
        print(f"trial {trial}: response = {convert_char_to_keycode([response])[0]}")

finally:
    acq.uninitialize_keyboard()                 # merges shards -> logs/participant_01.hdf5
    screen.close()

The trial is time-locked through the flip: screen.flip() returns the post-swap timestamp on TachyPy’s monotonic clock, which is passed straight to acquire_analog_values as trial_start_ns with trial_start_clock="mono". Every sample’s time_from_onset is then measured from that exact onset.

Reading the log back afterwards needs no keyboard:

from tachypy.wooting import load_session, load_trial, trial_to_dataframe

session = load_session("logs/participant_01.hdf5")   # every trial
trial = load_trial("logs/participant_01.hdf5", 1)     # one trial
df = trial_to_dataframe(trial)                        # tidy pandas DataFrame

trial["0006"]["position"]            # per-key trajectory (keycode "0006")
trial["_attrs"]["threshold_time"]    # trial metadata (see the table above)

Detecting finger removal

For the trajectory to be meaningful, the participant must keep their fingers on the keys throughout the trial. Lifting a finger mid-response breaks the continuous signal the analog keyboard is meant to capture — it is the in-trial counterpart to the pre-trial readiness check above. After each acquire_analog_values(), the acquisition object reports whether a removal occurred, so you can warn the participant before it becomes a habit:

acq.acquire_analog_values([YES, NO], trial_start_ns=onset)

if acq.reached_consecutive_removal_limit(2):
    # show your own on-screen message (e.g. a TachyPy Text)
    warn(f"Keep your fingers on the keys — lifted {acq.current_removal_streak} trials in a row.")
elif acq.last_trial_had_removal:
    warn("Try to keep your fingers resting on the keys.")

Related counters let you monitor data quality across the whole session: last_trial_had_removal, current_removal_streak, reached_consecutive_removal_limit(n), removal_trials, and removal_trial_proportion (the fraction of trials with at least one removal). Logging these alongside your responses makes it easy to flag or exclude compromised trials during analysis.

Custom widgets (advanced)

wait_light_press_visual builds an InteractiveFixationCross by default. To render feedback differently, subclass PressureFeedbackWidget and pass it via widget=. The settings (PressureFeedbackConfig, which also computes the pressure-to-scale mapping via scale_for()) and the pressure state machine (PressureFeedbackState) are reusable building blocks. See API Reference for the full tachypy.feedback reference.

These tools are keyboard-agnostic: any object exposing the PressureSource contract (read_pressures plus the light-press thresholds) can drive the same feedback loop.

Console demos

The integration installs two on-screen demos (they require a display):

tachypy-wooting-fixation-demo   # gamified interactive fixation cross
tachypy-wooting-mini-bw         # minimal black/white response experiment
Minimal black/white response experiment