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.
Files changed (59) hide show
  1. emg_speech-1.0.0/LICENCE +21 -0
  2. emg_speech-1.0.0/MANIFEST.in +13 -0
  3. emg_speech-1.0.0/PKG-INFO +575 -0
  4. emg_speech-1.0.0/README.md +549 -0
  5. emg_speech-1.0.0/_bootstrap.py +19 -0
  6. emg_speech-1.0.0/docs/PUBLISHING.md +137 -0
  7. emg_speech-1.0.0/docs/STUDENT_GUIDE.md +138 -0
  8. emg_speech-1.0.0/docs/brain_storming.md +9 -0
  9. emg_speech-1.0.0/docs/prompt-simple.md +66 -0
  10. emg_speech-1.0.0/docs/prompt.md +601 -0
  11. emg_speech-1.0.0/inspect_take.py +26 -0
  12. emg_speech-1.0.0/pyproject.toml +63 -0
  13. emg_speech-1.0.0/recognize.py +91 -0
  14. emg_speech-1.0.0/record.py +49 -0
  15. emg_speech-1.0.0/requirements.txt +11 -0
  16. emg_speech-1.0.0/setup.cfg +4 -0
  17. emg_speech-1.0.0/simulate.py +40 -0
  18. emg_speech-1.0.0/src/emg_speech/__init__.py +17 -0
  19. emg_speech-1.0.0/src/emg_speech/api.py +125 -0
  20. emg_speech-1.0.0/src/emg_speech/apps/__init__.py +3 -0
  21. emg_speech-1.0.0/src/emg_speech/apps/acquire.py +32 -0
  22. emg_speech-1.0.0/src/emg_speech/apps/infer.py +67 -0
  23. emg_speech-1.0.0/src/emg_speech/apps/simulate.py +347 -0
  24. emg_speech-1.0.0/src/emg_speech/apps/train.py +136 -0
  25. emg_speech-1.0.0/src/emg_speech/config.py +338 -0
  26. emg_speech-1.0.0/src/emg_speech/constants.py +33 -0
  27. emg_speech-1.0.0/src/emg_speech/dataset.py +298 -0
  28. emg_speech-1.0.0/src/emg_speech/dsp.py +519 -0
  29. emg_speech-1.0.0/src/emg_speech/inference.py +273 -0
  30. emg_speech-1.0.0/src/emg_speech/jsonio.py +47 -0
  31. emg_speech-1.0.0/src/emg_speech/model.py +317 -0
  32. emg_speech-1.0.0/src/emg_speech/protocol.py +185 -0
  33. emg_speech-1.0.0/src/emg_speech/ring_buffer.py +94 -0
  34. emg_speech-1.0.0/src/emg_speech/simulator.py +369 -0
  35. emg_speech-1.0.0/src/emg_speech/sources.py +485 -0
  36. emg_speech-1.0.0/src/emg_speech/storage.py +213 -0
  37. emg_speech-1.0.0/src/emg_speech/synthetic.py +148 -0
  38. emg_speech-1.0.0/src/emg_speech/train.py +383 -0
  39. emg_speech-1.0.0/src/emg_speech/ui/__init__.py +3 -0
  40. emg_speech-1.0.0/src/emg_speech/ui/acquisition_window.py +916 -0
  41. emg_speech-1.0.0/src/emg_speech/ui/app.py +56 -0
  42. emg_speech-1.0.0/src/emg_speech/ui/inference_window.py +572 -0
  43. emg_speech-1.0.0/src/emg_speech/ui/theme.py +230 -0
  44. emg_speech-1.0.0/src/emg_speech/ui/widgets.py +695 -0
  45. emg_speech-1.0.0/src/emg_speech/ui/workers.py +331 -0
  46. emg_speech-1.0.0/src/emg_speech.egg-info/SOURCES.txt +56 -0
  47. emg_speech-1.0.0/tests/conftest.py +30 -0
  48. emg_speech-1.0.0/tests/test_architecture.py +77 -0
  49. emg_speech-1.0.0/tests/test_config.py +48 -0
  50. emg_speech-1.0.0/tests/test_dataset_model.py +118 -0
  51. emg_speech-1.0.0/tests/test_dsp.py +142 -0
  52. emg_speech-1.0.0/tests/test_inference.py +140 -0
  53. emg_speech-1.0.0/tests/test_protocol.py +93 -0
  54. emg_speech-1.0.0/tests/test_scripts.py +37 -0
  55. emg_speech-1.0.0/tests/test_simulator.py +155 -0
  56. emg_speech-1.0.0/tests/test_storage.py +105 -0
  57. emg_speech-1.0.0/tests/test_student_api.py +63 -0
  58. emg_speech-1.0.0/tests/test_ui_widgets.py +175 -0
  59. emg_speech-1.0.0/train.py +73 -0
@@ -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.