Core API¶
App lifecycle¶
App ¶
Top-level application object. Owns the GUI loop, the Context,
the run-loop lifecycle hooks, and the recording state machine.
Construct one per process. Register streams via app.streams(...),
register your UI via @app.ui, then call app.run(). Optional
extensions like Pipeline(app) register themselves via
app.before_run_hooks / app.cleanup_hooks - user code rarely
needs to touch those lists directly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Window title. Also used for the persisted ImGui state
file ( |
required |
theme
|
bool
|
Apply MyoGestic's built-in ImGui theme. Set |
True
|
docking
|
bool
|
Experimental - enable ImGui docking + multi-viewport
so panels registered via |
False
|
ui_scale
|
float | None
|
Global UI zoom factor - scales the font and imgui's style
metrics (padding, spacing, rounding). |
None
|
Source code in myogestic/core.py
streams ¶
streams(*streams: Stream) -> None
Register one or more streams with the app.
Each stream is keyed by its name into ctx.streams.
Acquisition threads start when app.run() is called, not at
registration time. Calling this with the same name overwrites
the previous registration - typically you call it once at setup.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*streams
|
Stream
|
One or more :class: |
()
|
Source code in myogestic/core.py
bridges ¶
bridges(*bridges: Any) -> None
Register one or more Bridge subprocesses with the app.
Bridges run in their own process (webcam, ultrasound, depth
camera, …) and publish an LSL clock stream the main app
subscribes to. The cockpit's process_launcher widget shows
their start/stop state. Mirrors :meth:streams exactly: each
bridge is keyed by its .name into ctx.bridges; calling
with the same name overwrites the previous registration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*bridges
|
Any
|
One or more bridge instances - each must expose a
|
()
|
Source code in myogestic/core.py
ui ¶
Decorator. Register the render callback.
@app.ui def my_ui(ctx): imgui.text(f"State: {ctx.state}")
popout ¶
popout(title: str, gui_fn: Callable[[], None], *, default_open: bool = True, can_be_closed: bool = True, remember_is_visible: bool | None = None) -> None
Register a dockable window before run().
This is the preferred path for examples/apps using App(docking=True).
It gives Hello ImGui the complete DockableWindow list before launch,
instead of discovering windows on the first render frame.
Source code in myogestic/core.py
start_recording ¶
start_recording(base_path: str = 'sessions') -> None
Begin recording all connected streams to a new session.
Creates base_path/<timestamp>/ and starts appending each
stream's data + timestamps to per-stream Zarr arrays. Streams
whose info is still None (disconnected) are skipped -
they won't be retroactively captured if they connect later in
the recording. Refuses to start if ctx.state isn't
"idle"; updates ctx.status_message with the result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_path
|
str
|
Directory where the per-session subfolder is
created. Defaults to |
'sessions'
|
Source code in myogestic/core.py
stop_recording ¶
Stop the active recording and pack the session to a .session.zip.
Finalises the per-stream Zarr arrays, writes the label track to
labels.json, and kicks off a daemon thread that packs the
session folder into a single <timestamp>.session.zip archive
(the original folder is kept until the pack succeeds). Refuses
to stop if ctx.state isn't "recording".
Source code in myogestic/core.py
run ¶
run(mode: str = 'gui', window_size: tuple[int, int] = (1280, 800), fullscreen: bool = False) -> None
Blocking entry point.
Call tree (top → bottom = runtime order):
App.run()
├─ 1. Stream.start() per stream → daemon acquire thread
├─ 2. before_run_hooks(app) extensions register here
│ └─ e.g. myogestic.ml.attach_pipeline → starts predict thread
├─ 3. self._gui_loop() ← main thread, BLOCKS
│ └─ immapp.run → per frame: self._ui_fn(self.ctx) (your @app.ui)
└─ 4. [finally] cleanup - always runs, even on startup failure
├─ cleanup_hooks(app) each wrapped in try/except
├─ Stream.stop() per stream
├─ Bridge.stop() per bridge
└─ process_launcher._cleanup_all()
Core has only idle ↔ recording. myogestic.ml.attach_pipeline(app) adds
training/predicting states + their transition methods.
Source code in myogestic/core.py
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 | |
AppState ¶
Bases: StrEnum
Core app-state values. Extensions (e.g. myogestic.ml.PipelineState) add more.
Context.state is a bare str so extensions can introduce their own states
without subclassing. Each module validates transitions within its own
namespace only.
Context
dataclass
¶
Context(streams: dict[str, Stream] = dict(), bridges: dict[str, Any] = dict(), state: str = IDLE, session: Session | None = None, class_names: list[str] = list(), current_label: int = -1, status_message: str = '', logs: list[str] = list())
Shared state all threads read/write. Extensions may add own fields
dynamically on the owning App, but Context itself is core-only.
log ¶
Append a one-line app event for the log_panel widget.
Bounded to max_lines (oldest dropped). Use for high-level events -
recording saved, training start/done, model load - not per-frame
chatter. Safe to call from any thread (list.append/pop are GIL-atomic).
Source code in myogestic/core.py
Stream ¶
A named ring-buffered live stream backed by a :class:Source.
The framework's central data primitive: pair a name ("emg") with
a source (LSLSource("TestEMG1")) and a window duration, register
the stream with app.streams(...), and the rest of the framework
can pull windows (for ML), display snapshots (for the signal
viewer), or recorded chunks (for the session) by stream name.
Architecture:
- One daemon acquisition thread is started per Stream when
App.run()begins. It loopssource.read(), appends to the ring buffer, refreshes the display snapshot, and (if a recording session is active) appends to the session's Zarr store. - Two consumer surfaces are then available concurrently:
:meth:
get_window(channels-first, exact window-seconds long, consumed by@pipeline.extract) and :meth:get_display(min/max envelope decimated for 60 fps rendering, consumed bysignal_viewer). - The ring buffer holds the last
buffer_secondsof samples so transient consumers (slow extract, momentary GUI hitches) don't lose data.
Example
from myogestic import App, Stream from myogestic.sources import LSLSource app = App("hello") app.streams( ... Stream("emg", source=LSLSource("TestEMG1"), ... window_seconds=1.0, buffer_seconds=10), ... )
See Streams concept for the buffer + decimation model in depth, and Add a custom source for the matching source-side contract.
Live ring-buffered stream with display decimation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Stream label (also used as the recorded zarr stream key). |
required |
source
|
Source
|
Anything implementing the :class: |
required |
window_seconds
|
float
|
Duration in seconds of the window returned
by :meth: |
required |
buffer_seconds
|
float
|
Ring-buffer depth in seconds. Defaults to 10. |
10
|
Source code in myogestic/stream.py
reconnect ¶
Reconnect source. Optionally switch to a different target.
If the source implements reconnect(), uses that (preserves source-specific logic like LSL resolve or serial port open). Otherwise falls back to disconnect + connect. Either way the source is connected ONCE — buffers are then (re)allocated from the returned StreamInfo.
Holds self._lock for the whole swap to prevent the acquire loop
from reading through a half-torn state.
Source code in myogestic/stream.py
get_window ¶
Return the most recent window_seconds as (data, ts).
data is channels-first (n_channels, n_samples) — the
same convention used everywhere user code touches signal data.
Both arrays are views into a reusable per-stream buffer; copy
explicitly if you need to retain them past the next call.
Source code in myogestic/stream.py
get_display ¶
Read pre-computed M4 result. Zero work on render thread.
The acquire thread computes M4 in _update_display_snapshot. This just reads the result via atomic ref (GIL-safe).
Source code in myogestic/stream.py
get_raw_snapshot ¶
Lock-free read of the display snapshot.
last_timestamp ¶
last_timestamp() -> float | None
Most recent sample timestamp, or None if no samples yet.
Holds _lock while reading _display_t[_display_n-1] so a concurrent
reconnect() (which zeroes _display_n and reallocates _display_t)
cannot strand the read on a torn buffer.
Source code in myogestic/stream.py
StreamInfo
dataclass
¶
StreamInfo(n_channels: int, fs: float, dtype: dtype = dtype(float32), channel_names: list[str] | None = None)
Describes the shape and dtype of a :class:Source's data.
Returned by :meth:Source.connect. The framework uses it to size
the ring buffer, lay out the signal viewer, and decide how to
serialise the stream when recording.
Attributes:
| Name | Type | Description |
|---|---|---|
n_channels |
int
|
Channel count. Fixed for the life of the source. |
fs |
float
|
Sample rate in Hz. Used to convert |
dtype |
dtype
|
NumPy dtype of each sample. Defaults to |
channel_names |
list[str] | None
|
Optional per-channel labels for the signal viewer
legend. |
TrainingData
dataclass
¶
Inputs delivered to the user's @pipeline.train callback.
Built by session_manager() and assigned by the user to
pipeline.training_data from inside @app.ui::
@app.ui
def ui(ctx):
pipeline.training_data = session_manager(...)
Attributes:
| Name | Type | Description |
|---|---|---|
paths |
list[str]
|
Session locations (folders or |
class_names |
list[str]
|
Human-readable labels — same list passed to
|
classes |
set[int]
|
Active class indices to include. Pass as the |
Layout¶
Grid ¶
Grid(rows: int, cols: int, row_height: list[Track] | None = None, col_width: list[Track] | None = None)
Grid layout manager. Index with [row, col] or [row, col_start:col_end].
Both axes accept the same Px/Fr track specs. See module docstring for examples.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
int
|
Number of rows. |
required |
cols
|
int
|
Number of columns. |
required |
row_height
|
list[Track] | None
|
Per-row track specs (length must equal |
None
|
col_width
|
list[Track] | None
|
Per-column track specs (length must equal |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if a list length doesn't match |
TypeError
|
if a track entry isn't Px, Fr, or a number. |
Source code in myogestic/grid.py
Fr
dataclass
¶
Fr(value: float)
Fractional unit (CSS-grid fr). Fr(1) means "1 share of the
space remaining after :class:Px tracks are subtracted". Multiple
Fr entries split the remainder proportionally to their values, so
[Fr(1), Fr(2)] splits leftover space 1:2.
Event helpers¶
EdgeTrigger ¶
EdgeTrigger(callback: Callable[[T], None])
Bases: Generic[T]
Calls callback(value) only when value differs from the last fire.
Thread-safety: the typical pattern is "one writer (predict thread) +
occasional rebase() from the UI thread". Both assignments to
self._last are atomic under CPython's GIL, so no explicit lock is
needed; the cost is that a race between the two callers can result in
one extra suppressed-or-fired callback, which is harmless for the
intended use cases (RPC dedup, audio cue gating, etc.).
Source code in myogestic/edge_trigger.py
fire_if_changed ¶
fire_if_changed(value: T) -> bool
Fire iff value differs from the last fired value.
Returns True when the callback ran, False when suppressed.
Source code in myogestic/edge_trigger.py
rebase ¶
Set the "last fired" value without firing.
Use when another code path already performed the equivalent action and the trigger should treat that as the new baseline.
Built-in features¶
features ¶
Classic time-domain EMG features — the starter set every example used to copy-paste.
Use as-is, mix with your own, or replace entirely::
from myogestic.contrib.features import rms, mav, wl
from myogestic.widgets import FeatureSelector
feats = FeatureSelector(
{"RMS": rms, "MAV": mav, "WL": wl, "MyCustom": my_custom_fn},
default=["RMS", "MAV"],
)
All take an EMG window of shape (n_channels, n_samples) and return a
per-channel scalar vector (n_channels,) of dtype float32.
External interfaces¶
virtual_hand ¶
virtual_hand(godot_bin: str | None = None, vhi_path: str | None = None, grpc_host: str | None = None, grpc_port: int | None = None, mode: str | None = None) -> InterfaceSpec
The MyoGestic Virtual Hand Interface (VHI).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
godot_bin
|
str | None
|
Path to the Godot binary, for source-mode launch. Falls
back to |
None
|
vhi_path
|
str | None
|
Directory containing VHI (binary install OR Godot project).
Falls back to |
None
|
grpc_host
|
str | None
|
VHI gRPC host. Falls back to |
None
|
grpc_port
|
int | None
|
VHI gRPC port. Falls back to |
None
|
mode
|
str | None
|
Launch mode — |
None
|
Returns: An InterfaceSpec with the resolved argv, ready to wire into
process_launcher(). If VHI isn't installed yet, launcher() raises
a FileNotFoundError pointing at install_vhi.
Source code in myogestic/interfaces.py
InterfaceSpec
dataclass
¶
InterfaceSpec(name: str, process: list[str], output_stream: str, output_channels: int, output_hz: float, control_stream: str | None = None, control_channels: int | None = None, control_pose_stream: str | None = None, control_pose_channels: int | None = None, control_pose_hz: float | None = None, grpc_host: str = '127.0.0.1', grpc_port: int = 50051, install_root: Path | None = None)
Description of an external visual-feedback interface (e.g. VHI).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Human label, used as the process_launcher row title. |
required |
process
|
list[str]
|
argv to spawn the interface (passed to |
required |
output_stream
|
str
|
LSL outlet name the interface listens on. |
required |
output_channels
|
int
|
Number of channels in the output vector. |
required |
output_hz
|
float
|
Outlet send rate. |
required |
control_stream
|
str | None
|
LSL inlet name the interface publishes when the user drives it manually (used for regression targets). May be None. |
None
|
control_channels
|
int | None
|
Channel count of the control stream, if known. |
None
|
control_pose_stream
|
str | None
|
LSL outlet name for streaming a continuous pose
TO the interface's control hand (opt-in; consumed only when VHI is
in STREAM control mode). Opposite direction to |
None
|
control_pose_channels
|
int | None
|
Channel count of the control-pose outlet. |
None
|
control_pose_hz
|
float | None
|
Send rate of the control-pose outlet. |
None
|
grpc_host
|
str
|
VHI gRPC control-server host. |
'127.0.0.1'
|
grpc_port
|
int
|
VHI gRPC control-server port. |
50051
|
install_root
|
Path | None
|
The directory we resolved |
None
|
control_client ¶
Construct a gRPC control client for this interface.
Imported lazily so a plain install (no [grpc] extra) can still use
outlet() / launcher() without grpcio present.
Source code in myogestic/interfaces.py
control_outlet ¶
control_outlet() -> LSLOutlet
Construct an LSLOutlet for streaming a continuous pose to the control
hand. Opt-in: only consumed when VHI is put in STREAM control mode via
control_client().set_control_mode("STREAM"). Raises if this
interface has no control-pose stream configured.
Source code in myogestic/interfaces.py
launcher ¶
Return the (name, argv) tuple list expected by process_launcher.
Raises FileNotFoundError with an install_vhi hint when VHI
is not installed at the resolved location — better than a silent
Popen failure on first run.
Source code in myogestic/interfaces.py
Tools¶
control_outlet ¶
control_outlet(name: str = DEFAULT_CONTROL_STREAM) -> StreamOutlet
LSL outlet for steering the EMG generator from another script.
The generator listens on a stream named name for a single float
(channel = 1) that selects the next gesture amplitude — typically
0.0 (rest) … 1.0 (full). Push samples like::
from myogestic.tools.emg_generator import control_outlet
out = control_outlet()
out.push_sample(np.array([0.0], dtype=np.float32)) # rest
out.push_sample(np.array([1.0], dtype=np.float32)) # fist
Matches the protocol the --control flag on
python -m myogestic.tools.emg_generator listens for.
Source code in myogestic/tools/emg_generator.py
myogestic.tools.install_vhi ¶
Install the Virtual Hand Interface release binary for this platform.
VHI ships pre-built artifacts on every release at
https://github.com/NsquaredLab/MyoGestic-VHI/releases. This CLI picks the
right asset for the host OS/arch, downloads it, unpacks it into the location
virtual_hand() looks at, and drops a vhi-version.txt marker so a
later install knows what's already there.
Usage
python -m myogestic.tools.install_vhi # latest, default dest python -m myogestic.tools.install_vhi --tag v1.0.0 # pinned version python -m myogestic.tools.install_vhi --dest /custom/path python -m myogestic.tools.install_vhi --force # reinstall over existing
Or after pip install myogestic:
myogestic-install-vhi
Pin --tag in production: latest is convenient for a fresh checkout but
not reproducible — a downstream rebuild months later may pick up a different
VHI version with subtly different behaviour.
main ¶
Source code in myogestic/tools/install_vhi.py
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 | |