@yam8d/m8-sdk 0.5.2 → 0.5.4

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.
package/README.md CHANGED
@@ -1,800 +1,803 @@
1
- # @yam8d/m8-sdk
2
-
3
- Client SDK for apps that read and drive a Dirtywave M8 tracker. Inside a [yam8d](https://github.com/mio-moto/yam8d) External Apps iframe it talks to yam8d; anywhere else it connects to the M8 itself over Web Serial, WebUSB or Web MIDI ([standalone mode](#standalone-mode)). The API is the same in both modes.
4
-
5
- > **License:** MIT — © kronsilds
6
-
7
- ---
8
-
9
- ## Table of contents
10
-
11
- - [Install](#install)
12
- - [Quick start](#quick-start)
13
- - [Factory functions](#factory-functions)
14
- - [Standalone mode](#standalone-mode)
15
- - [M8Client](#m8client)
16
- - [Properties](#properties)
17
- - [Navigation](#navigation)
18
- - [Value setters](#value-setters)
19
- - [Key input](#key-input)
20
- - [State](#state)
21
- - [Event subscriptions](#event-subscriptions)
22
- - [Semantic context](#semantic-context-on-the-client)
23
- - [Device connection (standalone)](#device-connection-standalone)
24
- - [Lifecycle](#lifecycle)
25
- - [Standalone semantic context functions](#standalone-semantic-context-functions)
26
- - [Types reference](#types-reference)
27
- - [M8State](#m8state)
28
- - [CursorPos / CursorRect / RGB / SystemInfos](#cursorpos--cursorrect--rgb--systeminfos)
29
- - [M8SemanticContext](#m8semanticcontext)
30
- - [M8DescriptionPart](#m8descriptionpart)
31
- - [M8ParsedRow](#m8parsedrow)
32
- - [M8ActiveField / M8ParsedField](#m8activefield--m8parsedfield)
33
- - [M8FieldType](#m8fieldtype)
34
- - [M8KeyName](#m8keyname)
35
- - [M8SdkConfig](#m8sdkconfig)
36
- - [Host events](#host-events)
37
- - [Supported views](#supported-views)
38
- - [Notes](#notes)
39
-
40
- ---
41
-
42
- ## Install
43
-
44
- ```bash
45
- npm install @yam8d/m8-sdk
46
- ```
47
-
48
- For local development before publishing:
49
-
50
- ```bash
51
- npm install ../yam8d/packages/m8-sdk
52
- ```
53
-
54
- > Inside yam8d the app uses yam8d's connection to the M8. Opened in its own tab, it runs in [standalone mode](#standalone-mode) and connects to the M8 itself.
55
-
56
- ---
57
-
58
- ## Quick start
59
-
60
- ```ts
61
- import { createM8Client } from '@yam8d/m8-sdk'
62
-
63
- const m8 = await createM8Client()
64
-
65
- console.log(m8.state.viewName) // e.g. 'song'
66
-
67
- await m8.sendKeyPress(['right'])
68
-
69
- const off = m8.onStateChange((state) => {
70
- console.log(state.cursorPos)
71
- })
72
-
73
- off() // unsubscribe
74
- m8.disconnect()
75
- ```
76
-
77
- ---
78
-
79
- ## Factory functions
80
-
81
- ### `createM8Client(config?): Promise<M8Client>`
82
-
83
- Creates and connects an `M8Client` in one step using top-level `await`.
84
-
85
- ```ts
86
- const m8 = await createM8Client({ debug: true })
87
- ```
88
-
89
- ### `createM8ClientSync(config?): { client: M8Client; connect: () => Promise<void> }`
90
-
91
- Returns a client instance and a deferred `connect()` call. Useful in React or other frameworks where top-level `await` is inconvenient.
92
-
93
- ```ts
94
- import { createM8ClientSync } from '@yam8d/m8-sdk'
95
-
96
- const { client: m8, connect } = createM8ClientSync()
97
-
98
- useEffect(() => {
99
- connect().then(() => console.log('connected'))
100
- return () => m8.disconnect()
101
- }, [])
102
- ```
103
-
104
- ---
105
-
106
- ## Standalone mode
107
-
108
- `createM8Client()` decides the mode when it connects (`client.mode`):
109
-
110
- - **`'host'`**: the page runs in an iframe that yam8d named `yam8d-external-app` (exported as `YAM8D_FRAME_NAME`). The client waits for yam8d's handshake, however long it takes.
111
- - **`'standalone'`**: the page is a top-level tab. The client runs the same engine yam8d uses, in the page itself: the Web Serial / WebUSB / Web MIDI SysEx connection, the display protocol (SLIP, draw rect/character commands), screen text extraction, cursor tracking, view navigation over the view graph, the value editors, file browsing and the MIDI clock.
112
- - In an iframe that yam8d did not name (an older yam8d, or another page embedding the app), the client tries the handshake for `handshakeTimeoutMs` (3 s) and then goes standalone.
113
- - Force a mode with `createM8Client({ mode: 'host' | 'standalone' })`.
114
-
115
- In standalone mode the M8 has to be connected before any call works:
116
-
117
- ```ts
118
- const m8 = await createM8Client()
119
- if (m8.mode === 'standalone' && !m8.isConnected) {
120
- // The browser's device picker only opens from a user gesture
121
- connectButton.onclick = () => m8.connectDevice() // or connectDevice({ transport: 'midi' })
122
- }
123
- m8.onConnectionChange((connected) => updateUi(connected))
124
- ```
125
-
126
- - **Audio**: `connectDevice({ audio: true })` (or `connectAudio()` later) plays the M8's USB audio input through the page, as yam8d does. The browser asks for audio permission; only an input whose label contains "M8" is played, never another microphone. Without it the device stays connected and the warning goes to the console; `isAudioConnected` tells. `disconnectDevice()` stops it too.
127
- - **MIDI clock**: `enableMidiTransport()` / `onTransport()` work the same as in yam8d: the page opens the M8's MIDI input itself (Web MIDI permission). Enable it again after each new device connection.
128
- - At start, a standalone client reopens a device the page was granted before, without a prompt (`autoConnect: false` turns that off).
129
- - Only one page can hold the M8's serial port. Disconnect it in yam8d (or close that tab) before connecting from another page, and call `disconnectDevice()` to hand it back.
130
- - `getDevice()` gives standalone apps the raw protocol events (`text`, `rect`, `wave`, `key`, `system`) and device commands (`sendKeys`, `resetScreen`, `sendNoteOn`, `sendNoteOff`, `sendProgramChange`), e.g. to draw the screen themselves.
131
- - `getDeviceSupport()` tells which of the three browser APIs are available (Chromium browsers have all three; Firefox and Safari have none).
132
- - The engine is exported too, for apps that want to drive the device below the client:
133
- - `createM8Connection` (open a device bus) and `createM8Engine` (the engine yam8d and standalone clients run on a bus);
134
- - `M8KeyMask` (the bit of each key in a key mask) and `pressKeys({ left, up, shift, ... })`, which builds a mask from booleans;
135
- - `createM8Audio()` (plays the M8's USB audio input through the page: `connect()`, `disconnect()`, `connected`, `getOutputCaptureStream()` for recording), `getM8InputStream({ requireM8 })` (the raw input stream; without `requireM8` it falls back to the default input) and `M8_AUDIO_CAPTURE_CONSTRAINTS` (no echo cancellation, noise suppression or gain control).
136
-
137
- ---
138
-
139
- ## M8Client
140
-
141
- ### Properties
142
-
143
- | Property | Type | Description |
144
- |---|---|---|
145
- | `state` | `M8State` | Last known full device state (updated on every host event). |
146
- | `isConnected` | `boolean` | Whether calls reach an M8: host mode, after the yam8d handshake; standalone, while a device is connected. |
147
- | `mode` | `'host' \| 'standalone' \| null` | Chosen at connect; `null` before. |
148
- | `deviceTransport` | `'serial' \| 'usb' \| 'midi' \| null` | Standalone: the API the device is connected through. |
149
-
150
- ---
151
-
152
- ### Navigation
153
-
154
- #### `navigateToView(viewName: string): Promise<boolean>`
155
-
156
- Asks the host to navigate to a named view (e.g. `'song'`, `'phrase'`, `'systemsettings'`, `'mixeq'`). Returns `true` when the view is reached, `false` when no way is left or a newer navigation took over.
157
-
158
- The views and the moves between them are in `m8Graph.generated.json`: Shift+arrows between the main views, buttons that open sub-views (the cursor goes to the button, found by its label, then Edit or Shift+Right), and Option to leave a sub-view. The path is recomputed after every step from the view the M8 actually shows, and what each move did is remembered for the rest of the navigation: a move that changed nothing (Shift+Right on an empty chain) is avoided, so the Instrument view is then reached through Project > View Inst. Pool. Some keys go back to the previous view (the Mixer's Shift+Up; the Instrument's Shift+Left after the Instrument Pool), so the planner keeps track of where the M8 came from. On a view the graph does not know, Option is tried first, then Shift+Up. A button is pressed only when the cursor is on it. The project file browser has no way in (on a changed song, LOAD asks to lose the changes with OK selected); navigation can only leave it.
159
-
160
- ```ts
161
- await m8.navigateToView('phrase')
162
- ```
163
-
164
- #### `navigateTo(x: number, y: number): Promise<void>`
165
-
166
- Moves the cursor to the given character-grid position.
167
-
168
- ```ts
169
- await m8.navigateTo(3, 5)
170
- ```
171
-
172
- ---
173
-
174
- ### Value setters
175
-
176
- All setters act on the field currently under the cursor.
177
-
178
- #### `setValueToHex(targetHex: number): Promise<boolean>`
179
-
180
- Sets the current field to the given hex integer (e.g. `0x0A`). Returns `true` if the value was successfully applied.
181
-
182
- #### `setValueToInt(targetInt: number): Promise<boolean>`
183
-
184
- Sets the current field to the given decimal integer. Returns `true` on success.
185
-
186
- #### `setValueFloat(targetFloat: number): Promise<boolean>`
187
-
188
- Sets a float parameter parsed from the whole line, e.g. `TUNE 440.00 G` or `GAIN 05.25 -19.75 -15.00`
189
- (multiple floats per line are supported; the cursor's highlight selects which one is edited).
190
- Works with the cursor on a single digit group (`440` / `00`) or spanning the whole float (`19.75`,
191
- with the sign usually excluded from the highlight). With whole-float focus, edit+up/down moves the
192
- integer part and edit+left/right the decimal part; with group focus the SDK relocates the cursor
193
- between the groups for the fastest path. Negative values are supported and step sizes are measured
194
- per field from the device.
195
- Returns `true` when the displayed value matches.
196
-
197
- #### `setNote(noteString: string): Promise<boolean>`
198
-
199
- Sets a note field using M8 note format, e.g. `'C-4'`, `'A#3'`, `'OFF'`. Returns `true` on success.
200
-
201
- #### `setValueToString(targetString: string, exact?: boolean, searchInCurrentLine?: boolean): Promise<boolean>`
202
-
203
- Sets the current field by navigating to match `targetString`.
204
-
205
- - `exact` (default `true`): require an exact string match.
206
- - `searchInCurrentLine` (default `false`): restrict the search to the current row.
207
-
208
- Returns `true` if the target was reached.
209
-
210
- #### `browseFile(targetText: string, exact?: boolean): Promise<boolean>`
211
-
212
- Navigates a file-browse dialog to an entry matching `targetText`. Returns `true` on success.
213
-
214
- ---
215
-
216
- ### Key input
217
-
218
- #### `sendKeyPress(keys: M8KeyName[]): Promise<void>`
219
-
220
- Sends a simultaneous key press + release for the given keys.
221
-
222
- ```ts
223
- await m8.sendKeyPress(['shift', 'play'])
224
- ```
225
-
226
- #### `sendKeyDown(keys: M8KeyName[]): Promise<void>`
227
-
228
- Holds the given keys down without releasing. Pair with `sendKeyUp()`.
229
-
230
- ```ts
231
- await m8.sendKeyDown(['shift'])
232
- await m8.sendKeyPress(['right'])
233
- await m8.sendKeyUp()
234
- ```
235
-
236
- #### `sendKeyUp(): Promise<void>`
237
-
238
- Releases all currently held keys.
239
-
240
- ---
241
-
242
- ### State
243
-
244
- #### `getState(): M8State`
245
-
246
- Returns the in-memory cached state synchronously. Same as reading `m8.state`.
247
-
248
- #### `fetchState(): Promise<M8State>`
249
-
250
- Requests a fresh state snapshot from the host and updates the internal cache.
251
-
252
- ```ts
253
- const state = await m8.fetchState()
254
- ```
255
-
256
- #### `getScreen(): Promise<M8Screen>`
257
-
258
- The whole screen as text rows (40 columns, column 0 is the margin where play markers are drawn), read from the character stream without moving the cursor.
259
-
260
- #### `getScreenCells(): Promise<M8ScreenCells>`
261
-
262
- The same screen plus the colour of every character cell, so an app can look like the device or learn which theme colour the M8 uses for what. `fg[row][col]` and `bg[row][col]` are `'#rrggbb'`, `null` where nothing was drawn. Needs a yam8d host with SDK 0.5 or later; older hosts reject the call.
263
-
264
- ```ts
265
- const cells = await m8.getScreenCells()
266
- cells.fg[5][3] // colour of the first note of a phrase, e.g. '#ffffff'
267
- ```
268
-
269
- #### `captureSnapshot(): Promise<M8Snapshot>`
270
-
271
- A fresh state, its semantic context, the description (as text and as parts) and the whole screen in one call. The result is plain JSON, safe to store.
272
-
273
- ```ts
274
- const snap = await m8.captureSnapshot()
275
- snap.description // e.g. "Note (N): Note C-4 — Phrase View row 00"
276
- snap.screen.lines // the screen it was read from
277
- ```
278
-
279
- ---
280
-
281
- ### Event subscriptions
282
-
283
- Every `on*` method returns an **unsubscribe function** — call it to stop listening.
284
-
285
- #### `onStateChange(callback: (state: M8State) => void): () => void`
286
-
287
- Fires whenever any part of the device state changes.
288
-
289
- ```ts
290
- const off = m8.onStateChange((state) => {
291
- console.log(state.viewName, state.cursorPos)
292
- })
293
- off() // unsubscribe
294
- ```
295
-
296
- #### `onViewChange(callback: (viewName: string | null, viewTitle: string | null) => void): () => void`
297
-
298
- Fires when the active view changes.
299
-
300
- ```ts
301
- m8.onViewChange((name, title) => {
302
- console.log(`Switched to ${name}: ${title}`)
303
- })
304
- ```
305
-
306
- #### `onCursorMove(callback: (pos: CursorPos | null, rect: CursorRect | null, selectionMode: boolean) => void): () => void`
307
-
308
- Fires when the cursor position or selection mode changes.
309
-
310
- ```ts
311
- m8.onCursorMove((pos, rect, sel) => {
312
- console.log(`Cursor at (${pos?.x}, ${pos?.y}), selecting: ${sel}`)
313
- })
314
- ```
315
-
316
- #### `onTextUpdate(callback: (textUnderCursor: string | null, currentLine: string | null) => void): () => void`
317
-
318
- Fires when the text under the cursor or the full current line changes.
319
-
320
- ```ts
321
- m8.onTextUpdate((text, line) => {
322
- console.log('Under cursor:', text)
323
- console.log('Full line:', line)
324
- })
325
- ```
326
-
327
- #### `onKeyPress(callback: (keys: number) => void): () => void`
328
-
329
- Fires when a physical key event occurs on the device. `keys` is a bitmask of active keys (see `M8KeyMask`).
330
-
331
- #### `enableMidiTransport(): Promise<{ ok: boolean; error?: string }>`
332
-
333
- Starts forwarding the M8's MIDI clock and transport. The M8 only sends it with SYNC OUT set in its MIDI Settings (a setting stored in each song), and the page that owns the device needs Web MIDI permission: yam8d in host mode, the app itself in standalone mode. Hosts before SDK 0.5 reject the call. In standalone mode, call it again after each new device connection.
334
-
335
- #### `onTransport(callback: (event: M8TransportEvent) => void): () => void`
336
-
337
- MIDI start / stop / continue, the clock, and song positions, after `enableMidiTransport()`.
338
-
339
- ```ts
340
- interface M8TransportEvent {
341
- kind: 'start' | 'stop' | 'continue' | 'clock' | 'position'
342
- ticks: number // clock ticks since the host started listening (monotonic)
343
- at: number // epoch ms (performance.timeOrigin + timestamp), comparable across frames
344
- msPerTick: number | null // measured tick length, null until known
345
- spp?: number // Song Position Pointer in sixteenths, on 'position' events
346
- }
347
- ```
348
-
349
- `clock` events come once per sixteenth (6 ticks at 24 PPQ), not on every tick. The M8 can send clock while stopped.
350
-
351
- ```ts
352
- const { ok, error } = await m8.enableMidiTransport()
353
- if (!ok) console.warn(error)
354
- m8.onTransport((e) => {
355
- if (e.kind === 'position') console.log('play from sixteenth', e.spp)
356
- })
357
- ```
358
-
359
- ---
360
-
361
- ### Semantic context on the client
362
-
363
- #### `getSemanticContext(): M8SemanticContext | null`
364
-
365
- Parses `m8.state` into structured field data. Returns `null` when no view is active.
366
-
367
- ```ts
368
- const ctx = m8.getSemanticContext()
369
-
370
- if (ctx?.activeField?.type === 'chainRef') {
371
- const track = ctx.activeField.meta?.trackIndex // 1–8
372
- const chain = ctx.activeField.hexValue // 0–255 or null if empty
373
- const row = ctx.row?.rowIndex
374
- }
375
- ```
376
-
377
- #### `describeContext(): string | null`
378
-
379
- Returns a human-readable one-liner, e.g.:
380
-
381
- - `"Track 3: 0A — Track 3 — Song View row 01"`
382
- - `"Note (N): Note C-4 — Phrase View row 00"`
383
- - `"FX2 Command: KIL — (Kill: stops the playing instrument after XX ticks) — value 03: stops the note after 3 ticks — Phrase View row 01"`
384
-
385
- Returns `null` when not connected or no view is active. It is the text of `describeContextParts()` joined with `' — '`.
386
-
387
- It reads the cursor against its surroundings when that changes the meaning:
388
-
389
- - **FX values are explained** — `ARP37` on `C-4` → `"+3 and +7 semitones: C-4 D#4 G-4"`, `KIL04` → `"stops the note after 4 ticks"`, relative instrument commands show their sign.
390
- - **REP is followed down its FX lane.** With `VOL10` above, `REP05` reads `"repeats VOL from 10 (step 1), +05 per step → 15 on this step"`, and an empty cell below it reads `"REP still active: VOL reaches 1A on this step (+05 per step since step 3)"`. A new command, `REP00`, or an `RTO` limit in the same lane ends or caps it. `^^` (a REP carried over from the previous phrase) is recognised when it can be attributed to one lane.
391
- - **Relative commands are tracked.** Per the manual, a relative instrument command (`VOL`, `CUT`, `PIT`, `EA1`… — the ones the device help marks `(RELATIVE)`) keeps its change until a note is triggered with a `RET` or with an instrument number in the `I` column. `VOL02` twice reads `"VOL has moved by +04 in 2 steps since the instrument number on step 0; stays until a note with an instrument number (I) or a RET resets it"`, REP repeats are counted, and the `I` cell or `RET` that resets it lists what it wipes (`"resets the relative changes still in force (CUT +10, VOL +0B)"`). Only the current phrase is visible, so totals start at the phrase's own last reset.
392
- - **Instrument-type commands say who offers them.** `FM3` → `"…; offered by FM Synth instruments"`.
393
- - **Scale View** names the scale the enabled notes spell (`"Scale: C D E F G A B — Major (Ionian)"`), says what the row under the cursor is (`"E — a major 3rd (+4 semitones above C) — is in the scale"`), reports detuning, and explains the KEY / TUNE / NAME rows.
394
-
395
- The surroundings come from a copy of the screen that the client refreshes in the background after every state event. It is only used when its row under the cursor still matches `currentLine`; otherwise the description quietly falls back to the cursor row alone. Call `await m8.getScreen()` or `await m8.captureSnapshot()` first when you need it guaranteed current.
396
-
397
- #### `describeContextParts(): M8DescriptionPart[] | null`
398
-
399
- The same description as ordered, tagged parts, so you can keep, drop, reorder or style some of them instead of parsing the string:
400
-
401
- ```ts
402
- m8.describeContextParts()
403
- // [{ kind: 'field', text: 'FX2 Command: KIL' },
404
- // { kind: 'fxInfo', text: '(Kill: stops the playing instrument after XX ticks)' },
405
- // { kind: 'meaning', text: 'value 03: stops the note after 3 ticks' },
406
- // { kind: 'location', text: 'Phrase View row 01' }]
407
-
408
- // Your own view label instead of the SDK's location:
409
- const text = m8.describeContextParts()?.filter((p) => p.kind !== 'location').map((p) => p.text).join(' · ')
410
- ```
411
-
412
- See [`M8DescriptionPart`](#m8descriptionpart) for the kinds. Everything a part says is also in the context as data (`activeField`, `fxLane`, `relativeFx`, `resetsRelative`, `scaleNote`, `scale`, …), for when you want to write the text yourself.
413
-
414
- ---
415
-
416
- ### Device connection (standalone)
417
-
418
- #### `connectDevice(options?: { transport?: 'auto' | 'serial' | 'usb' | 'midi'; audio?: boolean }): Promise<void>`
419
-
420
- Connects to the M8 from this page. `'auto'` (default) uses Web Serial, then WebUSB, then Web MIDI SysEx, by browser support. Without a previously granted device the browser shows its picker, which needs a user gesture: call it from a click handler. Replaces the current device. No-op in host mode.
421
-
422
- #### `disconnectDevice(): Promise<void>`
423
-
424
- Releases the device (and stops its audio) so yam8d or another page can open it.
425
-
426
- #### `connectAudio(): Promise<void>` / `disconnectAudio(): Promise<void>`
427
-
428
- Standalone: plays the M8's USB audio through the page, or stops it. `connectAudio()` rejects when no audio input named M8 exists. No-ops in host mode (yam8d plays it). `isAudioConnected` reports the state.
429
-
430
- #### `onConnectionChange(callback: (connected: boolean) => void): () => void`
431
-
432
- Fires when `isConnected` changes: handshake done, device connected, unplugged or released.
433
-
434
- #### `getDevice(): M8DeviceBus | null`
435
-
436
- Standalone: raw protocol events and device commands. `null` in host mode or while disconnected.
437
-
438
- ---
439
-
440
- ### Lifecycle
441
-
442
- #### `disconnect(): void`
443
-
444
- Closes the host connection (and the device, in standalone mode) and clears all event listeners and internal state.
445
-
446
- ---
447
-
448
- ## Standalone semantic context functions
449
-
450
- These are re-exported from `@yam8d/m8-sdk` for use without an `M8Client` instance.
451
-
452
- ### `getSemanticContext(state: M8State, screen?: M8Screen | null): M8SemanticContext | null`
453
-
454
- Parses an `M8State` snapshot into a semantic context. Returns `null` if `state.viewName` is not set.
455
-
456
- Pass the whole `screen` (from `getScreen()`) to fill the context's look-around fields: `steps` (all 16 phrase steps), `fxLane` (what the FX cell under the cursor means in its lane), `relativeFx`, `resetsRelative`, `carriedRepLane`, and `scale` (Scale View). A screen whose cursor row no longer matches `state.currentLine` is ignored. `lineText` is always set for grid views.
457
-
458
- ```ts
459
- import { getSemanticContext } from '@yam8d/m8-sdk'
460
-
461
- const ctx = getSemanticContext(state)
462
- if (ctx?.isGridView && ctx.row) {
463
- const note = ctx.row.fields['note']
464
- console.log(note?.rawValue) // e.g. 'C-4'
465
- }
466
- ```
467
-
468
- ### `describeContext(ctx: M8SemanticContext): string`
469
-
470
- Returns a human-readable summary of the context (see examples above).
471
-
472
- ### `describeContextParts(ctx: M8SemanticContext): M8DescriptionPart[]`
473
-
474
- The summary as ordered, tagged parts; `describeContext()` joins their text with `' — '`.
475
-
476
- ### `formatFieldValue(field: M8ParsedField): string`
477
-
478
- Formats a single parsed field as a display string:
479
-
480
- | Type | Example output |
481
- |---|---|
482
- | `note` | `"Note C-4"`, `"Note Off"` |
483
- | `transpose` | `"+3 st"`, `"-12 st"`, `"±0 st"` |
484
- | `velocity` | `"57h (87 / 127 max)"` |
485
- | `fxValue` | `"1Ah (dec 26)"` |
486
- | `ticks` | `"06h (6 ticks)"`, `"00 (skip step)"` |
487
- | `chainRef` / `phraseRef` / `fxCommand` / `volume` | raw value, e.g. `"0A"`, `"KIL"` |
488
- | empty cell | `"(empty)"` |
489
-
490
- ### `lookupFxCommand(cmd: string): M8FxCommandInfo | undefined`
491
-
492
- Looks up a 3-character FX command name (case-insensitive) across all categories (sequencer, instrument, modulation, mixer). Modulator commands carry their slot as the third character (`EA1`, `LF3`).
493
-
494
- ```ts
495
- import { lookupFxCommand } from '@yam8d/m8-sdk'
496
-
497
- lookupFxCommand('KIL')
498
- // → { description: 'Kill: stops the playing instrument after XX ticks.',
499
- // category: 'sequencer', relative: false, instrumentTypes: null, slot: null }
500
- lookupFxCommand('FM3')
501
- // → { …, category: 'instrument', relative: true, instrumentTypes: ['fmsynth'], slot: null }
502
- lookupFxCommand('LF3')
503
- // → { …, category: 'modulation', relative: false, instrumentTypes: null, slot: 3 }
504
- ```
505
-
506
- `instrumentTypes` lists the instrument types that offer the command when not every type does (`null` = all, or not an instrument command).
507
-
508
- ### `getInstrumentFxCommands(type: string): M8InstrumentFxList | null`
509
-
510
- The instrument commands offered by an instrument type (the "Current Instrument" section of the Effect Command Help view, opened with `[EDIT]+[UP/DOWN]` on an FX command). `type` is the key from `M8SemanticContext.instrumentType.key`.
511
-
512
- ```ts
513
- getInstrumentFxCommands('hypersynth')
514
- // → { type: 'hypersynth', verified: true,
515
- // commands: ['VOL','PIT','FIN','CRD','CVO','SWM','WID','SUB','FIL','CUT','RES','AMP','LIM','PAN','SNC','ERR','DRY','SMX','SDL','SRV'] }
516
- ```
517
-
518
- `verified: true` means the list was read from a real M8 (Model:02): **sampler, macrosynth, fmsynth, hypersynth**. Wavsynth and External were not available on that device, so their lists are unknown (`null`); `midiout` only holds the documented `CHD`/`ADD`. The Sequencer FX and Mixer/Effects sections are the same for every type, and the Instrument Mods section depends on the kind of each modulator slot (envelope: `EA AT HO DE ET`, LFO: `LA LO LS LF LT`).
519
-
520
- ### `RELATIVE_FX_RULE: string`
521
-
522
- The manual's rule for relative commands, for tooltips.
523
-
524
- ### `parseGridScreen(viewName: string, screen: M8Screen): M8ScreenGridRow[]`
525
-
526
- Every content row of a grid view (`song`, `chain`, `phrase`, `table`, `groove`, `instrumentpool`) read off the whole screen, top to bottom, each with the `screenRow` it is drawn on. Use it to read a full page at once instead of only the cursor row. Rows whose index does not parse are skipped, so check `rowIndex` continuity when you need the whole page.
527
-
528
- ```ts
529
- const steps = parseGridScreen('phrase', await m8.getScreen())
530
- steps[0].fields.note.rawValue // 'c-4' (the text stream is lower case)
531
- ```
532
-
533
- ### `parseViewTitle(rawTitle: string | null): { viewId: string | null; isUnsaved: boolean }`
534
-
535
- The item number of a title (`'PHRASE 1F*'` → `{ viewId: '1F', isUnsaved: true }`).
536
-
537
- ### `readPlayPositions(viewName: string, screen: M8Screen): M8PlayPositions | null`
538
-
539
- Reads the play markers off a grid view. On the Song view the M8 draws a marker just left of each playing chain (`00>00>01>02`), so `tracks` holds the song row of each track's marker (`null` where it is not on screen). On the chain, phrase, table and groove views the marker follows the row index of the playing row (`F>--- -- --`), so `row` holds that row. Returns `null` for views without a grid, and all nulls when nothing plays. Seen on a Model:02, firmware 6.5.
540
-
541
- ```ts
542
- interface M8PlayPositions {
543
- tracks: (number | null)[] | null // song view only
544
- row: number | null // other grid views
545
- }
546
- ```
547
-
548
- ### `SCREEN_MARGIN: number`
549
-
550
- The one-column left margin of every screen row (`1`). `screen.lines[y].slice(SCREEN_MARGIN)` lines columns up with the schema's `x` positions.
551
-
552
- ### Theme: `parseThemeScreen`, `observeThemeColors`, `M8_THEME_ROLES`, `M8_THEME_VIEW`
553
-
554
- The device theme has 13 colours, edited in System Settings › EDIT THEME (view name `themesettings`, exported as `M8_THEME_VIEW`).
555
-
556
- - `parseThemeScreen(screen)` reads them off the Theme View screen: `{ colors: { background, textEmpty, textInfo, textDefault, textValue, textTitles, playMarkers, cursor, selection, scopeSlider, meterLow, meterMid, meterPeak }, mode: 'rgb' | 'hsv', name }`, colours as `'#rrggbb'`. HSV rows are converted (each channel read as 00-FF). It returns `null` unless all 13 rows are on screen.
557
- - `observeThemeColors(state)` gives the colours any screen shows without opening the Theme View: `background` and `textTitles`. (`state.cursorColor` is the cursor rectangle as drawn; the M8 animates it, so it is not the theme's CURSOR colour.)
558
- - `M8_THEME_ROLES` lists the roles with the labels the device shows.
559
-
560
- ```ts
561
- if (m8.state.viewName === M8_THEME_VIEW) {
562
- const theme = parseThemeScreen(await m8.getScreen())
563
- }
564
- ```
565
-
566
- ---
567
-
568
- ## Types reference
569
-
570
- ### `M8State`
571
-
572
- The complete snapshot of the device at a point in time.
573
-
574
- ```ts
575
- interface M8State {
576
- viewName: string | null // active view key, e.g. 'song', 'phrase'
577
- viewTitle: string | null // raw title string from screen, e.g. ' SONG '
578
- minimapKey: string | null // current minimap position key
579
- cursorPos: CursorPos | null // character-grid position of the cursor
580
- cursorRect: CursorRect | null // pixel rectangle of the cursor overlay
581
- selectionMode: boolean // true when a selection is active
582
- highlightColor: RGB | null // cursor highlight colour
583
- titleColor: RGB | null // view title text colour
584
- backgroundColor: RGB | null // screen background colour
585
- cursorColor?: RGB | null // cursor rectangle as drawn (animated, not the theme CURSOR); SDK 0.5+
586
- textUnderCursor: string | null // characters covered by the cursor rect
587
- currentLine: string | null // full text of the row at cursorPos.y
588
- deviceModel: string | null // e.g. 'Headless', 'Model:02'
589
- fontMode: number | null // 0=standard, 1=bold, 2=large
590
- systemInfo: SystemInfos | null // device-reported system properties
591
- macroRunning: boolean // true while a macro is executing
592
- macroCurrentStep?: number // current macro step index
593
- macroSequenceLength?: number // total steps in the running macro
594
- }
595
- ```
596
-
597
- ### `CursorPos / CursorRect / RGB / SystemInfos`
598
-
599
- ```ts
600
- interface CursorPos { x: number; y: number }
601
- interface CursorRect { x: number; y: number; w: number; h: number }
602
- interface RGB { r: number; g: number; b: number }
603
- interface SystemInfos { [key: string]: string | number | boolean | undefined }
604
- ```
605
-
606
- ### `M8SemanticContext`
607
-
608
- ```ts
609
- interface M8SemanticContext {
610
- viewName: string // e.g. 'song', 'phrase', 'instrumentpool'
611
- viewTitle: string | null // e.g. 'Song View'
612
- viewDescription: string | null // long description from schema
613
- viewId: string | null // item ID parsed from screen title, e.g. 'F2', '03'; null for song/instrumentpool
614
- isUnsaved: boolean // true when the title carries a '*' suffix (unsaved changes)
615
- isGridView: boolean // true for song/chain/phrase/table/groove/scale/instrumentpool
616
- isParameterView: boolean // true for the label/value views (inst, mixer, project, …)
617
- section: string | null // effectsettings: 'MODFX' | 'DELAY' | 'REVERB'
618
- instrumentType: { text: string; key: string | null } | null // inst view + screen, e.g. { text: 'WAVSYNTH', key: 'wavsynth' }
619
- row: M8ParsedRow | null // all columns on the line at cursorPos.y
620
- activeField: M8ActiveField | null // the column currently under the cursor
621
- lineText: string | null // cursor row text, playback marker removed
622
- steps: M8ParsedRow[] | null // phrase view + screen: all 16 steps (index = step)
623
- carriedRepLane: number | null // FX lane (1–3) showing '^^' (REP from previous phrase)
624
- fxLane: M8FxLaneTrace | null // phrase view + screen: what the FX cell means in its lane
625
- relativeFx: Record<string, M8RelativeFxState> | null // phrase view + screen: relative commands in force, with net change since the last trigger
626
- resetsRelative: Record<string, M8RelativeFxState> | null // phrase view + screen: when the cursor step triggers the instrument (I number or RET), the relative changes it puts back
627
- scaleNote: M8ScaleNote | null // scale view: the interval row under the cursor { interval, semitone, enabled, offset }
628
- scale: M8ScaleInfo | null // scale view + screen: the 12 intervals interpreted together
629
- }
630
- ```
631
-
632
- ### `M8DescriptionPart`
633
-
634
- ```ts
635
- interface M8DescriptionPart {
636
- kind: M8DescriptionPartKind
637
- text: string
638
- }
639
- ```
640
-
641
- Parts come in reading order; a kind can appear more than once.
642
-
643
- | Kind | Example |
644
- |---|---|
645
- | `field` | `"FX1 Command: KIL"`, `"Tempo (TEMPO): 120.00 BPM"` |
646
- | `fxInfo` | `"(Kill: stops the playing instrument after XX ticks)"` |
647
- | `fxTarget` | on an FX value: `"for KIL (…)"` |
648
- | `meaning` | `"value 03: stops the note after 3 ticks"`, `"E is detuned by −0.50 st"` |
649
- | `fxLane` | `"REP still active: VOL reaches 09 on this step (+02 per step since step 1)"` |
650
- | `relative` | `"relative: VOL has moved by +0C over 2 steps since …"`, `"triggers the instrument again: resets …"` |
651
- | `optionInfo` | what the selected option does |
652
- | `fieldInfo` | what the field is |
653
- | `fileInfo` | `"directory: [EDIT] opens it"` |
654
- | `instrument` | `"Wavsynth instrument"` |
655
- | `section` | `"Delay"`, `"MOD2"` |
656
- | `scale` | `"Scale: C D E F G A B"`, `"Major (Ionian); detuned: E −0.50 st"` |
657
- | `track` | `"Track 3"` |
658
- | `location` | `"Phrase View row 07"`, `"Mixer View"` |
659
- | `line` | the cursor row's text, when no field on it is recognised |
660
-
661
- ### `M8ParsedRow`
662
-
663
- ```ts
664
- interface M8ParsedRow {
665
- rowIndex: number | null // hex row index from leftmost chars; null if unparseable
666
- rowKey: string // schema key, e.g. 'songRow', 'step', 'chainPos'
667
- rowId: string | null // parameter views: matched schema row ('tempo', 'gain', …)
668
- fields: Record<string, M8ParsedField> // column key → parsed field
669
- }
670
- ```
671
-
672
- ### `M8ActiveField / M8ParsedField`
673
-
674
- `M8ActiveField` extends `M8ParsedField` with two extra convenience fields.
675
-
676
- ```ts
677
- interface M8ParsedField {
678
- key: string // schema key, e.g. 'fx1cmd', 'track3', 'note'
679
- label: string // human label, e.g. 'FX1 Command', 'Track 3'
680
- type: M8FieldType
681
- rawValue: string // trimmed text sliced from currentLine
682
- hexValue: number | null // parsed integer; null for empty cells and text types
683
- isEmpty: boolean // true when '--', '---', '---00', etc.
684
- meta?: Record<string, unknown> // view-specific extras (e.g. { trackIndex: 3 })
685
- // parameter views (all optional):
686
- description?: string // what the field is
687
- text?: string | null // non-numeric part: option name, caption, name, instrument type
688
- numericValue?: number | null // value in its natural base (hex, decimal, fractional)
689
- values?: number[] | null // both numbers of an 'XX:YY' pair
690
- parts?: string[] // names of those numbers, e.g. ['left time', 'right time']
691
- unit?: string // e.g. ' BPM', ' dB'
692
- valueDescription?: string // what the selected option does
693
- fileKind?: M8FileKind // file browser rows: 'parent' | 'directory' | 'instrumentPreset' | 'sample' | 'm8file' | 'file'
694
- }
695
-
696
- interface M8ActiveField extends M8ParsedField {
697
- viewName: string // repeated for convenience
698
- rowIndex: number | null // row index of the line containing this field
699
- }
700
- ```
701
-
702
- ### `M8FieldType`
703
-
704
- ```ts
705
- type M8FieldType =
706
- | 'chainRef' // chain number hex 00–FF; '--' = empty
707
- | 'phraseRef' // phrase number hex 00–FF; '--' = empty
708
- | 'note' // note name e.g. 'C-4', 'A#3'; '---' = empty; 'OFF' = note-off
709
- | 'velocity' // velocity 00–7F hex; '--' = inherit
710
- | 'instrumentRef' // instrument slot 00–7F hex; '--' = keep current
711
- | 'fxCommand' // 3-char command name e.g. 'KIL'; '---' = none
712
- | 'fxValue' // FX parameter 00–FF hex
713
- | 'transpose' // relative semitones: 00=none, 01–7F=+1–+127, FF–80=−1–−128
714
- | 'ticks' // groove ticks per step hex; '00'=skip; '--'=end of groove
715
- | 'volume' // level 00–FF hex; '--' = no override
716
- | 'ppq' // pulses-per-quarter-note decimal; groove row 0 only
717
- | 'instrumentName' // instrument name text ≤12 chars; no hexValue
718
- | 'eqSlot' // EQ slot assignment hex; '--' = none
719
- | 'rowIndex' // row/step index hex
720
- | 'noteInterval' // scale view row label: C, C#, D, …
721
- | 'onOff' // 'ON' / 'OFF' toggle
722
- | 'semitoneOffset' // scale view detune, e.g. '-00.50'
723
- | 'swing' // groove swing percentage
724
- | 'instrumentType' // instrument engine: WAVSYNTH, SAMPLER, FMSYNTH, …
725
- | 'name' // free-text name field; dashes = unnamed
726
- | 'parameter' // named editable value: CUTOFF, TEMPO, GAIN, …
727
- | 'option' // choice from a list: '00CHORUS', 'BELL', 'STEREO'
728
- | 'action' // button: LOAD, SAVE, RENDER, SETTINGS, EQ, …
729
- | 'fileEntry' // file browser row: '/folder', '/..', 'Bass.m8i'
730
- ```
731
-
732
- ### `M8KeyName`
733
-
734
- ```ts
735
- type M8KeyName = 'left' | 'right' | 'up' | 'down' | 'shift' | 'play' | 'opt' | 'edit'
736
- ```
737
-
738
- ### `M8SdkConfig`
739
-
740
- ```ts
741
- interface M8SdkConfig {
742
- debug?: boolean // verbose logging (postMessage traffic, engine events)
743
- mode?: 'auto' | 'host' | 'standalone' // default 'auto', see Standalone mode
744
- handshakeTimeoutMs?: number // auto mode in an unmarked iframe; default 3000
745
- autoConnect?: boolean // standalone: reopen a granted device at start; default true
746
- }
747
- ```
748
-
749
- ---
750
-
751
- ## Host events
752
-
753
- These events are emitted by the host and consumed internally by `M8Client`. They are listed here for reference if you use the `post-me` connection directly.
754
-
755
- | Event | Payload | Fired when |
756
- |---|---|---|
757
- | `stateChanged` | `M8State` | Any state field changes |
758
- | `viewChanged` | `{ viewName, viewTitle }` | The active view changes |
759
- | `cursorMoved` | `{ pos, rect, selectionMode }` | Cursor position or selection mode changes |
760
- | `textUpdated` | `{ textUnderCursor, currentLine }` | Text under cursor or current line changes |
761
- | `keyPressed` | `{ keys: number }` | A physical key event fires on the device |
762
- | `transport` | `M8TransportEvent` | MIDI clock / start / stop / continue / song position, after `enableMidiTransport()` |
763
-
764
- ---
765
-
766
- ## Supported views
767
-
768
- The semantic context parser supports these `viewName` values:
769
-
770
- | `viewName` | Columns |
771
- |---|---|
772
- | `song` | `track1`–`track8` (chainRef, meta: trackIndex 1–8) |
773
- | `chain` | `phrase` (phraseRef), `transpose` |
774
- | `phrase` | `note`, `vel`, `inst`, `fx1cmd`, `fx1val`, `fx2cmd`, `fx2val`, `fx3cmd`, `fx3val` |
775
- | `table` | `transpose`, `volume`, `fx1cmd`, `fx1val`, `fx2cmd`, `fx2val`, `fx3cmd`, `fx3val` |
776
- | `groove` | `ticks`, `ppq` (row 0 only), `swing` (row 0 only) |
777
- | `scale` | `interval`, `en` (onOff), `offset` (semitoneOffset) |
778
- | `instrumentpool` | `name`, `dry`, `mx`, `de`, `rv`, `eq` |
779
- | `inst` | header: `instrumentType`, `load`/`save` (action), `name`, `transpose` (onOff), `tableTic`, `eq`; then the type's parameters as label/value pairs (`parameter` / `option`), described per instrument type |
780
- | `instmods` | per modulation slot: type (`option`), `dest`, and the type's parameters (`amt`, `atk`, `hold`, `dec`, `sus`, `rel`, `peak`, `body`, `osc`, `trig`, `freq`, `src`, `lval`, `hval`) |
781
- | `mixer` | `speakerVol`, `track1`–`track8`, `modfxVol`/`delayVol`/`reverbVol`, `inputVol`/`inputVol2`/`usbVol` and their sends, `mixEq` (action), `mainVol`, `limiter`, `djFilter`, `ott` |
782
- | `project` | `tempo`, `transpose`, `groove`/`scale`/`liveQuantize` (option), `name`, and the `load`/`save`/`new`/`render`/`bundle`/… actions |
783
- | `effectsettings` | per `section` (MODFX / DELAY / REVERB): type, input EQ (action), depth/frequency pairs, width, sends, delay time, feedback, room size, decay/shimmer |
784
- | `mixeq`, `modfxeq`, `delayeq`, `reverbeq` | LOW/MID/HIGH band `gain`, `freq`, `q`, `type` (option), `mode` (option) |
785
- | `mixscope`, `limiterscope` | `zoom`, `peak`, `softClip`, `mainVol`, `limiter`, `djFilter`, `ott`, `mixEq`; `atk`/`rel`/`type`/`res`/`time`/`color` as label/value pairs |
786
- | `systemsettings` | `backlight`, `fontOptions`, `editTheme` (action), `notePreview`, `recCountIn`, `metronomeVol`, `usbAudioMode`, `usbMainOut`, `lineInGate`, `keyDelayRep`, … |
787
- | `loadinstrument`, `selectsavedirectory` | `entry` (fileEntry) |
788
- | `createdirectory` | `name` |
789
-
790
- Grid views are read by column position (`src/m8-view-context.json`). Parameter views are label/value screens: a row is recognised from its label text and its cells come from `src/m8-parameter-views.json`, whose column positions were measured on the manual's screenshots. Instrument parameters vary with the instrument type, so they are read as label/value pairs and looked up in a per-type vocabulary. Layouts that only appear for some instrument types (Sampler `SAMPLE` row, FM operators, Hypersynth chord, MIDI CC rows) are best-effort.
791
-
792
- ---
793
-
794
- ## Notes
795
-
796
- - **Screen geometry** — The screen is 40 × 24 characters. Every row has a one-column left margin, so schema `x` positions count from column 1 and `cursorPos.x` is the schema `x` + 1 (the SDK accounts for this). Titles are on row 3 and content starts on row 5. The text stream is lower case (`tempo`, `b-4`, `ff`); the SDK matches case-insensitively and shows values upper case, except user-typed names and file names.
797
- - **Margin markers** — The M8 draws `<` in the left margin of the cursor row, so it can appear as the first character of `currentLine`. The SDK strips the margin before parsing so column offsets remain consistent. Play markers are drawn inside the row instead (see [`readPlayPositions`](#readplaypositionsviewname-string-screen-m8screen-m8playpositions--null)).
798
- - **Font mode** — All column offsets assume font mode 0 (Headless: 8×10 px cells; Model:02: 12×14 px cells). Font modes 1 (bold) and 2 (large) may shift positions.
799
- - **Schema file** — View and column definitions live in `src/m8-view-context.json` and are bundled into `dist/index.js` at build time.
800
- - **Modes** — Inside a yam8d iframe named `yam8d-external-app` the client waits for yam8d's `post-me` handshake; in a top-level tab it runs the engine in the page and connects to the M8 itself (see [Standalone mode](#standalone-mode)). In an unnamed iframe it goes standalone after `handshakeTimeoutMs`.
1
+ # @yam8d/m8-sdk
2
+
3
+ Client SDK for apps that read and drive a Dirtywave M8 tracker. Inside a [yam8d](https://github.com/mio-moto/yam8d) External Apps iframe it talks to yam8d; anywhere else it connects to the M8 itself over Web Serial, WebUSB or Web MIDI ([standalone mode](#standalone-mode)). The API is the same in both modes.
4
+
5
+ > **License:** MIT — © kronsilds
6
+
7
+ ---
8
+
9
+ ## Table of contents
10
+
11
+ - [Install](#install)
12
+ - [Quick start](#quick-start)
13
+ - [Factory functions](#factory-functions)
14
+ - [Standalone mode](#standalone-mode)
15
+ - [M8Client](#m8client)
16
+ - [Properties](#properties)
17
+ - [Navigation](#navigation)
18
+ - [Value setters](#value-setters)
19
+ - [Key input](#key-input)
20
+ - [State](#state)
21
+ - [Event subscriptions](#event-subscriptions)
22
+ - [Semantic context](#semantic-context-on-the-client)
23
+ - [Device connection (standalone)](#device-connection-standalone)
24
+ - [Lifecycle](#lifecycle)
25
+ - [Standalone semantic context functions](#standalone-semantic-context-functions)
26
+ - [Types reference](#types-reference)
27
+ - [M8State](#m8state)
28
+ - [CursorPos / CursorRect / RGB / SystemInfos](#cursorpos--cursorrect--rgb--systeminfos)
29
+ - [M8SemanticContext](#m8semanticcontext)
30
+ - [M8DescriptionPart](#m8descriptionpart)
31
+ - [M8ParsedRow](#m8parsedrow)
32
+ - [M8ActiveField / M8ParsedField](#m8activefield--m8parsedfield)
33
+ - [M8FieldType](#m8fieldtype)
34
+ - [M8KeyName](#m8keyname)
35
+ - [M8SdkConfig](#m8sdkconfig)
36
+ - [Host events](#host-events)
37
+ - [Supported views](#supported-views)
38
+ - [Notes](#notes)
39
+
40
+ ---
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ npm install @yam8d/m8-sdk
46
+ ```
47
+
48
+ For local development before publishing:
49
+
50
+ ```bash
51
+ npm install ../yam8d/packages/m8-sdk
52
+ ```
53
+
54
+ > Inside yam8d the app uses yam8d's connection to the M8. Opened in its own tab, it runs in [standalone mode](#standalone-mode) and connects to the M8 itself.
55
+
56
+ ---
57
+
58
+ ## Quick start
59
+
60
+ ```ts
61
+ import { createM8Client } from '@yam8d/m8-sdk'
62
+
63
+ const m8 = await createM8Client()
64
+
65
+ console.log(m8.state.viewName) // e.g. 'song'
66
+
67
+ await m8.sendKeyPress(['right'])
68
+
69
+ const off = m8.onStateChange((state) => {
70
+ console.log(state.cursorPos)
71
+ })
72
+
73
+ off() // unsubscribe
74
+ m8.disconnect()
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Factory functions
80
+
81
+ ### `createM8Client(config?): Promise<M8Client>`
82
+
83
+ Creates and connects an `M8Client` in one step using top-level `await`.
84
+
85
+ ```ts
86
+ const m8 = await createM8Client({ debug: true })
87
+ ```
88
+
89
+ ### `createM8ClientSync(config?): { client: M8Client; connect: () => Promise<void> }`
90
+
91
+ Returns a client instance and a deferred `connect()` call. Useful in React or other frameworks where top-level `await` is inconvenient.
92
+
93
+ ```ts
94
+ import { createM8ClientSync } from '@yam8d/m8-sdk'
95
+
96
+ const { client: m8, connect } = createM8ClientSync()
97
+
98
+ useEffect(() => {
99
+ connect().then(() => console.log('connected'))
100
+ return () => m8.disconnect()
101
+ }, [])
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Standalone mode
107
+
108
+ `createM8Client()` decides the mode when it connects (`client.mode`):
109
+
110
+ - **`'host'`**: the page runs in an iframe that yam8d named `yam8d-external-app` (exported as `YAM8D_FRAME_NAME`). The client waits for yam8d's handshake, however long it takes.
111
+ - **`'standalone'`**: the page is a top-level tab. The client runs the same engine yam8d uses, in the page itself: the Web Serial / WebUSB / Web MIDI SysEx connection, the display protocol (SLIP, draw rect/character commands), screen text extraction, cursor tracking, view navigation over the view graph, the value editors, file browsing and the MIDI clock.
112
+ - In an iframe that yam8d did not name (an older yam8d, or another page embedding the app), the client tries the handshake for `handshakeTimeoutMs` (3 s) and then goes standalone.
113
+ - Force a mode with `createM8Client({ mode: 'host' | 'standalone' })`.
114
+
115
+ In standalone mode the M8 has to be connected before any call works:
116
+
117
+ ```ts
118
+ const m8 = await createM8Client()
119
+ if (m8.mode === 'standalone' && !m8.isConnected) {
120
+ // The browser's device picker only opens from a user gesture
121
+ connectButton.onclick = () => m8.connectDevice() // or connectDevice({ transport: 'midi' })
122
+ }
123
+ m8.onConnectionChange((connected) => updateUi(connected))
124
+ ```
125
+
126
+ - **Audio**: `connectDevice({ audio: true })` (or `connectAudio()` later) plays the M8's USB audio input through the page, as yam8d does. The browser asks for audio permission; only an input whose label contains "M8" is played, never another microphone. Without it the device stays connected and the warning goes to the console; `isAudioConnected` tells. `disconnectDevice()` stops it too.
127
+ - **MIDI clock**: `enableMidiTransport()` / `onTransport()` work the same as in yam8d: the page opens the M8's MIDI input itself (Web MIDI permission). Enable it again after each new device connection.
128
+ - At start, a standalone client reopens a device the page was granted before, without a prompt (`autoConnect: false` turns that off).
129
+ - Only one page can hold the M8's serial port. Disconnect it in yam8d (or close that tab) before connecting from another page, and call `disconnectDevice()` to hand it back.
130
+ - `getDevice()` gives standalone apps the raw protocol events (`text`, `rect`, `wave`, `key`, `system`) and device commands (`sendKeys`, `resetScreen`, `sendNoteOn`, `sendNoteOff`, `sendProgramChange`), e.g. to draw the screen themselves.
131
+ - `getDeviceSupport()` tells which of the three browser APIs are available (Chromium browsers have all three; Firefox and Safari have none).
132
+ - The engine is exported too, for apps that want to drive the device below the client:
133
+ - `createM8Connection` (open a device bus) and `createM8Engine` (the engine yam8d and standalone clients run on a bus);
134
+ - `M8KeyMask` (the bit of each key in a key mask) and `pressKeys({ left, up, shift, ... })`, which builds a mask from booleans;
135
+ - `createM8Audio()` (plays the M8's USB audio input through the page: `connect()`, `disconnect()`, `connected`, `getOutputCaptureStream()` for recording), `getM8InputStream({ requireM8 })` (the raw input stream; without `requireM8` it falls back to the default input) and `M8_AUDIO_CAPTURE_CONSTRAINTS` (no echo cancellation, noise suppression or gain control).
136
+
137
+ ---
138
+
139
+ ## M8Client
140
+
141
+ ### Properties
142
+
143
+ | Property | Type | Description |
144
+ |---|---|---|
145
+ | `state` | `M8State` | Last known full device state (updated on every host event). |
146
+ | `isConnected` | `boolean` | Whether calls reach an M8: host mode, after the yam8d handshake; standalone, while a device is connected. |
147
+ | `mode` | `'host' \| 'standalone' \| null` | Chosen at connect; `null` before. |
148
+ | `deviceTransport` | `'serial' \| 'usb' \| 'midi' \| null` | Standalone: the API the device is connected through. |
149
+
150
+ ---
151
+
152
+ ### Navigation
153
+
154
+ #### `navigateToView(viewName: string): Promise<boolean>`
155
+
156
+ Asks the host to navigate to a named view (e.g. `'song'`, `'phrase'`, `'systemsettings'`, `'mixeq'`). Returns `true` when the view is reached, `false` when no way is left or a newer navigation took over.
157
+
158
+ The views and the moves between them are in `m8Graph.generated.json`: Shift+arrows between the main views, buttons that open sub-views (the cursor goes to the button, found by its label, then Edit or Shift+Right), and Option to leave a sub-view. The path is recomputed after every step from the view the M8 actually shows, and what each move did is remembered for the rest of the navigation: a move that changed nothing (Shift+Right on an empty chain) is avoided, so the Instrument view is then reached through Project > View Inst. Pool. Some keys go back to the previous view (the Mixer's Shift+Up; the Instrument's Shift+Left after the Instrument Pool), so the planner keeps track of where the M8 came from. On a view the graph does not know, Option is tried first, then Shift+Up. A button is pressed only when the cursor is on it. The project file browser has no way in (on a changed song, LOAD asks to lose the changes with OK selected); navigation can only leave it.
159
+
160
+ ```ts
161
+ await m8.navigateToView('phrase')
162
+ ```
163
+
164
+ #### `navigateTo(x: number, y: number): Promise<boolean>`
165
+
166
+ Moves the cursor to the cell at the given character-grid position, or to the nearest cell when
167
+ no cursor cell is there (a label, a gap between cells). Resolves `true` when the cursor reached
168
+ that cell, `false` when it stopped on the closest cell it could reach. Arrow keys only; what each
169
+ press does is learned per layout (view, model, font mode), so later moves take the shortest way.
170
+
171
+ ```ts
172
+ await m8.navigateTo(3, 5)
173
+ ```
174
+
175
+ ---
176
+
177
+ ### Value setters
178
+
179
+ All setters act on the field currently under the cursor.
180
+
181
+ #### `setValueToHex(targetHex: number): Promise<boolean>`
182
+
183
+ Sets the current field to the given hex integer (e.g. `0x0A`). Returns `true` if the value was successfully applied.
184
+
185
+ #### `setValueToInt(targetInt: number): Promise<boolean>`
186
+
187
+ Sets the current field to the given decimal integer. Returns `true` on success.
188
+
189
+ #### `setValueFloat(targetFloat: number): Promise<boolean>`
190
+
191
+ Sets a float parameter parsed from the whole line, e.g. `TUNE 440.00 G` or `GAIN 05.25 -19.75 -15.00`
192
+ (multiple floats per line are supported; the cursor's highlight selects which one is edited).
193
+ Works with the cursor on a single digit group (`440` / `00`) or spanning the whole float (`19.75`,
194
+ with the sign usually excluded from the highlight). With whole-float focus, edit+up/down moves the
195
+ integer part and edit+left/right the decimal part; with group focus the SDK relocates the cursor
196
+ between the groups for the fastest path. Negative values are supported and step sizes are measured
197
+ per field from the device.
198
+ Returns `true` when the displayed value matches.
199
+
200
+ #### `setNote(noteString: string): Promise<boolean>`
201
+
202
+ Sets a note field using M8 note format, e.g. `'C-4'`, `'A#3'`, `'OFF'`. Returns `true` on success.
203
+
204
+ #### `setValueToString(targetString: string, exact?: boolean, searchInCurrentLine?: boolean): Promise<boolean>`
205
+
206
+ Sets the current field by navigating to match `targetString`.
207
+
208
+ - `exact` (default `true`): require an exact string match.
209
+ - `searchInCurrentLine` (default `false`): restrict the search to the current row.
210
+
211
+ Returns `true` if the target was reached.
212
+
213
+ #### `browseFile(targetText: string, exact?: boolean): Promise<boolean>`
214
+
215
+ Navigates a file-browse dialog to an entry matching `targetText`. Returns `true` on success.
216
+
217
+ ---
218
+
219
+ ### Key input
220
+
221
+ #### `sendKeyPress(keys: M8KeyName[]): Promise<void>`
222
+
223
+ Sends a simultaneous key press + release for the given keys.
224
+
225
+ ```ts
226
+ await m8.sendKeyPress(['shift', 'play'])
227
+ ```
228
+
229
+ #### `sendKeyDown(keys: M8KeyName[]): Promise<void>`
230
+
231
+ Holds the given keys down without releasing. Pair with `sendKeyUp()`.
232
+
233
+ ```ts
234
+ await m8.sendKeyDown(['shift'])
235
+ await m8.sendKeyPress(['right'])
236
+ await m8.sendKeyUp()
237
+ ```
238
+
239
+ #### `sendKeyUp(): Promise<void>`
240
+
241
+ Releases all currently held keys.
242
+
243
+ ---
244
+
245
+ ### State
246
+
247
+ #### `getState(): M8State`
248
+
249
+ Returns the in-memory cached state synchronously. Same as reading `m8.state`.
250
+
251
+ #### `fetchState(): Promise<M8State>`
252
+
253
+ Requests a fresh state snapshot from the host and updates the internal cache.
254
+
255
+ ```ts
256
+ const state = await m8.fetchState()
257
+ ```
258
+
259
+ #### `getScreen(): Promise<M8Screen>`
260
+
261
+ The whole screen as text rows (40 columns, column 0 is the margin where play markers are drawn), read from the character stream without moving the cursor.
262
+
263
+ #### `getScreenCells(): Promise<M8ScreenCells>`
264
+
265
+ The same screen plus the colour of every character cell, so an app can look like the device or learn which theme colour the M8 uses for what. `fg[row][col]` and `bg[row][col]` are `'#rrggbb'`, `null` where nothing was drawn. Needs a yam8d host with SDK 0.5 or later; older hosts reject the call.
266
+
267
+ ```ts
268
+ const cells = await m8.getScreenCells()
269
+ cells.fg[5][3] // colour of the first note of a phrase, e.g. '#ffffff'
270
+ ```
271
+
272
+ #### `captureSnapshot(): Promise<M8Snapshot>`
273
+
274
+ A fresh state, its semantic context, the description (as text and as parts) and the whole screen in one call. The result is plain JSON, safe to store.
275
+
276
+ ```ts
277
+ const snap = await m8.captureSnapshot()
278
+ snap.description // e.g. "Note (N): Note C-4 — Phrase View row 00"
279
+ snap.screen.lines // the screen it was read from
280
+ ```
281
+
282
+ ---
283
+
284
+ ### Event subscriptions
285
+
286
+ Every `on*` method returns an **unsubscribe function** — call it to stop listening.
287
+
288
+ #### `onStateChange(callback: (state: M8State) => void): () => void`
289
+
290
+ Fires whenever any part of the device state changes.
291
+
292
+ ```ts
293
+ const off = m8.onStateChange((state) => {
294
+ console.log(state.viewName, state.cursorPos)
295
+ })
296
+ off() // unsubscribe
297
+ ```
298
+
299
+ #### `onViewChange(callback: (viewName: string | null, viewTitle: string | null) => void): () => void`
300
+
301
+ Fires when the active view changes.
302
+
303
+ ```ts
304
+ m8.onViewChange((name, title) => {
305
+ console.log(`Switched to ${name}: ${title}`)
306
+ })
307
+ ```
308
+
309
+ #### `onCursorMove(callback: (pos: CursorPos | null, rect: CursorRect | null, selectionMode: boolean) => void): () => void`
310
+
311
+ Fires when the cursor position or selection mode changes.
312
+
313
+ ```ts
314
+ m8.onCursorMove((pos, rect, sel) => {
315
+ console.log(`Cursor at (${pos?.x}, ${pos?.y}), selecting: ${sel}`)
316
+ })
317
+ ```
318
+
319
+ #### `onTextUpdate(callback: (textUnderCursor: string | null, currentLine: string | null) => void): () => void`
320
+
321
+ Fires when the text under the cursor or the full current line changes.
322
+
323
+ ```ts
324
+ m8.onTextUpdate((text, line) => {
325
+ console.log('Under cursor:', text)
326
+ console.log('Full line:', line)
327
+ })
328
+ ```
329
+
330
+ #### `onKeyPress(callback: (keys: number) => void): () => void`
331
+
332
+ Fires when a physical key event occurs on the device. `keys` is a bitmask of active keys (see `M8KeyMask`).
333
+
334
+ #### `enableMidiTransport(): Promise<{ ok: boolean; error?: string }>`
335
+
336
+ Starts forwarding the M8's MIDI clock and transport. The M8 only sends it with SYNC OUT set in its MIDI Settings (a setting stored in each song), and the page that owns the device needs Web MIDI permission: yam8d in host mode, the app itself in standalone mode. Hosts before SDK 0.5 reject the call. In standalone mode, call it again after each new device connection.
337
+
338
+ #### `onTransport(callback: (event: M8TransportEvent) => void): () => void`
339
+
340
+ MIDI start / stop / continue, the clock, and song positions, after `enableMidiTransport()`.
341
+
342
+ ```ts
343
+ interface M8TransportEvent {
344
+ kind: 'start' | 'stop' | 'continue' | 'clock' | 'position'
345
+ ticks: number // clock ticks since the host started listening (monotonic)
346
+ at: number // epoch ms (performance.timeOrigin + timestamp), comparable across frames
347
+ msPerTick: number | null // measured tick length, null until known
348
+ spp?: number // Song Position Pointer in sixteenths, on 'position' events
349
+ }
350
+ ```
351
+
352
+ `clock` events come once per sixteenth (6 ticks at 24 PPQ), not on every tick. The M8 can send clock while stopped.
353
+
354
+ ```ts
355
+ const { ok, error } = await m8.enableMidiTransport()
356
+ if (!ok) console.warn(error)
357
+ m8.onTransport((e) => {
358
+ if (e.kind === 'position') console.log('play from sixteenth', e.spp)
359
+ })
360
+ ```
361
+
362
+ ---
363
+
364
+ ### Semantic context on the client
365
+
366
+ #### `getSemanticContext(): M8SemanticContext | null`
367
+
368
+ Parses `m8.state` into structured field data. Returns `null` when no view is active.
369
+
370
+ ```ts
371
+ const ctx = m8.getSemanticContext()
372
+
373
+ if (ctx?.activeField?.type === 'chainRef') {
374
+ const track = ctx.activeField.meta?.trackIndex // 1–8
375
+ const chain = ctx.activeField.hexValue // 0–255 or null if empty
376
+ const row = ctx.row?.rowIndex
377
+ }
378
+ ```
379
+
380
+ #### `describeContext(): string | null`
381
+
382
+ Returns a human-readable one-liner, e.g.:
383
+
384
+ - `"Track 3: 0A — Track 3 — Song View row 01"`
385
+ - `"Note (N): Note C-4 — Phrase View row 00"`
386
+ - `"FX2 Command: KIL — (Kill: stops the playing instrument after XX ticks) — value 03: stops the note after 3 ticks — Phrase View row 01"`
387
+
388
+ Returns `null` when not connected or no view is active. It is the text of `describeContextParts()` joined with `' — '`.
389
+
390
+ It reads the cursor against its surroundings when that changes the meaning:
391
+
392
+ - **FX values are explained** — `ARP37` on `C-4` → `"+3 and +7 semitones: C-4 D#4 G-4"`, `KIL04` → `"stops the note after 4 ticks"`, relative instrument commands show their sign.
393
+ - **REP is followed down its FX lane.** With `VOL10` above, `REP05` reads `"repeats VOL from 10 (step 1), +05 per step → 15 on this step"`, and an empty cell below it reads `"REP still active: VOL reaches 1A on this step (+05 per step since step 3)"`. A new command, `REP00`, or an `RTO` limit in the same lane ends or caps it. `^^` (a REP carried over from the previous phrase) is recognised when it can be attributed to one lane.
394
+ - **Relative commands are tracked.** Per the manual, a relative instrument command (`VOL`, `CUT`, `PIT`, `EA1`… — the ones the device help marks `(RELATIVE)`) keeps its change until a note is triggered with a `RET` or with an instrument number in the `I` column. `VOL02` twice reads `"VOL has moved by +04 in 2 steps since the instrument number on step 0; stays until a note with an instrument number (I) or a RET resets it"`, REP repeats are counted, and the `I` cell or `RET` that resets it lists what it wipes (`"resets the relative changes still in force (CUT +10, VOL +0B)"`). Only the current phrase is visible, so totals start at the phrase's own last reset.
395
+ - **Instrument-type commands say who offers them.** `FM3` → `"…; offered by FM Synth instruments"`.
396
+ - **Scale View** names the scale the enabled notes spell (`"Scale: C D E F G A B — Major (Ionian)"`), says what the row under the cursor is (`"E — a major 3rd (+4 semitones above C) — is in the scale"`), reports detuning, and explains the KEY / TUNE / NAME rows.
397
+
398
+ The surroundings come from a copy of the screen that the client refreshes in the background after every state event. It is only used when its row under the cursor still matches `currentLine`; otherwise the description quietly falls back to the cursor row alone. Call `await m8.getScreen()` or `await m8.captureSnapshot()` first when you need it guaranteed current.
399
+
400
+ #### `describeContextParts(): M8DescriptionPart[] | null`
401
+
402
+ The same description as ordered, tagged parts, so you can keep, drop, reorder or style some of them instead of parsing the string:
403
+
404
+ ```ts
405
+ m8.describeContextParts()
406
+ // [{ kind: 'field', text: 'FX2 Command: KIL' },
407
+ // { kind: 'fxInfo', text: '(Kill: stops the playing instrument after XX ticks)' },
408
+ // { kind: 'meaning', text: 'value 03: stops the note after 3 ticks' },
409
+ // { kind: 'location', text: 'Phrase View row 01' }]
410
+
411
+ // Your own view label instead of the SDK's location:
412
+ const text = m8.describeContextParts()?.filter((p) => p.kind !== 'location').map((p) => p.text).join(' · ')
413
+ ```
414
+
415
+ See [`M8DescriptionPart`](#m8descriptionpart) for the kinds. Everything a part says is also in the context as data (`activeField`, `fxLane`, `relativeFx`, `resetsRelative`, `scaleNote`, `scale`, …), for when you want to write the text yourself.
416
+
417
+ ---
418
+
419
+ ### Device connection (standalone)
420
+
421
+ #### `connectDevice(options?: { transport?: 'auto' | 'serial' | 'usb' | 'midi'; audio?: boolean }): Promise<void>`
422
+
423
+ Connects to the M8 from this page. `'auto'` (default) uses Web Serial, then WebUSB, then Web MIDI SysEx, by browser support. Without a previously granted device the browser shows its picker, which needs a user gesture: call it from a click handler. Replaces the current device. No-op in host mode.
424
+
425
+ #### `disconnectDevice(): Promise<void>`
426
+
427
+ Releases the device (and stops its audio) so yam8d or another page can open it.
428
+
429
+ #### `connectAudio(): Promise<void>` / `disconnectAudio(): Promise<void>`
430
+
431
+ Standalone: plays the M8's USB audio through the page, or stops it. `connectAudio()` rejects when no audio input named M8 exists. No-ops in host mode (yam8d plays it). `isAudioConnected` reports the state.
432
+
433
+ #### `onConnectionChange(callback: (connected: boolean) => void): () => void`
434
+
435
+ Fires when `isConnected` changes: handshake done, device connected, unplugged or released.
436
+
437
+ #### `getDevice(): M8DeviceBus | null`
438
+
439
+ Standalone: raw protocol events and device commands. `null` in host mode or while disconnected.
440
+
441
+ ---
442
+
443
+ ### Lifecycle
444
+
445
+ #### `disconnect(): void`
446
+
447
+ Closes the host connection (and the device, in standalone mode) and clears all event listeners and internal state.
448
+
449
+ ---
450
+
451
+ ## Standalone semantic context functions
452
+
453
+ These are re-exported from `@yam8d/m8-sdk` for use without an `M8Client` instance.
454
+
455
+ ### `getSemanticContext(state: M8State, screen?: M8Screen | null): M8SemanticContext | null`
456
+
457
+ Parses an `M8State` snapshot into a semantic context. Returns `null` if `state.viewName` is not set.
458
+
459
+ Pass the whole `screen` (from `getScreen()`) to fill the context's look-around fields: `steps` (all 16 phrase steps), `fxLane` (what the FX cell under the cursor means in its lane), `relativeFx`, `resetsRelative`, `carriedRepLane`, and `scale` (Scale View). A screen whose cursor row no longer matches `state.currentLine` is ignored. `lineText` is always set for grid views.
460
+
461
+ ```ts
462
+ import { getSemanticContext } from '@yam8d/m8-sdk'
463
+
464
+ const ctx = getSemanticContext(state)
465
+ if (ctx?.isGridView && ctx.row) {
466
+ const note = ctx.row.fields['note']
467
+ console.log(note?.rawValue) // e.g. 'C-4'
468
+ }
469
+ ```
470
+
471
+ ### `describeContext(ctx: M8SemanticContext): string`
472
+
473
+ Returns a human-readable summary of the context (see examples above).
474
+
475
+ ### `describeContextParts(ctx: M8SemanticContext): M8DescriptionPart[]`
476
+
477
+ The summary as ordered, tagged parts; `describeContext()` joins their text with `' — '`.
478
+
479
+ ### `formatFieldValue(field: M8ParsedField): string`
480
+
481
+ Formats a single parsed field as a display string:
482
+
483
+ | Type | Example output |
484
+ |---|---|
485
+ | `note` | `"Note C-4"`, `"Note Off"` |
486
+ | `transpose` | `"+3 st"`, `"-12 st"`, `"±0 st"` |
487
+ | `velocity` | `"57h (87 / 127 max)"` |
488
+ | `fxValue` | `"1Ah (dec 26)"` |
489
+ | `ticks` | `"06h (6 ticks)"`, `"00 (skip step)"` |
490
+ | `chainRef` / `phraseRef` / `fxCommand` / `volume` | raw value, e.g. `"0A"`, `"KIL"` |
491
+ | empty cell | `"(empty)"` |
492
+
493
+ ### `lookupFxCommand(cmd: string): M8FxCommandInfo | undefined`
494
+
495
+ Looks up a 3-character FX command name (case-insensitive) across all categories (sequencer, instrument, modulation, mixer). Modulator commands carry their slot as the third character (`EA1`, `LF3`).
496
+
497
+ ```ts
498
+ import { lookupFxCommand } from '@yam8d/m8-sdk'
499
+
500
+ lookupFxCommand('KIL')
501
+ // → { description: 'Kill: stops the playing instrument after XX ticks.',
502
+ // category: 'sequencer', relative: false, instrumentTypes: null, slot: null }
503
+ lookupFxCommand('FM3')
504
+ // → { …, category: 'instrument', relative: true, instrumentTypes: ['fmsynth'], slot: null }
505
+ lookupFxCommand('LF3')
506
+ // → { …, category: 'modulation', relative: false, instrumentTypes: null, slot: 3 }
507
+ ```
508
+
509
+ `instrumentTypes` lists the instrument types that offer the command when not every type does (`null` = all, or not an instrument command).
510
+
511
+ ### `getInstrumentFxCommands(type: string): M8InstrumentFxList | null`
512
+
513
+ The instrument commands offered by an instrument type (the "Current Instrument" section of the Effect Command Help view, opened with `[EDIT]+[UP/DOWN]` on an FX command). `type` is the key from `M8SemanticContext.instrumentType.key`.
514
+
515
+ ```ts
516
+ getInstrumentFxCommands('hypersynth')
517
+ // → { type: 'hypersynth', verified: true,
518
+ // commands: ['VOL','PIT','FIN','CRD','CVO','SWM','WID','SUB','FIL','CUT','RES','AMP','LIM','PAN','SNC','ERR','DRY','SMX','SDL','SRV'] }
519
+ ```
520
+
521
+ `verified: true` means the list was read from a real M8 (Model:02): **sampler, macrosynth, fmsynth, hypersynth**. Wavsynth and External were not available on that device, so their lists are unknown (`null`); `midiout` only holds the documented `CHD`/`ADD`. The Sequencer FX and Mixer/Effects sections are the same for every type, and the Instrument Mods section depends on the kind of each modulator slot (envelope: `EA AT HO DE ET`, LFO: `LA LO LS LF LT`).
522
+
523
+ ### `RELATIVE_FX_RULE: string`
524
+
525
+ The manual's rule for relative commands, for tooltips.
526
+
527
+ ### `parseGridScreen(viewName: string, screen: M8Screen): M8ScreenGridRow[]`
528
+
529
+ Every content row of a grid view (`song`, `chain`, `phrase`, `table`, `groove`, `instrumentpool`) read off the whole screen, top to bottom, each with the `screenRow` it is drawn on. Use it to read a full page at once instead of only the cursor row. Rows whose index does not parse are skipped, so check `rowIndex` continuity when you need the whole page.
530
+
531
+ ```ts
532
+ const steps = parseGridScreen('phrase', await m8.getScreen())
533
+ steps[0].fields.note.rawValue // 'c-4' (the text stream is lower case)
534
+ ```
535
+
536
+ ### `parseViewTitle(rawTitle: string | null): { viewId: string | null; isUnsaved: boolean }`
537
+
538
+ The item number of a title (`'PHRASE 1F*'` → `{ viewId: '1F', isUnsaved: true }`).
539
+
540
+ ### `readPlayPositions(viewName: string, screen: M8Screen): M8PlayPositions | null`
541
+
542
+ Reads the play markers off a grid view. On the Song view the M8 draws a marker just left of each playing chain (`00>00>01>02`), so `tracks` holds the song row of each track's marker (`null` where it is not on screen). On the chain, phrase, table and groove views the marker follows the row index of the playing row (`F>--- -- --`), so `row` holds that row. Returns `null` for views without a grid, and all nulls when nothing plays. Seen on a Model:02, firmware 6.5.
543
+
544
+ ```ts
545
+ interface M8PlayPositions {
546
+ tracks: (number | null)[] | null // song view only
547
+ row: number | null // other grid views
548
+ }
549
+ ```
550
+
551
+ ### `SCREEN_MARGIN: number`
552
+
553
+ The one-column left margin of every screen row (`1`). `screen.lines[y].slice(SCREEN_MARGIN)` lines columns up with the schema's `x` positions.
554
+
555
+ ### Theme: `parseThemeScreen`, `observeThemeColors`, `M8_THEME_ROLES`, `M8_THEME_VIEW`
556
+
557
+ The device theme has 13 colours, edited in System Settings › EDIT THEME (view name `themesettings`, exported as `M8_THEME_VIEW`).
558
+
559
+ - `parseThemeScreen(screen)` reads them off the Theme View screen: `{ colors: { background, textEmpty, textInfo, textDefault, textValue, textTitles, playMarkers, cursor, selection, scopeSlider, meterLow, meterMid, meterPeak }, mode: 'rgb' | 'hsv', name }`, colours as `'#rrggbb'`. HSV rows are converted (each channel read as 00-FF). It returns `null` unless all 13 rows are on screen.
560
+ - `observeThemeColors(state)` gives the colours any screen shows without opening the Theme View: `background` and `textTitles`. (`state.cursorColor` is the cursor rectangle as drawn; the M8 animates it, so it is not the theme's CURSOR colour.)
561
+ - `M8_THEME_ROLES` lists the roles with the labels the device shows.
562
+
563
+ ```ts
564
+ if (m8.state.viewName === M8_THEME_VIEW) {
565
+ const theme = parseThemeScreen(await m8.getScreen())
566
+ }
567
+ ```
568
+
569
+ ---
570
+
571
+ ## Types reference
572
+
573
+ ### `M8State`
574
+
575
+ The complete snapshot of the device at a point in time.
576
+
577
+ ```ts
578
+ interface M8State {
579
+ viewName: string | null // active view key, e.g. 'song', 'phrase'
580
+ viewTitle: string | null // raw title string from screen, e.g. ' SONG '
581
+ minimapKey: string | null // current minimap position key
582
+ cursorPos: CursorPos | null // character-grid position of the cursor
583
+ cursorRect: CursorRect | null // pixel rectangle of the cursor overlay
584
+ selectionMode: boolean // true when a selection is active
585
+ highlightColor: RGB | null // cursor highlight colour
586
+ titleColor: RGB | null // view title text colour
587
+ backgroundColor: RGB | null // screen background colour
588
+ cursorColor?: RGB | null // cursor rectangle as drawn (animated, not the theme CURSOR); SDK 0.5+
589
+ textUnderCursor: string | null // characters covered by the cursor rect
590
+ currentLine: string | null // full text of the row at cursorPos.y
591
+ deviceModel: string | null // e.g. 'Headless', 'Model:02'
592
+ fontMode: number | null // 0=standard, 1=bold, 2=large
593
+ systemInfo: SystemInfos | null // device-reported system properties
594
+ macroRunning: boolean // true while a macro is executing
595
+ macroCurrentStep?: number // current macro step index
596
+ macroSequenceLength?: number // total steps in the running macro
597
+ }
598
+ ```
599
+
600
+ ### `CursorPos / CursorRect / RGB / SystemInfos`
601
+
602
+ ```ts
603
+ interface CursorPos { x: number; y: number }
604
+ interface CursorRect { x: number; y: number; w: number; h: number }
605
+ interface RGB { r: number; g: number; b: number }
606
+ interface SystemInfos { [key: string]: string | number | boolean | undefined }
607
+ ```
608
+
609
+ ### `M8SemanticContext`
610
+
611
+ ```ts
612
+ interface M8SemanticContext {
613
+ viewName: string // e.g. 'song', 'phrase', 'instrumentpool'
614
+ viewTitle: string | null // e.g. 'Song View'
615
+ viewDescription: string | null // long description from schema
616
+ viewId: string | null // item ID parsed from screen title, e.g. 'F2', '03'; null for song/instrumentpool
617
+ isUnsaved: boolean // true when the title carries a '*' suffix (unsaved changes)
618
+ isGridView: boolean // true for song/chain/phrase/table/groove/scale/instrumentpool
619
+ isParameterView: boolean // true for the label/value views (inst, mixer, project, …)
620
+ section: string | null // effectsettings: 'MODFX' | 'DELAY' | 'REVERB'
621
+ instrumentType: { text: string; key: string | null } | null // inst view + screen, e.g. { text: 'WAVSYNTH', key: 'wavsynth' }
622
+ row: M8ParsedRow | null // all columns on the line at cursorPos.y
623
+ activeField: M8ActiveField | null // the column currently under the cursor
624
+ lineText: string | null // cursor row text, playback marker removed
625
+ steps: M8ParsedRow[] | null // phrase view + screen: all 16 steps (index = step)
626
+ carriedRepLane: number | null // FX lane (1–3) showing '^^' (REP from previous phrase)
627
+ fxLane: M8FxLaneTrace | null // phrase view + screen: what the FX cell means in its lane
628
+ relativeFx: Record<string, M8RelativeFxState> | null // phrase view + screen: relative commands in force, with net change since the last trigger
629
+ resetsRelative: Record<string, M8RelativeFxState> | null // phrase view + screen: when the cursor step triggers the instrument (I number or RET), the relative changes it puts back
630
+ scaleNote: M8ScaleNote | null // scale view: the interval row under the cursor { interval, semitone, enabled, offset }
631
+ scale: M8ScaleInfo | null // scale view + screen: the 12 intervals interpreted together
632
+ }
633
+ ```
634
+
635
+ ### `M8DescriptionPart`
636
+
637
+ ```ts
638
+ interface M8DescriptionPart {
639
+ kind: M8DescriptionPartKind
640
+ text: string
641
+ }
642
+ ```
643
+
644
+ Parts come in reading order; a kind can appear more than once.
645
+
646
+ | Kind | Example |
647
+ |---|---|
648
+ | `field` | `"FX1 Command: KIL"`, `"Tempo (TEMPO): 120.00 BPM"` |
649
+ | `fxInfo` | `"(Kill: stops the playing instrument after XX ticks)"` |
650
+ | `fxTarget` | on an FX value: `"for KIL (…)"` |
651
+ | `meaning` | `"value 03: stops the note after 3 ticks"`, `"E is detuned by −0.50 st"` |
652
+ | `fxLane` | `"REP still active: VOL reaches 09 on this step (+02 per step since step 1)"` |
653
+ | `relative` | `"relative: VOL has moved by +0C over 2 steps since …"`, `"triggers the instrument again: resets …"` |
654
+ | `optionInfo` | what the selected option does |
655
+ | `fieldInfo` | what the field is |
656
+ | `fileInfo` | `"directory: [EDIT] opens it"` |
657
+ | `instrument` | `"Wavsynth instrument"` |
658
+ | `section` | `"Delay"`, `"MOD2"` |
659
+ | `scale` | `"Scale: C D E F G A B"`, `"Major (Ionian); detuned: E −0.50 st"` |
660
+ | `track` | `"Track 3"` |
661
+ | `location` | `"Phrase View row 07"`, `"Mixer View"` |
662
+ | `line` | the cursor row's text, when no field on it is recognised |
663
+
664
+ ### `M8ParsedRow`
665
+
666
+ ```ts
667
+ interface M8ParsedRow {
668
+ rowIndex: number | null // hex row index from leftmost chars; null if unparseable
669
+ rowKey: string // schema key, e.g. 'songRow', 'step', 'chainPos'
670
+ rowId: string | null // parameter views: matched schema row ('tempo', 'gain', …)
671
+ fields: Record<string, M8ParsedField> // column key → parsed field
672
+ }
673
+ ```
674
+
675
+ ### `M8ActiveField / M8ParsedField`
676
+
677
+ `M8ActiveField` extends `M8ParsedField` with two extra convenience fields.
678
+
679
+ ```ts
680
+ interface M8ParsedField {
681
+ key: string // schema key, e.g. 'fx1cmd', 'track3', 'note'
682
+ label: string // human label, e.g. 'FX1 Command', 'Track 3'
683
+ type: M8FieldType
684
+ rawValue: string // trimmed text sliced from currentLine
685
+ hexValue: number | null // parsed integer; null for empty cells and text types
686
+ isEmpty: boolean // true when '--', '---', '---00', etc.
687
+ meta?: Record<string, unknown> // view-specific extras (e.g. { trackIndex: 3 })
688
+ // parameter views (all optional):
689
+ description?: string // what the field is
690
+ text?: string | null // non-numeric part: option name, caption, name, instrument type
691
+ numericValue?: number | null // value in its natural base (hex, decimal, fractional)
692
+ values?: number[] | null // both numbers of an 'XX:YY' pair
693
+ parts?: string[] // names of those numbers, e.g. ['left time', 'right time']
694
+ unit?: string // e.g. ' BPM', ' dB'
695
+ valueDescription?: string // what the selected option does
696
+ fileKind?: M8FileKind // file browser rows: 'parent' | 'directory' | 'instrumentPreset' | 'sample' | 'm8file' | 'file'
697
+ }
698
+
699
+ interface M8ActiveField extends M8ParsedField {
700
+ viewName: string // repeated for convenience
701
+ rowIndex: number | null // row index of the line containing this field
702
+ }
703
+ ```
704
+
705
+ ### `M8FieldType`
706
+
707
+ ```ts
708
+ type M8FieldType =
709
+ | 'chainRef' // chain number hex 00–FF; '--' = empty
710
+ | 'phraseRef' // phrase number hex 00–FF; '--' = empty
711
+ | 'note' // note name e.g. 'C-4', 'A#3'; '---' = empty; 'OFF' = note-off
712
+ | 'velocity' // velocity 00–7F hex; '--' = inherit
713
+ | 'instrumentRef' // instrument slot 00–7F hex; '--' = keep current
714
+ | 'fxCommand' // 3-char command name e.g. 'KIL'; '---' = none
715
+ | 'fxValue' // FX parameter 00–FF hex
716
+ | 'transpose' // relative semitones: 00=none, 01–7F=+1–+127, FF–80=−1–−128
717
+ | 'ticks' // groove ticks per step hex; '00'=skip; '--'=end of groove
718
+ | 'volume' // level 00–FF hex; '--' = no override
719
+ | 'ppq' // pulses-per-quarter-note decimal; groove row 0 only
720
+ | 'instrumentName' // instrument name text ≤12 chars; no hexValue
721
+ | 'eqSlot' // EQ slot assignment hex; '--' = none
722
+ | 'rowIndex' // row/step index hex
723
+ | 'noteInterval' // scale view row label: C, C#, D, …
724
+ | 'onOff' // 'ON' / 'OFF' toggle
725
+ | 'semitoneOffset' // scale view detune, e.g. '-00.50'
726
+ | 'swing' // groove swing percentage
727
+ | 'instrumentType' // instrument engine: WAVSYNTH, SAMPLER, FMSYNTH, …
728
+ | 'name' // free-text name field; dashes = unnamed
729
+ | 'parameter' // named editable value: CUTOFF, TEMPO, GAIN, …
730
+ | 'option' // choice from a list: '00CHORUS', 'BELL', 'STEREO'
731
+ | 'action' // button: LOAD, SAVE, RENDER, SETTINGS, EQ, …
732
+ | 'fileEntry' // file browser row: '/folder', '/..', 'Bass.m8i'
733
+ ```
734
+
735
+ ### `M8KeyName`
736
+
737
+ ```ts
738
+ type M8KeyName = 'left' | 'right' | 'up' | 'down' | 'shift' | 'play' | 'opt' | 'edit'
739
+ ```
740
+
741
+ ### `M8SdkConfig`
742
+
743
+ ```ts
744
+ interface M8SdkConfig {
745
+ debug?: boolean // verbose logging (postMessage traffic, engine events)
746
+ mode?: 'auto' | 'host' | 'standalone' // default 'auto', see Standalone mode
747
+ handshakeTimeoutMs?: number // auto mode in an unmarked iframe; default 3000
748
+ autoConnect?: boolean // standalone: reopen a granted device at start; default true
749
+ }
750
+ ```
751
+
752
+ ---
753
+
754
+ ## Host events
755
+
756
+ These events are emitted by the host and consumed internally by `M8Client`. They are listed here for reference if you use the `post-me` connection directly.
757
+
758
+ | Event | Payload | Fired when |
759
+ |---|---|---|
760
+ | `stateChanged` | `M8State` | Any state field changes |
761
+ | `viewChanged` | `{ viewName, viewTitle }` | The active view changes |
762
+ | `cursorMoved` | `{ pos, rect, selectionMode }` | Cursor position or selection mode changes |
763
+ | `textUpdated` | `{ textUnderCursor, currentLine }` | Text under cursor or current line changes |
764
+ | `keyPressed` | `{ keys: number }` | A physical key event fires on the device |
765
+ | `transport` | `M8TransportEvent` | MIDI clock / start / stop / continue / song position, after `enableMidiTransport()` |
766
+
767
+ ---
768
+
769
+ ## Supported views
770
+
771
+ The semantic context parser supports these `viewName` values:
772
+
773
+ | `viewName` | Columns |
774
+ |---|---|
775
+ | `song` | `track1`–`track8` (chainRef, meta: trackIndex 1–8) |
776
+ | `chain` | `phrase` (phraseRef), `transpose` |
777
+ | `phrase` | `note`, `vel`, `inst`, `fx1cmd`, `fx1val`, `fx2cmd`, `fx2val`, `fx3cmd`, `fx3val` |
778
+ | `table` | `transpose`, `volume`, `fx1cmd`, `fx1val`, `fx2cmd`, `fx2val`, `fx3cmd`, `fx3val` |
779
+ | `groove` | `ticks`, `ppq` (row 0 only), `swing` (row 0 only) |
780
+ | `scale` | `interval`, `en` (onOff), `offset` (semitoneOffset) |
781
+ | `instrumentpool` | `name`, `dry`, `mx`, `de`, `rv`, `eq` |
782
+ | `inst` | header: `instrumentType`, `load`/`save` (action), `name`, `transpose` (onOff), `tableTic`, `eq`; then the type's parameters as label/value pairs (`parameter` / `option`), described per instrument type |
783
+ | `instmods` | per modulation slot: type (`option`), `dest`, and the type's parameters (`amt`, `atk`, `hold`, `dec`, `sus`, `rel`, `peak`, `body`, `osc`, `trig`, `freq`, `src`, `lval`, `hval`) |
784
+ | `mixer` | `speakerVol`, `track1`–`track8`, `modfxVol`/`delayVol`/`reverbVol`, `inputVol`/`inputVol2`/`usbVol` and their sends, `mixEq` (action), `mainVol`, `limiter`, `djFilter`, `ott` |
785
+ | `project` | `tempo`, `transpose`, `groove`/`scale`/`liveQuantize` (option), `name`, and the `load`/`save`/`new`/`render`/`bundle`/… actions |
786
+ | `effectsettings` | per `section` (MODFX / DELAY / REVERB): type, input EQ (action), depth/frequency pairs, width, sends, delay time, feedback, room size, decay/shimmer |
787
+ | `mixeq`, `modfxeq`, `delayeq`, `reverbeq` | LOW/MID/HIGH band `gain`, `freq`, `q`, `type` (option), `mode` (option) |
788
+ | `mixscope`, `limiterscope` | `zoom`, `peak`, `softClip`, `mainVol`, `limiter`, `djFilter`, `ott`, `mixEq`; `atk`/`rel`/`type`/`res`/`time`/`color` as label/value pairs |
789
+ | `systemsettings` | `backlight`, `fontOptions`, `editTheme` (action), `notePreview`, `recCountIn`, `metronomeVol`, `usbAudioMode`, `usbMainOut`, `lineInGate`, `keyDelayRep`, … |
790
+ | `loadinstrument`, `selectsavedirectory` | `entry` (fileEntry) |
791
+ | `createdirectory` | `name` |
792
+
793
+ Grid views are read by column position (`src/m8-view-context.json`). Parameter views are label/value screens: a row is recognised from its label text and its cells come from `src/m8-parameter-views.json`, whose column positions were measured on the manual's screenshots. Instrument parameters vary with the instrument type, so they are read as label/value pairs and looked up in a per-type vocabulary. Layouts that only appear for some instrument types (Sampler `SAMPLE` row, FM operators, Hypersynth chord, MIDI CC rows) are best-effort.
794
+
795
+ ---
796
+
797
+ ## Notes
798
+
799
+ - **Screen geometry** — The screen is 40 × 24 characters. Every row has a one-column left margin, so schema `x` positions count from column 1 and `cursorPos.x` is the schema `x` + 1 (the SDK accounts for this). Titles are on row 3 and content starts on row 5. The text stream is lower case (`tempo`, `b-4`, `ff`); the SDK matches case-insensitively and shows values upper case, except user-typed names and file names.
800
+ - **Margin markers** — The M8 draws `<` in the left margin of the cursor row, so it can appear as the first character of `currentLine`. The SDK strips the margin before parsing so column offsets remain consistent. Play markers are drawn inside the row instead (see [`readPlayPositions`](#readplaypositionsviewname-string-screen-m8screen-m8playpositions--null)).
801
+ - **Font mode** — All column offsets assume font mode 0 (Headless: 8×10 px cells; Model:02: 12×14 px cells). Font modes 1 (bold) and 2 (large) may shift positions.
802
+ - **Schema file** — View and column definitions live in `src/m8-view-context.json` and are bundled into `dist/index.js` at build time.
803
+ - **Modes** — Inside a yam8d iframe named `yam8d-external-app` the client waits for yam8d's `post-me` handshake; in a top-level tab it runs the engine in the page and connects to the M8 itself (see [Standalone mode](#standalone-mode)). In an unnamed iframe it goes standalone after `handshakeTimeoutMs`.