LSL streams¶
Every LSL stream VHI consumes or publishes. All are managed by
LSLCommunicationController; see LSL streams (concept)
for the why.
The two directions no longer share a shape, so read the one you need:
- Inbound, into VHI: one stream per DOF, named for the DOF's own address and one channel wide. Eighteen of them — nine driving the predicted hand, nine driving the control hand. A sample is applied the moment it arrives.
- Outbound, out of VHI: two read-back outlets,
VHI_ControlandVHI_Predict, each publishing a whole nine-channel pose at 60 Hz. A read-back is a recording, and a recording wants one row per instant.
Inlets - consumed by VHI¶
A DOF's stream name is its address, and that is the whole of the inbound transport
contract. The manifest carries nothing else about a wire — no stream name beside the
address, no channel number — because a capability's address already says everything a
producer needs: publish one stream under it, one float32 channel wide. A client reads
the address off GetControlManifest and there is no positional layout to get wrong.
Nine drive the predicted hand:
vhi.prediction.thumb.flexion vhi.prediction.wrist.flexion
vhi.prediction.thumb.abduction vhi.prediction.wrist.abduction
vhi.prediction.index vhi.prediction.wrist.rotation
vhi.prediction.middle
vhi.prediction.ring
vhi.prediction.little
…and nine drive the control hand, while any of them is delivering:
vhi.control.pose.thumb.flexion vhi.control.pose.wrist.flexion
vhi.control.pose.thumb.abduction vhi.control.pose.wrist.abduction
vhi.control.pose.index vhi.control.pose.wrist.rotation
vhi.control.pose.middle
vhi.control.pose.ring
vhi.control.pose.little
Every one of them is optional. Publish only the DOFs you drive — nothing waits for the rest, and a name nobody publishes costs only an unresolved lookup.
| Channels | 1 × float32, exactly. Any other width is refused outright — see below |
| Rate | the producer's, per stream. MyoGestic's prediction loop is ~32 Hz; VHI imposes nothing |
| Resolution | by name only; one liblsl resolve every ~5 s covers every stream still missing, so the resolve count does not grow with the number of DOFs |
| Type | not checked. A stream is found on its name alone; the type it advertises is informational |
| Goes stale after | 5 s of silence (PredictionStaleAfterSeconds / ControlPoseStaleAfterSeconds). VHI drops that one inlet and looks for the name again, so the next producer of it is picked up |
| Nominal rate | advertised by the producer, ignored by VHI. It applies what arrives and renders at its own physics tick |
A stream that is not one channel wide is never opened¶
Names find a stream; width decides whether it is opened. A resolved stream whose channel count is anything other than exactly 1 is logged as an error and its inlet is not created:
❌ vhi.prediction.index is published 9 channels wide, and this contract is one address
per stream, one float32 channel. Not opening it — publish one stream per DOF, named for
the address.
This used to be tolerated: the inlet resized its buffer to whatever turned up and read element zero. That quietly accepted a nine-channel whole-pose outlet from a producer too old to know the streams had been split apart — and element zero of that frame is the thumb, so every DOF would have rendered the thumb's value with nothing anywhere saying so. A receiver that advertises an invariant is the thing that has to enforce it, and a loud refusal is the only reading of a mis-shaped stream that a producer can act on.
A DOF nobody is driving holds its last commanded value¶
There is no whole-pose frame and nothing waits for one. Each sample lands on its own DOF the moment it arrives, and the DOFs that did not deliver keep what they were last commanded to.
That is the point of the shape, not a tolerance for missing data: the DOFs are independently actuated, may come from different producers, and may update at different rates. A hand whose index has moved and whose thumb has not is a real pose, not a corrupt or half-delivered one. Two producers — one publishing the thumb, one the index — drive the same hand without contending or agreeing on anything.
Two consequences worth knowing:
- A held value is not a stale value. A DOF sits where it was put until something
commands it again. If you want it back at rest, push a
0; going silent is not the same statement. - Going silent releases the control hand entirely. When the last control-pose stream goes quiet the hand is genuinely unclaimed: VHI stops any running recording trajectory, clears the whole commanded pose to rest, and gives the hand back to its own movements. It has to clear the pose and not just the rig — otherwise the next single DOF to arrive would bring a departed producer's other eight values back with it.
Outlets - published by VHI¶
Both outlets are created at startup and are unchanged by the per-DOF inbound shape: each publishes its hand's whole pose, all nine channels, every physics tick.
VHI_Control¶
| Name | VHI_Control (configurable: ControlOutletName) |
| Type | MyoGestic_9DVector |
| Channels | 9 × float32, labelled (see layout below) |
| Nominal rate | 60 Hz |
| Source ID | control_hand_002_standard |
| Carries | the control hand's current pose |
VHI_Predict¶
| Name | VHI_Predict (configurable: PredictedOutletName) |
| Type | MyoGestic_9DVector |
| Channels | 9 × float32, labelled |
| Nominal rate | 60 Hz |
| Source ID | predicted_hand_001 |
| Carries | the predicted hand's current pose |
Both outlets carry a config_file metadata entry pointing at the active
movements TOML, and a pose_convention entry — see
the sign convention.
The nine DOFs¶
This table is the authoritative map. It was read out of Vhi.VhiControlService's own
address tables and confirmed against recorded sessions. Do not restate it from memory —
earlier descriptions put thumb rotation where thumb flexion is, and called channels 6-8
a dead wrist when they are a live one.
Inbound, an address is a stream name, and that stream has exactly one channel, so
there is no index to choose. Outbound, the same DOF is a numbered channel of the
read-back. The # column is the outlets' channel number; it is not something an
inbound producer indexes.
| Address suffix | # on the outlets | Outlet channel label | Bones |
|---|---|---|---|
thumb.flexion |
0 | ThumbFlexion |
½/3, X axis |
thumb.abduction |
1 | ThumbAbduction |
½/3, Z axis. The distal bone's Z gain is 0, so only two of the three move. |
index |
2 | IndexFlexion |
4, 5, 6 |
middle |
3 | MiddleFlexion |
7, 8, 9 |
ring |
4 | RingFlexion |
10, 11, 12 |
little |
5 | PinkyFlexion |
13, 14, 15 |
wrist.flexion |
6 | WristFlexion |
bone 0, X axis |
wrist.abduction |
7 | WristAbduction |
bone 0, Z axis |
wrist.rotation |
8 | WristRotation |
bone 0, Y axis - pronation/supination, ±179° |
Prefix a suffix with vhi.prediction. for the predicted hand or vhi.control.pose. for
the control hand. The two namespaces are deliberately separate: the prediction streams
carry model output, and the control-pose streams drive the hand that output is supposed
to be the ground truth for.
All nine DOFs render
The wrist is bone 0, the common ancestor of all five digit chains, so turning it turns the whole hand. All three of its axes are driven.
They are 0 in every reference recording because nothing wrote them then, which is
not the same as being unrenderable — do not read the corpus as evidence about the rig.
Rotation is ±179°, and the missing degree is deliberate
GetEuler returns angles in (-180°, +180°], so -180 and +180 are the same
orientation and the decode picks the positive one. At exactly ±180 the pose is
correct and the read-back inverts: a commanded +1 comes back as -1 on
VHI_Predict channel 8. One degree short removes the wrap entirely and the round-trip
is exact at every value.
There is no forearm to carry the motion, so what turns is the hand about its own long
axis. Unlike flexion and abduction, both the range and the sign of this axis are
chosen — no movement in the library touches joint 0's Y. See Vhi.StandardPose.
One sign convention, in both directions¶
The shapes differ; the sign does not. That has not always been true, and getting it wrong inverts every joint, so it is worth stating per direction:
| Stream | Direction | Convention |
|---|---|---|
vhi.prediction.* (inlets) |
into VHI | Standard — +1 is the direction the DOF's name denotes, so +1 on vhi.prediction.index flexes. |
vhi.control.pose.* (inlets) |
into VHI | Standard, unconditionally — see below. |
VHI_Control (outlet) |
out of VHI | Standard — +1 flexes. It published VHI's rig units, -1 flexing, before 2.0. |
VHI_Predict (outlet) |
out of VHI | Standard — +1 flexes. |
The domain is [-1, 1] everywhere, 0 is rest, and VHI clamps to it. Both outlets
advertise pose_convention = "standard" in their metadata, and the control outlet's
source_id is control_hand_002_standard, so the two conventions are distinguishable on
the wire rather than by remembering which release you are on.
The control-pose streams are standard, always¶
The prediction side had its convention changed in 2.0. The control-pose side used to be
handled differently again: its convention was chosen by the client through a field on a
Declare handshake, defaulting to the old rig units. Both the field and the
handshake are gone. There is one encoding now — standard, unconditionally, on every
inbound stream — and VHI multiplies into rig degrees internally by
Vhi.StandardPose.AtPlusOne, so a producer never chooses a convention. It only chooses
whether to publish.
Publishing is the whole request¶
There is no flag, no mode and no RPC. VHI resolves each stream by name whenever it does not have it, and the control hand follows the control-pose streams whenever any of them is delivering:
| What you do | VHI does |
|---|---|
| Publish nothing (the default) | The control hand runs its own movement state machine — named movements from discrete DOFs, a recording trajectory, or the keyboard. |
Publish any vhi.control.pose.* stream |
The control hand renders each sample as a standard value on that DOF, and keeps doing so while samples keep arriving. The DOFs you do not publish hold where they are. |
| Stop publishing all of them | Five seconds (ControlPoseStaleAfterSeconds) after the last sample on the last live stream, the hand stops any running trajectory, returns to rest, and resumes its movement state machine. VHI drops the inlets and re-resolves by name, so the next producer of each is picked up. |
Three things worth knowing:
- One DOF is enough to take the hand. The control hand follows any live control-pose stream, not all nine. Requiring all nine would mean a producer that drives a single DOF never took the hand at all — which is the case this shape exists for. The falling edge is the last stream going quiet, which is when the hand is genuinely unclaimed.
- Presence is the request because an inlet nobody reads is indistinguishable from a stream that is not arriving. This is exactly how the predicted hand has always treated its own streams; the control hand needed a handshake for the same idea and no longer does.
- A live stream and a discrete DOF cannot both drive the hand. A discrete DOF renders
as a control-hand movement, and a streamed pose drives the same bones. v1 arbitrated
per command through
ControlMode; now the stream simply wins, and a discrete DOF sent while it is live comes back inControlAck.rejectedreading'<movement>' was refused — a control-pose stream is driving the control hand. Read the ack rather than assume.
The pose convention and VHI_Control both changed in 2.0; see
Upgrading to VHI 2.0. Sessions recorded before that release are
in the old units and are converted once with myogestic.tools.migrate_vhi_sessions,
which stamps pose_convention into the session so a reader never has to guess.
Read the stream name off the manifest
The addresses above are what this build exports today. Call GetControlManifest and
publish under each capability's address rather than a name copied from this page —
a build that renames or adds a DOF then moves your client with it, and a name this
build does not export is refused by SetControl with the alternatives named, instead
of silently never resolving.
Check ControlManifest.vocabulary_version while you are there. It is "2" on
this build, and it is a gate rather than a label: one stream per DOF, named for the
address, one channel wide. A client declares the oldest vocabulary it can drive and
refuses anything below it, by name, at bind. Vocabulary 1 described the transport
with per-capability stream_name and channel fields and allowed several controls to
share one wider stream; those fields are gone and their numbers and names are
reserved.
Minimal producer¶
One outlet, one channel, named for the DOF it drives:
from pylsl import StreamInfo, StreamOutlet
info = StreamInfo(
name="vhi.prediction.index", # the address, straight off the manifest
type="MyoGestic_Control",
channel_count=1,
nominal_srate=32,
channel_format="float32",
source_id="my-source:vhi.prediction.index",
)
outlet = StreamOutlet(info)
outlet.push_sample([0.0]) # rest
outlet.push_sample([1.0]) # the predicted hand's index flexes; nothing else moves
Drive a second DOF by creating a second outlet under its own address. Nothing links the two: they may run at different rates, and they may live in different processes.
0.0 is rest, which is why the example starts there. +1.0 is standard — the
direction the DOF's name denotes — so 1.0 on vhi.prediction.index flexes the index
and -1.0 extends it. What "flexes" means is not a matter of taste here:
Vhi.StandardPose.AtPlusOne is the one place that rule lives, in rig-native degrees, and
tests/test_v2_contract.py checks the rendered degrees against the movement library
rather than against a table of its own.
A whole fist, across the nine addresses in the order of the table above, is
[1, -1, 1, 1, 1, 1, 0, 0, 0] — five flexions and an adducted thumb, since abduction
is the direction that name denotes. Nine outlets, nine values; nothing requires them to
be pushed together.
tests/test_lsl_sender.py is a runnable version of all nine.