moontide-mcp 2.2.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.
- package/README.md +54 -383
- package/dist/ask.js +12 -4
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +3 -3
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +80 -3
- package/dist/reference.js +12 -0
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +77 -2
- package/page/ask.js +57 -4
- package/page/bar-model.js +1 -0
- package/page/index.html +2 -0
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -2
- package/page/main.js +348 -7
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +552 -46
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- package/page/played-buffer.js +208 -0
- package/page/scratch-gesture.js +239 -0
- package/page/scratch-listen.js +260 -0
- package/page/scratch-model.js +1696 -0
- package/page/scratch-view.js +2176 -0
- package/page/style.css +842 -0
- package/page/views.js +5 -5
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
|
@@ -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
|
+
}
|