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,1696 @@
|
|
|
1
|
+
// Pure module for the Scratchpad view: the motif model (notes on the cycle grid, kept as whole units so a
|
|
2
|
+
// grid change never drifts), the commands that change it, the names and positions the page reads out, and every
|
|
3
|
+
// word the view shows. No DOM access, window or timer at import time, so this module can be imported directly by
|
|
4
|
+
// node:test and by page/main.js without a build step. A command never changes its input: it returns a new frozen
|
|
5
|
+
// motif and the sentence that says what it did.
|
|
6
|
+
|
|
7
|
+
/** One cycle is 48 units: the least common multiple of the steps per cycle of the five grids (4, 8, 16, 12 and 24), so every grid step is a whole number of units. */
|
|
8
|
+
export const UNITS_PER_CYCLE = 48;
|
|
9
|
+
|
|
10
|
+
/** A beat is a quarter note and a cycle holds four beats; a beat is therefore 12 units. */
|
|
11
|
+
const UNITS_PER_BEAT = 12;
|
|
12
|
+
const BEATS_PER_CYCLE = UNITS_PER_CYCLE / UNITS_PER_BEAT;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The five grids, in the order the page lists them. `steps` is the steps per cycle, `unit` the units in one step, `text` the
|
|
16
|
+
* button word, `name` the tooltip and accessible name, and `one` and `many` the words that count a length in steps.
|
|
17
|
+
*/
|
|
18
|
+
export const GRIDS = Object.freeze([
|
|
19
|
+
Object.freeze({ id: 'quarter', steps: 4, unit: 12, text: 'Quarters', name: 'Quarter notes, 4 per cycle', one: 'quarter', many: 'quarters' }),
|
|
20
|
+
Object.freeze({ id: 'eighth', steps: 8, unit: 6, text: 'Eighths', name: 'Eighth notes, 8 per cycle', one: 'eighth', many: 'eighths' }),
|
|
21
|
+
Object.freeze({ id: 'sixteenth', steps: 16, unit: 3, text: 'Sixteenths', name: 'Sixteenth notes, 16 per cycle', one: 'sixteenth', many: 'sixteenths' }),
|
|
22
|
+
Object.freeze({ id: 'eighth-triplet', steps: 12, unit: 4, text: 'Eighth triplets', name: 'Eighth-note triplets, 12 per cycle', one: 'eighth triplet', many: 'eighth triplets' }),
|
|
23
|
+
Object.freeze({ id: 'sixteenth-triplet', steps: 24, unit: 2, text: 'Sixteenth triplets', name: 'Sixteenth-note triplets, 24 per cycle', one: 'sixteenth triplet', many: 'sixteenth triplets' }),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_GRID = 'sixteenth';
|
|
27
|
+
export const DEFAULT_CYCLES = 2;
|
|
28
|
+
export const MIN_CYCLES = 1;
|
|
29
|
+
export const MAX_CYCLES = 8;
|
|
30
|
+
/** A new note starts at 0.80, stored as whole hundredths; the last value used is never remembered. */
|
|
31
|
+
export const DEFAULT_VELOCITY = 80;
|
|
32
|
+
/** The pitches a note can have: C0 to B8 as MIDI numbers, with C4 = 60. */
|
|
33
|
+
export const MIN_PITCH = 12;
|
|
34
|
+
export const MAX_PITCH = 119;
|
|
35
|
+
/** The roll shows 24 chromatic rows (two octaves) that start on a C. */
|
|
36
|
+
export const WINDOW_ROWS = 24;
|
|
37
|
+
export const MIN_WINDOW_START = 12;
|
|
38
|
+
export const MAX_WINDOW_START = 96;
|
|
39
|
+
/** Where the window starts when the motif is empty: C3, the first key of the Jam piano. */
|
|
40
|
+
export const EMPTY_WINDOW_START = 48;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Page-visible copy for the Scratchpad view. `{name}` slots are filled by fillScratch. The note line is written in two parts,
|
|
44
|
+
* the name and the rest, so the page can show the name heavier than the rest.
|
|
45
|
+
*/
|
|
46
|
+
export const SCRATCH_COPY = Object.freeze({
|
|
47
|
+
emptyHeading: 'No notes yet',
|
|
48
|
+
emptyBody: 'Double-click the grid or press Enter to write a note, or turn Draw on and drag. Play a MIDI keyboard and press Record that. Listen plays the motif on an instrument you choose; the motif is sent only when Send to Claude is pressed.',
|
|
49
|
+
hintSelect: 'Click or drag to select. Double-click or Enter writes a note. Delete erases. Listen plays the motif.',
|
|
50
|
+
hintDraw: 'Draw is on: drag to write a note. Turn it off to select by dragging.',
|
|
51
|
+
modeGroup: 'Mode',
|
|
52
|
+
draw: 'Draw',
|
|
53
|
+
drawName: 'Draw notes with a drag',
|
|
54
|
+
drawTitle: 'Draw notes with a drag (key d)',
|
|
55
|
+
early: 'Early experimental',
|
|
56
|
+
earlyTitle: 'The whole Scratchpad is new, may still change and has known problems; check a played motif on the roll before you send it.',
|
|
57
|
+
drawOn: 'Draw is on.',
|
|
58
|
+
drawOff: 'Draw is off.',
|
|
59
|
+
placed: 'Placed {name} at cycle {cycle}, beat {beat}.',
|
|
60
|
+
erased: 'Erased {name} at cycle {cycle}, beat {beat}. Undo brings it back.',
|
|
61
|
+
noteName: '{name}',
|
|
62
|
+
noteRest: ' · cycle {cycle}, beat {beat} · length {length} · velocity {velocity}',
|
|
63
|
+
emptyCellRest: ' · cycle {cycle}, beat {beat} · empty',
|
|
64
|
+
spokenNote: '{name}, starts at cycle {cycle}, beat {beat}, lasts {length}, velocity {velocity}.',
|
|
65
|
+
spokenEmpty: 'Row {name}, cycle {cycle}, beat {beat}, empty',
|
|
66
|
+
summary: '{notes} in {cycles} on {grid}.',
|
|
67
|
+
oneNote: '1 note',
|
|
68
|
+
manyNotes: '{count} notes',
|
|
69
|
+
oneCycle: '1 cycle',
|
|
70
|
+
manyCycles: '{count} cycles',
|
|
71
|
+
beatOne: 'beat',
|
|
72
|
+
beatMany: 'beats',
|
|
73
|
+
lengthWithBeats: '{count} {word} ({beats} {beatWord})',
|
|
74
|
+
moved: 'Moved {name} to cycle {cycle}, beat {beat}.',
|
|
75
|
+
movedPitch: 'Moved {from} to {name} at cycle {cycle}, beat {beat}.',
|
|
76
|
+
lasts: '{name} now lasts {length}.',
|
|
77
|
+
noNote: 'No note here. Move the cursor onto a note, or press Enter to place one.',
|
|
78
|
+
cut: ' {count} note cut where a note of the same pitch starts.',
|
|
79
|
+
cutMany: ' {count} notes cut where a note of the same pitch starts.',
|
|
80
|
+
merged: ' Merged with the note there (same pitch in the same cell).',
|
|
81
|
+
gridNotice: 'Grid: {grid}.{rest}',
|
|
82
|
+
gridMoved: ' {notes} moved onto it{merged}.',
|
|
83
|
+
gridMerged: ', {count} merged (same pitch in the same cell)',
|
|
84
|
+
cyclesNow: 'Motif is now {cycles}.',
|
|
85
|
+
cyclesCut: ' 1 note was cut at the end.',
|
|
86
|
+
cyclesCutMany: ' {count} notes were cut at the end.',
|
|
87
|
+
cyclesRefused: 'Cycle {cycle} has {notes}. Erase or move them first.',
|
|
88
|
+
windowRange: '{low}–{high}',
|
|
89
|
+
windowSpokenRange: '{low} to {high}',
|
|
90
|
+
fewer: 'Fewer cycles',
|
|
91
|
+
more: 'More cycles',
|
|
92
|
+
fewerLeast: 'The motif is already 1 cycle long, the least.',
|
|
93
|
+
moreMost: 'The motif is already 8 cycles long, the most.',
|
|
94
|
+
lower: 'Lower',
|
|
95
|
+
lowerName: 'Move the starting pitch window down one octave. The roll still grows to show every note.',
|
|
96
|
+
lowerLowest: 'The starting pitch window is already at the lowest octave.',
|
|
97
|
+
higher: 'Higher',
|
|
98
|
+
higherName: 'Move the starting pitch window up one octave. The roll still grows to show every note.',
|
|
99
|
+
higherHighest: 'The starting pitch window is already at the highest octave.',
|
|
100
|
+
fewerSign: '−',
|
|
101
|
+
moreSign: '+',
|
|
102
|
+
laneLabel: 'Velocity',
|
|
103
|
+
velocitySet: '{name} velocity {velocity}.',
|
|
104
|
+
barName: '{name} velocity {velocity}',
|
|
105
|
+
undo: 'Undo',
|
|
106
|
+
redo: 'Redo',
|
|
107
|
+
nothingToUndo: 'Nothing to undo.',
|
|
108
|
+
nothingToRedo: 'Nothing to redo.',
|
|
109
|
+
undid: 'Undid: {what}.',
|
|
110
|
+
redid: 'Redid: {what}.',
|
|
111
|
+
whatPlaced: 'placed {name}',
|
|
112
|
+
whatErased: 'erased {name}',
|
|
113
|
+
whatMoved: 'moved {name}',
|
|
114
|
+
whatLength: 'changed the length of {name}',
|
|
115
|
+
whatVelocity: 'changed the velocity of {name}',
|
|
116
|
+
whatMovedMany: 'moved {notes}',
|
|
117
|
+
whatLengthMany: 'changed the length of {notes}',
|
|
118
|
+
whatErasedMany: 'erased {notes}',
|
|
119
|
+
whatVelocityMany: 'changed the velocity of {notes}',
|
|
120
|
+
theMotif: 'the motif',
|
|
121
|
+
movedMany: 'Moved {who} {how}.',
|
|
122
|
+
movedLater: 'later',
|
|
123
|
+
movedEarlier: 'earlier',
|
|
124
|
+
movedUp: 'up {distance}',
|
|
125
|
+
movedDown: 'down {distance}',
|
|
126
|
+
movedAnd: ' and ',
|
|
127
|
+
oneOctave: 'one octave',
|
|
128
|
+
manyOctaves: '{count} octaves',
|
|
129
|
+
oneSemitone: 'one semitone',
|
|
130
|
+
manySemitones: '{count} semitones',
|
|
131
|
+
oneStepOf: 'one {word}',
|
|
132
|
+
manyStepsOf: '{count} {word}',
|
|
133
|
+
movedFirstAtStart: 'Not moved: the first note is already at the start of the motif.',
|
|
134
|
+
movedLastAtEnd: 'Not moved: the last note already ends at the end of the motif.',
|
|
135
|
+
movedHighest: 'Not moved: a note is already at the highest pitch.',
|
|
136
|
+
movedLowest: 'Not moved: a note is already at the lowest pitch.',
|
|
137
|
+
longerMany: 'Made {who} {distance} longer.',
|
|
138
|
+
shorterMany: 'Made {who} {distance} shorter.',
|
|
139
|
+
erasedMany: 'Erased {notes}. Undo brings them back.',
|
|
140
|
+
velocityRaised: 'Raised the velocity of {notes} by {amount}.',
|
|
141
|
+
velocityLowered: 'Lowered the velocity of {notes} by {amount}.',
|
|
142
|
+
selectedAll: 'Selected all {notes}.',
|
|
143
|
+
selectedOnly: 'Selected the only note.',
|
|
144
|
+
selectNoNotes: 'No notes to select.',
|
|
145
|
+
selectedNone: 'Selected none.',
|
|
146
|
+
selectedAdded: 'Selected {name}. {count}',
|
|
147
|
+
selectedRemoved: 'Removed {name} from the selection. {count}',
|
|
148
|
+
selectedCount: '{notes} selected.',
|
|
149
|
+
selectedNothing: 'Nothing selected.',
|
|
150
|
+
selectNoNote: 'No note here to select.',
|
|
151
|
+
selectionName: '{notes} selected',
|
|
152
|
+
selectionRange: ' · {low} to {high} · from cycle {cycle}, beat {beat}',
|
|
153
|
+
selectionOnePitch: ' · {low} · from cycle {cycle}, beat {beat}',
|
|
154
|
+
noLaterNote: 'No later note.',
|
|
155
|
+
noEarlierNote: 'No earlier note.',
|
|
156
|
+
editGroup: 'Edit',
|
|
157
|
+
earlier: 'Earlier',
|
|
158
|
+
earlierName: 'Move the selection, or the whole motif, one grid step earlier',
|
|
159
|
+
later: 'Later',
|
|
160
|
+
laterName: 'Move the selection, or the whole motif, one grid step later',
|
|
161
|
+
align: 'Align',
|
|
162
|
+
alignName: 'Align the motif so its first note falls on the nearest beat',
|
|
163
|
+
alignTitle: 'Align the motif so its first note falls on the nearest beat (key l)',
|
|
164
|
+
clear: 'Clear',
|
|
165
|
+
clearName: 'Clear the roll',
|
|
166
|
+
moveNothing: 'Nothing to move: the roll has no notes.',
|
|
167
|
+
aligned: 'Aligned: the motif moved {distance} {direction}, so its first note is on cycle {cycle}, beat {beat}.',
|
|
168
|
+
alignedAlready: 'Already aligned: the first note is on a beat.',
|
|
169
|
+
alignOverflow: 'Not aligned: the last note would pass the end of the motif. Make the motif longer, or move the last note earlier first.',
|
|
170
|
+
alignNothing: 'Nothing to align: the roll has no notes.',
|
|
171
|
+
whatAligned: 'aligned the motif',
|
|
172
|
+
clearedOne: 'Cleared 1 note. Undo brings it back.',
|
|
173
|
+
clearedMany: 'Cleared {count} notes. Undo brings them back.',
|
|
174
|
+
clearNothing: 'Nothing to clear: the roll has no notes.',
|
|
175
|
+
whatCleared: 'cleared the roll',
|
|
176
|
+
whatGrid: 'changed the grid to {grid}',
|
|
177
|
+
whatCycles: 'changed the motif to {cycles}',
|
|
178
|
+
tap: 'Tap rhythm',
|
|
179
|
+
tapTitle: 'Tap a rhythm with the Space key while the music plays (key t)',
|
|
180
|
+
tapNeedsMusic: "Tap needs the music playing, because the music's clock places the taps. Press Play, then try again.",
|
|
181
|
+
tapClockUnreadable: "The music's clock could not be read, so nothing was placed.",
|
|
182
|
+
tapStampRefused: 'The time stamp of the key press was refused ({reason}), so nothing was placed.',
|
|
183
|
+
tapReasonZero: 'it was zero',
|
|
184
|
+
tapReasonNotANumber: 'it was not a number',
|
|
185
|
+
tapReasonAhead: "it was ahead of the page's clock",
|
|
186
|
+
tapReasonStale: 'it was more than a second old',
|
|
187
|
+
tapStarted: 'Tap is on. Press Space on the beat; the marker shows where the next tap lands.',
|
|
188
|
+
tapStopped: 'Tap is off.',
|
|
189
|
+
tapMusicStopped: 'Tap is off, because the music stopped.',
|
|
190
|
+
tapped: 'Tapped {name} at cycle {cycle}, beat {beat}.',
|
|
191
|
+
whatTapped: 'tapped {name}',
|
|
192
|
+
listenAbsent: 'Not listening: there is no instruments.json, so no MIDI input is named. To listen to a keyboard, add "inputs": [{ "port": "its exact name" }] to one.',
|
|
193
|
+
listenInvalid: 'Not listening: instruments.json has a problem. The MIDI popover shows it.',
|
|
194
|
+
listenNone: 'Not listening: instruments.json names no MIDI input. To listen to a keyboard, add "inputs": [{ "port": "its exact name" }] next to "instruments".',
|
|
195
|
+
listenNeedsPermission: 'The page can listen to {ports} once MIDI is allowed.',
|
|
196
|
+
listenPending: 'Waiting for the answer to the MIDI permission.',
|
|
197
|
+
listenDenied: "Not listening: MIDI was refused for this page. Allow it in the browser's site settings, then reload.",
|
|
198
|
+
listenUnsupported: 'Not listening: this browser has no Web MIDI.',
|
|
199
|
+
listenFailed: 'Not listening: MIDI could not start ({failure}).',
|
|
200
|
+
listenHidden: 'Listening is paused: this tab is hidden, behind another tab or covered by another window. It listens again when the tab is shown.',
|
|
201
|
+
listenOtherTab: 'Not listening: another Moontide tab has the music. Take it back to listen here.',
|
|
202
|
+
listenListening: 'Listening to {ports}. Play, and the notes show here.',
|
|
203
|
+
listenNotFound: ' Not found: {ports}.',
|
|
204
|
+
listenNotTold: ' Not told apart: {ports}.',
|
|
205
|
+
listenNotOpenedToo: ' Could not be opened: {ports}.',
|
|
206
|
+
listenNotOpened: 'Not listening: {port} is connected but could not be opened; another program may be using it. Close that program, then plug the keyboard in again or reload the page.',
|
|
207
|
+
listenMissing: 'Not listening: no MIDI input is named exactly {port}. Inputs here: {names}.',
|
|
208
|
+
listenUnplugged: 'Not listening: {port} was unplugged. Plug it in again; the page notices by itself. Inputs here: {names}.',
|
|
209
|
+
listenAmbiguous: 'Not listening: {count} inputs are named exactly {port}, so the page cannot tell them apart.',
|
|
210
|
+
allow: 'Allow MIDI to listen',
|
|
211
|
+
allowTitle: 'Ask the browser to let this page read the MIDI keyboard',
|
|
212
|
+
listenNoInputs: 'none',
|
|
213
|
+
listenJoin: ', ',
|
|
214
|
+
heard: 'Heard {name} · {notes} in the buffer.',
|
|
215
|
+
heardRefusedStamp: 'A note from the keyboard was not kept: its time stamp was refused ({reason}).',
|
|
216
|
+
heardRefusedClock: "A note from the keyboard was not kept: the music's clock gives no cycle and the tempo is not above 0.",
|
|
217
|
+
heardRefusedFull: 'The keyboard buffer is full (2000 notes), so the newest notes were not kept.',
|
|
218
|
+
record: 'Record that',
|
|
219
|
+
recordGroup: 'Keyboard',
|
|
220
|
+
recordTitle: 'Put what the keyboard played in the last 8 cycles on the roll, in place of the motif',
|
|
221
|
+
recordNothing: 'Nothing to record: the keyboard played nothing in the last 8 cycles.',
|
|
222
|
+
recordClockUnreadable: "The music's clock could not be read, so nothing was recorded.",
|
|
223
|
+
recorded: 'Recorded {notes} over {cycles} on {grid}.',
|
|
224
|
+
recordOutside: " {count} notes were outside the roll's range and were left out.",
|
|
225
|
+
recordOutsideOne: " 1 note was outside the roll's range and was left out.",
|
|
226
|
+
recordOnlyOutside: 'Nothing to record.{outside}',
|
|
227
|
+
recordUndo: ' Undo brings back the earlier motif.',
|
|
228
|
+
whatRecorded: 'recorded the keyboard',
|
|
229
|
+
phraseLabel: 'Phrase of the motif',
|
|
230
|
+
phraseEmpty: 'No phrase yet: the motif has no notes.',
|
|
231
|
+
wordsLabel: 'Words for Claude',
|
|
232
|
+
defaultWords: 'Add this motif to the music.',
|
|
233
|
+
send: 'Send to Claude',
|
|
234
|
+
sendTitle: 'Send the motif to Claude as a request. The Scratchpad changes no code.',
|
|
235
|
+
sent: 'Sent to Claude at cycle {cycle}. The Requests panel shows what becomes of it.',
|
|
236
|
+
sentNoMoment: 'Sent to Claude with no moment. The Requests panel shows what becomes of it.',
|
|
237
|
+
sendNoNotes: 'Nothing to send: the motif has no notes.',
|
|
238
|
+
sendNoWords: 'Nothing to send: write a few words for Claude first.',
|
|
239
|
+
sendWordsTooLong: 'Not sent: the words are {length} characters, {max} at most.',
|
|
240
|
+
sendDisconnected: 'Not sent: the page is not connected to Moontide.',
|
|
241
|
+
sendPending: 'Not sent: the last motif request is still on its way.',
|
|
242
|
+
sendTooBig: 'Not sent: the request would be {bytes} bytes, {max} at most. Fewer cycles, a coarser grid, fewer notes sounding together or shorter words make it smaller.',
|
|
243
|
+
sendRefused: 'Not sent: {reason}',
|
|
244
|
+
sendNotConfirmed: 'Moontide did not confirm the motif request. Try again.',
|
|
245
|
+
sendWaiting: "It waits until Claude's next Moontide call; Send can send another motif meanwhile.",
|
|
246
|
+
hearGroup: 'Hear the motif',
|
|
247
|
+
hearOutputName: 'Instrument and channel',
|
|
248
|
+
hearOutputPlaceholder: 'Choose an instrument',
|
|
249
|
+
hearOutputOption: '{id} · channel {channel}',
|
|
250
|
+
hearOutputTitle: '{id} · channel {channel} · {port}',
|
|
251
|
+
hearStartGroup: 'Start',
|
|
252
|
+
hearStartMusic: 'In the music',
|
|
253
|
+
hearStartMusicTitle: 'Start on the next cycle line of the music, so the motif is heard in context.',
|
|
254
|
+
hearStartNow: 'At once',
|
|
255
|
+
hearStartNowTitle: 'Start now, whatever the music does.',
|
|
256
|
+
loop: 'Loop',
|
|
257
|
+
loopTitle: 'Repeat the motif until Stop. Turning it on or off while it plays applies from the next pass.',
|
|
258
|
+
listen: 'Listen',
|
|
259
|
+
listenTitle: 'Play the motif on the chosen instrument. Nothing is sent to Claude and the code does not change.',
|
|
260
|
+
stop: 'Stop',
|
|
261
|
+
stopTitle: 'Silence every note Listen started and cancel a wait.',
|
|
262
|
+
stopNothing: 'Nothing is playing or waiting.',
|
|
263
|
+
hearChoose: 'Choose an instrument to listen on.',
|
|
264
|
+
hearNoNotes: 'Nothing to listen to: the motif has no notes.',
|
|
265
|
+
hearNoInstrumentNone: 'No instrument to listen on: instruments.json names none.',
|
|
266
|
+
hearNoInstrumentAbsent: 'No instrument to listen on: there is no instruments.json.',
|
|
267
|
+
hearNoInstrumentInvalid: 'No instrument to listen on: instruments.json has a problem. The MIDI popover shows it.',
|
|
268
|
+
hearPlaying: 'Playing on {instrument}, channel {channel}.',
|
|
269
|
+
hearPlayingLoop: 'Playing on {instrument}, channel {channel}, in a loop.',
|
|
270
|
+
hearEnded: 'Played once on {instrument}, channel {channel}.',
|
|
271
|
+
hearStopped: 'Stopped. Held notes are silent.',
|
|
272
|
+
hearNoOutput: 'Not played: no MIDI output contains the name {port}. Outputs here: {names}.',
|
|
273
|
+
hearAmbiguous: 'Not played: {count} MIDI outputs contain the name {port}, so the page cannot tell them apart.',
|
|
274
|
+
hearUnplugged: 'Stopped: {port} is no longer connected.',
|
|
275
|
+
hearOff: 'Not played: {instrument} is switched off in the Instruments view.',
|
|
276
|
+
hearResetting: 'Not played: {instrument} is being reset or was just plugged in again. Try again in a moment.',
|
|
277
|
+
hearSuperseded: 'Not played: another Moontide tab has the music. Take it back to listen here.',
|
|
278
|
+
hearNoRig: 'Not played: there is no valid instruments.json.',
|
|
279
|
+
hearUnknownInstrument: 'Not played: instruments.json does not name an instrument {instrument}.',
|
|
280
|
+
hearTempo: 'Not played: the tempo is not above 0.',
|
|
281
|
+
hearChannelBusy: 'Heads up: channel {channel} on {instrument} is used by the track {tracks}, which is playing. Both would sound on it.',
|
|
282
|
+
hearChannelBusyMany: 'Heads up: channel {channel} on {instrument} is used by the tracks {tracks}, which are playing. Both would sound on it.',
|
|
283
|
+
hearMonophonic: 'Heads up: {instrument} plays one note at a time, so a chord would be heard as one note.',
|
|
284
|
+
hearInvalid: 'Not played: the note could not be sent to the MIDI output.',
|
|
285
|
+
hearSendFailed: 'Not played: the MIDI output refused the note.',
|
|
286
|
+
hearWaiting: 'Waiting for cycle {cycle} of the music, then playing on {instrument}, channel {channel}. Stop cancels.',
|
|
287
|
+
hearAsking: 'Waiting for the answer to the MIDI permission.',
|
|
288
|
+
hearMusicNotPlaying: 'The music is not playing, so there is no cycle line to wait for. Press Play, or choose At once.',
|
|
289
|
+
hearNoClock: "Not played: the music's clock gives no time for the next cycle line yet. Try again in a moment.",
|
|
290
|
+
hearDenied: "Not played: MIDI was refused for this page. Allow it in the browser's site settings, then reload.",
|
|
291
|
+
hearUnsupported: 'Not played: this browser has no Web MIDI.',
|
|
292
|
+
hearFailed: 'Not played: MIDI could not start ({failure}).',
|
|
293
|
+
hearHidden: 'Stopped: this tab was hidden, and a hidden tab cannot keep time.',
|
|
294
|
+
hearMusicStopped: 'Stopped: the music stopped, so there is no cycle line to play on.',
|
|
295
|
+
hearClosed: 'Stopped: the Scratchpad was closed.',
|
|
296
|
+
hearLeft: 'Stopped: the page was left.',
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A sentence of the copy with each `{name}` replaced by its value in `values`. A placeholder with no value throws, so a
|
|
301
|
+
* sentence is never shown with a hole in it. The values are put in as plain text.
|
|
302
|
+
*/
|
|
303
|
+
export function fillScratch(template, values) {
|
|
304
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
|
305
|
+
if (!Object.hasOwn(values, name)) throw new Error(`Moontide: the Scratchpad copy has no value for {${name}} in '${template}'`);
|
|
306
|
+
return String(values[name]);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The grid with this id. An id outside the list throws. */
|
|
311
|
+
export function gridOf(id) {
|
|
312
|
+
const grid = GRIDS.find((candidate) => candidate.id === id);
|
|
313
|
+
if (grid === undefined) throw new Error(`Moontide: unknown Scratchpad grid '${String(id)}'`);
|
|
314
|
+
return grid;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The empty motif: two cycles on sixteenths, no notes. A note is a frozen { id, pitch, start, length, velocity }, with start and length in units and velocity in hundredths.
|
|
319
|
+
* `source` says how the notes got on the roll (one of MOTIF_SOURCES, null while there are none) and `recorded` is null, or `{ cycle }` for a motif
|
|
320
|
+
* made by Record that: the cycle where it was played (a whole number, or null when the music was stopped). Both ride on the motif itself, so Undo brings
|
|
321
|
+
* them back with the notes.
|
|
322
|
+
*/
|
|
323
|
+
export function emptyMotif() {
|
|
324
|
+
return Object.freeze({ cycles: DEFAULT_CYCLES, grid: DEFAULT_GRID, notes: Object.freeze([]), nextId: 1, source: null, recorded: null });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** How the notes of a motif got on the roll, in the order the request lists them; 'mixed' is more than one way. */
|
|
328
|
+
export const MOTIF_SOURCES = Object.freeze(['played', 'drawn', 'tapped', 'mixed']);
|
|
329
|
+
|
|
330
|
+
/** The motif's length in units. */
|
|
331
|
+
export function motifUnits(motif) {
|
|
332
|
+
return motif.cycles * UNITS_PER_CYCLE;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function makeNote(id, pitch, start, length, velocity) {
|
|
336
|
+
return Object.freeze({ id, pitch, start, length, velocity });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function checkPitch(pitch) {
|
|
340
|
+
if (!Number.isInteger(pitch) || pitch < MIN_PITCH || pitch > MAX_PITCH) {
|
|
341
|
+
throw new Error(`Moontide: a Scratchpad pitch must be a whole number from ${MIN_PITCH} to ${MAX_PITCH}, got ${String(pitch)}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function checkUnit(motif, unit) {
|
|
346
|
+
if (!Number.isInteger(unit) || unit < 0 || unit >= motifUnits(motif)) {
|
|
347
|
+
throw new Error(`Moontide: a Scratchpad position must be a whole number of units from 0 to ${motifUnits(motif) - 1}, got ${String(unit)}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A start must be a whole number of units on the grid; `inside` also asks for a place inside the motif. */
|
|
352
|
+
function checkStart(motif, start, { inside }) {
|
|
353
|
+
const step = gridOf(motif.grid).unit;
|
|
354
|
+
if (!Number.isInteger(start) || start % step !== 0 || (inside && (start < 0 || start >= motifUnits(motif)))) {
|
|
355
|
+
throw new Error(`Moontide: a Scratchpad start must be a whole number of units on the ${motif.grid} grid (a multiple of ${step})${inside ? ' inside the motif' : ''}, got ${String(start)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function checkLength(motif, length) {
|
|
360
|
+
const step = gridOf(motif.grid).unit;
|
|
361
|
+
if (!Number.isInteger(length) || length % step !== 0) {
|
|
362
|
+
throw new Error(`Moontide: a Scratchpad length must be a whole number of units on the ${motif.grid} grid (a multiple of ${step}), got ${String(length)}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function checkVelocity(velocity) {
|
|
367
|
+
if (!Number.isInteger(velocity) || velocity < 1 || velocity > 100) {
|
|
368
|
+
throw new Error(`Moontide: a Scratchpad velocity must be a whole number of hundredths from 1 to 100, got ${String(velocity)}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function findNote(motif, id) {
|
|
373
|
+
const note = motif.notes.find((candidate) => candidate.id === id);
|
|
374
|
+
if (note === undefined) throw new Error(`Moontide: there is no note with id ${String(id)} in the Scratchpad motif`);
|
|
375
|
+
return note;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
|
|
379
|
+
|
|
380
|
+
/** The nearest step to `position` for steps of `size`, with a tie going to the earlier step; exact in whole numbers. */
|
|
381
|
+
export function nearestStep(position, size) {
|
|
382
|
+
return Math.floor((2 * position + size - 1) / (2 * size));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Makes the notes obey the rules of the roll after a command. The motif's end holds every note; two notes of the same pitch at the
|
|
387
|
+
* same start become the stronger one, with its length and, on a tie, the older (smaller id); and a note stops where a later note of
|
|
388
|
+
* the same pitch starts. The phrase form cannot say two notes of one pitch sounding at once, so the roll never shows them. Returns
|
|
389
|
+
* the notes in id order and how many were cut and merged.
|
|
390
|
+
*/
|
|
391
|
+
function settle(notes, units) {
|
|
392
|
+
const bounded = notes.map((note) => (note.start + note.length > units ? makeNote(note.id, note.pitch, note.start, units - note.start, note.velocity) : note));
|
|
393
|
+
const byCell = new Map();
|
|
394
|
+
let merged = 0;
|
|
395
|
+
for (const note of bounded) {
|
|
396
|
+
const key = `${note.pitch}:${note.start}`;
|
|
397
|
+
const known = byCell.get(key);
|
|
398
|
+
if (known === undefined) {
|
|
399
|
+
byCell.set(key, note);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
merged += 1;
|
|
403
|
+
const wins = note.velocity > known.velocity || (note.velocity === known.velocity && note.id < known.id);
|
|
404
|
+
byCell.set(key, wins ? note : known);
|
|
405
|
+
}
|
|
406
|
+
const survivors = [...byCell.values()];
|
|
407
|
+
const inTime = [...survivors].sort((a, b) => a.pitch - b.pitch || a.start - b.start);
|
|
408
|
+
const stops = new Map();
|
|
409
|
+
inTime.forEach((note, index) => {
|
|
410
|
+
const next = inTime[index + 1];
|
|
411
|
+
if (next !== undefined && next.pitch === note.pitch && note.start + note.length > next.start) stops.set(note.id, next.start - note.start);
|
|
412
|
+
});
|
|
413
|
+
const result = survivors
|
|
414
|
+
.map((note) => (stops.has(note.id) ? makeNote(note.id, note.pitch, note.start, stops.get(note.id), note.velocity) : note))
|
|
415
|
+
.sort((a, b) => a.id - b.id);
|
|
416
|
+
return { notes: result, cut: stops.size, merged };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function sameNotes(a, b) {
|
|
420
|
+
return a.length === b.length && a.every((note, index) => note.id === b[index].id && note.pitch === b[index].pitch && note.start === b[index].start && note.length === b[index].length && note.velocity === b[index].velocity);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Settles `notes` into a motif; the motif is returned as it was when nothing changed, else as a new frozen motif. */
|
|
424
|
+
function rebuild(motif, notes, nextId) {
|
|
425
|
+
const settled = settle(notes, motifUnits(motif));
|
|
426
|
+
const unchanged = nextId === motif.nextId && sameNotes(settled.notes, motif.notes);
|
|
427
|
+
const next = unchanged ? motif : Object.freeze({ ...motif, notes: Object.freeze(settled.notes), nextId });
|
|
428
|
+
return { motif: next, notes: settled.notes, cut: settled.cut, merged: settled.merged };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The words that say what the same-pitch rule did: the notes cut, then the merge. */
|
|
432
|
+
function ruleWords({ cut, merged }) {
|
|
433
|
+
const cutWords = cut === 0 ? '' : fillScratch(cut === 1 ? SCRATCH_COPY.cut : SCRATCH_COPY.cutMany, { count: cut });
|
|
434
|
+
return cutWords + (merged === 0 ? '' : SCRATCH_COPY.merged);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The note of a settled list that stands where the command put its note: the note itself, else the one it merged into. */
|
|
438
|
+
function survivor(notes, id, pitch, start) {
|
|
439
|
+
return notes.find((note) => note.id === id) ?? notes.find((note) => note.pitch === pitch && note.start === start) ?? null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The note of this pitch whose span holds this unit, or null. */
|
|
443
|
+
export function noteAt(motif, pitch, unit) {
|
|
444
|
+
return motif.notes.find((note) => note.pitch === pitch && unit >= note.start && unit < note.start + note.length) ?? null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The ways a note can be placed on the roll by hand: drawn (mouse or Enter) or tapped. A recorded motif is made by recordBuffer. */
|
|
448
|
+
const PLACE_WAYS = Object.freeze(['drawn', 'tapped']);
|
|
449
|
+
|
|
450
|
+
/** The source of a motif once a note was placed the way `how` says: the way itself for a motif with no notes, the same way when it matches, else mixed. */
|
|
451
|
+
function sourceAfterPlacing(motif, how) {
|
|
452
|
+
if (motif.notes.length === 0 || motif.source === null) return how;
|
|
453
|
+
return motif.source === how ? how : 'mixed';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Places a note of `length` units at `start`, at `velocity` (80 when not given). `how` says how the note was made ('drawn' unless given, or
|
|
458
|
+
* 'tapped') and sets the motif's source: the way itself on a motif with no notes, 'mixed' when the motif already holds notes made another way.
|
|
459
|
+
* Returns `{ motif, notice, cut, merged, note, what }`:
|
|
460
|
+
* the new motif, the sentence that says what happened, how many notes the same-pitch rule cut and merged, the note that now
|
|
461
|
+
* stands there, and `what`, a short past-tense phrase of the edit that the history repeats when it is undone ('placed E4'; every command
|
|
462
|
+
* carries one, empty when it changed nothing). A note that merges away into a note that stays leaves the motif as it was, with an empty `what`. A pitch, start, length or velocity that is not valid, or a note that would run past the motif's end, throws.
|
|
463
|
+
*/
|
|
464
|
+
export function placeNote(motif, { pitch, start, length, velocity = DEFAULT_VELOCITY }, how = 'drawn') {
|
|
465
|
+
if (!PLACE_WAYS.includes(how)) throw new Error(`Moontide: a Scratchpad note is placed drawn or tapped, got ${String(how)}`);
|
|
466
|
+
checkPitch(pitch);
|
|
467
|
+
checkStart(motif, start, { inside: true });
|
|
468
|
+
checkLength(motif, length);
|
|
469
|
+
if (length < gridOf(motif.grid).unit) {
|
|
470
|
+
throw new Error(`Moontide: a Scratchpad length must be at least one grid step (${gridOf(motif.grid).unit} units), got ${String(length)}`);
|
|
471
|
+
}
|
|
472
|
+
if (start + length > motifUnits(motif)) {
|
|
473
|
+
throw new Error(`Moontide: a Scratchpad note of length ${length} from ${start} runs past the end of the motif (${motifUnits(motif)} units)`);
|
|
474
|
+
}
|
|
475
|
+
checkVelocity(velocity);
|
|
476
|
+
const note = makeNote(motif.nextId, pitch, start, length, velocity);
|
|
477
|
+
const done = rebuild(motif, [...motif.notes, note], motif.nextId + 1);
|
|
478
|
+
const placed = survivor(done.notes, note.id, pitch, start);
|
|
479
|
+
const notice = fillScratch(SCRATCH_COPY.placed, { name: noteName(pitch), ...positionOf(placed.start) }) + ruleWords(done);
|
|
480
|
+
// A note that lost the same-pitch merge leaves the notes as they were: the motif stays the one it was, so no history step is added.
|
|
481
|
+
const same = sameNotes(done.notes, motif.notes);
|
|
482
|
+
const source = sourceAfterPlacing(motif, how);
|
|
483
|
+
const next = same ? motif : source === done.motif.source ? done.motif : Object.freeze({ ...done.motif, source });
|
|
484
|
+
return { motif: next, notice, cut: done.cut, merged: done.merged, note: placed, what: same ? '' : fillScratch(SCRATCH_COPY.whatPlaced, { name: noteName(pitch) }) };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Moves the note `id` to `start` and `pitch` (either may be left out); the length stays. The start is held from 0 to the motif's end
|
|
489
|
+
* minus the length and the pitch from 12 to 119. A move to where the note already is returns the same motif and an empty notice.
|
|
490
|
+
* Returns `{ motif, notice, cut, merged, note }` like placeNote.
|
|
491
|
+
*/
|
|
492
|
+
export function moveNote(motif, id, { start, pitch } = {}) {
|
|
493
|
+
const old = findNote(motif, id);
|
|
494
|
+
if (start !== undefined) checkStart(motif, start, { inside: false });
|
|
495
|
+
if (pitch !== undefined && !Number.isInteger(pitch)) throw new Error(`Moontide: a Scratchpad pitch must be a whole number, got ${String(pitch)}`);
|
|
496
|
+
const newStart = clamp(start ?? old.start, 0, motifUnits(motif) - old.length);
|
|
497
|
+
const newPitch = clamp(pitch ?? old.pitch, MIN_PITCH, MAX_PITCH);
|
|
498
|
+
if (newStart === old.start && newPitch === old.pitch) return { motif, notice: '', cut: 0, merged: 0, note: old, what: '' };
|
|
499
|
+
const moved = makeNote(old.id, newPitch, newStart, old.length, old.velocity);
|
|
500
|
+
const done = rebuild(motif, motif.notes.map((note) => (note.id === id ? moved : note)), motif.nextId);
|
|
501
|
+
const placed = survivor(done.notes, id, newPitch, newStart);
|
|
502
|
+
const where = { name: noteName(newPitch), ...positionOf(placed.start) };
|
|
503
|
+
const sentence = newPitch === old.pitch ? fillScratch(SCRATCH_COPY.moved, where) : fillScratch(SCRATCH_COPY.movedPitch, { from: noteName(old.pitch), ...where });
|
|
504
|
+
return { motif: done.motif, notice: sentence + ruleWords(done), cut: done.cut, merged: done.merged, note: placed, what: fillScratch(SCRATCH_COPY.whatMoved, { name: noteName(newPitch) }) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Gives the note `id` this length: at least one grid step and at most the motif's end, and no further than the next note of the
|
|
509
|
+
* same pitch starts. A length the note already has returns the same motif and an empty notice, except that a length cut by the
|
|
510
|
+
* next note says so. Returns `{ motif, notice, cut, merged, note }` like placeNote.
|
|
511
|
+
*/
|
|
512
|
+
export function resizeNote(motif, id, length) {
|
|
513
|
+
const old = findNote(motif, id);
|
|
514
|
+
checkLength(motif, length);
|
|
515
|
+
const wanted = clamp(length, gridOf(motif.grid).unit, motifUnits(motif) - old.start);
|
|
516
|
+
const resized = makeNote(old.id, old.pitch, old.start, wanted, old.velocity);
|
|
517
|
+
const done = rebuild(motif, motif.notes.map((note) => (note.id === id ? resized : note)), motif.nextId);
|
|
518
|
+
const placed = survivor(done.notes, id, old.pitch, old.start);
|
|
519
|
+
const silent = wanted === old.length && done.cut === 0 && done.merged === 0;
|
|
520
|
+
const notice = silent ? '' : fillScratch(SCRATCH_COPY.lasts, { name: noteName(old.pitch), length: lengthText(placed.length, motif.grid) }) + ruleWords(done);
|
|
521
|
+
return { motif: done.motif, notice, cut: done.cut, merged: done.merged, note: placed, what: silent ? '' : fillScratch(SCRATCH_COPY.whatLength, { name: noteName(old.pitch) }) };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Gives the note `id` this velocity, a whole number of hundredths from 1 to 100 (anything else, or an unknown note, throws). The
|
|
526
|
+
* velocity the note already has returns the same motif and an empty notice. Returns `{ motif, notice, cut, merged, note }` like placeNote.
|
|
527
|
+
*/
|
|
528
|
+
export function setVelocity(motif, id, velocity) {
|
|
529
|
+
checkVelocity(velocity);
|
|
530
|
+
const old = findNote(motif, id);
|
|
531
|
+
if (old.velocity === velocity) return { motif, notice: '', cut: 0, merged: 0, note: old, what: '' };
|
|
532
|
+
const note = makeNote(old.id, old.pitch, old.start, old.length, velocity);
|
|
533
|
+
const next = Object.freeze({ ...motif, notes: Object.freeze(motif.notes.map((candidate) => (candidate.id === id ? note : candidate))) });
|
|
534
|
+
return { motif: next, notice: fillScratch(SCRATCH_COPY.velocitySet, { name: noteName(old.pitch), velocity: velocityText(velocity) }), cut: 0, merged: 0, note, what: fillScratch(SCRATCH_COPY.whatVelocity, { name: noteName(old.pitch) }) };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The ids of the notes in the order the velocity lane visits them: by start, then the lower pitch first, then the older note. */
|
|
538
|
+
export function laneOrder(motif) {
|
|
539
|
+
return [...motif.notes].sort((a, b) => a.start - b.start || a.pitch - b.pitch || a.id - b.id).map((note) => note.id);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Removes the note `id` and says where it started; the last note to go takes the source and the recorded moment with it. Returns `{ motif, notice, cut, merged, note }` with `note` null. */
|
|
543
|
+
export function eraseNote(motif, id) {
|
|
544
|
+
const old = findNote(motif, id);
|
|
545
|
+
const notes = Object.freeze(motif.notes.filter((note) => note !== old));
|
|
546
|
+
return {
|
|
547
|
+
motif: Object.freeze({ ...motif, notes, ...(notes.length === 0 ? { source: null, recorded: null } : {}) }),
|
|
548
|
+
notice: fillScratch(SCRATCH_COPY.erased, { name: noteName(old.pitch), ...positionOf(old.start) }),
|
|
549
|
+
cut: 0,
|
|
550
|
+
merged: 0,
|
|
551
|
+
note: null,
|
|
552
|
+
what: fillScratch(SCRATCH_COPY.whatErased, { name: noteName(old.pitch) }),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Removes the note of `pitch` whose span holds `unit`; with none there, places a one-step note at the start of the grid step that
|
|
558
|
+
* holds `unit`, at the default velocity. Returns `{ motif, notice, cut, merged, note }`. A pitch or a unit that is not a whole number
|
|
559
|
+
* inside the roll throws.
|
|
560
|
+
*/
|
|
561
|
+
export function toggleAt(motif, { pitch, unit }) {
|
|
562
|
+
checkPitch(pitch);
|
|
563
|
+
checkUnit(motif, unit);
|
|
564
|
+
const existing = noteAt(motif, pitch, unit);
|
|
565
|
+
if (existing !== null) return eraseNote(motif, existing.id);
|
|
566
|
+
const step = gridOf(motif.grid).unit;
|
|
567
|
+
return placeNote(motif, { pitch, start: Math.floor(unit / step) * step, length: step });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The notes with these ids in the motif's order; an id that is not there throws, and so does an empty list. */
|
|
571
|
+
function pickNotes(motif, ids) {
|
|
572
|
+
if (!Array.isArray(ids) || ids.length === 0) throw new Error('Moontide: a Scratchpad command on several notes needs at least one note.');
|
|
573
|
+
const wanted = new Set(ids);
|
|
574
|
+
for (const id of wanted) findNote(motif, id);
|
|
575
|
+
return motif.notes.filter((note) => wanted.has(note.id));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** A command that changes nothing: the same motif, an empty notice and an empty `what`. */
|
|
579
|
+
const unchanged = (motif) => ({ motif, notice: '', cut: 0, merged: 0, what: '' });
|
|
580
|
+
|
|
581
|
+
/** 'one sixteenth' or '4 sixteenths', in the words of the grid. */
|
|
582
|
+
function stepsText(count, grid) {
|
|
583
|
+
return count === 1 ? fillScratch(SCRATCH_COPY.oneStepOf, { word: grid.one }) : fillScratch(SCRATCH_COPY.manyStepsOf, { count, word: grid.many });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** A pitch distance in octaves when it is a whole number of them, else in semitones: 'one octave', '2 octaves', '3 semitones'. */
|
|
587
|
+
function pitchDistanceText(distance) {
|
|
588
|
+
const size = Math.abs(distance);
|
|
589
|
+
if (size % 12 === 0) return size === 12 ? SCRATCH_COPY.oneOctave : fillScratch(SCRATCH_COPY.manyOctaves, { count: size / 12 });
|
|
590
|
+
return size === 1 ? SCRATCH_COPY.oneSemitone : fillScratch(SCRATCH_COPY.manySemitones, { count: size });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** The undo phrase for several notes: '{notes}' is '3 notes'. */
|
|
594
|
+
const manyWhat = (template, count) => fillScratch(template, { notes: notesText(count) });
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Moves every note with an id in `ids` by the same `dStart` units (a multiple of the grid step) and `dPitch` semitones, as one command: the
|
|
598
|
+
* group moves rigidly and the same-pitch rule runs once on the result, so notes of the group never cut each other. The move is all or
|
|
599
|
+
* nothing: when any note would pass the motif's start or end, or C0 or B8, the motif stays as it was and the sentence is in `refused`.
|
|
600
|
+
* Returns `{ motif, notice, cut, merged, what }` like placeNote. One note says where it stands now, as moveNote does; several say how
|
|
601
|
+
* many and how far ('Moved 3 notes 2 sixteenths later.'), and all the notes of the motif say 'the motif'. No distance gives the same motif.
|
|
602
|
+
*/
|
|
603
|
+
export function moveNotes(motif, ids, { dStart = 0, dPitch = 0 } = {}) {
|
|
604
|
+
const picked = pickNotes(motif, ids);
|
|
605
|
+
const grid = gridOf(motif.grid);
|
|
606
|
+
if (!Number.isInteger(dStart) || !Number.isInteger(dPitch)) throw new Error(`Moontide: a Scratchpad move needs a whole number of units and semitones, got ${String(dStart)} and ${String(dPitch)}`);
|
|
607
|
+
if (dStart % grid.unit !== 0) throw new Error(`Moontide: a Scratchpad move must be a multiple of the ${motif.grid} step (${grid.unit} units), got ${String(dStart)}`);
|
|
608
|
+
if (dStart === 0 && dPitch === 0) return unchanged(motif);
|
|
609
|
+
const first = Math.min(...picked.map((note) => note.start));
|
|
610
|
+
const last = Math.max(...picked.map((note) => note.start + note.length));
|
|
611
|
+
const highest = Math.max(...picked.map((note) => note.pitch));
|
|
612
|
+
const lowest = Math.min(...picked.map((note) => note.pitch));
|
|
613
|
+
const refusal =
|
|
614
|
+
first + dStart < 0 ? SCRATCH_COPY.movedFirstAtStart
|
|
615
|
+
: last + dStart > motifUnits(motif) ? SCRATCH_COPY.movedLastAtEnd
|
|
616
|
+
: highest + dPitch > MAX_PITCH ? SCRATCH_COPY.movedHighest
|
|
617
|
+
: lowest + dPitch < MIN_PITCH ? SCRATCH_COPY.movedLowest
|
|
618
|
+
: null;
|
|
619
|
+
if (refusal !== null) return recordRefusal(motif, refusal);
|
|
620
|
+
const moved = new Map(picked.map((note) => [note.id, makeNote(note.id, note.pitch + dPitch, note.start + dStart, note.length, note.velocity)]));
|
|
621
|
+
const done = rebuild(motif, motif.notes.map((note) => moved.get(note.id) ?? note), motif.nextId);
|
|
622
|
+
let sentence;
|
|
623
|
+
let what;
|
|
624
|
+
if (picked.length === 1) {
|
|
625
|
+
const [old] = picked;
|
|
626
|
+
const placed = survivor(done.notes, old.id, old.pitch + dPitch, old.start + dStart);
|
|
627
|
+
const where = { name: noteName(placed.pitch), ...positionOf(placed.start) };
|
|
628
|
+
sentence = dPitch === 0 ? fillScratch(SCRATCH_COPY.moved, where) : fillScratch(SCRATCH_COPY.movedPitch, { from: noteName(old.pitch), ...where });
|
|
629
|
+
what = fillScratch(SCRATCH_COPY.whatMoved, { name: noteName(placed.pitch) });
|
|
630
|
+
} else {
|
|
631
|
+
const how = [];
|
|
632
|
+
if (dStart !== 0) how.push(`${stepsText(Math.abs(dStart) / grid.unit, grid)} ${dStart < 0 ? SCRATCH_COPY.movedEarlier : SCRATCH_COPY.movedLater}`);
|
|
633
|
+
if (dPitch !== 0) how.push(fillScratch(dPitch > 0 ? SCRATCH_COPY.movedUp : SCRATCH_COPY.movedDown, { distance: pitchDistanceText(dPitch) }));
|
|
634
|
+
const who = picked.length === motif.notes.length ? SCRATCH_COPY.theMotif : notesText(picked.length);
|
|
635
|
+
sentence = fillScratch(SCRATCH_COPY.movedMany, { who, how: how.join(SCRATCH_COPY.movedAnd) });
|
|
636
|
+
what = manyWhat(SCRATCH_COPY.whatMovedMany, picked.length);
|
|
637
|
+
}
|
|
638
|
+
return { motif: done.motif, notice: sentence + ruleWords(done), cut: done.cut, merged: done.merged, what };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Gives every note with an id in `ids` a length `steps` grid steps longer (negative: shorter), as one command: each note is held between
|
|
643
|
+
* one step and the motif's end, and no further than the next note of its pitch starts. Returns `{ motif, notice, cut, merged, what }`;
|
|
644
|
+
* no change gives the same motif and an empty notice. One note says its new length, several say how many and by how much.
|
|
645
|
+
*/
|
|
646
|
+
export function resizeNotes(motif, ids, steps) {
|
|
647
|
+
const picked = pickNotes(motif, ids);
|
|
648
|
+
if (!Number.isInteger(steps)) throw new Error(`Moontide: a Scratchpad length change needs a whole number of steps, got ${String(steps)}`);
|
|
649
|
+
const grid = gridOf(motif.grid);
|
|
650
|
+
const units = motifUnits(motif);
|
|
651
|
+
const resized = new Map(picked.map((note) => [note.id, makeNote(note.id, note.pitch, note.start, clamp(note.length + steps * grid.unit, grid.unit, units - note.start), note.velocity)]));
|
|
652
|
+
const done = rebuild(motif, motif.notes.map((note) => resized.get(note.id) ?? note), motif.nextId);
|
|
653
|
+
if (done.motif === motif && (picked.length > 1 || (done.cut === 0 && done.merged === 0))) return unchanged(motif);
|
|
654
|
+
if (picked.length === 1) {
|
|
655
|
+
// One note that a neighbor of its pitch holds at the same length still says so, as resizeNote does.
|
|
656
|
+
const [old] = picked;
|
|
657
|
+
const placed = survivor(done.notes, old.id, old.pitch, old.start);
|
|
658
|
+
return {
|
|
659
|
+
motif: done.motif,
|
|
660
|
+
notice: fillScratch(SCRATCH_COPY.lasts, { name: noteName(old.pitch), length: lengthText(placed.length, motif.grid) }) + ruleWords(done),
|
|
661
|
+
cut: done.cut,
|
|
662
|
+
merged: done.merged,
|
|
663
|
+
what: fillScratch(SCRATCH_COPY.whatLength, { name: noteName(old.pitch) }),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const sentence = fillScratch(steps > 0 ? SCRATCH_COPY.longerMany : SCRATCH_COPY.shorterMany, { who: notesText(picked.length), distance: stepsText(Math.abs(steps), grid) });
|
|
667
|
+
return { motif: done.motif, notice: sentence + ruleWords(done), cut: done.cut, merged: done.merged, what: manyWhat(SCRATCH_COPY.whatLengthMany, picked.length) };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Removes every note with an id in `ids` as one command; the last note to go takes the source and the recorded moment with it, as
|
|
672
|
+
* eraseNote does. One note says where it was, several say how many and the way back.
|
|
673
|
+
*/
|
|
674
|
+
export function eraseNotes(motif, ids) {
|
|
675
|
+
const picked = pickNotes(motif, ids);
|
|
676
|
+
if (picked.length === 1) return eraseNote(motif, picked[0].id);
|
|
677
|
+
const gone = new Set(picked.map((note) => note.id));
|
|
678
|
+
const notes = Object.freeze(motif.notes.filter((note) => !gone.has(note.id)));
|
|
679
|
+
return {
|
|
680
|
+
motif: Object.freeze({ ...motif, notes, ...(notes.length === 0 ? { source: null, recorded: null } : {}) }),
|
|
681
|
+
notice: fillScratch(SCRATCH_COPY.erasedMany, { notes: notesText(picked.length) }),
|
|
682
|
+
cut: 0,
|
|
683
|
+
merged: 0,
|
|
684
|
+
what: manyWhat(SCRATCH_COPY.whatErasedMany, picked.length),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Changes the velocity of every note with an id in `ids` by `delta` hundredths, each held from 1 to 100, as one command. No change gives
|
|
690
|
+
* the same motif and an empty notice. One note says its new velocity, several say how many and by how much.
|
|
691
|
+
*/
|
|
692
|
+
export function setVelocities(motif, ids, delta) {
|
|
693
|
+
const picked = pickNotes(motif, ids);
|
|
694
|
+
if (!Number.isInteger(delta)) throw new Error(`Moontide: a Scratchpad velocity change needs a whole number of hundredths, got ${String(delta)}`);
|
|
695
|
+
const changed = new Map(picked.map((note) => [note.id, makeNote(note.id, note.pitch, note.start, note.length, clamp(note.velocity + delta, 1, 100))]));
|
|
696
|
+
const notes = motif.notes.map((note) => changed.get(note.id) ?? note);
|
|
697
|
+
if (notes.every((note, index) => note === motif.notes[index] || note.velocity === motif.notes[index].velocity)) return unchanged(motif);
|
|
698
|
+
const next = Object.freeze({ ...motif, notes: Object.freeze(notes) });
|
|
699
|
+
if (picked.length === 1) {
|
|
700
|
+
const [old] = picked;
|
|
701
|
+
return {
|
|
702
|
+
motif: next,
|
|
703
|
+
notice: fillScratch(SCRATCH_COPY.velocitySet, { name: noteName(old.pitch), velocity: velocityText(changed.get(old.id).velocity) }),
|
|
704
|
+
cut: 0,
|
|
705
|
+
merged: 0,
|
|
706
|
+
what: fillScratch(SCRATCH_COPY.whatVelocity, { name: noteName(old.pitch) }),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
const sentence = fillScratch(delta > 0 ? SCRATCH_COPY.velocityRaised : SCRATCH_COPY.velocityLowered, { notes: notesText(picked.length), amount: velocityText(Math.abs(delta)) });
|
|
710
|
+
return { motif: next, notice: sentence, cut: 0, merged: 0, what: manyWhat(SCRATCH_COPY.whatVelocityMany, picked.length) };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Moves the whole motif so its first note falls on the nearest beat (a beat is a quarter note, 12 units), forward or back; an exact half
|
|
715
|
+
* goes to the earlier beat. Every note keeps its length, pitch, velocity and the spacing between notes, and the motif keeps its source and
|
|
716
|
+
* its recorded moment, because notes moving inside the motif do not change which music cycle its first cycle stands at. Every grid step
|
|
717
|
+
* divides a beat, so the move is always whole steps. Returns `{ motif, notice, cut, merged, what }`: a first note already on a beat gives the
|
|
718
|
+
* same motif and says so; a move later that would push the last note past the motif's end, and a roll with no notes, give the motif as it was
|
|
719
|
+
* and the sentence in `refused`.
|
|
720
|
+
*/
|
|
721
|
+
export function alignMotif(motif) {
|
|
722
|
+
if (motif.notes.length === 0) return recordRefusal(motif, SCRATCH_COPY.alignNothing);
|
|
723
|
+
const first = Math.min(...motif.notes.map((note) => note.start));
|
|
724
|
+
const delta = nearestStep(first, UNITS_PER_BEAT) * UNITS_PER_BEAT - first;
|
|
725
|
+
if (delta === 0) return { motif, notice: SCRATCH_COPY.alignedAlready, cut: 0, merged: 0, what: '' };
|
|
726
|
+
if (Math.max(...motif.notes.map((note) => note.start + note.length)) + delta > motifUnits(motif)) return recordRefusal(motif, SCRATCH_COPY.alignOverflow);
|
|
727
|
+
const done = rebuild(motif, motif.notes.map((note) => makeNote(note.id, note.pitch, note.start + delta, note.length, note.velocity)), motif.nextId);
|
|
728
|
+
const grid = gridOf(motif.grid);
|
|
729
|
+
return {
|
|
730
|
+
motif: done.motif,
|
|
731
|
+
notice: fillScratch(SCRATCH_COPY.aligned, {
|
|
732
|
+
distance: stepsText(Math.abs(delta) / grid.unit, grid),
|
|
733
|
+
direction: delta < 0 ? SCRATCH_COPY.movedEarlier : SCRATCH_COPY.movedLater,
|
|
734
|
+
...positionOf(first + delta),
|
|
735
|
+
}),
|
|
736
|
+
cut: done.cut,
|
|
737
|
+
merged: done.merged,
|
|
738
|
+
what: SCRATCH_COPY.whatAligned,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Removes every note as one command, with no question asked: Undo is the way back, and the notice says so. The grid, the cycles and the
|
|
744
|
+
* ids still to come stay; the source and the recorded moment go with the notes. A roll with no notes gives the motif as it was and the
|
|
745
|
+
* sentence in `refused`.
|
|
746
|
+
*/
|
|
747
|
+
export function clearMotif(motif) {
|
|
748
|
+
if (motif.notes.length === 0) return recordRefusal(motif, SCRATCH_COPY.clearNothing);
|
|
749
|
+
const count = motif.notes.length;
|
|
750
|
+
return {
|
|
751
|
+
motif: Object.freeze({ ...motif, notes: Object.freeze([]), source: null, recorded: null }),
|
|
752
|
+
notice: count === 1 ? SCRATCH_COPY.clearedOne : fillScratch(SCRATCH_COPY.clearedMany, { count }),
|
|
753
|
+
cut: 0,
|
|
754
|
+
merged: 0,
|
|
755
|
+
what: SCRATCH_COPY.whatCleared,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The sentence for a selection change, from `{ kind, name, count }`: 'all' (with the number of notes in the motif), 'none', 'added' and
|
|
761
|
+
* 'removed' (the note's name and how many notes are selected now) and 'nothing-here'. Any other kind throws.
|
|
762
|
+
*/
|
|
763
|
+
export function selectionText({ kind, name, count }) {
|
|
764
|
+
const tail = count === 0 ? SCRATCH_COPY.selectedNothing : fillScratch(SCRATCH_COPY.selectedCount, { notes: notesText(count) });
|
|
765
|
+
switch (kind) {
|
|
766
|
+
case 'all':
|
|
767
|
+
if (count === 0) return SCRATCH_COPY.selectNoNotes;
|
|
768
|
+
return count === 1 ? SCRATCH_COPY.selectedOnly : fillScratch(SCRATCH_COPY.selectedAll, { notes: notesText(count) });
|
|
769
|
+
case 'none':
|
|
770
|
+
return SCRATCH_COPY.selectedNone;
|
|
771
|
+
case 'added':
|
|
772
|
+
return fillScratch(SCRATCH_COPY.selectedAdded, { name, count: tail });
|
|
773
|
+
case 'removed':
|
|
774
|
+
return fillScratch(SCRATCH_COPY.selectedRemoved, { name, count: tail });
|
|
775
|
+
case 'nothing-here':
|
|
776
|
+
return SCRATCH_COPY.selectNoNote;
|
|
777
|
+
default:
|
|
778
|
+
throw new Error(`Moontide: the Scratchpad does not know the selection change '${String(kind)}'`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** The line for several selected notes as two parts, `{ name, rest }`: '3 notes selected' and ' · C4 to E4 · from cycle 1, beat 1'. */
|
|
783
|
+
export function selectionParts(motif, ids) {
|
|
784
|
+
const picked = pickNotes(motif, ids);
|
|
785
|
+
const low = Math.min(...picked.map((note) => note.pitch));
|
|
786
|
+
const high = Math.max(...picked.map((note) => note.pitch));
|
|
787
|
+
const where = positionOf(Math.min(...picked.map((note) => note.start)));
|
|
788
|
+
return {
|
|
789
|
+
name: fillScratch(SCRATCH_COPY.selectionName, { notes: notesText(picked.length) }),
|
|
790
|
+
rest: low === high ? fillScratch(SCRATCH_COPY.selectionOnePitch, { low: noteName(low), ...where }) : fillScratch(SCRATCH_COPY.selectionRange, { low: noteName(low), high: noteName(high), ...where }),
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* The note before (`direction` -1) or after (1) a cursor `{ unit, pitch }` in time order: by start, then the lower pitch first, the
|
|
796
|
+
* order the velocity lane walks. A note at the cursor itself is neither. Null when there is none that way.
|
|
797
|
+
*/
|
|
798
|
+
export function neighborNote(motif, cursor, direction) {
|
|
799
|
+
const order = (note) => note.start * 1000 + note.pitch;
|
|
800
|
+
const at = cursor.unit * 1000 + cursor.pitch;
|
|
801
|
+
const ahead = motif.notes.filter((note) => (direction > 0 ? order(note) > at : order(note) < at)).sort((a, b) => order(a) - order(b) || a.id - b.id);
|
|
802
|
+
if (ahead.length === 0) return null;
|
|
803
|
+
return direction > 0 ? ahead[0] : ahead[ahead.length - 1];
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** A key press older than this many milliseconds, by the handler's clock, is not placed. */
|
|
807
|
+
export const STAMP_WINDOW_MS = 1000;
|
|
808
|
+
/** A key press stamped more than this many milliseconds after the handler's clock is not placed. */
|
|
809
|
+
export const AHEAD_MS = 1;
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Whether the time stamp `ts` of a key press can be placed, judged against `h`, the handler's own clock read first (both in
|
|
813
|
+
* milliseconds on the page's clock). Gives null for a usable stamp, else one reason word: 'zero' for a stamp of exactly 0,
|
|
814
|
+
* 'not-a-number' when either value is not a finite number, 'ahead' when the stamp is more than 1 ms after the handler's clock and
|
|
815
|
+
* 'stale' when it is more than 1000 ms before it. There is no fallback to the wall clock and no correction.
|
|
816
|
+
*/
|
|
817
|
+
export function checkStamp(ts, h) {
|
|
818
|
+
if (typeof ts !== 'number' || typeof h !== 'number' || !Number.isFinite(ts) || !Number.isFinite(h)) return 'not-a-number';
|
|
819
|
+
if (ts === 0) return 'zero';
|
|
820
|
+
if (ts > h + AHEAD_MS) return 'ahead';
|
|
821
|
+
if (ts < h - STAMP_WINDOW_MS) return 'stale';
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function checkAnchor(anchor, name) {
|
|
826
|
+
if (anchor === null || typeof anchor !== 'object' || typeof anchor.c !== 'number' || typeof anchor.t !== 'number' || !Number.isFinite(anchor.c) || !Number.isFinite(anchor.t)) {
|
|
827
|
+
throw new Error(`Moontide: the cycle line ${name} needs a finite cycle and a finite stamp.`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The music's cycle at the stamp `t` on the straight line through the two cycle-line anchors `a` and `b`, each `{ c, t }` (a cycle
|
|
833
|
+
* and its stamp on the page clock). An anchor that is not finite, a stamp that is not finite, or two anchors with the same stamp throws.
|
|
834
|
+
*/
|
|
835
|
+
export function cycleAtStamp(t, a, b) {
|
|
836
|
+
if (typeof t !== 'number' || !Number.isFinite(t)) throw new Error(`Moontide: a stamp must be a finite number, got ${String(t)}`);
|
|
837
|
+
checkAnchor(a, 'a');
|
|
838
|
+
checkAnchor(b, 'b');
|
|
839
|
+
if (a.t === b.t) throw new Error('Moontide: the two cycle lines have the same stamp, so no line runs through them.');
|
|
840
|
+
return a.c + ((t - a.t) * (b.c - a.c)) / (b.t - a.t);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Where a music cycle falls inside a motif of `cycles` cycles, in units and not rounded: the cycle modulo the motif's length (a positive modulo). */
|
|
844
|
+
export function unitInMotif(cycle, cycles) {
|
|
845
|
+
if (typeof cycle !== 'number' || !Number.isFinite(cycle)) throw new Error(`Moontide: a cycle must be a finite number, got ${String(cycle)}`);
|
|
846
|
+
return (((cycle % cycles) + cycles) % cycles) * UNITS_PER_CYCLE;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Where a music cycle falls in the motif, in units: the cycle modulo the motif's cycles (a positive modulo), snapped to the nearest
|
|
851
|
+
* step of the motif's grid with a tie to the earlier step. A position that rounds to the motif's end wraps to step 0. A cycle that is
|
|
852
|
+
* not a finite number throws.
|
|
853
|
+
*/
|
|
854
|
+
export function tapStart({ cycle, motif }) {
|
|
855
|
+
if (typeof cycle !== 'number' || !Number.isFinite(cycle)) throw new Error(`Moontide: a tap needs a finite cycle, got ${String(cycle)}`);
|
|
856
|
+
const step = gridOf(motif.grid).unit;
|
|
857
|
+
// The position is fractional here (nearestStep is for whole units): the nearest step, an exact half going to the earlier one.
|
|
858
|
+
const start = Math.max(0, Math.ceil(unitInMotif(cycle, motif.cycles) / step - 0.5)) * step;
|
|
859
|
+
return start >= motifUnits(motif) ? 0 : start;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** The longest port name the listening line shows; the setup file allows no longer one. */
|
|
863
|
+
const MAX_PORT_TEXT = 200;
|
|
864
|
+
|
|
865
|
+
/** A port name as plain text, cut at the name limit with an ellipsis. */
|
|
866
|
+
function portText(name) {
|
|
867
|
+
const points = Array.from(String(name));
|
|
868
|
+
return points.length <= MAX_PORT_TEXT ? points.join('') : `${points.slice(0, MAX_PORT_TEXT - 1).join('')}\u2026`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const STAMP_REASONS = Object.freeze({
|
|
872
|
+
zero: 'tapReasonZero',
|
|
873
|
+
'not-a-number': 'tapReasonNotANumber',
|
|
874
|
+
ahead: 'tapReasonAhead',
|
|
875
|
+
stale: 'tapReasonStale',
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
function tapRefusal(motif, refused) {
|
|
879
|
+
return { motif, notice: '', cut: 0, merged: 0, note: null, what: '', refused };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** True for two cycle-line anchors that can make a line: both finite, with different stamps. */
|
|
883
|
+
function anchorsUsable(anchors) {
|
|
884
|
+
if (anchors === null || typeof anchors !== 'object') return false;
|
|
885
|
+
try {
|
|
886
|
+
checkAnchor(anchors.a, 'a');
|
|
887
|
+
checkAnchor(anchors.b, 'b');
|
|
888
|
+
} catch {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
return anchors.a.t !== anchors.b.t;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Places a one-step note at velocity 80 where a key press fell: `ts` is the press's own time stamp, `h` the handler's clock read
|
|
896
|
+
* before it, `anchors` the stamps of two cycle lines `{ a: { c, t }, b: { c, t } }` or null when the music's clock could not give
|
|
897
|
+
* them, and `pitch` the row of the cursor. A refused stamp, or anchors that cannot make a line, gives the motif as it was and the
|
|
898
|
+
* sentence in `refused`; otherwise the result is the one of placeNote, with the tap's own notice and `what`.
|
|
899
|
+
*/
|
|
900
|
+
export function tapNote(motif, { ts, h, anchors, pitch }) {
|
|
901
|
+
const reason = checkStamp(ts, h);
|
|
902
|
+
if (reason !== null) return tapRefusal(motif, fillScratch(SCRATCH_COPY.tapStampRefused, { reason: SCRATCH_COPY[STAMP_REASONS[reason]] }));
|
|
903
|
+
if (!anchorsUsable(anchors)) return tapRefusal(motif, SCRATCH_COPY.tapClockUnreadable);
|
|
904
|
+
const start = tapStart({ cycle: cycleAtStamp(ts, anchors.a, anchors.b), motif });
|
|
905
|
+
const placed = placeNote(motif, { pitch, start, length: gridOf(motif.grid).unit }, 'tapped');
|
|
906
|
+
const name = noteName(pitch);
|
|
907
|
+
return {
|
|
908
|
+
...placed,
|
|
909
|
+
notice: fillScratch(SCRATCH_COPY.tapped, { name, ...positionOf(placed.note.start) }) + ruleWords(placed),
|
|
910
|
+
what: placed.what === '' ? '' : fillScratch(SCRATCH_COPY.whatTapped, { name }),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function recordRefusal(motif, refused) {
|
|
915
|
+
return { motif, notice: '', cut: 0, merged: 0, what: '', refused };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** The cycles of the ring the live view draws: the keyboard buffer's 8 cycles, drawn as one loop that the playhead runs round. */
|
|
919
|
+
export const RING_CYCLES = MAX_CYCLES;
|
|
920
|
+
const RING_UNITS = RING_CYCLES * UNITS_PER_CYCLE;
|
|
921
|
+
|
|
922
|
+
// The nearest step of a grid to a place given in cycles, an exact half going to the earlier step; the first step at the least. The one
|
|
923
|
+
// rule that puts a played note on the grid: the live layer and Record that both get their places from ringLayout, which calls this.
|
|
924
|
+
function snapToStep(cycle, step) {
|
|
925
|
+
return Math.max(0, Math.ceil((cycle * UNITS_PER_CYCLE) / step - 0.5)) * step;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Where a snapshot of the keyboard buffer sits on the ring: the one layout the live layer draws and Record that writes, so the motif after
|
|
930
|
+
* the press holds exactly what was shown. `snapshot` is the one of page/played-buffer.js: `{ start, cycles, notes }` with each note
|
|
931
|
+
* `{ pitch, velocity, start, end, run, held }` in cycles from the window's first cycle (`held` may be left out and then means not held).
|
|
932
|
+
* `gridId` is one of the five grids.
|
|
933
|
+
*
|
|
934
|
+
* A note's place is its axis cycle, the window's start plus its cycle in the window, snapped to the grid (the nearest step, an exact half going
|
|
935
|
+
* to the earlier one, and the end at least one step after the start), and then taken round the ring: the column is the axis cycle modulo 8,
|
|
936
|
+
* the unit is that place modulo 384. A start that snaps onto the next cycle line lands in the next column, and one that snaps onto the ring's end
|
|
937
|
+
* lands at unit 0. A note that passes unit 384 is cut there and goes on from unit 0, as two pieces of the same pitch and velocity; a note never
|
|
938
|
+
* covers more than one lap. Returns `{ pieces, outside }`: each piece is `{ pitch, velocity, start, end, held, run, lap }` with `start` and
|
|
939
|
+
* `end` in units (0 to 384), the velocity in hundredths (1 to 100), `held` true only for the last piece of a note whose key is still down (its
|
|
940
|
+
* right edge is open), `run` the run of the music the note was placed in (a whole number or null) and `lap` the axis cycle of the piece's start
|
|
941
|
+
* divided by 8, rounded down (the piece that goes on from unit 0 is in the next lap). The pieces come in the order the notes were played.
|
|
942
|
+
* A pitch outside C0 to B8 is not placed and is counted in `outside`. A snapshot or a value that cannot be used throws.
|
|
943
|
+
*/
|
|
944
|
+
export function ringLayout(snapshot, gridId) {
|
|
945
|
+
if (snapshot === null || typeof snapshot !== 'object' || !Array.isArray(snapshot.notes)) throw new Error('Moontide: the ring layout needs a snapshot with a list of notes.');
|
|
946
|
+
if (!Number.isFinite(snapshot.cycles) || snapshot.cycles <= 0) throw new Error(`Moontide: a snapshot needs a positive number of cycles, got ${String(snapshot.cycles)}`);
|
|
947
|
+
if (!Number.isFinite(snapshot.start)) throw new Error(`Moontide: a snapshot needs a finite start, got ${String(snapshot.start)}`);
|
|
948
|
+
const step = gridOf(gridId).unit;
|
|
949
|
+
const pieces = [];
|
|
950
|
+
let outside = 0;
|
|
951
|
+
for (const note of snapshot.notes) {
|
|
952
|
+
if (!Number.isInteger(note.pitch)) throw new Error(`Moontide: a played note needs a whole pitch, got ${String(note.pitch)}`);
|
|
953
|
+
if (!Number.isInteger(note.velocity) || note.velocity < 1 || note.velocity > 127) throw new Error(`Moontide: a played note needs a velocity from 1 to 127, got ${String(note.velocity)}`);
|
|
954
|
+
if (!Number.isFinite(note.start) || !Number.isFinite(note.end)) throw new Error('Moontide: a played note needs a finite start and end.');
|
|
955
|
+
if (note.run !== null && !Number.isInteger(note.run)) throw new Error(`Moontide: a played note needs a run that is null or a whole number, got ${String(note.run)}`);
|
|
956
|
+
if (note.pitch < MIN_PITCH || note.pitch > MAX_PITCH) {
|
|
957
|
+
outside += 1;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
const velocity = Math.max(1, Math.round((note.velocity * 100) / 127));
|
|
961
|
+
const from = snapToStep(snapshot.start + note.start, step);
|
|
962
|
+
const to = Math.max(snapToStep(snapshot.start + note.end, step), from + step);
|
|
963
|
+
const start = from % RING_UNITS;
|
|
964
|
+
const lap = Math.floor(from / RING_UNITS);
|
|
965
|
+
const length = Math.min(to - from, RING_UNITS);
|
|
966
|
+
const held = note.held === true;
|
|
967
|
+
if (start + length <= RING_UNITS) {
|
|
968
|
+
pieces.push({ pitch: note.pitch, velocity, start, end: start + length, held, run: note.run, lap });
|
|
969
|
+
} else {
|
|
970
|
+
pieces.push({ pitch: note.pitch, velocity, start, end: RING_UNITS, held: false, run: note.run, lap });
|
|
971
|
+
pieces.push({ pitch: note.pitch, velocity, start: 0, end: start + length - RING_UNITS, held, run: note.run, lap: lap + 1 });
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return { pieces, outside };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Turns what the keyboard played into the motif, in place of the one there (the motif's grid stays). `snapshot` is the one of
|
|
979
|
+
* page/played-buffer.js: `{ start, cycles, notes }` with each note `{ pitch, velocity, start, end, run }` in cycles from the window's start;
|
|
980
|
+
* `run` is the whole-number offset of the cycle axis the note was placed at, or null when the tempo placed it.
|
|
981
|
+
* The notes are placed on the ring by ringLayout, the very layout the live view draws: a note played in the axis cycle c sits in column c
|
|
982
|
+
* modulo 8, snapped to the grid, and a note that crosses the ring's right end is two notes, one ending at the motif's end and one starting
|
|
983
|
+
* at 0. A take that runs past the right end (played from column 8 into column 1) therefore becomes a motif with the later bar first and
|
|
984
|
+
* empty cycles between. A pitch outside the roll is left out and counted. The motif's cycles run from the column of the earliest start to the
|
|
985
|
+
* column that holds the latest end (1 to 8), and positions move back by whole columns, so the music's downbeats stay downbeats. Velocities are
|
|
986
|
+
* kept in hundredths. Then the same-pitch rule runs. The new motif's recorded moment is `{ cycle }`: when every note the roll can show was
|
|
987
|
+
* placed by one run R of the music and lies in one lap of the ring, it is the music's own cycle where the motif's first column was played,
|
|
988
|
+
* 8 times the lap plus that column minus R (null when it is below 0); otherwise null, because nothing is guessed (two laps, two runs, or notes
|
|
989
|
+
* placed by the tempo). Returns `{ motif, notice, cut, merged, what }` like every command; an empty or missing snapshot, or one with no
|
|
990
|
+
* note the roll can show, gives the motif as it was and the sentence in `refused`. A value that cannot be used throws.
|
|
991
|
+
*/
|
|
992
|
+
export function recordBuffer(motif, snapshot) {
|
|
993
|
+
const played = snapshot === null || typeof snapshot !== 'object' || !Array.isArray(snapshot.notes) ? [] : snapshot.notes;
|
|
994
|
+
if (played.length === 0) return recordRefusal(motif, SCRATCH_COPY.recordNothing);
|
|
995
|
+
const { pieces, outside } = ringLayout(snapshot, motif.grid);
|
|
996
|
+
if (pieces.length === 0) return recordRefusal(motif, fillScratch(SCRATCH_COPY.recordOnlyOutside, { outside: outsideWords(outside) }));
|
|
997
|
+
const firstCycle = Math.floor(Math.min(...pieces.map((piece) => piece.start)) / UNITS_PER_CYCLE);
|
|
998
|
+
const lastCycle = Math.floor((Math.max(...pieces.map((piece) => piece.end)) - 1) / UNITS_PER_CYCLE);
|
|
999
|
+
const cycles = lastCycle - firstCycle + 1;
|
|
1000
|
+
const shift = firstCycle * UNITS_PER_CYCLE;
|
|
1001
|
+
// The new motif is the keyboard's: played. Its moment is the music's cycle where its first column was played, only when one run of the music
|
|
1002
|
+
// and one lap of the ring placed every note (the axis then differs from the music's count by that run); else null, no moment.
|
|
1003
|
+
const runs = new Set(pieces.map((piece) => piece.run));
|
|
1004
|
+
const laps = new Set(pieces.map((piece) => piece.lap));
|
|
1005
|
+
const [run] = runs;
|
|
1006
|
+
const [lap] = laps;
|
|
1007
|
+
let cycle = null;
|
|
1008
|
+
if (runs.size === 1 && run !== null && laps.size === 1) {
|
|
1009
|
+
const music = RING_CYCLES * lap + firstCycle - run;
|
|
1010
|
+
if (Number.isInteger(music) && music >= 0) cycle = music;
|
|
1011
|
+
}
|
|
1012
|
+
const base = Object.freeze({ ...motif, cycles, notes: Object.freeze([]), source: 'played', recorded: Object.freeze({ cycle }) });
|
|
1013
|
+
const notes = pieces.map((piece, index) => makeNote(motif.nextId + index, piece.pitch, piece.start - shift, piece.end - piece.start, piece.velocity));
|
|
1014
|
+
const done = rebuild(base, notes, motif.nextId + notes.length);
|
|
1015
|
+
const notice =
|
|
1016
|
+
fillScratch(SCRATCH_COPY.recorded, { notes: notesText(done.notes.length), cycles: cyclesText(cycles), grid: gridOf(motif.grid).many }) +
|
|
1017
|
+
ruleWords(done) +
|
|
1018
|
+
(outside === 0 ? '' : outsideWords(outside)) +
|
|
1019
|
+
SCRATCH_COPY.recordUndo;
|
|
1020
|
+
return { motif: done.motif, notice, cut: done.cut, merged: done.merged, what: SCRATCH_COPY.whatRecorded };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* The sentence for the keyboard's listening state `{ listen, ports, listened, notOpened, missing, unplugged, several, available, failure }` (see page/midi-in.js):
|
|
1025
|
+
* what is listened to, or why not and what to do, with the inputs that exist when the reason is a name. Each state word has its own sentence. Port names are cut at the name limit and are plain text.
|
|
1026
|
+
*/
|
|
1027
|
+
export function listeningText(state) {
|
|
1028
|
+
const names = (list) => (list.length === 0 ? SCRATCH_COPY.listenNoInputs : list.map(portText).join(SCRATCH_COPY.listenJoin));
|
|
1029
|
+
const ports = names(state.ports);
|
|
1030
|
+
switch (state.listen) {
|
|
1031
|
+
case 'absent':
|
|
1032
|
+
return SCRATCH_COPY.listenAbsent;
|
|
1033
|
+
case 'invalid':
|
|
1034
|
+
return SCRATCH_COPY.listenInvalid;
|
|
1035
|
+
case 'none':
|
|
1036
|
+
return SCRATCH_COPY.listenNone;
|
|
1037
|
+
case 'needs-permission':
|
|
1038
|
+
return fillScratch(SCRATCH_COPY.listenNeedsPermission, { ports });
|
|
1039
|
+
case 'pending':
|
|
1040
|
+
return SCRATCH_COPY.listenPending;
|
|
1041
|
+
case 'denied':
|
|
1042
|
+
return SCRATCH_COPY.listenDenied;
|
|
1043
|
+
case 'unsupported':
|
|
1044
|
+
return SCRATCH_COPY.listenUnsupported;
|
|
1045
|
+
case 'failed':
|
|
1046
|
+
return fillScratch(SCRATCH_COPY.listenFailed, { failure: portText(state.failure ?? 'Error') });
|
|
1047
|
+
case 'hidden':
|
|
1048
|
+
return SCRATCH_COPY.listenHidden;
|
|
1049
|
+
case 'other-tab':
|
|
1050
|
+
return SCRATCH_COPY.listenOtherTab;
|
|
1051
|
+
case 'listening': {
|
|
1052
|
+
const others = state.missing.filter((name) => !state.several.includes(name));
|
|
1053
|
+
return (
|
|
1054
|
+
fillScratch(SCRATCH_COPY.listenListening, { ports: names(state.listened) }) +
|
|
1055
|
+
(others.length === 0 ? '' : fillScratch(SCRATCH_COPY.listenNotFound, { ports: names(others) })) +
|
|
1056
|
+
(state.several.length === 0 ? '' : fillScratch(SCRATCH_COPY.listenNotTold, { ports: names(state.several) })) +
|
|
1057
|
+
(state.notOpened.length === 0 ? '' : fillScratch(SCRATCH_COPY.listenNotOpenedToo, { ports: names(state.notOpened) }))
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
case 'not-opened':
|
|
1061
|
+
return fillScratch(SCRATCH_COPY.listenNotOpened, { port: portText(state.notOpened[0]) });
|
|
1062
|
+
case 'ambiguous': {
|
|
1063
|
+
const port = state.several[0];
|
|
1064
|
+
return fillScratch(SCRATCH_COPY.listenAmbiguous, { count: state.available.filter((name) => name === port).length, port: portText(port) });
|
|
1065
|
+
}
|
|
1066
|
+
case 'unplugged':
|
|
1067
|
+
return fillScratch(SCRATCH_COPY.listenUnplugged, { port: portText(state.unplugged[0]), names: names(state.available) });
|
|
1068
|
+
case 'missing':
|
|
1069
|
+
return fillScratch(SCRATCH_COPY.listenMissing, { port: portText(state.missing[0]), names: names(state.available) });
|
|
1070
|
+
default:
|
|
1071
|
+
throw new Error(`Moontide: the keyboard's listening state is not known: ${String(state.listen)}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* The line about the last note the keyboard played, or '' when nothing was heard or refused: the note's name and how many notes are kept,
|
|
1077
|
+
* or why the last note was not kept. `state` is the one of page/midi-in.js.
|
|
1078
|
+
*/
|
|
1079
|
+
export function heardText(state) {
|
|
1080
|
+
if (state.refused !== null) {
|
|
1081
|
+
if (state.refused === 'clock') return SCRATCH_COPY.heardRefusedClock;
|
|
1082
|
+
if (state.refused === 'full') return SCRATCH_COPY.heardRefusedFull;
|
|
1083
|
+
return fillScratch(SCRATCH_COPY.heardRefusedStamp, { reason: SCRATCH_COPY[STAMP_REASONS[state.refused]] });
|
|
1084
|
+
}
|
|
1085
|
+
if (state.heard === null) return '';
|
|
1086
|
+
return fillScratch(SCRATCH_COPY.heard, { name: noteName(state.heard.pitch), notes: notesText(state.count) });
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* The motif as the notes Listen plays: events `{ unit, kind: 'on' | 'off', pitch, velocity }` in units from the motif's start, sorted by unit with
|
|
1091
|
+
* the offs first at one unit (so two notes of one pitch, one right after the other, both sound) and, inside one kind, by pitch. A velocity is the
|
|
1092
|
+
* MIDI byte of the note's hundredths (rounded, at least 1) on an on and 0 on an off. A note-off sits at the note's written end; the player ends
|
|
1093
|
+
* the sound a little before it, as the music's own MIDI path does.
|
|
1094
|
+
*/
|
|
1095
|
+
export function listenSchedule(motif) {
|
|
1096
|
+
const events = [];
|
|
1097
|
+
for (const note of motif.notes) {
|
|
1098
|
+
events.push({ unit: note.start, kind: 'on', pitch: note.pitch, velocity: Math.max(1, Math.round((note.velocity * 127) / 100)) });
|
|
1099
|
+
events.push({ unit: note.start + note.length, kind: 'off', pitch: note.pitch, velocity: 0 });
|
|
1100
|
+
}
|
|
1101
|
+
return events.sort((a, b) => a.unit - b.unit || (a.kind === b.kind ? 0 : a.kind === 'off' ? -1 : 1) || a.pitch - b.pitch);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* The menu of Listen: one option for each instrument of the setup file and each channel it uses (both roles, once each), in the file's order,
|
|
1106
|
+
* as `{ value, text, title, instrumentId, channel, enabled, port }`. `value` joins the id and the channel with a bar, `text` reads
|
|
1107
|
+
* `sc55 · channel 3` and `title` adds the port, and `monophonic` is true only when the rig says the instrument plays one note at a time. Names are plain text cut at the name limit. A rig that is absent, has a problem or is null gives none.
|
|
1108
|
+
*/
|
|
1109
|
+
export function listenOptions(rig) {
|
|
1110
|
+
if (rig === null || rig === undefined || rig.setup !== 'valid') return [];
|
|
1111
|
+
const options = [];
|
|
1112
|
+
for (const instrument of rig.instruments) {
|
|
1113
|
+
const channels = [...new Set(instrument.channels.map((entry) => entry.channel))].sort((a, b) => a - b);
|
|
1114
|
+
for (const channel of channels) {
|
|
1115
|
+
const names = { id: portText(instrument.id), channel, port: portText(instrument.port) };
|
|
1116
|
+
options.push({
|
|
1117
|
+
value: `${instrument.id}|${channel}`,
|
|
1118
|
+
text: fillScratch(SCRATCH_COPY.hearOutputOption, names),
|
|
1119
|
+
title: fillScratch(SCRATCH_COPY.hearOutputTitle, names),
|
|
1120
|
+
instrumentId: instrument.id,
|
|
1121
|
+
channel,
|
|
1122
|
+
enabled: instrument.enabled !== false,
|
|
1123
|
+
port: instrument.port,
|
|
1124
|
+
// Only what the instrument's model states; the page never guesses it, so a rig that says nothing gives false.
|
|
1125
|
+
monophonic: instrument.monophonic === true,
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return options;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// A sentence in two or three parts, so the page can show the instrument id heavier than the rest, as the note line shows the note name.
|
|
1133
|
+
function hearParts(template, values) {
|
|
1134
|
+
const at = template.indexOf('{instrument}');
|
|
1135
|
+
if (at === -1) return { before: fillScratch(template, values), id: '', after: '' };
|
|
1136
|
+
return { before: fillScratch(template.slice(0, at), values), id: portText(values.instrument), after: fillScratch(template.slice(at + '{instrument}'.length), values) };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* True when two notes of the motif sound at the same time: their spans overlap, whether or not they start together. A note that starts where
|
|
1141
|
+
* another ends is not a chord.
|
|
1142
|
+
*/
|
|
1143
|
+
export function hasChord(motif) {
|
|
1144
|
+
let reach = -Infinity;
|
|
1145
|
+
for (const note of [...motif.notes].sort((a, b) => a.start - b.start)) {
|
|
1146
|
+
if (note.start < reach) return true;
|
|
1147
|
+
reach = Math.max(reach, note.start + note.length);
|
|
1148
|
+
}
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The Heads up sentences of the hear strip for the chosen instrument and channel, each as `{ before, id, after }` (the instrument id apart, as in
|
|
1154
|
+
* hearText), in the order they are shown: the channel first, then the chord. `tracks` are the display names of the unmuted tracks of the playing
|
|
1155
|
+
* code on that instrument and channel (none: nothing is said), `monophonic` is the rig's fact for the instrument (absent or false: unknown, nothing
|
|
1156
|
+
* is said) and `chord` says whether the motif has one. Track names are plain text, cut at the name limit. A warning never blocks Listen: this only
|
|
1157
|
+
* words what the page already knows.
|
|
1158
|
+
*/
|
|
1159
|
+
export function hearWarnings({ instrumentId, channel, tracks = [], monophonic = false, chord = false }) {
|
|
1160
|
+
const values = { instrument: instrumentId, channel, tracks: tracks.map(portText).join(SCRATCH_COPY.listenJoin) };
|
|
1161
|
+
const warnings = [];
|
|
1162
|
+
if (tracks.length > 0) warnings.push(hearParts(tracks.length === 1 ? SCRATCH_COPY.hearChannelBusy : SCRATCH_COPY.hearChannelBusyMany, values));
|
|
1163
|
+
if (monophonic === true && chord === true) warnings.push(hearParts(SCRATCH_COPY.hearMonophonic, values));
|
|
1164
|
+
return warnings;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* The sentence of the hear strip for a state, as `{ before, id, after }`: the instrument id apart (it is shown heavier) and the words around it. A
|
|
1169
|
+
* state is `{ kind, ... }` with `instrumentId` and `channel` when an instrument is chosen: 'choose', 'no-instrument' (with `setup`, the setup file's
|
|
1170
|
+
* word), 'playing' (with `loop`), 'ended', 'stopped' and 'refused' (with `refused`, the reason word, and `port`, `names`, `count` and `mid` where
|
|
1171
|
+
* it has them). A word or kind the model does not know throws.
|
|
1172
|
+
*/
|
|
1173
|
+
export function hearText(state) {
|
|
1174
|
+
const values = { instrument: state.instrumentId ?? '', channel: state.channel ?? '', port: portText(state.port ?? ''), count: state.count ?? 0, failure: portText(state.failure ?? 'Error'), names: (state.names ?? []).length === 0 ? SCRATCH_COPY.listenNoInputs : (state.names ?? []).map(portText).join(SCRATCH_COPY.listenJoin) };
|
|
1175
|
+
switch (state.kind) {
|
|
1176
|
+
case 'choose':
|
|
1177
|
+
return hearParts(SCRATCH_COPY.hearChoose, values);
|
|
1178
|
+
case 'no-instrument':
|
|
1179
|
+
return hearParts(state.setup === 'absent' ? SCRATCH_COPY.hearNoInstrumentAbsent : state.setup === 'invalid' ? SCRATCH_COPY.hearNoInstrumentInvalid : SCRATCH_COPY.hearNoInstrumentNone, values);
|
|
1180
|
+
case 'playing':
|
|
1181
|
+
return hearParts(state.loop === true ? SCRATCH_COPY.hearPlayingLoop : SCRATCH_COPY.hearPlaying, values);
|
|
1182
|
+
case 'ended':
|
|
1183
|
+
return hearParts(SCRATCH_COPY.hearEnded, values);
|
|
1184
|
+
case 'waiting':
|
|
1185
|
+
return hearParts(SCRATCH_COPY.hearWaiting, { ...values, cycle: state.cycle });
|
|
1186
|
+
case 'asking':
|
|
1187
|
+
return hearParts(SCRATCH_COPY.hearAsking, values);
|
|
1188
|
+
case 'stopped':
|
|
1189
|
+
return hearParts(stoppedWords(state.reason), values);
|
|
1190
|
+
case 'refused':
|
|
1191
|
+
return hearParts(refusedWords(state), values);
|
|
1192
|
+
default:
|
|
1193
|
+
throw new Error(`Moontide: the Scratchpad hear state is not known: ${String(state.kind)}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// The template of a stop, by the reason the player gives.
|
|
1198
|
+
function stoppedWords(reason) {
|
|
1199
|
+
switch (reason) {
|
|
1200
|
+
case 'stop':
|
|
1201
|
+
return SCRATCH_COPY.hearStopped;
|
|
1202
|
+
case 'hidden':
|
|
1203
|
+
return SCRATCH_COPY.hearHidden;
|
|
1204
|
+
case 'music-stopped':
|
|
1205
|
+
return SCRATCH_COPY.hearMusicStopped;
|
|
1206
|
+
case 'closed':
|
|
1207
|
+
return SCRATCH_COPY.hearClosed;
|
|
1208
|
+
case 'page':
|
|
1209
|
+
return SCRATCH_COPY.hearLeft;
|
|
1210
|
+
default:
|
|
1211
|
+
throw new Error(`Moontide: a Listen stop reason is not known: ${String(reason)}`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// The template of a refusal: the word the page's MIDI layer or the player gives. A play that had begun and lost its output says it stopped.
|
|
1216
|
+
function refusedWords(state) {
|
|
1217
|
+
switch (state.refused) {
|
|
1218
|
+
case 'empty':
|
|
1219
|
+
return SCRATCH_COPY.hearNoNotes;
|
|
1220
|
+
case 'no-output':
|
|
1221
|
+
return state.mid === true ? SCRATCH_COPY.hearUnplugged : SCRATCH_COPY.hearNoOutput;
|
|
1222
|
+
case 'ambiguous':
|
|
1223
|
+
return SCRATCH_COPY.hearAmbiguous;
|
|
1224
|
+
case 'off':
|
|
1225
|
+
return SCRATCH_COPY.hearOff;
|
|
1226
|
+
case 'resetting':
|
|
1227
|
+
return SCRATCH_COPY.hearResetting;
|
|
1228
|
+
case 'superseded':
|
|
1229
|
+
return SCRATCH_COPY.hearSuperseded;
|
|
1230
|
+
case 'no-rig':
|
|
1231
|
+
return SCRATCH_COPY.hearNoRig;
|
|
1232
|
+
case 'no-instrument':
|
|
1233
|
+
return SCRATCH_COPY.hearUnknownInstrument;
|
|
1234
|
+
case 'tempo':
|
|
1235
|
+
return SCRATCH_COPY.hearTempo;
|
|
1236
|
+
case 'invalid':
|
|
1237
|
+
return SCRATCH_COPY.hearInvalid;
|
|
1238
|
+
case 'send-failed':
|
|
1239
|
+
return SCRATCH_COPY.hearSendFailed;
|
|
1240
|
+
case 'music-not-playing':
|
|
1241
|
+
return SCRATCH_COPY.hearMusicNotPlaying;
|
|
1242
|
+
case 'no-clock':
|
|
1243
|
+
return SCRATCH_COPY.hearNoClock;
|
|
1244
|
+
case 'denied':
|
|
1245
|
+
return SCRATCH_COPY.hearDenied;
|
|
1246
|
+
case 'unsupported':
|
|
1247
|
+
return SCRATCH_COPY.hearUnsupported;
|
|
1248
|
+
case 'failed':
|
|
1249
|
+
return SCRATCH_COPY.hearFailed;
|
|
1250
|
+
default:
|
|
1251
|
+
throw new Error(`Moontide: Listen was refused for a word the Scratchpad does not know: ${String(state.refused)}`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** The sentence that says how many played notes the roll could not show. */
|
|
1256
|
+
function outsideWords(count) {
|
|
1257
|
+
return count === 1 ? SCRATCH_COPY.recordOutsideOne : fillScratch(SCRATCH_COPY.recordOutside, { count });
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** The notes with a count in words: '1 note' or '3 notes'. */
|
|
1261
|
+
export function notesText(count) {
|
|
1262
|
+
return count === 1 ? SCRATCH_COPY.oneNote : fillScratch(SCRATCH_COPY.manyNotes, { count });
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** '1 cycle' or 'N cycles'. */
|
|
1266
|
+
export function cyclesText(cycles) {
|
|
1267
|
+
return cycles === 1 ? SCRATCH_COPY.oneCycle : fillScratch(SCRATCH_COPY.manyCycles, { count: cycles });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Moves every note onto the grid `gridId` at once: a start goes to the nearest step and a length to the nearest whole step, a tie
|
|
1272
|
+
* going to the earlier step and down, a length never below one step; a start that rounds to the motif's end wraps to step 0, as a tap
|
|
1273
|
+
* does. Then the same-pitch rule runs, so notes of one pitch that land in one cell become the stronger one. Returns
|
|
1274
|
+
* `{ motif, notice, cut, merged }`; the grid already in use gives the same motif and an empty notice. An id outside the list throws.
|
|
1275
|
+
*/
|
|
1276
|
+
export function setGrid(motif, gridId) {
|
|
1277
|
+
const grid = gridOf(gridId);
|
|
1278
|
+
if (gridId === motif.grid) return { motif, notice: '', cut: 0, merged: 0, what: '' };
|
|
1279
|
+
const units = motifUnits(motif);
|
|
1280
|
+
let moved = 0;
|
|
1281
|
+
const notes = motif.notes.map((note) => {
|
|
1282
|
+
const start = nearestStep(note.start, grid.unit) * grid.unit;
|
|
1283
|
+
const length = Math.max(1, nearestStep(note.length, grid.unit)) * grid.unit;
|
|
1284
|
+
const wrapped = start >= units ? 0 : start;
|
|
1285
|
+
if (wrapped === note.start && length === note.length) return note;
|
|
1286
|
+
moved += 1;
|
|
1287
|
+
return makeNote(note.id, note.pitch, wrapped, length, note.velocity);
|
|
1288
|
+
});
|
|
1289
|
+
const next = Object.freeze({ ...motif, grid: gridId });
|
|
1290
|
+
const done = rebuild(next, notes, motif.nextId);
|
|
1291
|
+
const mergedWords = done.merged === 0 ? '' : fillScratch(SCRATCH_COPY.gridMerged, { count: done.merged });
|
|
1292
|
+
const rest = moved === 0 ? '' : fillScratch(SCRATCH_COPY.gridMoved, { notes: notesText(moved), merged: mergedWords });
|
|
1293
|
+
const cutWords = done.cut === 0 ? '' : fillScratch(done.cut === 1 ? SCRATCH_COPY.cut : SCRATCH_COPY.cutMany, { count: done.cut });
|
|
1294
|
+
return {
|
|
1295
|
+
motif: done.motif,
|
|
1296
|
+
notice: fillScratch(SCRATCH_COPY.gridNotice, { grid: grid.many, rest }) + cutWords,
|
|
1297
|
+
cut: done.cut,
|
|
1298
|
+
merged: done.merged,
|
|
1299
|
+
what: fillScratch(SCRATCH_COPY.whatGrid, { grid: grid.many }),
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Sets the motif to `cycles` cycles, a whole number from 1 to 8 (anything else throws). More cycles keeps every note. Fewer is
|
|
1305
|
+
* refused, with the motif unchanged and the sentence in `refused`, while a note starts in a cycle that would go (the first such
|
|
1306
|
+
* cycle is named); otherwise a note that runs into the cycles that go is cut at the new end, and `cut` counts them. The same number
|
|
1307
|
+
* gives the same motif and an empty notice. Returns `{ motif, notice, cut, merged }`, plus `refused` when it refuses.
|
|
1308
|
+
*/
|
|
1309
|
+
export function setCycles(motif, cycles) {
|
|
1310
|
+
if (!Number.isInteger(cycles) || cycles < MIN_CYCLES || cycles > MAX_CYCLES) {
|
|
1311
|
+
throw new Error(`Moontide: the Scratchpad cycles must be a whole number from ${MIN_CYCLES} to ${MAX_CYCLES}, got ${String(cycles)}`);
|
|
1312
|
+
}
|
|
1313
|
+
if (cycles === motif.cycles) return { motif, notice: '', cut: 0, merged: 0, what: '' };
|
|
1314
|
+
const units = cycles * UNITS_PER_CYCLE;
|
|
1315
|
+
if (cycles < motif.cycles) {
|
|
1316
|
+
for (let cycle = cycles + 1; cycle <= motif.cycles; cycle += 1) {
|
|
1317
|
+
const holding = motif.notes.filter((note) => Math.floor(note.start / UNITS_PER_CYCLE) + 1 === cycle).length;
|
|
1318
|
+
if (holding > 0) {
|
|
1319
|
+
const refused = fillScratch(SCRATCH_COPY.cyclesRefused, { cycle, notes: notesText(holding) });
|
|
1320
|
+
return { motif, notice: '', cut: 0, merged: 0, what: '', refused };
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
const cutCount = motif.notes.filter((note) => note.start + note.length > units).length;
|
|
1325
|
+
const next = Object.freeze({ ...motif, cycles });
|
|
1326
|
+
const done = rebuild(next, motif.notes, motif.nextId);
|
|
1327
|
+
const cutWords = cutCount === 0 ? '' : cutCount === 1 ? SCRATCH_COPY.cyclesCut : fillScratch(SCRATCH_COPY.cyclesCutMany, { count: cutCount });
|
|
1328
|
+
return {
|
|
1329
|
+
motif: done.motif,
|
|
1330
|
+
notice: fillScratch(SCRATCH_COPY.cyclesNow, { cycles: cyclesText(cycles) }) + cutWords,
|
|
1331
|
+
cut: cutCount,
|
|
1332
|
+
merged: 0,
|
|
1333
|
+
what: fillScratch(SCRATCH_COPY.whatCycles, { cycles: cyclesText(cycles) }),
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** The semitones of room the shown range keeps above and below every pitch it has to show. */
|
|
1338
|
+
const RANGE_MARGIN = 2;
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* The pitch rows the roll shows, worked out from the notes each time it is drawn and never stored, so Undo, Redo, Clear, Record that and
|
|
1342
|
+
* Align always show the notes again: `{ low, high }`, the lowest and the highest shown pitch. It starts from the base window (the 24 rows
|
|
1343
|
+
* from `base`, which Lower and Higher move), joins the pitch of every note of the motif, every pitch in `extras` and the `cursor` pitch,
|
|
1344
|
+
* each with a margin of two semitones, then rounds the low end down to a C and the high end up to a B and holds the result to C0 to B8.
|
|
1345
|
+
* The rows are therefore never fewer than 24 and always a whole number of octaves. A base outside the windows or a pitch that is not a
|
|
1346
|
+
* whole number throws.
|
|
1347
|
+
*/
|
|
1348
|
+
export function shownRows(motif, { base, extras = [], cursor = null }) {
|
|
1349
|
+
if (!Number.isInteger(base) || base < MIN_WINDOW_START || base > MAX_WINDOW_START) {
|
|
1350
|
+
throw new Error(`Moontide: the base of the shown rows must be a whole number from ${MIN_WINDOW_START} to ${MAX_WINDOW_START}, got ${String(base)}`);
|
|
1351
|
+
}
|
|
1352
|
+
const pitches = [...motif.notes.map((note) => note.pitch), ...extras, ...(cursor === null ? [] : [cursor])];
|
|
1353
|
+
for (const pitch of pitches) {
|
|
1354
|
+
if (!Number.isInteger(pitch)) throw new Error(`Moontide: a pitch of the shown rows must be a whole number, got ${String(pitch)}`);
|
|
1355
|
+
}
|
|
1356
|
+
const lowest = Math.min(base, ...pitches.map((pitch) => pitch - RANGE_MARGIN));
|
|
1357
|
+
const highest = Math.max(base + WINDOW_ROWS - 1, ...pitches.map((pitch) => pitch + RANGE_MARGIN));
|
|
1358
|
+
return { low: Math.max(MIN_PITCH, Math.floor(lowest / 12) * 12), high: Math.min(MAX_PITCH, Math.floor(highest / 12) * 12 + 11) };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** The Pitch readout: the shown range as 'C2–B5'. */
|
|
1362
|
+
export function rangeText(low, high) {
|
|
1363
|
+
return fillScratch(SCRATCH_COPY.windowRange, { low: noteName(low), high: noteName(high) });
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** The Pitch readout in full words: 'C2 to B5'. */
|
|
1367
|
+
export function rangeSpoken(low, high) {
|
|
1368
|
+
return fillScratch(SCRATCH_COPY.windowSpokenRange, { low: noteName(low), high: noteName(high) });
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** The most earlier states the history keeps; the oldest is dropped past this, so the memory stays flat. */
|
|
1372
|
+
export const HISTORY_DEPTH = 100;
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* The Scratchpad's own history of edits, kept apart from the music's History: a frozen `{ present, past, future }`. `past` and
|
|
1376
|
+
* `future` hold `{ motif, what }`, an earlier or a later motif and the phrase of the edit between it and its neighbour. Nothing here
|
|
1377
|
+
* ever changes what it is given: each function returns a new history, or the same one when nothing changed.
|
|
1378
|
+
*/
|
|
1379
|
+
export function createHistory(motif) {
|
|
1380
|
+
return Object.freeze({ present: motif, past: Object.freeze([]), future: Object.freeze([]) });
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* Makes the result of a command the present. A result whose motif is the present one (no change), or that carries a refusal, adds nothing; a real edit clears the redo list.
|
|
1385
|
+
* With `amend` the result replaces the present and the phrase of the last step instead of adding one, so the auto-repeats of one held key
|
|
1386
|
+
* stay one step that Undo takes back to the motif before the first press; with no earlier step it adds the step like any commit.
|
|
1387
|
+
*/
|
|
1388
|
+
export function commit(history, result, { amend = false } = {}) {
|
|
1389
|
+
if (result.motif === history.present || result.refused !== undefined) return history;
|
|
1390
|
+
if (amend && history.past.length > 0) {
|
|
1391
|
+
const last = history.past[history.past.length - 1];
|
|
1392
|
+
const past = [...history.past.slice(0, -1), Object.freeze({ motif: last.motif, what: result.what })];
|
|
1393
|
+
return Object.freeze({ present: result.motif, past: Object.freeze(past), future: Object.freeze([]) });
|
|
1394
|
+
}
|
|
1395
|
+
const past = [...history.past, Object.freeze({ motif: history.present, what: result.what })].slice(-HISTORY_DEPTH);
|
|
1396
|
+
return Object.freeze({ present: result.motif, past: Object.freeze(past), future: Object.freeze([]) });
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Steps back one edit: `{ history, motif, notice }`. With nothing to undo the history is the same one and the notice says so. */
|
|
1400
|
+
export function undo(history) {
|
|
1401
|
+
if (history.past.length === 0) return { history, motif: history.present, notice: SCRATCH_COPY.nothingToUndo };
|
|
1402
|
+
const entry = history.past[history.past.length - 1];
|
|
1403
|
+
const next = Object.freeze({
|
|
1404
|
+
present: entry.motif,
|
|
1405
|
+
past: Object.freeze(history.past.slice(0, -1)),
|
|
1406
|
+
future: Object.freeze([...history.future, Object.freeze({ motif: history.present, what: entry.what })]),
|
|
1407
|
+
});
|
|
1408
|
+
return { history: next, motif: next.present, notice: fillScratch(SCRATCH_COPY.undid, { what: entry.what }) };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Steps forward one undone edit: `{ history, motif, notice }`. With nothing to redo the history is the same one and the notice says so. */
|
|
1412
|
+
export function redo(history) {
|
|
1413
|
+
if (history.future.length === 0) return { history, motif: history.present, notice: SCRATCH_COPY.nothingToRedo };
|
|
1414
|
+
const entry = history.future[history.future.length - 1];
|
|
1415
|
+
const next = Object.freeze({
|
|
1416
|
+
present: entry.motif,
|
|
1417
|
+
past: Object.freeze([...history.past, Object.freeze({ motif: history.present, what: entry.what })].slice(-HISTORY_DEPTH)),
|
|
1418
|
+
future: Object.freeze(history.future.slice(0, -1)),
|
|
1419
|
+
});
|
|
1420
|
+
return { history: next, motif: next.present, notice: fillScratch(SCRATCH_COPY.redid, { what: entry.what }) };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* The notes an Undo or a Redo brought back or changed: the notes of `after` that `before` does not hold with the same id, pitch, start,
|
|
1425
|
+
* length and velocity, in the order of `after.notes`. A step that only took notes away gives an empty list.
|
|
1426
|
+
*/
|
|
1427
|
+
export function touchedNotes(before, after) {
|
|
1428
|
+
const held = new Map(before.notes.map((note) => [note.id, note]));
|
|
1429
|
+
return after.notes.filter((note) => {
|
|
1430
|
+
const old = held.get(note.id);
|
|
1431
|
+
return old === undefined || old.pitch !== note.pitch || old.start !== note.start || old.length !== note.length || old.velocity !== note.velocity;
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** Where the window of 24 rows starts for a motif: the C at or below its lowest note, held from 12 to 96, and C3 when it is empty. */
|
|
1436
|
+
export function windowStartFor(motif) {
|
|
1437
|
+
if (motif.notes.length === 0) return EMPTY_WINDOW_START;
|
|
1438
|
+
const lowest = Math.min(...motif.notes.map((note) => note.pitch));
|
|
1439
|
+
return clamp(Math.floor(lowest / 12) * 12, MIN_WINDOW_START, MAX_WINDOW_START);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const SHARP_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
1443
|
+
|
|
1444
|
+
/** The note name with sharps and C4 = 60: 48 is 'C3', 61 is 'C#4', 119 is 'B8'. */
|
|
1445
|
+
export function noteName(midi) {
|
|
1446
|
+
if (!Number.isInteger(midi) || midi < 0 || midi > 127) throw new Error(`Moontide: a note name needs a whole MIDI number from 0 to 127, got ${String(midi)}`);
|
|
1447
|
+
return `${SHARP_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** True for the five black keys of an octave. */
|
|
1451
|
+
export function isBlackKey(midi) {
|
|
1452
|
+
return SHARP_NAMES[midi % 12].includes('#');
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** A number of beats as up to two decimals without trailing zeros: 1, 2.25, 1.33. */
|
|
1456
|
+
function beatNumber(beats) {
|
|
1457
|
+
return String(Number(beats.toFixed(2)));
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** The beat text of a unit inside one cycle: the beat number with the part of the beat as decimals ('1', '2.25', '1.33'). */
|
|
1461
|
+
export function beatText(unitInCycle) {
|
|
1462
|
+
return beatNumber(1 + unitInCycle / UNITS_PER_BEAT);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** Where a start unit lies: `{ cycle, beat }` with the cycle counted from 1 and the beat as text. */
|
|
1466
|
+
export function positionOf(start) {
|
|
1467
|
+
return { cycle: Math.floor(start / UNITS_PER_CYCLE) + 1, beat: beatText(start % UNITS_PER_CYCLE) };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Number of beats in the whole motif. */
|
|
1471
|
+
export function beatsIn(motif) {
|
|
1472
|
+
return motif.cycles * BEATS_PER_CYCLE;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** A length in the grid's own words with the beats in brackets, except on quarters: '1 sixteenth (0.25 beat)', '6 sixteenths (1.5 beats)'. */
|
|
1476
|
+
export function lengthText(length, gridId) {
|
|
1477
|
+
const grid = gridOf(gridId);
|
|
1478
|
+
const beats = length / UNITS_PER_BEAT;
|
|
1479
|
+
const steps = length / grid.unit;
|
|
1480
|
+
if (!Number.isInteger(steps)) {
|
|
1481
|
+
return `${beatNumber(beats)} ${beats > 1 ? SCRATCH_COPY.beatMany : SCRATCH_COPY.beatOne}`;
|
|
1482
|
+
}
|
|
1483
|
+
const word = steps === 1 ? grid.one : grid.many;
|
|
1484
|
+
if (grid.id === 'quarter') return `${steps} ${word}`;
|
|
1485
|
+
return fillScratch(SCRATCH_COPY.lengthWithBeats, { count: steps, word, beats: beatNumber(beats), beatWord: beats > 1 ? SCRATCH_COPY.beatMany : SCRATCH_COPY.beatOne });
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** A velocity in hundredths as text with two decimals: 80 is '0.80'. */
|
|
1489
|
+
export function velocityText(velocity) {
|
|
1490
|
+
return (velocity / 100).toFixed(2);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** The visible dot form of a note, `{ name, rest }`: 'E4' and ' · cycle 1, beat 2.25 · length 2 sixteenths (0.5 beat) · velocity 0.80'. */
|
|
1494
|
+
export function noteParts(note, gridId) {
|
|
1495
|
+
return {
|
|
1496
|
+
name: fillScratch(SCRATCH_COPY.noteName, { name: noteName(note.pitch) }),
|
|
1497
|
+
rest: fillScratch(SCRATCH_COPY.noteRest, { ...positionOf(note.start), length: lengthText(note.length, gridId), velocity: velocityText(note.velocity) }),
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** The spoken form of a note, also its accessible name: 'E4, starts at cycle 1, beat 2.25, lasts 2 sixteenths (0.5 beat), velocity 0.80.' */
|
|
1502
|
+
export function spokenNote(note, gridId) {
|
|
1503
|
+
return fillScratch(SCRATCH_COPY.spokenNote, {
|
|
1504
|
+
name: noteName(note.pitch),
|
|
1505
|
+
...positionOf(note.start),
|
|
1506
|
+
length: lengthText(note.length, gridId),
|
|
1507
|
+
velocity: velocityText(note.velocity),
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** The visible dot form of an empty cell, `{ name, rest }`. */
|
|
1512
|
+
export function emptyCellParts(pitch, unit) {
|
|
1513
|
+
return { name: fillScratch(SCRATCH_COPY.noteName, { name: noteName(pitch) }), rest: fillScratch(SCRATCH_COPY.emptyCellRest, positionOf(unit)) };
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** The spoken form of an empty cell: 'Row E4, cycle 1, beat 2.25, empty'. */
|
|
1517
|
+
export function spokenEmptyCell(pitch, unit) {
|
|
1518
|
+
return fillScratch(SCRATCH_COPY.spokenEmpty, { name: noteName(pitch), ...positionOf(unit) });
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** '6 notes in 2 cycles on sixteenths.' and '1 note in 1 cycle on quarters.' */
|
|
1522
|
+
export function summaryLine(motif) {
|
|
1523
|
+
return fillScratch(SCRATCH_COPY.summary, { notes: notesText(motif.notes.length), cycles: cyclesText(motif.cycles), grid: gridOf(motif.grid).many });
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// --- the phrase and the motif request ---
|
|
1527
|
+
|
|
1528
|
+
const PHRASE_NAMES = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
|
|
1529
|
+
|
|
1530
|
+
/** The phrase's spelling of a MIDI number: lowercase letter, '#' for a sharp, octave number (60 is 'c4'). It is not the display name of noteName. */
|
|
1531
|
+
function phraseName(midi) {
|
|
1532
|
+
return `${PHRASE_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** A velocity in whole hundredths (1 to 100) as the phrase writes it: two decimals, trailing zeros dropped (80 is '0.8', 100 is '1', 1 is '0.01'). */
|
|
1536
|
+
function phraseVelocity(hundredths) {
|
|
1537
|
+
return String(Number((hundredths / 100).toFixed(2)));
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const PHRASE_LANE = 'note\\("[a-g0-9#.~_\\[\\],/ <>]*"\\)\\.velocity\\("[a-g0-9#.~_\\[\\],/ <>]*"\\)';
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* The shape of a phrase: one lane, note("...").velocity("..."), or stack(...) of two or more such lanes, with only the characters of the
|
|
1544
|
+
* phrase (letters a to g, digits, '#', '.', '~', '_', brackets, commas, '/', spaces and angle brackets) inside the quotes. src/protocol.ts's
|
|
1545
|
+
* MOTIF_PHRASE_SHAPE mirrors it by hand (test/ask.test.js checks both are the same pattern).
|
|
1546
|
+
*/
|
|
1547
|
+
export const PHRASE_SHAPE = new RegExp(`^(${PHRASE_LANE}|stack\\(${PHRASE_LANE}(, ${PHRASE_LANE})+\\))$`);
|
|
1548
|
+
|
|
1549
|
+
/** Returns `code` when it has the shape of a phrase; anything else throws, so a writer bug is loud and nothing but notes ever travels as a phrase. */
|
|
1550
|
+
export function assertPhraseCode(code) {
|
|
1551
|
+
if (typeof code !== 'string') throw new TypeError('Moontide: phrase code must be a string');
|
|
1552
|
+
if (!PHRASE_SHAPE.test(code)) throw new Error('Moontide: phrase code does not have the shape note("...").velocity("...") or stack of such lanes');
|
|
1553
|
+
return code;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Groups cells into tokens (a chord is voices with the same step, length and velocity) and the tokens into lanes (the first lane where the token fits). */
|
|
1557
|
+
function phraseLanes(cells) {
|
|
1558
|
+
const groups = new Map();
|
|
1559
|
+
for (const cell of cells) {
|
|
1560
|
+
const key = `${cell.step}|${cell.steps}|${cell.velocity}`;
|
|
1561
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
1562
|
+
groups.get(key).push(cell);
|
|
1563
|
+
}
|
|
1564
|
+
const tokens = [...groups.values()].map((members) => ({
|
|
1565
|
+
step: members[0].step,
|
|
1566
|
+
steps: members[0].steps,
|
|
1567
|
+
velocity: members[0].velocity,
|
|
1568
|
+
pitches: members.map((cell) => cell.pitch).sort((a, b) => a - b),
|
|
1569
|
+
}));
|
|
1570
|
+
tokens.sort((a, b) => a.step - b.step || a.pitches[0] - b.pitches[0]);
|
|
1571
|
+
const lanes = [];
|
|
1572
|
+
for (const token of tokens) {
|
|
1573
|
+
let lane = lanes.find((entry) => entry.end <= token.step);
|
|
1574
|
+
if (lane === undefined) {
|
|
1575
|
+
lane = { end: 0, tokens: [] };
|
|
1576
|
+
lanes.push(lane);
|
|
1577
|
+
}
|
|
1578
|
+
lane.tokens.push(token);
|
|
1579
|
+
lane.end = token.step + token.steps;
|
|
1580
|
+
}
|
|
1581
|
+
return lanes;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/** One lane as the text of its two strings, one token per grid step: the notes, and the velocities mirrored token for token ('~' a rest, '_' a held step). */
|
|
1585
|
+
function laneStrings(lane, total, cycles) {
|
|
1586
|
+
const notes = new Array(total).fill('~');
|
|
1587
|
+
const velocities = new Array(total).fill('~');
|
|
1588
|
+
for (const token of lane.tokens) {
|
|
1589
|
+
const names = token.pitches.map(phraseName);
|
|
1590
|
+
notes[token.step] = names.length === 1 ? names[0] : `[${names.join(',')}]`;
|
|
1591
|
+
velocities[token.step] = token.velocity;
|
|
1592
|
+
for (let held = 1; held < token.steps; held += 1) {
|
|
1593
|
+
notes[token.step + held] = '_';
|
|
1594
|
+
velocities[token.step + held] = '_';
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
// Several cycles are one bracketed sequence slowed by the cycle count, so a note can be held across a cycle line.
|
|
1598
|
+
const arrange = (tokens) => (cycles === 1 ? tokens.join(' ') : `[${tokens.join(' ')}]/${cycles}`);
|
|
1599
|
+
return { notes: arrange(notes), velocities: arrange(velocities) };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* The phrase of a motif: Strudel code in the form the played-notes measurement proved, note("...").velocity("..."), or stack(...) of such lanes
|
|
1604
|
+
* when notes overlap in ways one string cannot say. One token per grid step, '~' a rest, '_' a held step, voices with the same step, length and
|
|
1605
|
+
* velocity in a [a,b] chord, the whole motif one sequence slowed by its cycle count. It depends only on the notes (not on ids or the order they
|
|
1606
|
+
* were placed), and null for a motif with no notes. The result always passes assertPhraseCode.
|
|
1607
|
+
*/
|
|
1608
|
+
export function motifPhrase(motif) {
|
|
1609
|
+
if (motif.notes.length === 0) return null;
|
|
1610
|
+
const grid = gridOf(motif.grid);
|
|
1611
|
+
const cells = motif.notes.map((note) => ({ pitch: note.pitch, step: note.start / grid.unit, steps: note.length / grid.unit, velocity: phraseVelocity(note.velocity) }));
|
|
1612
|
+
const total = motif.cycles * grid.steps;
|
|
1613
|
+
const lanes = phraseLanes(cells).map((lane) => laneStrings(lane, total, motif.cycles)).map(({ notes, velocities }) => `note("${notes}").velocity("${velocities}")`);
|
|
1614
|
+
return assertPhraseCode(lanes.length === 1 ? lanes[0] : `stack(${lanes.join(', ')})`);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* The cycle to tell Claude for a motif, or null for none. A played motif (made by Record that, with no note placed by hand since) keeps the cycle
|
|
1619
|
+
* where it was played (null when the music was stopped then, whatever the music does now). Any other motif, a mixed one that began as a recording
|
|
1620
|
+
* included, takes the cycle where the roll's first cycle stands at the press: the music's cycle rounded down to a whole number of the motif's cycles,
|
|
1621
|
+
* because Tap and the marker place the music on the roll by the cycle modulo the motif's length. `musicCycle` is the music's cycle now, or null while
|
|
1622
|
+
* the music is stopped; a value that is neither null nor a finite number of at least 0 throws.
|
|
1623
|
+
*/
|
|
1624
|
+
export function motifMoment(motif, musicCycle) {
|
|
1625
|
+
if (motif.source === 'played' && motif.recorded !== null) return motif.recorded.cycle;
|
|
1626
|
+
if (musicCycle === null) return null;
|
|
1627
|
+
if (typeof musicCycle !== 'number' || !Number.isFinite(musicCycle) || musicCycle < 0) {
|
|
1628
|
+
throw new Error(`Moontide: the music cycle for a motif must be null or a finite number of at least 0, got ${String(musicCycle)}`);
|
|
1629
|
+
}
|
|
1630
|
+
return Math.floor(musicCycle / motif.cycles) * motif.cycles;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* The motif field of a page request: `{ source, cycle, cycles, grid, notes, phrase }` with the cycle key only when there is a moment, or null
|
|
1635
|
+
* for a motif with no notes. A motif with notes and no source is a bug and throws.
|
|
1636
|
+
*/
|
|
1637
|
+
export function motifRequest(motif, musicCycle) {
|
|
1638
|
+
if (motif.notes.length === 0) return null;
|
|
1639
|
+
if (!MOTIF_SOURCES.includes(motif.source)) throw new Error(`Moontide: a motif with notes needs a source of ${MOTIF_SOURCES.join(', ')}, got ${String(motif.source)}`);
|
|
1640
|
+
const cycle = motifMoment(motif, musicCycle);
|
|
1641
|
+
return {
|
|
1642
|
+
source: motif.source,
|
|
1643
|
+
...(cycle === null ? {} : { cycle }),
|
|
1644
|
+
cycles: motif.cycles,
|
|
1645
|
+
grid: motif.grid,
|
|
1646
|
+
notes: motif.notes.length,
|
|
1647
|
+
phrase: motifPhrase(motif),
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/** The status line after a send: the cycle the request carried, or the words for no moment. */
|
|
1652
|
+
export function sentText(request) {
|
|
1653
|
+
return Object.hasOwn(request, 'cycle') ? fillScratch(SCRATCH_COPY.sent, { cycle: request.cycle }) : SCRATCH_COPY.sentNoMoment;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* The sentence for a refusal: `{ refused: word, ...numbers }` as the view and askUi.sendMotif give it. The words are no-notes, empty-words,
|
|
1658
|
+
* words-too-long (with `length` and `max`), disconnected, pending and too-big (with `bytes` and `max`); any other word throws.
|
|
1659
|
+
*/
|
|
1660
|
+
export function sendRefusalText(result) {
|
|
1661
|
+
switch (result.refused) {
|
|
1662
|
+
case 'no-notes':
|
|
1663
|
+
return SCRATCH_COPY.sendNoNotes;
|
|
1664
|
+
case 'empty-words':
|
|
1665
|
+
return SCRATCH_COPY.sendNoWords;
|
|
1666
|
+
case 'words-too-long':
|
|
1667
|
+
return fillScratch(SCRATCH_COPY.sendWordsTooLong, { length: result.length, max: result.max });
|
|
1668
|
+
case 'disconnected':
|
|
1669
|
+
return SCRATCH_COPY.sendDisconnected;
|
|
1670
|
+
case 'pending':
|
|
1671
|
+
return SCRATCH_COPY.sendPending;
|
|
1672
|
+
case 'too-big':
|
|
1673
|
+
return fillScratch(SCRATCH_COPY.sendTooBig, { bytes: result.bytes, max: result.max });
|
|
1674
|
+
default:
|
|
1675
|
+
throw new Error(`Moontide: a motif send was refused for a word the Scratchpad does not know: ${String(result.refused)}`);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* The sentence for what became of a sent motif request, from `{ status }` as page/ask-view.js's onMotifResult gives it, or null when it only arrived
|
|
1681
|
+
* (the sent line stays). The sentence for a request that waits for Claude goes after the sent line.
|
|
1682
|
+
*/
|
|
1683
|
+
export function motifResultText(result) {
|
|
1684
|
+
switch (result.status) {
|
|
1685
|
+
case 'received':
|
|
1686
|
+
return null;
|
|
1687
|
+
case 'waiting':
|
|
1688
|
+
return SCRATCH_COPY.sendWaiting;
|
|
1689
|
+
case 'refused':
|
|
1690
|
+
return fillScratch(SCRATCH_COPY.sendRefused, { reason: result.text });
|
|
1691
|
+
case 'not-confirmed':
|
|
1692
|
+
return SCRATCH_COPY.sendNotConfirmed;
|
|
1693
|
+
default:
|
|
1694
|
+
throw new Error(`Moontide: a motif request ended in a state the Scratchpad does not know: ${String(result.status)}`);
|
|
1695
|
+
}
|
|
1696
|
+
}
|