@yam8d/m8-sdk 0.5.0 → 0.5.2

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
@@ -129,7 +129,10 @@ m8.onConnectionChange((connected) => updateUi(connected))
129
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
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
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 (`createM8Connection`, `createM8Engine`, `M8KeyMask`) for apps that want to drive the device below the client.
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).
133
136
 
134
137
  ---
135
138
 
@@ -150,7 +153,9 @@ m8.onConnectionChange((connected) => updateUi(connected))
150
153
 
151
154
  #### `navigateToView(viewName: string): Promise<boolean>`
152
155
 
153
- Asks the host to navigate to a named view (e.g. `'song'`, `'phrase'`). Returns `true` on success.
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.
154
159
 
155
160
  ```ts
156
161
  await m8.navigateToView('phrase')
@@ -261,6 +266,16 @@ const cells = await m8.getScreenCells()
261
266
  cells.fg[5][3] // colour of the first note of a phrase, e.g. '#ffffff'
262
267
  ```
263
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
+
264
279
  ---
265
280
 
266
281
  ### Event subscriptions
@@ -311,7 +326,35 @@ m8.onTextUpdate((text, line) => {
311
326
 
312
327
  #### `onKeyPress(callback: (keys: number) => void): () => void`
313
328
 
314
- Fires when a physical key event occurs on the device. `keys` is a bitmask of active keys.
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
+ ```
315
358
 
316
359
  ---
317
360
 
@@ -491,6 +534,21 @@ steps[0].fields.note.rawValue // 'c-4' (the text stream is lower case)
491
534
 
492
535
  The item number of a title (`'PHRASE 1F*'` → `{ viewId: '1F', isUnsaved: true }`).
493
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
+
494
552
  ### Theme: `parseThemeScreen`, `observeThemeColors`, `M8_THEME_ROLES`, `M8_THEME_VIEW`
495
553
 
496
554
  The device theme has 13 colours, edited in System Settings › EDIT THEME (view name `themesettings`, exported as `M8_THEME_VIEW`).
@@ -701,6 +759,7 @@ These events are emitted by the host and consumed internally by `M8Client`. They
701
759
  | `cursorMoved` | `{ pos, rect, selectionMode }` | Cursor position or selection mode changes |
702
760
  | `textUpdated` | `{ textUnderCursor, currentLine }` | Text under cursor or current line changes |
703
761
  | `keyPressed` | `{ keys: number }` | A physical key event fires on the device |
762
+ | `transport` | `M8TransportEvent` | MIDI clock / start / stop / continue / song position, after `enableMidiTransport()` |
704
763
 
705
764
  ---
706
765
 
@@ -735,164 +794,7 @@ Grid views are read by column position (`src/m8-view-context.json`). Parameter v
735
794
  ## Notes
736
795
 
737
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.
738
- - **Playback indicator** — When a row is actively playing the M8 draws `<` or `>` in the left margin, so it appears as the first character of `currentLine`. The SDK strips this automatically before parsing so column offsets remain consistent.
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)).
739
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.
740
799
  - **Schema file** — View and column definitions live in `src/m8-view-context.json` and are bundled into `dist/index.js` at build time.
741
- - **Requirement** — The app must run inside the yam8d host iframe. The `ChildHandshake` from `post-me` will never resolve in a standalone tab.
742
-
743
-
744
- Client SDK for iframe applications that communicate with an M8 host.
745
-
746
- ## Install
747
-
748
- ```bash
749
- npm install @yam8d/m8-sdk
750
- ```
751
-
752
- For local development before the package is published:
753
-
754
- ```bash
755
- npm install ../yam8d/packages/m8-sdk
756
- ```
757
-
758
- ## Usage
759
-
760
- ```ts
761
- import { createM8Client } from '@yam8d/m8-sdk'
762
-
763
- const m8 = await createM8Client()
764
-
765
- console.log(m8.state.viewName)
766
- await m8.sendKeyPress(['play'])
767
-
768
- const unsubscribe = m8.onStateChange((state) => {
769
- console.log(state.cursorPos)
770
- })
771
-
772
- unsubscribe()
773
- m8.disconnect()
774
- ```
775
-
776
- For React or other environments where top-level `await` is not convenient:
777
-
778
- ```ts
779
- import { createM8ClientSync } from '@yam8d/m8-sdk'
780
-
781
- const { client, connect } = createM8ClientSync()
782
- await connect()
783
- ```
784
-
785
- The app must run inside the host iframe. Opening it directly in a standalone browser tab cannot establish the SDK handshake.
786
-
787
- ---
788
-
789
- ## Semantic Context
790
-
791
- The SDK can interpret the current cursor position and row text as typed, labelled field values. This works for the five grid views (`song`, `chain`, `phrase`, `table`, `groove`) and the `instrumentpool` view.
792
-
793
- ### Via the client
794
-
795
- ```ts
796
- const ctx = m8.getSemanticContext()
797
- // or as a human-readable string:
798
- const description = m8.describeContext()
799
- // e.g. "Track 3: 0A — Track 3 — Song View row 01"
800
- // or as tagged parts to build your own:
801
- const parts = m8.describeContextParts()
802
- ```
803
-
804
- ### Standalone functions
805
-
806
- ```ts
807
- import { getSemanticContext, describeContext, formatFieldValue, lookupFxCommand } from '@yam8d/m8-sdk'
808
-
809
- const ctx = getSemanticContext(state)
810
-
811
- if (ctx?.activeField) {
812
- const field = ctx.activeField
813
-
814
- console.log(field.key) // e.g. 'track3', 'note', 'fx1cmd'
815
- console.log(field.label) // e.g. 'Track 3', 'Note (N)', 'FX1 Command'
816
- console.log(field.type) // e.g. 'chainRef', 'note', 'fxCommand'
817
- console.log(field.rawValue) // raw text from the line, e.g. '0A', 'C-4', 'KIL'
818
- console.log(field.hexValue) // parsed integer or null (null for empty cells and text types)
819
- console.log(field.isEmpty) // true when '--' / '---' / '---00'
820
-
821
- // Song view: which track is the cursor on?
822
- if (field.type === 'chainRef') {
823
- console.log(field.meta?.trackIndex) // 1–8
824
- }
825
-
826
- // FX command: look up its description
827
- if (field.type === 'fxCommand' && !field.isEmpty) {
828
- const info = lookupFxCommand(field.rawValue)
829
- console.log(info?.description)
830
- }
831
- }
832
-
833
- // Row-level fields (all columns on the current line)
834
- if (ctx?.row) {
835
- console.log(ctx.row.rowIndex) // hex-parsed row index, e.g. 2
836
- console.log(ctx.row.fields['note']) // M8ParsedField for the note column
837
- }
838
- ```
839
-
840
- ### Returned types
841
-
842
- ```ts
843
- interface M8SemanticContext {
844
- viewName: string // e.g. 'song', 'phrase'
845
- viewTitle: string | null // e.g. 'Song View'
846
- viewDescription: string | null
847
- isGridView: boolean
848
- row: M8ParsedRow | null // all columns on the cursor's line
849
- activeField: M8ActiveField | null // the column under the cursor
850
- }
851
-
852
- interface M8ParsedField {
853
- key: string // schema key, e.g. 'fx1cmd', 'track3'
854
- label: string // human label, e.g. 'FX1 Command', 'Track 3'
855
- type: M8FieldType
856
- rawValue: string // trimmed text from currentLine
857
- hexValue: number | null
858
- isEmpty: boolean
859
- meta?: Record<string, unknown>
860
- }
861
- ```
862
-
863
- ### Supported field types (`M8FieldType`)
864
-
865
- | Type | Description |
866
- |---|---|
867
- | `chainRef` | Chain number (hex). `--` = empty. |
868
- | `phraseRef` | Phrase number (hex). `--` = empty. |
869
- | `note` | Note name, e.g. `C-4`, `A#3`. `---` = empty. |
870
- | `velocity` | Note velocity 00–7F (hex). |
871
- | `instrumentRef` | Instrument slot 00–7F (hex). `--` = keep current. |
872
- | `fxCommand` | 3-char FX command name, e.g. `KIL`, `ARP`. `---` = none. |
873
- | `fxValue` | FX value 00–FF (hex). |
874
- | `transpose` | Relative transpose (hex): `00`=none, `01`–`7F`=+1–+127, `FF`–`80`=−1–−128. |
875
- | `ticks` | Groove ticks per step (hex). |
876
- | `volume` | Volume level 00–FF (hex). `--` = no override. |
877
- | `ppq` | Pulses Per Quarter note (decimal). Groove row 0 only. |
878
- | `instrumentName` | Instrument name text (up to 12 chars). No `hexValue`. |
879
- | `eqSlot` | EQ slot assignment (hex). `--` = none. |
880
- | `rowIndex` | Row/step index (hex). |
881
- | `noteInterval` | Scale View row label (`C`, `C#`, …). |
882
- | `onOff` | `ON` / `OFF` toggle. |
883
- | `semitoneOffset` | Scale View detune, e.g. `-00.50`. |
884
- | `swing` | Groove swing percentage. |
885
- | `instrumentType` | Instrument engine (`WAVSYNTH`, `SAMPLER`, …); `text` holds the name. |
886
- | `name` | Free-text name field; a row of dashes = unnamed. |
887
- | `parameter` | Named editable value (`CUTOFF`, `TEMPO`, `GAIN`); read `hexValue` / `numericValue` / `values`. |
888
- | `option` | Choice from a list (`00CHORUS`, `BELL`); `hexValue` is the index when shown, `text` the name. |
889
- | `action` | Button caption (`LOAD`, `SAVE`, `RENDER`, `SETTINGS`); `text` holds it. |
890
- | `fileEntry` | File browser row: `/folder`, `/..`, or a file name. |
891
-
892
- ### Playback indicator
893
-
894
- When a row is actively playing, the M8 prepends a `<` or `>` character to `currentLine`. The SDK strips this automatically before parsing, so column offsets are always consistent.
895
-
896
- ### Notes file
897
-
898
- View/column definitions live in `src/m8-view-context.json` and are bundled into `dist/index.js` at build time. All x/width values assume **font mode 0** (8×10 px cells on Headless, 12×14 px on Model:02). Font modes 1 and 2 may shift column positions.
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`.