moontide-mcp 2.1.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +54 -331
  2. package/dist/ask.js +59 -12
  3. package/dist/catalog.js +118 -9
  4. package/dist/instruments.js +83 -9
  5. package/dist/mcp.js +146 -20
  6. package/dist/midi-state.js +43 -1
  7. package/dist/protocol.js +123 -5
  8. package/dist/reference.js +12 -0
  9. package/dist/request-status.js +221 -0
  10. package/dist/session.js +203 -7
  11. package/dist/vocabulary.js +420 -0
  12. package/package.json +3 -2
  13. package/page/ask-view.js +118 -50
  14. package/page/ask.js +139 -169
  15. package/page/bar-model.js +44 -2
  16. package/page/change-timing.js +35 -0
  17. package/page/index.html +6 -2
  18. package/page/instruments-model.js +50 -3
  19. package/page/instruments-view.js +14 -2
  20. package/page/jam-analysis.js +68 -23
  21. package/page/jam-model.js +185 -29
  22. package/page/jam-resolve.js +271 -0
  23. package/page/jam-view.js +165 -10
  24. package/page/lights.js +29 -7
  25. package/page/main.js +807 -66
  26. package/page/midi-clock.js +22 -3
  27. package/page/midi-in.js +407 -0
  28. package/page/midi-out.js +557 -48
  29. package/page/midi-plan.js +42 -6
  30. package/page/midi-rig.js +286 -13
  31. package/page/midi-settings.js +507 -0
  32. package/page/mixer-view.js +7 -1
  33. package/page/note-panel.js +29 -8
  34. package/page/note-tag.js +19 -4
  35. package/page/page-rewrite.js +1 -1
  36. package/page/played-buffer.js +208 -0
  37. package/page/query-check.js +21 -2
  38. package/page/request-status-view.js +419 -0
  39. package/page/request-status.js +686 -0
  40. package/page/scratch-gesture.js +239 -0
  41. package/page/scratch-listen.js +260 -0
  42. package/page/scratch-model.js +1696 -0
  43. package/page/scratch-view.js +2176 -0
  44. package/page/song-grid-model.js +15 -0
  45. package/page/song-grid-view.js +1 -1
  46. package/page/structure.js +2 -1
  47. package/page/style.css +1243 -43
  48. package/page/tempo-glide.js +330 -0
  49. package/page/tempo-guard.js +62 -10
  50. package/page/timeline-edit-view.js +1 -1
  51. package/page/timeline.js +2 -1
  52. package/page/top-bar-view.js +18 -13
  53. package/page/views.js +20 -7
  54. package/reference/midi.md +2 -0
  55. package/reference/motif.md +83 -0
  56. package/reference/settings.md +161 -0
  57. package/reference/transitions.md +13 -1
@@ -0,0 +1,239 @@
1
+ // Pure module for the Scratchpad roll: the numbers behind a pointer. Distances are written in rem, the unit the whole page is sized
2
+ // in; the caller multiplies them by the root font size it reads when a press starts, so a larger root makes the targets larger too.
3
+ // A point on the roll becomes a cell, a drawn length, a move or a new length through the same whole units the model keeps, so what is
4
+ // previewed is what is committed. The rows of the roll are worked out from the notes, so every row function takes the number of rows
5
+ // shown and the pitch of the top row. No DOM access and no timers here, so node:test and the view import the module without a build step.
6
+
7
+ /** A press that moves less than this is a click; a press that moves more is a drag. */
8
+ export const DRAG_THRESHOLD_REM = 0.25;
9
+
10
+ /** The width of the handle at a note's right side that resizes it, and the most of a short note it may take. */
11
+ export const EDGE_HANDLE_REM = 0.5;
12
+ export const EDGE_HANDLE_SHARE = 0.4;
13
+
14
+ /** A drag within this distance of the edge of the visible roll scrolls it, by `EDGE_SCROLL_REM` on each pointer move. */
15
+ export const EDGE_ZONE_REM = 1.5;
16
+ export const EDGE_SCROLL_REM = 1;
17
+
18
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
19
+
20
+ /** The nearest grid line to `position` for lines `size` apart, with a tie going to the earlier line; `position` may be fractional. */
21
+ function nearestLine(position, size) {
22
+ return Math.ceil(position / size - 0.5);
23
+ }
24
+
25
+ /** Turns a negative zero into 0, so a start of 0 is the same value however it was worked out. */
26
+ const whole = (value) => value + 0;
27
+
28
+ /**
29
+ * Where the pointer is along the motif, in units, even outside the grid (a negative number left of it, more than `units` right of it).
30
+ * `rect` is the grid's box in the page (`left`, `width`), `units` the motif's length in units.
31
+ */
32
+ export function pointerUnit({ rect, x, units }) {
33
+ return ((x - rect.left) / rect.width) * units;
34
+ }
35
+
36
+ /** The row the pointer is in, counted from the top row at 0, even outside the grid. `rect` has `top` and `height` for the `rows` rows shown. */
37
+ export function pointerRow({ rect, y, rows }) {
38
+ return Math.floor((y - rect.top) / (rect.height / rows));
39
+ }
40
+
41
+ /**
42
+ * The cell under a point: `{ unit, pitch }`, where `unit` is the start of the grid step that holds the point and `pitch` the pitch
43
+ * of its row (the top of the `rows` rows shown is `high`). A point outside the grid's box gives null.
44
+ */
45
+ export function cellAt({ rect, x, y, units, gridUnit, rows, high }) {
46
+ if (x < rect.left || x >= rect.left + rect.width || y < rect.top || y >= rect.top + rect.height) return null;
47
+ const unit = Math.floor(pointerUnit({ rect, x, units }) / gridUnit) * gridUnit;
48
+ return { unit, pitch: high - pointerRow({ rect, y, rows }) };
49
+ }
50
+
51
+ /**
52
+ * The box a selection drag spans, in units and rows counted from the top row at 0: `{ fromUnit, toUnit, topRow, bottomRow }`, the same
53
+ * whichever way the pointer went from the press (`x0`, `y0`) to where it is (`x1`, `y1`), and held inside the grid. The numbers are
54
+ * fractions, so a box over part of a cell still says which part. `rect` is the grid's box (`left`, `top`, `width`, `height`), `units` the
55
+ * motif's length and `rows` the rows the grid shows.
56
+ */
57
+ export function selectionBox({ rect, x0, y0, x1, y1, units, rows }) {
58
+ const unitOf = (x) => clamp(pointerUnit({ rect, x, units }), 0, units);
59
+ const rowOf = (y) => clamp(((y - rect.top) / rect.height) * rows, 0, rows);
60
+ const [a, b] = [unitOf(x0), unitOf(x1)];
61
+ const [c, d] = [rowOf(y0), rowOf(y1)];
62
+ return { fromUnit: whole(Math.min(a, b)), toUnit: whole(Math.max(a, b)), topRow: whole(Math.min(c, d)), bottomRow: whole(Math.max(c, d)) };
63
+ }
64
+
65
+ /**
66
+ * The ids of the notes a selection box touches: a note whose span overlaps the box's span and whose row lies between the rows the box's
67
+ * top and bottom fall in. A note that only ends where the box starts, or starts where it ends, is not touched; a box with no width
68
+ * touches the note whose span holds it. `high` is the pitch of the top row, so a note's row is `high` minus its pitch.
69
+ */
70
+ export function notesInBox({ notes, box, high }) {
71
+ const first = Math.floor(box.topRow);
72
+ const last = Math.floor(box.bottomRow);
73
+ const flat = box.toUnit <= box.fromUnit;
74
+ return notes
75
+ .filter((note) => {
76
+ const row = high - note.pitch;
77
+ if (row < first || row > last) return false;
78
+ const end = note.start + note.length;
79
+ return flat ? note.start <= box.fromUnit && end > box.fromUnit : note.start < box.toUnit && end > box.fromUnit;
80
+ })
81
+ .map((note) => note.id);
82
+ }
83
+
84
+ /**
85
+ * The length of a note drawn from `start` to the pointer: up to the end of the grid step under the pointer, that step included, at
86
+ * least one step and at most to the motif's end. A pointer before the start gives one step. `pointerUnit` is where the pointer is, in units.
87
+ */
88
+ export function drawLength({ start, pointerUnit: at, gridUnit, units }) {
89
+ const end = (Math.floor(at / gridUnit) + 1) * gridUnit;
90
+ return clamp(end - start, gridUnit, units - start);
91
+ }
92
+
93
+ /**
94
+ * Where a dragged note lands: `{ start, pitch }`. The start is the nearest grid line to where the note's start was dragged to (`dUnits`
95
+ * units from where it began; a tie goes to the earlier line), held from 0 to the last start that keeps the note inside the motif.
96
+ * The pitch is `dRows` rows lower (negative: higher), held between `low` and `high`, the lowest and highest shown pitch.
97
+ */
98
+ export function moveTarget({ note, dUnits, dRows, gridUnit, units, low, high }) {
99
+ const last = Math.floor((units - note.length) / gridUnit) * gridUnit;
100
+ return {
101
+ start: whole(clamp(nearestLine(note.start + dUnits, gridUnit) * gridUnit, 0, last)),
102
+ pitch: clamp(note.pitch - dRows, low, high),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Where a dragged group of notes lands, as one move for the whole group: `{ dStart, dPitch }` in units and semitones. The start move is
108
+ * `dUnits` snapped to the nearest grid line (a tie goes to the earlier line) and held so the earliest note stays at or after 0 and the
109
+ * latest end at or before `units`; the group keeps its shape, so one note decides how far it can go. The pitch move is `dRows` rows lower
110
+ * (negative: higher) held so every pitch stays between `low` and `high`, the lowest and highest shown pitch. A note already outside those
111
+ * rows is not pulled in: the group just cannot go further out.
112
+ */
113
+ export function groupMoveTarget({ notes, dUnits, dRows, gridUnit, units, low, high }) {
114
+ const first = Math.min(...notes.map((note) => note.start));
115
+ const last = Math.max(...notes.map((note) => note.start + note.length));
116
+ const lowest = Math.min(...notes.map((note) => note.pitch));
117
+ const highest = Math.max(...notes.map((note) => note.pitch));
118
+ return {
119
+ dStart: whole(clamp(nearestLine(dUnits, gridUnit) * gridUnit, -first, units - last)),
120
+ dPitch: whole(clamp(-dRows, Math.min(0, low - lowest), Math.max(0, high - highest))),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * The length a dragged edge gives a note that starts at `start`: it ends on the nearest grid line to the pointer (a tie goes to the
126
+ * earlier line), at least one step long and at most to the motif's end.
127
+ */
128
+ export function resizeLength({ start, pointerUnit: at, gridUnit, units }) {
129
+ return clamp(nearestLine(at, gridUnit) * gridUnit - start, gridUnit, units - start);
130
+ }
131
+
132
+ /**
133
+ * Which part of a note a press is on: `'edge'` when it is within `EDGE_HANDLE_REM` of the note's right side, or within the share of a
134
+ * short note that `EDGE_HANDLE_SHARE` allows, else `'body'`. `left` and `width` are the note's box in pixels, `remPx` the root font size.
135
+ */
136
+ export function pickPart({ left, width, x, remPx }) {
137
+ const handle = Math.min(EDGE_HANDLE_REM * remPx, EDGE_HANDLE_SHARE * width);
138
+ return x >= left + width - handle ? 'edge' : 'body';
139
+ }
140
+
141
+ /** True once a pointer that went down has moved `thresholdPx` pixels or more (`dx` and `dy` are its distance from the press). */
142
+ export function lockDrag({ dx, dy, thresholdPx }) {
143
+ return Math.hypot(dx, dy) >= thresholdPx;
144
+ }
145
+
146
+ /**
147
+ * How far to scroll the roll, in pixels, for a drag whose pointer is at `x`: minus one `EDGE_SCROLL_REM` toward the left edge when the
148
+ * pointer is within `EDGE_ZONE_REM` of `left`, plus one toward the right edge when it is within that distance of `right` (a pointer
149
+ * past an edge counts), else 0. `left` and `right` are the edges of the part of the roll that shows. A box with no width gives 0.
150
+ */
151
+ export function edgeScrollPx({ x, left, right, remPx }) {
152
+ if (right <= left) return 0;
153
+ const zone = EDGE_ZONE_REM * remPx;
154
+ const fromLeft = x - left;
155
+ const fromRight = right - x;
156
+ if (fromLeft <= zone && fromLeft <= fromRight) return -EDGE_SCROLL_REM * remPx;
157
+ if (fromRight <= zone) return EDGE_SCROLL_REM * remPx;
158
+ return 0;
159
+ }
160
+
161
+ /**
162
+ * How far to scroll the box vertically after an Undo or a Redo, so the rows it touched show between the ruler and the velocity lane.
163
+ * `tops` are the screen tops of the rows to show (one per note, may repeat), `cursorTop` the top of the cursor row, `rowHeight` one row,
164
+ * and `visibleTop` and `visibleBottom` the band between the ruler and the lane. Returns `{ by, nearest }`: `by` is the pixels to add to
165
+ * the box's scroll (negative scrolls up), and `nearest` the index in `tops` of the row closest to the cursor row (null with no rows).
166
+ * When the cursor row and the nearest row cannot both fit in the band, `by` is null: the caller has to move the cursor.
167
+ * Everything that fits together is brought in with the smallest move; otherwise the cursor row goes to the band's edge that leaves the
168
+ * most room on the side of the nearest row.
169
+ */
170
+ export function historyScroll({ tops, cursorTop, rowHeight, visibleTop, visibleBottom }) {
171
+ if (!(rowHeight > 0)) throw new Error(`Moontide: a history scroll needs a row height above 0, got ${String(rowHeight)}`);
172
+ if (!(visibleBottom > visibleTop)) throw new Error(`Moontide: a history scroll needs a visible band with a height, got ${String(visibleTop)} to ${String(visibleBottom)}`);
173
+ let nearest = null;
174
+ tops.forEach((top, index) => {
175
+ if (nearest === null || Math.abs(top - cursorTop) < Math.abs(tops[nearest] - cursorTop)) nearest = index;
176
+ });
177
+ const band = visibleBottom - visibleTop;
178
+ const spanTop = Math.min(cursorTop, ...tops);
179
+ const spanBottom = Math.max(cursorTop, ...tops) + rowHeight;
180
+ if (spanBottom - spanTop <= band) {
181
+ if (spanTop < visibleTop) return { by: spanTop - visibleTop, nearest };
182
+ if (spanBottom > visibleBottom) return { by: spanBottom - visibleBottom, nearest };
183
+ return { by: 0, nearest };
184
+ }
185
+ if (nearest === null) return { by: cursorTop - visibleTop, nearest };
186
+ const target = tops[nearest];
187
+ const pairTop = Math.min(cursorTop, target);
188
+ const pairBottom = Math.max(cursorTop, target) + rowHeight;
189
+ if (pairBottom - pairTop > band) return { by: null, nearest };
190
+ const cursorAtBottom = target < cursorTop || (target === cursorTop && tops.filter((top) => top < cursorTop).length > tops.filter((top) => top > cursorTop).length);
191
+ return { by: cursorAtBottom ? cursorTop + rowHeight - visibleBottom : cursorTop - visibleTop, nearest };
192
+ }
193
+
194
+ /** The width of the column of piano names at the left of the roll, in rem; the stylesheet draws the same width. */
195
+ export const GUTTER_REM = 4.5;
196
+
197
+ /** The smallest a beat gets while the motif's cycles are shown, in rem (a sixteenth is 1 rem); less room than that scrolls sideways. */
198
+ export const BEAT_MIN_REM = 4;
199
+ /** The smallest a beat gets while the live view shows 8 cycles, in rem: eight cycles need to fit more often than the motif's. */
200
+ export const LIVE_BEAT_MIN_REM = 2.5;
201
+ /** A row is held from 0.625 rem to 1.25 rem high. */
202
+ const ROW_REM_RANGE = [0.625, 1.25];
203
+
204
+ /** The width of a beat in rem: the room divided by the beats shown, never less than `minRem` and never capped, so the roll takes all its width. */
205
+ export function beatWidthRem({ availableRem, beats, minRem = BEAT_MIN_REM }) {
206
+ return Math.max(minRem, availableRem / beats);
207
+ }
208
+
209
+ /** The height of a row in rem: the room divided by the `rows` rows shown, held from 0.625 to 1.25 rem. More rows than fit at the least scroll. */
210
+ export function rowHeightRem({ availableRem, rows }) {
211
+ return clamp(availableRem / rows, ...ROW_REM_RANGE);
212
+ }
213
+
214
+ /** A bar of the velocity lane is at least this wide, with a little air on each side: the room one bar of a chord takes. */
215
+ export const BAR_SLOT_REM = 0.375;
216
+
217
+ /**
218
+ * The velocity a pointer at height `y` gives in a lane whose bars area is `rect` (`top` and `height`): the height above the lane's
219
+ * bottom as a share of its height, in whole hundredths, held from 1 (the bottom edge) to 100 (the top edge). A pointer past either edge is held there.
220
+ */
221
+ export function velocityAt({ rect, y }) {
222
+ return clamp(Math.round(((rect.top + rect.height - y) / rect.height) * 100), 1, 100);
223
+ }
224
+
225
+ /**
226
+ * The note whose bar is under a pointer at `x`. A bar belongs to the column of its note's start, one grid step wide; notes that start
227
+ * together share the column side by side, and a bar's share is never narrower than `BAR_SLOT_REM`, so a crowded chord on a narrow step
228
+ * spreads to the right. `bars` lists `{ id, start, slot, slots }`, `rect` is the bars area (`left` and `width`) for `units` units, and
229
+ * `remPx` the root font size. Returns the id, or null where no bar is.
230
+ */
231
+ export function barAt({ rect, x, bars, units, gridUnit, remPx }) {
232
+ const column = (gridUnit / units) * rect.width;
233
+ for (const bar of bars) {
234
+ const share = Math.max(BAR_SLOT_REM * remPx, column / bar.slots);
235
+ const left = rect.left + (bar.start / units) * rect.width + bar.slot * share;
236
+ if (x >= left && x < left + share) return bar.id;
237
+ }
238
+ return null;
239
+ }
@@ -0,0 +1,260 @@
1
+ // The Scratchpad's Listen player: it plays the motif's notes, lengths and velocities on an output the user chose, by handing timed notes to an
2
+ // injected `emit`. It holds no browser global and no timer of its own (the page gives it the clock, the timer and the output, like
3
+ // page/midi-in.js), so node:test drives it with fakes, and it knows nothing of Web MIDI: `emit` is page/midi-out.js's sendListenNote.
4
+ //
5
+ // A pass runs every LISTEN_PASS_MS and hands over only the notes due within LISTEN_AHEAD_MS, because Chrome cannot take back a message that
6
+ // has a time stamp. Each note's time is worked out when it is next to go, never far ahead, so a tempo ramp of the music is followed. The player
7
+ // keeps its own record of every note-on it handed over and of every note that sounds, and Stop ends exactly those, with targeted note-offs
8
+ // (never an All Notes Off, which would cut a playing track on a shared channel): an off now for a note that sounds, and an off a moment after
9
+ // the stamp of a note handed over for later.
10
+ //
11
+ // Start 'now' begins a short look-ahead after the press and runs at the music's tempo. Start 'music' begins on a cycle line of the music, so
12
+ // the motif is heard in context. With Loop on the player reads the motif again at each lap, so an edit is heard from the next pass.
13
+
14
+ import { UNITS_PER_CYCLE, listenSchedule, motifUnits } from './scratch-model.js';
15
+
16
+ /** How often a pass runs, in milliseconds. */
17
+ export const LISTEN_PASS_MS = 25;
18
+ /** How far ahead of now a note may be handed over: above the page's measured margin for a stamped note, far below its fault limit. */
19
+ export const LISTEN_AHEAD_MS = 150;
20
+ /** How long after the press a start at once begins. */
21
+ export const LISTEN_LEAD_MS = 100;
22
+ /** A cycle line nearer than this when Listen is pressed is too close to catch: the next one is taken. */
23
+ export const LISTEN_LINE_MARGIN_MS = 60;
24
+ /** A note ends this long before its written end, as the music's own MIDI path ends its notes, so two notes of one pitch back to back both sound. */
25
+ export const LISTEN_OFF_EARLY_MS = 10;
26
+ /** The note-off of a note handed over for later goes out this long after its stamp (the same constant as page/midi-out.js). */
27
+ export const LISTEN_QUEUED_OFF_AFTER_MS = 1;
28
+
29
+ const STARTS = ['now', 'music'];
30
+
31
+ /**
32
+ * Builds the player and returns `{ play, stop, setLoop, position, state }`.
33
+ *
34
+ * - `perfNow()` is the page clock in milliseconds, the clock the stamps are on; `setTimer(fn, ms)` and `clearTimer(handle)` run the passes.
35
+ * - `emit({ instrumentId, channel, kind, pitch, velocity, at })` plays one note on or off at the stamp `at` (none: at once) and answers
36
+ * `{ ok: true }` or `{ refused: word, ... }`. A refusal ends the play.
37
+ * - `stampOfCycle(cycle)` gives the stamp of a cycle line of the music, or null; `musicCycle()` the music's cycle now, or null while it does not
38
+ * play; `readCps()` the tempo in cycles a second.
39
+ * - `onState(state)` is told every change: `{ kind: 'waiting', cycle }`, `{ kind: 'playing', loop }`, `{ kind: 'ended' }`, `{ kind: 'stopped', reason }` or
40
+ * `{ kind: 'refused', refused, ..., mid }`, each with `instrumentId` and `channel`; `mid` says whether a note had been handed over.
41
+ *
42
+ * `play({ readMotif, instrumentId, channel, start, loop })` starts a play ('now' or 'music'; `readMotif()` gives the motif, read again at each lap),
43
+ * ends one that is playing without a word, and answers whether the new one started. `stop(reason)` silences what the play started and reports it;
44
+ * it does nothing when nothing plays. `setLoop(on)` changes what happens at the end of the pass. `position()` is the unit in the motif now, or null
45
+ * while it waits or nothing plays.
46
+ */
47
+ export function createScratchListen({ perfNow, setTimer, clearTimer, emit, stampOfCycle, musicCycle, readCps, onState = () => {} }) {
48
+ for (const [name, value] of Object.entries({ perfNow, setTimer, clearTimer, emit, stampOfCycle, musicCycle, readCps, onState })) {
49
+ if (typeof value !== 'function') throw new TypeError(`Moontide: the Listen player needs ${name}, a function.`);
50
+ }
51
+
52
+ // The play in progress, or null: { readMotif, instrumentId, channel, start, loop, laps, handed, started, finishAt, waiting, timer }.
53
+ let run = null;
54
+ let last = { kind: 'idle' };
55
+
56
+ function report(state) {
57
+ last = state;
58
+ onState(state);
59
+ }
60
+
61
+ const here = () => ({ instrumentId: run.instrumentId, channel: run.channel });
62
+
63
+ // One note handed to the output. A refusal comes back as the answer's word; an exception counts as a refusal, so a pass never dies.
64
+ function send(kind, pitch, velocity, at) {
65
+ try {
66
+ const answer = emit({ instrumentId: run.instrumentId, channel: run.channel, kind, pitch, velocity, at });
67
+ if (answer !== null && typeof answer === 'object' && answer.refused !== undefined) return answer;
68
+ return null;
69
+ } catch {
70
+ return { refused: 'send-failed' };
71
+ }
72
+ }
73
+
74
+ // Ends what the play started: an off now for a note that sounds, an off just after the stamp of a note whose on is still to come. Best
75
+ // effort: an output that has gone refuses these too, and there is nothing more to do for it.
76
+ function silence() {
77
+ const now = perfNow();
78
+ for (const entry of run.handed) {
79
+ if (entry.offAt !== null && entry.offAt <= now) continue;
80
+ if (entry.onAt > now) send('off', entry.pitch, 0, entry.onAt + LISTEN_QUEUED_OFF_AFTER_MS);
81
+ else send('off', entry.pitch, 0, undefined);
82
+ }
83
+ run.handed = [];
84
+ }
85
+
86
+ // Ends the play with a state to report: the sound is cut, the pass is cancelled and nothing is left to run.
87
+ function finish(state) {
88
+ const closing = run;
89
+ if (closing.timer !== null) clearTimer(closing.timer);
90
+ closing.timer = null;
91
+ if (state.kind !== 'ended') silence();
92
+ run = null;
93
+ report({ ...state, instrumentId: closing.instrumentId, channel: closing.channel });
94
+ }
95
+
96
+ const refuse = (answer, extra = {}) => finish({ kind: 'refused', ...answer, ...extra, mid: run.started });
97
+
98
+ // The next lap of the play, or null after it has finished the play with the reason. `after` is the lap before it (null for the first).
99
+ // A lap holds the events of the motif as read now, `timeOf(unit)` (the stamp of a unit of the lap, or null when the music's clock gives none),
100
+ // `unitAt()` (the unit the lap is at now, or null before it begins) and `begun()`.
101
+ function startLap(after) {
102
+ const motif = run.readMotif();
103
+ const events = listenSchedule(motif);
104
+ if (events.length === 0) return refuse({ refused: 'empty' }) ?? null;
105
+ const units = motifUnits(motif);
106
+ const lap = { events, units, next: 0, onAt: new Map() };
107
+ if (run.start === 'music') {
108
+ let line;
109
+ if (after === null) {
110
+ const cycleNow = musicCycle();
111
+ if (cycleNow === null) return refuse({ refused: 'music-not-playing' }) ?? null;
112
+ line = Math.floor(cycleNow) + 1;
113
+ let stamp = stampOfCycle(line);
114
+ if (stamp !== null && stamp - perfNow() < LISTEN_LINE_MARGIN_MS) {
115
+ line += 1;
116
+ stamp = stampOfCycle(line);
117
+ }
118
+ if (stamp === null) return refuse({ refused: 'no-clock' }) ?? null;
119
+ } else {
120
+ line = after.line + after.units / UNITS_PER_CYCLE;
121
+ }
122
+ return {
123
+ ...lap,
124
+ line,
125
+ timeOf: (unit) => stampOfCycle(line + unit / UNITS_PER_CYCLE),
126
+ unitAt: () => {
127
+ const cycle = musicCycle();
128
+ if (cycle === null) return null;
129
+ const unit = (cycle - line) * UNITS_PER_CYCLE;
130
+ return unit < 0 ? null : Math.min(unit, units);
131
+ },
132
+ begun: () => {
133
+ const cycle = musicCycle();
134
+ return cycle !== null && cycle >= line;
135
+ },
136
+ };
137
+ }
138
+ const cps = readCps();
139
+ if (typeof cps !== 'number' || !(cps > 0) || !Number.isFinite(cps)) return refuse({ refused: 'tempo' }) ?? null;
140
+ const msPerUnit = 1000 / (cps * UNITS_PER_CYCLE);
141
+ const startPerf = after === null ? perfNow() + LISTEN_LEAD_MS : after.endPerf;
142
+ return {
143
+ ...lap,
144
+ endPerf: startPerf + units * msPerUnit,
145
+ timeOf: (unit) => startPerf + unit * msPerUnit,
146
+ unitAt: () => {
147
+ const unit = (perfNow() - startPerf) / msPerUnit;
148
+ return unit < 0 ? null : Math.min(unit, units);
149
+ },
150
+ begun: () => perfNow() >= startPerf,
151
+ };
152
+ }
153
+
154
+ // The stamp an event goes out at: its time, with a note ended a little before its written end but never less than 1 ms after it began. Null
155
+ // when the music's clock gives no time.
156
+ function stampOf(lap, event) {
157
+ const at = lap.timeOf(event.unit);
158
+ if (at === null || event.kind === 'on') return at;
159
+ return Math.max(at - LISTEN_OFF_EARLY_MS, (lap.onAt.get(event.pitch) ?? -Infinity) + 1);
160
+ }
161
+
162
+ // Records what a handed-over note adds to what is sounding.
163
+ function note(lap, event, at) {
164
+ if (event.kind === 'on') {
165
+ lap.onAt.set(event.pitch, at);
166
+ run.handed.push({ pitch: event.pitch, onAt: at, offAt: null });
167
+ return;
168
+ }
169
+ const open = run.handed.find((entry) => entry.pitch === event.pitch && entry.offAt === null && entry.onAt <= at);
170
+ if (open !== undefined) open.offAt = at;
171
+ run.finishAt = Math.max(run.finishAt, at);
172
+ }
173
+
174
+ function pass() {
175
+ run.timer = null;
176
+ const now = perfNow();
177
+ // In the music, the play ends when the music does: there is no cycle line to play on any more.
178
+ if (run.start === 'music' && musicCycle() === null) {
179
+ finish({ kind: 'stopped', reason: 'music-stopped' });
180
+ return;
181
+ }
182
+ run.handed = run.handed.filter((entry) => entry.offAt === null || entry.offAt > now);
183
+ while (run.laps.length > 1 && run.laps[1].begun()) run.laps.shift();
184
+ if (run.waiting && run.laps[0].begun()) {
185
+ run.waiting = false;
186
+ report({ kind: 'playing', ...here(), loop: run.loop });
187
+ }
188
+ for (;;) {
189
+ let lap = run.laps[run.laps.length - 1];
190
+ if (lap.next >= lap.events.length) {
191
+ if (run.loop) {
192
+ lap = startLap(lap);
193
+ if (lap === null) return;
194
+ run.laps.push(lap);
195
+ continue;
196
+ }
197
+ if (now >= run.finishAt) {
198
+ finish({ kind: 'ended' });
199
+ return;
200
+ }
201
+ break;
202
+ }
203
+ const event = lap.events[lap.next];
204
+ const at = stampOf(lap, event);
205
+ if (at === null) {
206
+ refuse({ refused: 'no-clock' });
207
+ return;
208
+ }
209
+ if (at - now > LISTEN_AHEAD_MS) break;
210
+ const refused = send(event.kind, event.pitch, event.velocity, at);
211
+ if (refused !== null) {
212
+ refuse(refused);
213
+ return;
214
+ }
215
+ run.started = true;
216
+ note(lap, event, at);
217
+ lap.next += 1;
218
+ }
219
+ run.timer = setTimer(pass, LISTEN_PASS_MS);
220
+ }
221
+
222
+ function play({ readMotif, instrumentId, channel, start, loop }) {
223
+ if (typeof readMotif !== 'function') throw new TypeError('Moontide: the Listen player needs readMotif, a function that gives the motif.');
224
+ if (!STARTS.includes(start)) throw new Error(`Moontide: a Listen start is 'now' or 'music', got ${String(start)}`);
225
+ if (run !== null) {
226
+ if (run.timer !== null) clearTimer(run.timer);
227
+ silence();
228
+ run = null;
229
+ }
230
+ run = { readMotif, instrumentId, channel, start, loop: loop === true, laps: [], handed: [], started: false, finishAt: -Infinity, waiting: start === 'music', timer: null };
231
+ const lap = startLap(null);
232
+ if (lap === null) return false;
233
+ run.laps.push(lap);
234
+ report(run.waiting ? { kind: 'waiting', ...here(), cycle: lap.line } : { kind: 'playing', ...here(), loop: run.loop });
235
+ pass();
236
+ return run !== null;
237
+ }
238
+
239
+ function stop(reason = 'stop') {
240
+ if (run === null) return;
241
+ finish({ kind: 'stopped', reason });
242
+ }
243
+
244
+ function setLoop(on) {
245
+ if (run === null) return;
246
+ run.loop = on === true;
247
+ if (!run.waiting) report({ kind: 'playing', ...here(), loop: run.loop });
248
+ }
249
+
250
+ function position() {
251
+ if (run === null) return null;
252
+ for (let index = run.laps.length - 1; index >= 0; index--) {
253
+ const unit = run.laps[index].unitAt();
254
+ if (unit !== null) return unit;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ return { play, stop, setLoop, position, state: () => last };
260
+ }