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,208 @@
|
|
|
1
|
+
// The notes played on a MIDI keyboard, kept in memory as notes with a start and an end in music cycles. Pure: no DOM, no window, no
|
|
2
|
+
// timer and no clock at import or at any call, so node:test and page/main.js import it the same way. Every call is given the cycle
|
|
3
|
+
// it happened at; this module never reads a clock and never sends anything. Which bytes are a note is decided here too, so the
|
|
4
|
+
// listener can drop everything else before it reads any clock.
|
|
5
|
+
//
|
|
6
|
+
// The buffer keeps a window: the 8 whole cycles that end with the current one, so everything it holds fits on the roll (whose longest motif
|
|
7
|
+
// is 8 cycles) and its edges sit on the music's cycle lines. `snapshot(now)` is what "record that" and, later, a request to Claude read:
|
|
8
|
+
// `{ start, cycles, notes }` where `start` is the window's first cycle, `cycles` its length (8) and each note
|
|
9
|
+
// `{ pitch, velocity, start, end, run, held }` in cycles from the window's start, in the order played, a note that began before the window cut at
|
|
10
|
+
// 0 and a note still held closed at `now` (`held` is true for it, so the live view can keep growing it). `run` says which run of the music the note was placed in: the cycle axis's whole-number offset
|
|
11
|
+
// (the axis cycle minus the music's own cycle count) or null for a note placed by the tempo; equal runs mean one run of the music.
|
|
12
|
+
|
|
13
|
+
import { MAX_CYCLES } from './scratch-model.js';
|
|
14
|
+
|
|
15
|
+
/** How many whole cycles the buffer keeps: the roll's longest motif. */
|
|
16
|
+
export const BUFFER_CYCLES = MAX_CYCLES;
|
|
17
|
+
/** The most notes the buffer keeps, so a stuck key or a flood never grows memory without bound. */
|
|
18
|
+
export const MAX_BUFFER_NOTES = 2000;
|
|
19
|
+
|
|
20
|
+
const NOTE_OFF_STATUS = 0x80;
|
|
21
|
+
const NOTE_ON_STATUS = 0x90;
|
|
22
|
+
|
|
23
|
+
function isDataByte(value) {
|
|
24
|
+
return Number.isInteger(value) && value >= 0 && value <= 127;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reads the bytes of one MIDI message. Gives `{ kind, pitch, velocity, channel }` for a three-byte note-on (status 0x90 to 0x9F) or
|
|
29
|
+
* note-off (0x80 to 0x8F) whose two data bytes are whole numbers from 0 to 127: `kind` is 'on' or 'off', `channel` 1 to 16, and a note-on
|
|
30
|
+
* with velocity 0 is an off, as the MIDI standard says. Everything else gives null: clock (0xF8), active sensing (0xFE), controllers
|
|
31
|
+
* (the sustain pedal), pitch bend, aftertouch, system exclusive, and data that is short, long or not bytes. Those are ignored on purpose:
|
|
32
|
+
* the phrase form of a motif cannot say them.
|
|
33
|
+
*/
|
|
34
|
+
export function parseNoteMessage(data) {
|
|
35
|
+
if (data === null || typeof data !== 'object' || data.length !== 3) return null;
|
|
36
|
+
const [status, pitch, velocity] = [data[0], data[1], data[2]];
|
|
37
|
+
if (!Number.isInteger(status) || status < NOTE_OFF_STATUS || status > 0x9f) return null;
|
|
38
|
+
if (!isDataByte(pitch) || !isDataByte(velocity)) return null;
|
|
39
|
+
const channel = (status & 0x0f) + 1;
|
|
40
|
+
const isOn = status >= NOTE_ON_STATUS && velocity > 0;
|
|
41
|
+
return { kind: isOn ? 'on' : 'off', pitch, velocity, channel };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkCycle(cycle) {
|
|
45
|
+
if (typeof cycle !== 'number' || !Number.isFinite(cycle)) throw new Error(`Moontide: a played note needs a finite cycle, got ${String(cycle)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkPitch(pitch) {
|
|
49
|
+
if (!isDataByte(pitch)) throw new Error(`Moontide: a played note needs a whole pitch from 0 to 127, got ${String(pitch)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The played notes, in play order. A note is `{ pitch, velocity, start, end, run }` with the cycles of its start and end and the velocity as the
|
|
54
|
+
* keyboard sent it (1 to 127); `end` is null while the key is held; `run` is the axis offset the note was placed at (a whole number)
|
|
55
|
+
* or null when it was placed by the tempo. `noteOn(cycle, pitch, velocity, run)` (run left out is null) starts a note (a second on of a held
|
|
56
|
+
* pitch ends the first at that cycle) and gives `{ kept: true }`, or `{ kept: false, full: true }` when 2000 notes are kept already;
|
|
57
|
+
* `noteOff(cycle, pitch)` ends the held note of that pitch (an off for a pitch that is not held does nothing); `closeAll(cycle)` ends every
|
|
58
|
+
* held note there. `count(now)` is how many notes the window holds (`now` is the current cycle; left out, the latest cycle any call gave),
|
|
59
|
+
* `notes()` a copy of them, `snapshot(now)` the window as described above (the buffer is left as it was) and `clear()` empties it.
|
|
60
|
+
* Every call that takes a cycle drops the notes that ended before the window's start, judged against the latest cycle any call has given,
|
|
61
|
+
* so a note stamped a little late never moves the window back. A held note is never dropped.
|
|
62
|
+
*/
|
|
63
|
+
export function createPlayedBuffer() {
|
|
64
|
+
let kept = [];
|
|
65
|
+
let latest = -Infinity;
|
|
66
|
+
const heldOf = (pitch) => kept.find((note) => note.pitch === pitch && note.end === null);
|
|
67
|
+
const windowEnd = (at) => Math.floor(at) + 1;
|
|
68
|
+
const outside = (note, windowStart) => note.end !== null && note.end <= windowStart && note.start < windowStart;
|
|
69
|
+
|
|
70
|
+
// Learns the cycle of a call and drops what has left the window.
|
|
71
|
+
function advance(cycle) {
|
|
72
|
+
if (cycle > latest) latest = cycle;
|
|
73
|
+
if (!Number.isFinite(latest)) return;
|
|
74
|
+
const windowStart = windowEnd(latest) - BUFFER_CYCLES;
|
|
75
|
+
if (kept.some((note) => outside(note, windowStart))) kept = kept.filter((note) => !outside(note, windowStart));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function noteOn(cycle, pitch, velocity, run = null) {
|
|
79
|
+
checkCycle(cycle);
|
|
80
|
+
checkPitch(pitch);
|
|
81
|
+
if (run !== null && !Number.isInteger(run)) throw new Error(`Moontide: a played note needs a run that is null or a whole number, got ${String(run)}`);
|
|
82
|
+
if (!Number.isInteger(velocity) || velocity < 1 || velocity > 127) throw new Error(`Moontide: a played note needs a velocity from 1 to 127, got ${String(velocity)}`);
|
|
83
|
+
advance(cycle);
|
|
84
|
+
if (kept.length >= MAX_BUFFER_NOTES) return { kept: false, full: true };
|
|
85
|
+
const held = heldOf(pitch);
|
|
86
|
+
if (held !== undefined) held.end = Math.max(cycle, held.start);
|
|
87
|
+
kept.push({ pitch, velocity, start: cycle, end: null, run });
|
|
88
|
+
return { kept: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function noteOff(cycle, pitch) {
|
|
92
|
+
checkCycle(cycle);
|
|
93
|
+
checkPitch(pitch);
|
|
94
|
+
advance(cycle);
|
|
95
|
+
const held = heldOf(pitch);
|
|
96
|
+
if (held !== undefined) held.end = Math.max(cycle, held.start);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function closeAll(cycle) {
|
|
100
|
+
checkCycle(cycle);
|
|
101
|
+
advance(cycle);
|
|
102
|
+
for (const note of kept) if (note.end === null) note.end = Math.max(cycle, note.start);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function count(now = latest) {
|
|
106
|
+
if (now !== latest) checkCycle(now);
|
|
107
|
+
advance(now);
|
|
108
|
+
return kept.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function snapshot(now) {
|
|
112
|
+
checkCycle(now);
|
|
113
|
+
const at = Math.max(latest, now);
|
|
114
|
+
const windowStart = windowEnd(at) - BUFFER_CYCLES;
|
|
115
|
+
const notes = kept
|
|
116
|
+
.filter((note) => !outside(note, windowStart))
|
|
117
|
+
.map((note) => {
|
|
118
|
+
const start = Math.max(0, note.start - windowStart);
|
|
119
|
+
const end = Math.max(start, (note.end === null ? Math.max(now, note.start) : note.end) - windowStart);
|
|
120
|
+
return { pitch: note.pitch, velocity: note.velocity, start, end, run: note.run, held: note.end === null };
|
|
121
|
+
});
|
|
122
|
+
return { start: windowStart, cycles: BUFFER_CYCLES, notes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
noteOn,
|
|
127
|
+
noteOff,
|
|
128
|
+
closeAll,
|
|
129
|
+
count,
|
|
130
|
+
snapshot,
|
|
131
|
+
clear: () => {
|
|
132
|
+
kept = [];
|
|
133
|
+
latest = -Infinity;
|
|
134
|
+
},
|
|
135
|
+
notes: () => kept.map((note) => ({ ...note })),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isLine(lines) {
|
|
140
|
+
if (lines === null || typeof lines !== 'object') return false;
|
|
141
|
+
const { a, b } = lines;
|
|
142
|
+
for (const anchor of [a, b]) {
|
|
143
|
+
if (anchor === null || typeof anchor !== 'object' || !Number.isFinite(anchor.c) || !Number.isFinite(anchor.t)) return false;
|
|
144
|
+
}
|
|
145
|
+
return a.t !== b.t;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const cycleOnLine = (line, h) => line.a.c + ((h - line.a.t) * (line.b.c - line.a.c)) / (line.b.t - line.a.t);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One cycle axis for the buffer. The page clock gives cycle lines only for a music that plays, yet the buffer must keep the last cycles
|
|
152
|
+
* by the tempo while the music is stopped. The axis uses the page clock's own lines while the music plays and runs on by the
|
|
153
|
+
* scheduler's tempo otherwise, and in both cases hands the same kind of two anchors to the placement code (checkStamp, then
|
|
154
|
+
* cycleAtStamp). It never goes back: notes already kept keep their order. While the music plays its whole cycles stay whole cycles of
|
|
155
|
+
* the axis, because a Play moves the axis forward to the next whole cycle: the axis cycle equals the music's own cycle count plus the
|
|
156
|
+
* offset (the count the code's patterns are queried at), and the offset changes only when the music restarts. Notes played before a Play are older cycles of the same axis;
|
|
157
|
+
* their place against the new music has no meaning, because they were played against no music.
|
|
158
|
+
*
|
|
159
|
+
* Pure: it reads no clock and keeps no timer; every read is handed its moment. `at(h, { music, cps, playing })` takes `h`, the handler's
|
|
160
|
+
* clock in milliseconds (performance.now() time), `music`, the page clock's two cycle lines `{ a: { c, t }, b: { c, t } }` or null,
|
|
161
|
+
* `cps`, the scheduler's cycles per second, and `playing`, whether the music plays. It gives `{ anchors: { a, b }, source, now }`:
|
|
162
|
+
* `source` is 'music' or 'free' (the tempo), `now` the axis cycle at `h` on those anchors, and `offset`, the whole number added to the music's line
|
|
163
|
+
* (null when `source` is 'free'); the whole answer is null when there are no lines and no tempo above 0 (nothing changes then).
|
|
164
|
+
*
|
|
165
|
+
* With usable lines the axis is the music's line plus a whole-number offset. The offset stays, except when the line reads more than half a
|
|
166
|
+
* cycle below the last cycle (the scheduler restarted unseen, as when the tab was hidden): it then grows by whole cycles; a smaller step
|
|
167
|
+
* back is clock jitter and changes nothing. While the music plays and the lines are briefly missing, the last line is kept, so a gap
|
|
168
|
+
* never costs a cycle. A line from before a Stop is never reused: a Play whose clock is still warming up stays on the tempo.
|
|
169
|
+
*/
|
|
170
|
+
export function createCycleAxis() {
|
|
171
|
+
let mode = null; // null before the first read, then 'music' or 'free'
|
|
172
|
+
let offset = 0;
|
|
173
|
+
let line = null; // the line in use, in axis cycles: the music's plus the offset, or the tempo's from its origin
|
|
174
|
+
let freeCps = 0;
|
|
175
|
+
let last = null; // the axis cycle of the latest read
|
|
176
|
+
|
|
177
|
+
function answer(h, source) {
|
|
178
|
+
last = cycleOnLine(line, h);
|
|
179
|
+
return { anchors: { a: { ...line.a }, b: { ...line.b } }, source, now: last, offset: source === 'music' ? offset : null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function at(h, { music, cps, playing }) {
|
|
183
|
+
if (!Number.isFinite(h)) throw new Error(`Moontide: the cycle axis needs a finite moment, got ${String(h)}`);
|
|
184
|
+
if (isLine(music)) {
|
|
185
|
+
const raw = cycleOnLine(music, h);
|
|
186
|
+
if (mode !== 'music') offset = line === null ? 0 : Math.ceil(cycleOnLine(line, h) - raw);
|
|
187
|
+
else if (raw + offset < last - 0.5) offset = Math.ceil(last - raw);
|
|
188
|
+
mode = 'music';
|
|
189
|
+
line = { a: { c: music.a.c + offset, t: music.a.t }, b: { c: music.b.c + offset, t: music.b.t } };
|
|
190
|
+
return answer(h, 'music');
|
|
191
|
+
}
|
|
192
|
+
if (playing === true && mode === 'music') return answer(h, 'music');
|
|
193
|
+
if (Number.isFinite(cps) && cps > 0) {
|
|
194
|
+
if (mode === 'free' && cps === freeCps) return answer(h, 'free');
|
|
195
|
+
const period = 1000 / cps;
|
|
196
|
+
const c = line === null ? 0 : cycleOnLine(line, h);
|
|
197
|
+
if (Number.isFinite(period) && period > 0 && Number.isFinite(c) && h + period !== h) {
|
|
198
|
+
mode = 'free';
|
|
199
|
+
freeCps = cps;
|
|
200
|
+
line = { a: { c, t: h }, b: { c: c + 1, t: h + period } };
|
|
201
|
+
return answer(h, 'free');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { at };
|
|
208
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Pure module for the Scratchpad roll: the numbers behind a pointer. Distances are written in rem, the unit the whole page is sized
|
|
2
|
+
// in; the caller multiplies them by the root font size it reads when a press starts, so a larger root makes the targets larger too.
|
|
3
|
+
// A point on the roll becomes a cell, a drawn length, a move or a new length through the same whole units the model keeps, so what is
|
|
4
|
+
// previewed is what is committed. The rows of the roll are worked out from the notes, so every row function takes the number of rows
|
|
5
|
+
// shown and the pitch of the top row. No DOM access and no timers here, so node:test and the view import the module without a build step.
|
|
6
|
+
|
|
7
|
+
/** A press that moves less than this is a click; a press that moves more is a drag. */
|
|
8
|
+
export const DRAG_THRESHOLD_REM = 0.25;
|
|
9
|
+
|
|
10
|
+
/** The width of the handle at a note's right side that resizes it, and the most of a short note it may take. */
|
|
11
|
+
export const EDGE_HANDLE_REM = 0.5;
|
|
12
|
+
export const EDGE_HANDLE_SHARE = 0.4;
|
|
13
|
+
|
|
14
|
+
/** A drag within this distance of the edge of the visible roll scrolls it, by `EDGE_SCROLL_REM` on each pointer move. */
|
|
15
|
+
export const EDGE_ZONE_REM = 1.5;
|
|
16
|
+
export const EDGE_SCROLL_REM = 1;
|
|
17
|
+
|
|
18
|
+
const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
|
|
19
|
+
|
|
20
|
+
/** The nearest grid line to `position` for lines `size` apart, with a tie going to the earlier line; `position` may be fractional. */
|
|
21
|
+
function nearestLine(position, size) {
|
|
22
|
+
return Math.ceil(position / size - 0.5);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Turns a negative zero into 0, so a start of 0 is the same value however it was worked out. */
|
|
26
|
+
const whole = (value) => value + 0;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where the pointer is along the motif, in units, even outside the grid (a negative number left of it, more than `units` right of it).
|
|
30
|
+
* `rect` is the grid's box in the page (`left`, `width`), `units` the motif's length in units.
|
|
31
|
+
*/
|
|
32
|
+
export function pointerUnit({ rect, x, units }) {
|
|
33
|
+
return ((x - rect.left) / rect.width) * units;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The row the pointer is in, counted from the top row at 0, even outside the grid. `rect` has `top` and `height` for the `rows` rows shown. */
|
|
37
|
+
export function pointerRow({ rect, y, rows }) {
|
|
38
|
+
return Math.floor((y - rect.top) / (rect.height / rows));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The cell under a point: `{ unit, pitch }`, where `unit` is the start of the grid step that holds the point and `pitch` the pitch
|
|
43
|
+
* of its row (the top of the `rows` rows shown is `high`). A point outside the grid's box gives null.
|
|
44
|
+
*/
|
|
45
|
+
export function cellAt({ rect, x, y, units, gridUnit, rows, high }) {
|
|
46
|
+
if (x < rect.left || x >= rect.left + rect.width || y < rect.top || y >= rect.top + rect.height) return null;
|
|
47
|
+
const unit = Math.floor(pointerUnit({ rect, x, units }) / gridUnit) * gridUnit;
|
|
48
|
+
return { unit, pitch: high - pointerRow({ rect, y, rows }) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The box a selection drag spans, in units and rows counted from the top row at 0: `{ fromUnit, toUnit, topRow, bottomRow }`, the same
|
|
53
|
+
* whichever way the pointer went from the press (`x0`, `y0`) to where it is (`x1`, `y1`), and held inside the grid. The numbers are
|
|
54
|
+
* fractions, so a box over part of a cell still says which part. `rect` is the grid's box (`left`, `top`, `width`, `height`), `units` the
|
|
55
|
+
* motif's length and `rows` the rows the grid shows.
|
|
56
|
+
*/
|
|
57
|
+
export function selectionBox({ rect, x0, y0, x1, y1, units, rows }) {
|
|
58
|
+
const unitOf = (x) => clamp(pointerUnit({ rect, x, units }), 0, units);
|
|
59
|
+
const rowOf = (y) => clamp(((y - rect.top) / rect.height) * rows, 0, rows);
|
|
60
|
+
const [a, b] = [unitOf(x0), unitOf(x1)];
|
|
61
|
+
const [c, d] = [rowOf(y0), rowOf(y1)];
|
|
62
|
+
return { fromUnit: whole(Math.min(a, b)), toUnit: whole(Math.max(a, b)), topRow: whole(Math.min(c, d)), bottomRow: whole(Math.max(c, d)) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The ids of the notes a selection box touches: a note whose span overlaps the box's span and whose row lies between the rows the box's
|
|
67
|
+
* top and bottom fall in. A note that only ends where the box starts, or starts where it ends, is not touched; a box with no width
|
|
68
|
+
* touches the note whose span holds it. `high` is the pitch of the top row, so a note's row is `high` minus its pitch.
|
|
69
|
+
*/
|
|
70
|
+
export function notesInBox({ notes, box, high }) {
|
|
71
|
+
const first = Math.floor(box.topRow);
|
|
72
|
+
const last = Math.floor(box.bottomRow);
|
|
73
|
+
const flat = box.toUnit <= box.fromUnit;
|
|
74
|
+
return notes
|
|
75
|
+
.filter((note) => {
|
|
76
|
+
const row = high - note.pitch;
|
|
77
|
+
if (row < first || row > last) return false;
|
|
78
|
+
const end = note.start + note.length;
|
|
79
|
+
return flat ? note.start <= box.fromUnit && end > box.fromUnit : note.start < box.toUnit && end > box.fromUnit;
|
|
80
|
+
})
|
|
81
|
+
.map((note) => note.id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The length of a note drawn from `start` to the pointer: up to the end of the grid step under the pointer, that step included, at
|
|
86
|
+
* least one step and at most to the motif's end. A pointer before the start gives one step. `pointerUnit` is where the pointer is, in units.
|
|
87
|
+
*/
|
|
88
|
+
export function drawLength({ start, pointerUnit: at, gridUnit, units }) {
|
|
89
|
+
const end = (Math.floor(at / gridUnit) + 1) * gridUnit;
|
|
90
|
+
return clamp(end - start, gridUnit, units - start);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where a dragged note lands: `{ start, pitch }`. The start is the nearest grid line to where the note's start was dragged to (`dUnits`
|
|
95
|
+
* units from where it began; a tie goes to the earlier line), held from 0 to the last start that keeps the note inside the motif.
|
|
96
|
+
* The pitch is `dRows` rows lower (negative: higher), held between `low` and `high`, the lowest and highest shown pitch.
|
|
97
|
+
*/
|
|
98
|
+
export function moveTarget({ note, dUnits, dRows, gridUnit, units, low, high }) {
|
|
99
|
+
const last = Math.floor((units - note.length) / gridUnit) * gridUnit;
|
|
100
|
+
return {
|
|
101
|
+
start: whole(clamp(nearestLine(note.start + dUnits, gridUnit) * gridUnit, 0, last)),
|
|
102
|
+
pitch: clamp(note.pitch - dRows, low, high),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Where a dragged group of notes lands, as one move for the whole group: `{ dStart, dPitch }` in units and semitones. The start move is
|
|
108
|
+
* `dUnits` snapped to the nearest grid line (a tie goes to the earlier line) and held so the earliest note stays at or after 0 and the
|
|
109
|
+
* latest end at or before `units`; the group keeps its shape, so one note decides how far it can go. The pitch move is `dRows` rows lower
|
|
110
|
+
* (negative: higher) held so every pitch stays between `low` and `high`, the lowest and highest shown pitch. A note already outside those
|
|
111
|
+
* rows is not pulled in: the group just cannot go further out.
|
|
112
|
+
*/
|
|
113
|
+
export function groupMoveTarget({ notes, dUnits, dRows, gridUnit, units, low, high }) {
|
|
114
|
+
const first = Math.min(...notes.map((note) => note.start));
|
|
115
|
+
const last = Math.max(...notes.map((note) => note.start + note.length));
|
|
116
|
+
const lowest = Math.min(...notes.map((note) => note.pitch));
|
|
117
|
+
const highest = Math.max(...notes.map((note) => note.pitch));
|
|
118
|
+
return {
|
|
119
|
+
dStart: whole(clamp(nearestLine(dUnits, gridUnit) * gridUnit, -first, units - last)),
|
|
120
|
+
dPitch: whole(clamp(-dRows, Math.min(0, low - lowest), Math.max(0, high - highest))),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The length a dragged edge gives a note that starts at `start`: it ends on the nearest grid line to the pointer (a tie goes to the
|
|
126
|
+
* earlier line), at least one step long and at most to the motif's end.
|
|
127
|
+
*/
|
|
128
|
+
export function resizeLength({ start, pointerUnit: at, gridUnit, units }) {
|
|
129
|
+
return clamp(nearestLine(at, gridUnit) * gridUnit - start, gridUnit, units - start);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Which part of a note a press is on: `'edge'` when it is within `EDGE_HANDLE_REM` of the note's right side, or within the share of a
|
|
134
|
+
* short note that `EDGE_HANDLE_SHARE` allows, else `'body'`. `left` and `width` are the note's box in pixels, `remPx` the root font size.
|
|
135
|
+
*/
|
|
136
|
+
export function pickPart({ left, width, x, remPx }) {
|
|
137
|
+
const handle = Math.min(EDGE_HANDLE_REM * remPx, EDGE_HANDLE_SHARE * width);
|
|
138
|
+
return x >= left + width - handle ? 'edge' : 'body';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** True once a pointer that went down has moved `thresholdPx` pixels or more (`dx` and `dy` are its distance from the press). */
|
|
142
|
+
export function lockDrag({ dx, dy, thresholdPx }) {
|
|
143
|
+
return Math.hypot(dx, dy) >= thresholdPx;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* How far to scroll the roll, in pixels, for a drag whose pointer is at `x`: minus one `EDGE_SCROLL_REM` toward the left edge when the
|
|
148
|
+
* pointer is within `EDGE_ZONE_REM` of `left`, plus one toward the right edge when it is within that distance of `right` (a pointer
|
|
149
|
+
* past an edge counts), else 0. `left` and `right` are the edges of the part of the roll that shows. A box with no width gives 0.
|
|
150
|
+
*/
|
|
151
|
+
export function edgeScrollPx({ x, left, right, remPx }) {
|
|
152
|
+
if (right <= left) return 0;
|
|
153
|
+
const zone = EDGE_ZONE_REM * remPx;
|
|
154
|
+
const fromLeft = x - left;
|
|
155
|
+
const fromRight = right - x;
|
|
156
|
+
if (fromLeft <= zone && fromLeft <= fromRight) return -EDGE_SCROLL_REM * remPx;
|
|
157
|
+
if (fromRight <= zone) return EDGE_SCROLL_REM * remPx;
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How far to scroll the box vertically after an Undo or a Redo, so the rows it touched show between the ruler and the velocity lane.
|
|
163
|
+
* `tops` are the screen tops of the rows to show (one per note, may repeat), `cursorTop` the top of the cursor row, `rowHeight` one row,
|
|
164
|
+
* and `visibleTop` and `visibleBottom` the band between the ruler and the lane. Returns `{ by, nearest }`: `by` is the pixels to add to
|
|
165
|
+
* the box's scroll (negative scrolls up), and `nearest` the index in `tops` of the row closest to the cursor row (null with no rows).
|
|
166
|
+
* When the cursor row and the nearest row cannot both fit in the band, `by` is null: the caller has to move the cursor.
|
|
167
|
+
* Everything that fits together is brought in with the smallest move; otherwise the cursor row goes to the band's edge that leaves the
|
|
168
|
+
* most room on the side of the nearest row.
|
|
169
|
+
*/
|
|
170
|
+
export function historyScroll({ tops, cursorTop, rowHeight, visibleTop, visibleBottom }) {
|
|
171
|
+
if (!(rowHeight > 0)) throw new Error(`Moontide: a history scroll needs a row height above 0, got ${String(rowHeight)}`);
|
|
172
|
+
if (!(visibleBottom > visibleTop)) throw new Error(`Moontide: a history scroll needs a visible band with a height, got ${String(visibleTop)} to ${String(visibleBottom)}`);
|
|
173
|
+
let nearest = null;
|
|
174
|
+
tops.forEach((top, index) => {
|
|
175
|
+
if (nearest === null || Math.abs(top - cursorTop) < Math.abs(tops[nearest] - cursorTop)) nearest = index;
|
|
176
|
+
});
|
|
177
|
+
const band = visibleBottom - visibleTop;
|
|
178
|
+
const spanTop = Math.min(cursorTop, ...tops);
|
|
179
|
+
const spanBottom = Math.max(cursorTop, ...tops) + rowHeight;
|
|
180
|
+
if (spanBottom - spanTop <= band) {
|
|
181
|
+
if (spanTop < visibleTop) return { by: spanTop - visibleTop, nearest };
|
|
182
|
+
if (spanBottom > visibleBottom) return { by: spanBottom - visibleBottom, nearest };
|
|
183
|
+
return { by: 0, nearest };
|
|
184
|
+
}
|
|
185
|
+
if (nearest === null) return { by: cursorTop - visibleTop, nearest };
|
|
186
|
+
const target = tops[nearest];
|
|
187
|
+
const pairTop = Math.min(cursorTop, target);
|
|
188
|
+
const pairBottom = Math.max(cursorTop, target) + rowHeight;
|
|
189
|
+
if (pairBottom - pairTop > band) return { by: null, nearest };
|
|
190
|
+
const cursorAtBottom = target < cursorTop || (target === cursorTop && tops.filter((top) => top < cursorTop).length > tops.filter((top) => top > cursorTop).length);
|
|
191
|
+
return { by: cursorAtBottom ? cursorTop + rowHeight - visibleBottom : cursorTop - visibleTop, nearest };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The width of the column of piano names at the left of the roll, in rem; the stylesheet draws the same width. */
|
|
195
|
+
export const GUTTER_REM = 4.5;
|
|
196
|
+
|
|
197
|
+
/** The smallest a beat gets while the motif's cycles are shown, in rem (a sixteenth is 1 rem); less room than that scrolls sideways. */
|
|
198
|
+
export const BEAT_MIN_REM = 4;
|
|
199
|
+
/** The smallest a beat gets while the live view shows 8 cycles, in rem: eight cycles need to fit more often than the motif's. */
|
|
200
|
+
export const LIVE_BEAT_MIN_REM = 2.5;
|
|
201
|
+
/** A row is held from 0.625 rem to 1.25 rem high. */
|
|
202
|
+
const ROW_REM_RANGE = [0.625, 1.25];
|
|
203
|
+
|
|
204
|
+
/** The width of a beat in rem: the room divided by the beats shown, never less than `minRem` and never capped, so the roll takes all its width. */
|
|
205
|
+
export function beatWidthRem({ availableRem, beats, minRem = BEAT_MIN_REM }) {
|
|
206
|
+
return Math.max(minRem, availableRem / beats);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The height of a row in rem: the room divided by the `rows` rows shown, held from 0.625 to 1.25 rem. More rows than fit at the least scroll. */
|
|
210
|
+
export function rowHeightRem({ availableRem, rows }) {
|
|
211
|
+
return clamp(availableRem / rows, ...ROW_REM_RANGE);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A bar of the velocity lane is at least this wide, with a little air on each side: the room one bar of a chord takes. */
|
|
215
|
+
export const BAR_SLOT_REM = 0.375;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The velocity a pointer at height `y` gives in a lane whose bars area is `rect` (`top` and `height`): the height above the lane's
|
|
219
|
+
* bottom as a share of its height, in whole hundredths, held from 1 (the bottom edge) to 100 (the top edge). A pointer past either edge is held there.
|
|
220
|
+
*/
|
|
221
|
+
export function velocityAt({ rect, y }) {
|
|
222
|
+
return clamp(Math.round(((rect.top + rect.height - y) / rect.height) * 100), 1, 100);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The note whose bar is under a pointer at `x`. A bar belongs to the column of its note's start, one grid step wide; notes that start
|
|
227
|
+
* together share the column side by side, and a bar's share is never narrower than `BAR_SLOT_REM`, so a crowded chord on a narrow step
|
|
228
|
+
* spreads to the right. `bars` lists `{ id, start, slot, slots }`, `rect` is the bars area (`left` and `width`) for `units` units, and
|
|
229
|
+
* `remPx` the root font size. Returns the id, or null where no bar is.
|
|
230
|
+
*/
|
|
231
|
+
export function barAt({ rect, x, bars, units, gridUnit, remPx }) {
|
|
232
|
+
const column = (gridUnit / units) * rect.width;
|
|
233
|
+
for (const bar of bars) {
|
|
234
|
+
const share = Math.max(BAR_SLOT_REM * remPx, column / bar.slots);
|
|
235
|
+
const left = rect.left + (bar.start / units) * rect.width + bar.slot * share;
|
|
236
|
+
if (x >= left && x < left + share) return bar.id;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|