Skip to content

Architecture

MyoGestic is built around three orthogonal concerns: acquisition (sources feeding ring buffers), decision (the predict thread), and rendering (the main thread drawing widgets). Actuation is a fourth: a Target driving a device from a sanitised control frame. Outputs are the paced sender a target writes through, and are owned by user code.

flowchart TB
    subgraph SRC["Sources - one daemon acquisition thread each"]
        direction LR
        S1[LSLSource]
        S2[ReplaySource]
        S3[SerialSource]
        S5[user Source]
    end

    SRC -->|sample-major chunks<br/>+ LSL clock timestamps| CTX

    subgraph CTX["Context (shared state)"]
        direction TB
        CS["streams: dict[str, Stream]<br/>(dvg-ringbuffer + Lock)"]
        ST["state: 'idle' | 'recording'<br/>'training' | 'predicting'"]
        SE["session: Session | None"]
    end

    CTX -->|get_window<br/>channels-first| PT
    CTX -->|get_display<br/>min/max envelope| RT

    subgraph THREADS["Threads consuming the buffer"]
        direction LR
        PT["Predict thread @ predict_hz<br/>extract → predict<br/>writes pipeline.predictions"]
        RT["Render thread (main)<br/>Dear ImGui + ImPlot<br/>draws widgets"]
        OUT["Output threads<br/>one LSLOutlet per driven control<br/>each @ its own hz"]
    end

    PT -.->|push| OUT

Data flow

A typical tick of the system:

  1. Acquisition thread for stream "emg" reads a chunk from LSLSource, appends to the ring buffer, and (if ctx.session is non-None) appends to the active Zarr array. It never copies the growing history.
  2. Predict thread, once per 1/predict_hz, pulls a window via Stream.get_window() (channels-first), forwards it to @pipeline.extract, then to @pipeline.predict(model, features). The returned dict[str, Any] is stored in pipeline.predictions.
  3. Render thread (main) draws widgets from ctx. SignalViewer asks for only its visible tail and builds a plot-width-sized min/max envelope. The pose-output filter (PostProcessor) renders its panel.
  4. Output thread for LSLOutlet reads its atomic latest-value slot every 1/hz and sends it — every tick, changed or not. Latest-wins, never queued.

Every box runs on its own daemon thread. The shared Context is the only synchronisation surface.

Module map

Module Responsibility
myogestic.core App, Context, lifecycle hooks, run loops
myogestic.stream Stream, ring buffer, acquisition thread, display snapshots
myogestic.sources LSLSource, ReplaySource, SerialSource
myogestic.outputs Outlet base + LSLOutlet, the output-side filters
myogestic.controls Target, ControlBus, Capability — everything that drives
myogestic.session Recording, label tracks, .session.zip, window iterators
myogestic.ml Pipeline, train/predict lifecycle, ML widgets
myogestic.recipes CatBoost / scikit-learn constructor recipes
myogestic.widgets Stateless ImGui function widgets
myogestic.outputs.filters OneEuro / Gaussian / Identity output smoothers
myogestic.bridges Subprocess pattern for heavy-data sources (webcam, ultrasound)

Public API boundary

Keep these import paths stable. The internal modules (myogestic.core, myogestic.stream) are subject to change; user code should import from the package root or named subpackages:

from myogestic import App, Grid, Stream, TrainingData
from myogestic.sources import LSLSource, ReplaySource
from myogestic.outputs import LSLOutlet
from myogestic.ml import Pipeline
from myogestic.session import open_session_store, iter_labeled_windows
from myogestic.widgets import signal_viewer, recording_controls, session_manager

See Public API cheatsheet for the full surface.