emg-speech 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- emg_speech-1.0.0/LICENCE +21 -0
- emg_speech-1.0.0/MANIFEST.in +13 -0
- emg_speech-1.0.0/PKG-INFO +575 -0
- emg_speech-1.0.0/README.md +549 -0
- emg_speech-1.0.0/_bootstrap.py +19 -0
- emg_speech-1.0.0/docs/PUBLISHING.md +137 -0
- emg_speech-1.0.0/docs/STUDENT_GUIDE.md +138 -0
- emg_speech-1.0.0/docs/brain_storming.md +9 -0
- emg_speech-1.0.0/docs/prompt-simple.md +66 -0
- emg_speech-1.0.0/docs/prompt.md +601 -0
- emg_speech-1.0.0/inspect_take.py +26 -0
- emg_speech-1.0.0/pyproject.toml +63 -0
- emg_speech-1.0.0/recognize.py +91 -0
- emg_speech-1.0.0/record.py +49 -0
- emg_speech-1.0.0/requirements.txt +11 -0
- emg_speech-1.0.0/setup.cfg +4 -0
- emg_speech-1.0.0/simulate.py +40 -0
- emg_speech-1.0.0/src/emg_speech/__init__.py +17 -0
- emg_speech-1.0.0/src/emg_speech/api.py +125 -0
- emg_speech-1.0.0/src/emg_speech/apps/__init__.py +3 -0
- emg_speech-1.0.0/src/emg_speech/apps/acquire.py +32 -0
- emg_speech-1.0.0/src/emg_speech/apps/infer.py +67 -0
- emg_speech-1.0.0/src/emg_speech/apps/simulate.py +347 -0
- emg_speech-1.0.0/src/emg_speech/apps/train.py +136 -0
- emg_speech-1.0.0/src/emg_speech/config.py +338 -0
- emg_speech-1.0.0/src/emg_speech/constants.py +33 -0
- emg_speech-1.0.0/src/emg_speech/dataset.py +298 -0
- emg_speech-1.0.0/src/emg_speech/dsp.py +519 -0
- emg_speech-1.0.0/src/emg_speech/inference.py +273 -0
- emg_speech-1.0.0/src/emg_speech/jsonio.py +47 -0
- emg_speech-1.0.0/src/emg_speech/model.py +317 -0
- emg_speech-1.0.0/src/emg_speech/protocol.py +185 -0
- emg_speech-1.0.0/src/emg_speech/ring_buffer.py +94 -0
- emg_speech-1.0.0/src/emg_speech/simulator.py +369 -0
- emg_speech-1.0.0/src/emg_speech/sources.py +485 -0
- emg_speech-1.0.0/src/emg_speech/storage.py +213 -0
- emg_speech-1.0.0/src/emg_speech/synthetic.py +148 -0
- emg_speech-1.0.0/src/emg_speech/train.py +383 -0
- emg_speech-1.0.0/src/emg_speech/ui/__init__.py +3 -0
- emg_speech-1.0.0/src/emg_speech/ui/acquisition_window.py +916 -0
- emg_speech-1.0.0/src/emg_speech/ui/app.py +56 -0
- emg_speech-1.0.0/src/emg_speech/ui/inference_window.py +572 -0
- emg_speech-1.0.0/src/emg_speech/ui/theme.py +230 -0
- emg_speech-1.0.0/src/emg_speech/ui/widgets.py +695 -0
- emg_speech-1.0.0/src/emg_speech/ui/workers.py +331 -0
- emg_speech-1.0.0/src/emg_speech.egg-info/SOURCES.txt +56 -0
- emg_speech-1.0.0/tests/conftest.py +30 -0
- emg_speech-1.0.0/tests/test_architecture.py +77 -0
- emg_speech-1.0.0/tests/test_config.py +48 -0
- emg_speech-1.0.0/tests/test_dataset_model.py +118 -0
- emg_speech-1.0.0/tests/test_dsp.py +142 -0
- emg_speech-1.0.0/tests/test_inference.py +140 -0
- emg_speech-1.0.0/tests/test_protocol.py +93 -0
- emg_speech-1.0.0/tests/test_scripts.py +37 -0
- emg_speech-1.0.0/tests/test_simulator.py +155 -0
- emg_speech-1.0.0/tests/test_storage.py +105 -0
- emg_speech-1.0.0/tests/test_student_api.py +63 -0
- emg_speech-1.0.0/tests/test_ui_widgets.py +175 -0
- emg_speech-1.0.0/train.py +73 -0
emg_speech-1.0.0/LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hosam Hatim Osman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
include LICENCE
|
|
2
|
+
include README.md
|
|
3
|
+
include requirements.txt
|
|
4
|
+
include _bootstrap.py
|
|
5
|
+
include record.py
|
|
6
|
+
include train.py
|
|
7
|
+
include recognize.py
|
|
8
|
+
include simulate.py
|
|
9
|
+
include inspect_take.py
|
|
10
|
+
recursive-include docs *.md
|
|
11
|
+
recursive-include tests *.py
|
|
12
|
+
global-exclude *.py[cod]
|
|
13
|
+
prune src/emg_speech.egg-info
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: emg-speech
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Subvocal EMG speech recognition: acquisition, training and real-time inference
|
|
5
|
+
Author-email: Hosam Hatim Osman <hosam.bosati@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/q2system/emg-speech
|
|
8
|
+
Project-URL: Repository, https://github.com/q2system/emg-speech
|
|
9
|
+
Project-URL: Issues, https://github.com/q2system/emg-speech/issues
|
|
10
|
+
Keywords: emg,speech,biosignals,esp32,pytorch
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENCE
|
|
16
|
+
Requires-Dist: numpy>=1.24
|
|
17
|
+
Requires-Dist: scipy>=1.10
|
|
18
|
+
Requires-Dist: pyserial>=3.5
|
|
19
|
+
Requires-Dist: torch>=2.1
|
|
20
|
+
Requires-Dist: PyQt6>=6.5
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
24
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# EMG Speech — subvocal recognition from a 9-channel ESP32 stream
|
|
28
|
+
|
|
29
|
+
A complete acquisition, training and real-time recognition project. It is
|
|
30
|
+
built so that the part students need to read is intentionally tiny:
|
|
31
|
+
|
|
32
|
+
## Start here: students
|
|
33
|
+
|
|
34
|
+
| Action | Command | What it shows |
|
|
35
|
+
|--------|---------|---------------|
|
|
36
|
+
| Record examples | `python record.py` | One word per button hold |
|
|
37
|
+
| Train a model | `python train.py` | Dataset → checkpoint |
|
|
38
|
+
| Recognise live | `python recognize.py` | Checkpoint → text |
|
|
39
|
+
| Test without hardware | `python simulate.py --pty` | Fake ESP32 stream |
|
|
40
|
+
|
|
41
|
+
The only student-facing programs are:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
record.py record labelled examples (with printed steps)
|
|
45
|
+
train.py train a classifier (with printed steps)
|
|
46
|
+
recognize.py run live recognition (with printed steps)
|
|
47
|
+
simulate.py create a fake ESP32 stream
|
|
48
|
+
inspect_take.py load and print one saved example
|
|
49
|
+
docs/STUDENT_GUIDE.md the classroom explanation
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Everything difficult — serial framing, filters, event detection, PyTorch and
|
|
53
|
+
the PyQt windows — is inside the **`emg_speech` library**. Students can use the
|
|
54
|
+
project without reading it, then open individual library modules when a lesson
|
|
55
|
+
needs the details.
|
|
56
|
+
|
|
57
|
+
The public API is just:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from emg_speech import record, train_model, recognize, simulate
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
These imports work after `pip install -e .` (or any normal installation). The
|
|
64
|
+
four scripts also run directly from a checkout without installing the package.
|
|
65
|
+
|
|
66
|
+
> **Students can stop after this section.** The rest of this README is a
|
|
67
|
+
> developer reference; `docs/STUDENT_GUIDE.md` explains the pipeline without
|
|
68
|
+
> the implementation details.
|
|
69
|
+
|
|
70
|
+
Two desktop applications are built on that library:
|
|
71
|
+
|
|
72
|
+
| App | Entry point | Job |
|
|
73
|
+
|-----|-------------|-----|
|
|
74
|
+
| **Acquisition & annotation** | `python record.py` | Record one word per button hold, detect the real muscle burst, normalise it to a fixed tensor, save `.npy` per class |
|
|
75
|
+
| **Training & inference** | `python train.py` / `python recognize.py` | Train a CNN + BiLSTM classifier, then stream UART, detect gestures and type recognised words live |
|
|
76
|
+
|
|
77
|
+
The PyQt layer only renders state and forwards user intent. Serial I/O, DSP, file
|
|
78
|
+
I/O and PyTorch all live in `src/emg_speech/` and run on worker threads, so the
|
|
79
|
+
UI never blocks. `emg_speech` is importable without Qt (and the DSP modules are
|
|
80
|
+
importable without torch), which is enforced by `tests/test_architecture.py`.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 1. Architecture
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
┌─────────── src/emg_speech/ui/ (PyQt6) ──────────┐
|
|
88
|
+
│ AcquisitionWindow InferenceWindow │
|
|
89
|
+
│ widgets / theme transcript, meter │
|
|
90
|
+
└───────▲───────────────────────────▲─────────────┘
|
|
91
|
+
│ Qt signals │ Qt signals
|
|
92
|
+
┌─────────────┴─────────────┐ ┌─────────┴──────────────┐
|
|
93
|
+
│ SerialReaderThread │ │ InferenceThread │
|
|
94
|
+
│ (QThread: UART read only) │ │ (+ model + recognizer) │
|
|
95
|
+
└─────────────▲─────────────┘ └─────────▲──────────────┘
|
|
96
|
+
│ │
|
|
97
|
+
┌───────┴───────┐ ┌───────┴────────────┐
|
|
98
|
+
│ sources.py │ │ dsp.py │
|
|
99
|
+
│ SerialSource │ │ filters + detector │
|
|
100
|
+
│ ReplaySource │ │ inference.py │
|
|
101
|
+
└───────────────┘ └───────▲────────────┘
|
|
102
|
+
│
|
|
103
|
+
ExtractionTask (QThreadPool) ─────────────┤
|
|
104
|
+
dsp.extract_sample -> storage.save_sample │
|
|
105
|
+
│
|
|
106
|
+
model.py (torch)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Key rule: **one direction only.** UI → worker → core; results come back as
|
|
110
|
+
signals. Nothing in `src/emg_speech/ui/` reads a serial port, applies a filter
|
|
111
|
+
or touches a tensor.
|
|
112
|
+
|
|
113
|
+
### Directory layout
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
LICENCE MIT license text
|
|
117
|
+
README.md this file
|
|
118
|
+
pyproject.toml package metadata and build configuration
|
|
119
|
+
MANIFEST.in extra files for the source distribution
|
|
120
|
+
requirements.txt runtime dependencies for `pip install -r`
|
|
121
|
+
|
|
122
|
+
record.py direct entry script: record examples
|
|
123
|
+
train.py direct entry script: train a model
|
|
124
|
+
recognize.py direct entry script: live recognition
|
|
125
|
+
simulate.py direct entry script: fake serial stream
|
|
126
|
+
inspect_take.py view one saved .npy example
|
|
127
|
+
_bootstrap.py lets the scripts import from src/ in a checkout
|
|
128
|
+
|
|
129
|
+
src/
|
|
130
|
+
emg_speech/ the library — all implementation
|
|
131
|
+
__init__.py exports record, train_model, recognize, simulate
|
|
132
|
+
api.py the small public API used by the scripts
|
|
133
|
+
constants.py channel order, file names, format version
|
|
134
|
+
config.py DspConfig, DetectionConfig, ModelConfig, TrainConfig,
|
|
135
|
+
DatasetMeta + UART bandwidth helpers
|
|
136
|
+
protocol.py defensive UART line -> (9,) float32 frame
|
|
137
|
+
sources.py SerialSource/Writer, PtyWriter, ReplaySource,
|
|
138
|
+
port discovery
|
|
139
|
+
ring_buffer.py lock-free-enough frame ring for take extraction
|
|
140
|
+
dsp.py causal filters, EventDetector, extract_sample,
|
|
141
|
+
time normalisation, NoiseProfile
|
|
142
|
+
storage.py class folders, atomic .npy writes, dataset scan
|
|
143
|
+
dataset.py DatasetIndex, EMGDataset, NormStats, splits, weights
|
|
144
|
+
model.py EMGSequenceClassifier, checkpoint save/load
|
|
145
|
+
train.py training loop, metrics, early stopping
|
|
146
|
+
inference.py StreamingRecognizer state machine
|
|
147
|
+
synthetic.py hardware-free dataset generator
|
|
148
|
+
simulator.py live raw-signal source for virtual serial testing
|
|
149
|
+
ui/ PyQt6 presentation layer (internal)
|
|
150
|
+
apps/ CLI wrappers used by the scripts (internal)
|
|
151
|
+
docs/STUDENT_GUIDE.md simple explanation for a classroom
|
|
152
|
+
docs/PUBLISHING.md install remotely / publish to PyPI
|
|
153
|
+
tests/ test suite (protocol, DSP, storage, model,
|
|
154
|
+
training, inference, simulator/PTY, UI, architecture)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### UART protocol
|
|
158
|
+
|
|
159
|
+
One frame per line, either keyed (firmware format) or positional:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
emg1:512,emg2:498,emg3:505,gx:1.2,gy:-0.4,gz:0.1,ax:0.02,ay:-0.01,az:0.98\n
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`protocol.LineFramer` tolerates partial reads, oversized junk lines and boot
|
|
166
|
+
logs; `FrameParser` counts malformed lines instead of raising. Unknown extra
|
|
167
|
+
keys are ignored, so the firmware can add fields without breaking old builds.
|
|
168
|
+
|
|
169
|
+
**Baud rate matters.** A keyed frame is ~110 bytes, i.e. ~1100 UART bits with
|
|
170
|
+
start/stop bits. At 500 Hz that is ~550 kbit/s. The default is therefore
|
|
171
|
+
**921600 baud**; `config.baudrate_warning()` flags a too-slow selection in both
|
|
172
|
+
apps.
|
|
173
|
+
|
|
174
|
+
| Frames/s | Minimum practical baud |
|
|
175
|
+
|---------:|-----------------------:|
|
|
176
|
+
| 250 | 460800 |
|
|
177
|
+
| **500** | **921600** |
|
|
178
|
+
| 1000 | 1500000 |
|
|
179
|
+
| 2000 | 2000000 |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 2. Application 1 — acquisition & annotation
|
|
184
|
+
|
|
185
|
+
### Workflow
|
|
186
|
+
|
|
187
|
+
1. Create a class ("Add" → folder `dataset/<word>/`).
|
|
188
|
+
2. Select the class, press **Connect**.
|
|
189
|
+
3. **Hold** the record button (or Space), say/subvocalise the word, **release**.
|
|
190
|
+
4. A take is sliced as `[press − 350 ms pre-roll, release + 250 ms tail]`.
|
|
191
|
+
5. DSP runs in a `QThreadPool` task: band-pass → envelope → burst detection →
|
|
192
|
+
crop with margins → resample to `seq_len` → save atomically.
|
|
193
|
+
6. The preview shows the three EMG channels by default (**EMG only** is ticked).
|
|
194
|
+
Untick it to show all nine accepted-take channels (EMG and IMU, z-scored per
|
|
195
|
+
channel for readability). Rejected takes display the measured peak vs.
|
|
196
|
+
threshold, so you can fix electrode contact or `on_sigma`.
|
|
197
|
+
|
|
198
|
+
Settings (`sample rate`, `seq_len`) are written to `dataset/dataset.json` and
|
|
199
|
+
locked as soon as one sample exists. Emptying the dataset (delete class /
|
|
200
|
+
delete last take) unlocks them again; adding samples at a different rate or
|
|
201
|
+
length is refused instead of silently mixing shapes.
|
|
202
|
+
|
|
203
|
+
### DSP chain (identical offline and live)
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
raw frame
|
|
207
|
+
-> 4th-order Butterworth band-pass 20–120 Hz (causal, sosfilt with state)
|
|
208
|
+
-> |x| (full-wave rectification)
|
|
209
|
+
-> 2nd-order low-pass 8 Hz (envelope)
|
|
210
|
+
-> max over EMG1..EMG3 (one detection signal)
|
|
211
|
+
-> IMU: 2nd-order low-pass 20 Hz (kept in physical units)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`StreamingPipeline` keeps `sosfilt` state per channel, so pushing frames one by
|
|
215
|
+
one gives exactly the same output as `process_array()` over a whole take.
|
|
216
|
+
|
|
217
|
+
### Event detector
|
|
218
|
+
|
|
219
|
+
`EventDetector` is a two-threshold hysteresis state machine over the envelope:
|
|
220
|
+
|
|
221
|
+
| Parameter | Default | Meaning |
|
|
222
|
+
|-----------|---------|---------|
|
|
223
|
+
| `on_sigma` | 4.0 | start when `mean + 4σ` exceeded for 20 ms |
|
|
224
|
+
| `off_sigma` | 2.0 | stop when below `mean + 2σ` for 60 ms |
|
|
225
|
+
| `min_event_ms` | 150 | shorter bursts are rejected |
|
|
226
|
+
| `max_event_ms` | 1600 | longer activity is truncated |
|
|
227
|
+
| `pre/post_margin_ms` | 40 / 100 | context kept around the burst |
|
|
228
|
+
| `refractory_ms` | 220 | ignore re-triggers after an event |
|
|
229
|
+
| `adapt_noise` | on, τ = 4 s | slow noise-floor tracking while idle |
|
|
230
|
+
| `calibration_ms` | 2000 | live calibration window |
|
|
231
|
+
|
|
232
|
+
`NoiseProfile` estimates idle mean/σ with the median and MAD (robust to a
|
|
233
|
+
movement spike), falling back to plain σ for short windows. In acquisition each
|
|
234
|
+
take is calibrated on its **own pre-roll** (`noise_profile=None`), so a profile
|
|
235
|
+
measured minutes earlier cannot mask a gesture after the electrodes settle.
|
|
236
|
+
Live inference calibrates once on `calibration_ms` of silence.
|
|
237
|
+
|
|
238
|
+
### Normalisation
|
|
239
|
+
|
|
240
|
+
* **Time:** linear resampling to exactly `seq_len` (default 128) — uniform
|
|
241
|
+
tensor shapes without losing the event.
|
|
242
|
+
* **EMG amplitude:** each sample's EMG channels are scaled to unit RMS
|
|
243
|
+
(`rms_floor=1e-3`), so the model learns shape, not effort.
|
|
244
|
+
* **IMU:** left in physical units and standardised with train-set statistics
|
|
245
|
+
stored inside the checkpoint.
|
|
246
|
+
|
|
247
|
+
Saved arrays are `float32`, shape `(128, 9)`, channel order fixed by
|
|
248
|
+
`constants.CHANNELS`. Writes are atomic (`tmp` + `os.replace`).
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 3. Application 2 — training & real-time inference
|
|
253
|
+
|
|
254
|
+
### Model
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
(x: B, 128, 9)
|
|
258
|
+
-> Conv1d stem (64 ch, kernel 5, same padding, GELU, dropout 0.1)
|
|
259
|
+
-> 2 × BiLSTM(96) with dropout
|
|
260
|
+
-> additive attention pooling over time
|
|
261
|
+
-> LayerNorm -> Linear(2·96, n_classes)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Normalisation buffers (`mean`, `std`) are registered in the checkpoint, so the
|
|
265
|
+
model receives standardised input without the UI having to know about it.
|
|
266
|
+
|
|
267
|
+
### Training
|
|
268
|
+
|
|
269
|
+
`DatasetIndex.build()` validates every file, drops unreadable/wrong-shape
|
|
270
|
+
samples with warnings, and **remaps labels** when a class has no valid samples
|
|
271
|
+
left (that class is reported and excluded). `EMGDataset` is a standard
|
|
272
|
+
`torch.utils.data.Dataset` returning `(x, y)`; `stratified_split()` guarantees
|
|
273
|
+
every class appears in train *and* validation.
|
|
274
|
+
|
|
275
|
+
The loop uses `CrossEntropyLoss` (inverse-frequency class weights by default),
|
|
276
|
+
`Adam`, gradient clipping, `ReduceLROnPlateau` and early stopping. The best
|
|
277
|
+
checkpoint embeds:
|
|
278
|
+
|
|
279
|
+
* model `state_dict` + `ModelConfig`
|
|
280
|
+
* label map
|
|
281
|
+
* `DspConfig` and `DetectionConfig`
|
|
282
|
+
* normalisation statistics
|
|
283
|
+
* metrics (accuracy, macro-F1, confusion matrix, class counts)
|
|
284
|
+
|
|
285
|
+
so inference can never drift from training.
|
|
286
|
+
|
|
287
|
+
### Streaming inference
|
|
288
|
+
|
|
289
|
+
`StreamingRecognizer` runs a single state machine inside `InferenceThread`:
|
|
290
|
+
|
|
291
|
+
```
|
|
292
|
+
CALIBRATING --(2 s of idle)--> LISTENING
|
|
293
|
+
LISTENING --(onset debounce)--> ACTIVE (keeps a bounded frame deque)
|
|
294
|
+
ACTIVE --(offset debounce + post margin)--> predict -> LISTENING
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Only the current event is buffered (`pre + max_event + post` frames), so memory
|
|
298
|
+
is constant. The first forward pass is warmed up at load time; steady-state
|
|
299
|
+
model latency is ~10–15 ms on CPU for the default architecture. Predictions
|
|
300
|
+
below the confidence threshold are **shown but not typed**, and a
|
|
301
|
+
`Recalibrate` button restarts the noise-floor calibration without reconnecting.
|
|
302
|
+
|
|
303
|
+
`ReplaySource` feeds a recorded dataset through the exact same path, which makes
|
|
304
|
+
the whole UI demoable without hardware.
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## 4. PyQt UI
|
|
309
|
+
|
|
310
|
+
Both windows are `QMainWindow` subclasses that own only widgets and a worker
|
|
311
|
+
reference. They never call `read()`, `sosfilt()` or `model()` directly.
|
|
312
|
+
|
|
313
|
+
* **Acquisition** — class list, 30 fps scrolling envelope with threshold line,
|
|
314
|
+
live level meter, normalised take preview, detection settings, hold-to-record
|
|
315
|
+
button (mouse or Space).
|
|
316
|
+
* **Inference** — checkpoint picker with metrics, confidence slider, calibration
|
|
317
|
+
progress, last-prediction detail, and a large transcript with Clear / Copy /
|
|
318
|
+
Save. The **Connect/Disconnect** button in the port selector and the **Start
|
|
319
|
+
listening** button control the same stream. The live view is a fixed-window
|
|
320
|
+
strip chart (it scrolls instead of shrinking the whole session): the default
|
|
321
|
+
view is the detection envelope, and **All 9 signals** shows every filtered
|
|
322
|
+
channel z-scored per channel. The envelope threshold stays visible in both
|
|
323
|
+
views.
|
|
324
|
+
|
|
325
|
+
Threading contract:
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
thread = SerialReaderThread(lambda: SerialSource(port, baud), parent=self)
|
|
329
|
+
thread.frames_batch.connect(self._on_frames)
|
|
330
|
+
thread.opened.connect(self._on_serial_opened)
|
|
331
|
+
thread.error.connect(self._on_error)
|
|
332
|
+
thread.closed.connect(self._on_serial_closed)
|
|
333
|
+
thread.finished.connect(self._on_thread_finished)
|
|
334
|
+
thread.start()
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
* UART reads and DSP happen off the GUI thread; frames arrive in ≤20 ms batches.
|
|
338
|
+
* `ExtractionTask` (a `QRunnable`) does DSP + disk I/O for a take.
|
|
339
|
+
* Link loss is retried with backoff; parse errors are counted, not fatal.
|
|
340
|
+
* Shutdown is idempotent: `closed` and `finished` both reset the controls, the
|
|
341
|
+
pool is drained on close, and a blocked driver read is terminated after 4 s.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## 5. Installation & usage
|
|
346
|
+
|
|
347
|
+
### Dependencies
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
python -m venv .venv && source .venv/bin/activate
|
|
351
|
+
|
|
352
|
+
# Recommended: install this project in editable mode.
|
|
353
|
+
python -m pip install -e ".[dev]"
|
|
354
|
+
|
|
355
|
+
# Or install only the runtime dependencies. The four scripts still run from
|
|
356
|
+
# a checkout because _bootstrap.py adds src/ to sys.path, but the installed
|
|
357
|
+
# console commands and `python -m emg_speech...` need the editable install.
|
|
358
|
+
# pip install -r requirements.txt
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Requires Python ≥ 3.10, `numpy`, `scipy`, `pyserial`, `torch`, `PyQt6`
|
|
362
|
+
(`pytest` for the test suite). CUDA/XPU/MPS is used automatically when
|
|
363
|
+
available (`--device auto`). The package uses the standard `src/` layout from
|
|
364
|
+
the [Python Packaging User Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/).
|
|
365
|
+
|
|
366
|
+
### Install on another computer
|
|
367
|
+
|
|
368
|
+
The project is a normal Python package. After pushing it to a Git repository:
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
pip install "git+https://github.com/<your-user>/<your-repo>.git"
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Or, after publishing to PyPI:
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
pip install emg-speech
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
This installs the library and four console commands:
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
emg-record --help
|
|
384
|
+
emg-train --help
|
|
385
|
+
emg-recognize --help
|
|
386
|
+
emg-simulate --help
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`docs/PUBLISHING.md` contains the full build, TestPyPI, PyPI and API-token
|
|
390
|
+
instructions. Datasets and `artifacts/` are **not** packaged; create them on
|
|
391
|
+
the target machine using `emg-record` / `emg-train`.
|
|
392
|
+
|
|
393
|
+
### No-hardware demo
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
# 1. 4 classes x 20 synthetic takes through the real DSP pipeline
|
|
397
|
+
emg-demo-data --out dataset_demo
|
|
398
|
+
|
|
399
|
+
# 2. train (CPU is fine)
|
|
400
|
+
python train.py --data dataset_demo --out artifacts/model.pt --epochs 20
|
|
401
|
+
|
|
402
|
+
# 3. replay the dataset through the live UI
|
|
403
|
+
python recognize.py --checkpoint artifacts/model.pt --replay dataset_demo
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### With hardware
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
python record.py --data dataset --port /dev/ttyUSB0 --baud 921600
|
|
410
|
+
python train.py --data dataset --out artifacts/model.pt --epochs 60
|
|
411
|
+
python recognize.py --checkpoint artifacts/model.pt --port /dev/ttyUSB0 --baud 921600
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Use `--port COM5` on Windows. The firmware must sample and transmit at the same
|
|
415
|
+
rate configured in the UI (default 500 Hz) and use the same baud rate.
|
|
416
|
+
|
|
417
|
+
#### Windows COM ports
|
|
418
|
+
|
|
419
|
+
The port box is **editable** in both windows: pick a detected port or click it
|
|
420
|
+
and type one directly (`COM3`, `COM10`, …). Ports that are not enumerated —
|
|
421
|
+
including a com0com pair or a POSIX PTY — still work. The refresh button keeps
|
|
422
|
+
your typed name, so a temporarily unplugged adapter does not clear the field.
|
|
423
|
+
On the command line the same applies:
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
python record.py --data dataset --port COM3 --baud 921600
|
|
427
|
+
python recognize.py --checkpoint artifacts/model.pt --port COM3
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
#### Virtual serial testing (no ESP32)
|
|
431
|
+
|
|
432
|
+
The simulator generates a continuous **raw** 9-channel stream (idle noise +
|
|
433
|
+
class-specific bursts and motion) and writes it to a serial endpoint. It does
|
|
434
|
+
not replay normalised dataset samples: the acquisition window, the threaded
|
|
435
|
+
serial reader, the DSP/detector and the inference state machine all run exactly
|
|
436
|
+
as they do with hardware.
|
|
437
|
+
|
|
438
|
+
On Linux/macOS, `--pty` creates a virtual pair with no extra dependencies:
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
# terminal 1 — note the printed path, e.g. /dev/pts/7
|
|
442
|
+
python simulate.py --pty
|
|
443
|
+
|
|
444
|
+
# terminal 2 — type /dev/pts/7 into the port box, or:
|
|
445
|
+
python record.py --data dataset --port /dev/pts/7
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
`auto` mode emits a gesture every `--interval` seconds and prints a cue; press
|
|
449
|
+
and hold record around the cue. `manual` mode is best for annotation practice —
|
|
450
|
+
type a word and press Enter while the record button is held:
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
python simulate.py --pty --mode manual --words hello,world
|
|
454
|
+
# [sim] 1.23s gesture 'hello'
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Inference over the fake UART (the bundled demo checkpoint understands the
|
|
458
|
+
default vocabulary):
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
python simulate.py --pty --interval 1.5
|
|
462
|
+
python recognize.py --checkpoint artifacts/model.pt --port /dev/pts/7
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Useful flags: `--noise`, `--amplitude`, `--start-idle`, `--seed`,
|
|
466
|
+
`--list-ports`, and `--dry-run --duration 6` (unpaced self-test that reports
|
|
467
|
+
cues, encoded frame size and whether the detector finds the burst).
|
|
468
|
+
|
|
469
|
+
On **Windows**, create a null-modem pair once with
|
|
470
|
+
[com0com](https://sourceforge.net/projects/com0com/) from an elevated prompt:
|
|
471
|
+
|
|
472
|
+
```bat
|
|
473
|
+
setupc.exe install PortName=COM5 PortName=COM6
|
|
474
|
+
python simulate.py --setup-windows :: prints the same recipe
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Then run the simulator on one end and the app on the **other** end (Windows
|
|
478
|
+
does not let two processes open the same COM port):
|
|
479
|
+
|
|
480
|
+
```bat
|
|
481
|
+
python simulate.py --port COM5 --baud 921600
|
|
482
|
+
python record.py --port COM6 --baud 921600
|
|
483
|
+
python recognize.py --checkpoint artifacts\model.pt --port COM6
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
A virtual pair ignores the physical baud rate, but keep the same value on both
|
|
487
|
+
sides. `--replay dataset_demo` streams recorded dataset samples instead of the
|
|
488
|
+
synthetic generator; the apps' own `--replay` mode remains the fastest way to
|
|
489
|
+
test inference without any serial layer.
|
|
490
|
+
|
|
491
|
+
### Minimal ESP32 firmware shape
|
|
492
|
+
|
|
493
|
+
```cpp
|
|
494
|
+
#include <Wire.h>
|
|
495
|
+
#include <MPU6050.h>
|
|
496
|
+
|
|
497
|
+
MPU6050 imu;
|
|
498
|
+
const uint32_t PERIOD_US = 2000; // 500 Hz
|
|
499
|
+
uint32_t next_us = 0;
|
|
500
|
+
|
|
501
|
+
void setup() {
|
|
502
|
+
Serial.begin(921600);
|
|
503
|
+
Wire.begin();
|
|
504
|
+
imu.initialize();
|
|
505
|
+
next_us = micros();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
void loop() {
|
|
509
|
+
uint32_t now = micros();
|
|
510
|
+
if ((int32_t)(now - next_us) < 0) return;
|
|
511
|
+
|
|
512
|
+
int16_t ax, ay, az, gx, gy, gz;
|
|
513
|
+
imu.getMotion6(&ax, &ay, &az, &gx, &gy, &gz);
|
|
514
|
+
int e1 = analogRead(34), e2 = analogRead(35), e3 = analogRead(32);
|
|
515
|
+
|
|
516
|
+
char line[128];
|
|
517
|
+
int n = snprintf(line, sizeof(line),
|
|
518
|
+
"emg1:%d,emg2:%d,emg3:%d,gx:%.2f,gy:%.2f,gz:%.2f,"
|
|
519
|
+
"ax:%.3f,ay:%.3f,az:%.3f\n",
|
|
520
|
+
e1, e2, e3,
|
|
521
|
+
gx / 131.0f, gy / 131.0f, gz / 131.0f,
|
|
522
|
+
ax / 16384.0f, ay / 16384.0f, az / 16384.0f);
|
|
523
|
+
Serial.write((const uint8_t*)line, n);
|
|
524
|
+
|
|
525
|
+
next_us += PERIOD_US;
|
|
526
|
+
if ((int32_t)(now - next_us) > (int32_t)(PERIOD_US * 4)) next_us = now + PERIOD_US;
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Notes:
|
|
531
|
+
|
|
532
|
+
* A 12-bit ADC centred near 2048 is fine; the detector adapts to any DC offset.
|
|
533
|
+
* Keep EMG electrodes dry and far from the mains; 20–120 Hz rejects most motion
|
|
534
|
+
and mains hum, but a notch at 50/60 Hz can be added in `DspConfig` if needed.
|
|
535
|
+
* If `Serial.write` occasionally blocks, lower the rate or raise the baud —
|
|
536
|
+
dropped frames look like jittery envelopes in the UI.
|
|
537
|
+
|
|
538
|
+
### Tuning
|
|
539
|
+
|
|
540
|
+
| Symptom | Fix |
|
|
541
|
+
|---------|-----|
|
|
542
|
+
| Nothing detected, peak far below threshold | Lower `on_sigma` (3.0), check electrodes, raise gain |
|
|
543
|
+
| Noise triggers takes | Raise `on_sigma`, improve grounding, keep still during calibration |
|
|
544
|
+
| Words cut at the end | Increase `post_margin_ms` or hold slightly longer before releasing |
|
|
545
|
+
| Two bursts kept per take | The strongest is saved; the UI warns. Slow down between repetitions |
|
|
546
|
+
| Confidence always low | More takes per class (20–40), consistent pronunciation, enable `--augment` |
|
|
547
|
+
| Frames arrive late / truncated | Use ≥921600 baud or lower the sample rate |
|
|
548
|
+
|
|
549
|
+
### Tests
|
|
550
|
+
|
|
551
|
+
```bash
|
|
552
|
+
QT_QPA_PLATFORM=offscreen PYTHONPATH=. python -m pytest -q
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
83 tests cover protocol edge cases, filter/detector behaviour, normalisation,
|
|
556
|
+
atomic storage, dataset remapping, model/checkpoint round-trips, training,
|
|
557
|
+
streaming inference, the live simulator (including a POSIX PTY round-trip),
|
|
558
|
+
the public library API, the four directly runnable entry scripts, layer
|
|
559
|
+
boundaries and offscreen UI tests for manual COM-port entry, the 3/9-channel
|
|
560
|
+
take preview, inference connect wiring, and the scrolling all-signal view.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## Dataset on disk
|
|
565
|
+
|
|
566
|
+
```
|
|
567
|
+
dataset/
|
|
568
|
+
dataset.json rate, seq_len, channel order, DSP config
|
|
569
|
+
hello/
|
|
570
|
+
hello_20261022-101500_ab12cd34.npy (128, 9) float32
|
|
571
|
+
world/
|
|
572
|
+
...
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Only `.npy` files are read; empty folders never reach training.
|