@xromdev/notation 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +159 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/notation.d.ts +414 -0
- package/dist/notation.js +1050 -0
- package/dist/notation.js.map +1 -0
- package/dist/score.d.ts +475 -0
- package/dist/score.js +926 -0
- package/dist/score.js.map +1 -0
- package/package.json +57 -4
package/dist/score.js
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A track as a SCORE: each pitched channel on a grand staff, the noise channel
|
|
3
|
+
* on a percussion staff, and the dynamics its volumes imply.
|
|
4
|
+
*
|
|
5
|
+
* Built on `./notation`, so a bar here is spelled exactly the way a single
|
|
6
|
+
* scrolling staff spells it — same bar line, same phase, same
|
|
7
|
+
* note values. What this file adds is the part a page of music needs and a
|
|
8
|
+
* single strip does not: which of two staves a note goes on, and what the
|
|
9
|
+
* volume column says in the words a player reads.
|
|
10
|
+
*/
|
|
11
|
+
import { MEASURE_TICKS, TICKS_PER_QUARTER, barAt, barCountOf, barring, chooseClef, shiftNotes, staffItems, staffMeasures, voicePhase, } from './notation.js';
|
|
12
|
+
/**
|
|
13
|
+
* Middle C: in a beam group kept on the bass, the heads from here up still go
|
|
14
|
+
* onto the treble and the beam crosses to them — see `crossStaffVoice`.
|
|
15
|
+
*/
|
|
16
|
+
export const GRAND_STAFF_SPLIT = 60;
|
|
17
|
+
export function splitGrandStaff(notes, split = GRAND_STAFF_SPLIT) {
|
|
18
|
+
const treble = [];
|
|
19
|
+
const bass = [];
|
|
20
|
+
for (const note of notes)
|
|
21
|
+
(note.midi >= split ? treble : bass).push(note);
|
|
22
|
+
return { treble, bass };
|
|
23
|
+
}
|
|
24
|
+
/** A beam group that never goes below E3 — three ledger lines under the treble — is written on the treble. */
|
|
25
|
+
export const TREBLE_FLOOR = 52;
|
|
26
|
+
/** Durations beamed together, one group per beat — `beamsFor`'s set. */
|
|
27
|
+
const BEAMED = new Set(['8', '16', '32', '64']);
|
|
28
|
+
/**
|
|
29
|
+
* A grand-staff part as ONE voice, each item saying which of the two staves it
|
|
30
|
+
* stands on.
|
|
31
|
+
*
|
|
32
|
+
* Decided per BEAM GROUP — the beamed notes of one beat, or a note on its own —
|
|
33
|
+
* because a beam is what a reader follows:
|
|
34
|
+
*
|
|
35
|
+
* - a group that never goes below E3 (`TREBLE_FLOOR`) is on the treble, E3
|
|
36
|
+
* included. Ragnarok's canyon, pulse 1, bar 29, the third beat: A3 G3 C4 A3
|
|
37
|
+
* E4 C4 G4 E4 — a reader wanted it all on the treble.
|
|
38
|
+
* - a group that does go below E3 stays on the bass, E3s and all — bar 29's
|
|
39
|
+
* second beat, E3 G3 C3 E3…, which "would be ugly otherwise". Only its heads
|
|
40
|
+
* from middle C up cross to the treble, and the beam crosses with them — the
|
|
41
|
+
* piano's cross-staff writing, drawn as one beam between the
|
|
42
|
+
* staves with no rest on either side. Bar 30's lone G5 among B2s is that case.
|
|
43
|
+
*
|
|
44
|
+
* Every rule tried per BAR before this failed on a bar of this music: a bar
|
|
45
|
+
* that starts low and climbs three octaves has a bass half and a treble half,
|
|
46
|
+
* and the only choices a whole bar has are to tear its beams with rests or put
|
|
47
|
+
* half of it on four ledger lines.
|
|
48
|
+
*
|
|
49
|
+
* A piece tied on from the head before it stays where that head stood; a rest
|
|
50
|
+
* stands where the head before it in the bar did (else the first head after
|
|
51
|
+
* it), and a bar with no heads at all is the treble's, so the bass shows its
|
|
52
|
+
* own bar rest rather than a second one.
|
|
53
|
+
*/
|
|
54
|
+
export function crossStaffVoice(bars) {
|
|
55
|
+
let carried = 'treble';
|
|
56
|
+
/*
|
|
57
|
+
* A head whose beam runs on into the next bar (`crossBarRuns`) belongs to the group on the OTHER
|
|
58
|
+
* side of the bar line, not to the beat it sits in — so it is written after that group has
|
|
59
|
+
* chosen, and put on the same staff. Ragnarok's canyon, bar 25: the last E3 ends a beat of G4s
|
|
60
|
+
* and went to the treble with them, while the D3 E3 it is beamed to are on the bass, and the
|
|
61
|
+
* beam fell across the whole grand staff.
|
|
62
|
+
*/
|
|
63
|
+
let hanging = null;
|
|
64
|
+
const written = bars.map((items) => {
|
|
65
|
+
/*
|
|
66
|
+
* The beat a head stands on, counted from ITS OWN bar line — the bar's
|
|
67
|
+
* first item, since a bar is filled from its line. Counted with `%` off the
|
|
68
|
+
* piece's clock instead, as this once was, it is right only while every bar
|
|
69
|
+
* is the same length and the first starts on tick 0: Volkmire's Inferno
|
|
70
|
+
* Race, barred from tick 6 with an opening bar of 19/32, had the two halves
|
|
71
|
+
* of one arpeggio sorted into each other's beats, and the run came out torn
|
|
72
|
+
* between the staves.
|
|
73
|
+
*/
|
|
74
|
+
const barStart = items[0]?.start ?? 0;
|
|
75
|
+
const groups = new Map();
|
|
76
|
+
items.forEach((item, index) => {
|
|
77
|
+
if (item.midi === null)
|
|
78
|
+
return;
|
|
79
|
+
const key = BEAMED.has(item.duration)
|
|
80
|
+
? `beat${Math.floor((item.start - barStart) / TICKS_PER_QUARTER)}`
|
|
81
|
+
: `note${index}`;
|
|
82
|
+
groups.set(key, [...(groups.get(key) ?? []), index]);
|
|
83
|
+
});
|
|
84
|
+
const chosen = items.map(() => 'treble');
|
|
85
|
+
for (const members of groups.values()) {
|
|
86
|
+
const low = Math.min(...members.map((index) => items[index].midi));
|
|
87
|
+
for (const index of members) {
|
|
88
|
+
const midi = items[index].midi;
|
|
89
|
+
chosen[index] = low >= TREBLE_FLOOR || midi >= GRAND_STAFF_SPLIT ? 'treble' : 'bass';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
let previous = carried;
|
|
93
|
+
const heads = items.map((item, index) => {
|
|
94
|
+
if (item.midi === null)
|
|
95
|
+
return item;
|
|
96
|
+
const staff = item.tieStop ? previous : chosen[index];
|
|
97
|
+
previous = staff;
|
|
98
|
+
return { ...item, staff };
|
|
99
|
+
});
|
|
100
|
+
carried = previous;
|
|
101
|
+
const firstHead = heads.find((item) => item.midi !== null)?.staff ?? 'treble';
|
|
102
|
+
let before;
|
|
103
|
+
const bar = heads.map((item) => {
|
|
104
|
+
if (item.midi !== null) {
|
|
105
|
+
before = item.staff;
|
|
106
|
+
return item;
|
|
107
|
+
}
|
|
108
|
+
return { ...item, staff: before ?? firstHead };
|
|
109
|
+
});
|
|
110
|
+
const reached = bar.find((item) => item.across === 'join');
|
|
111
|
+
if (hanging && reached) {
|
|
112
|
+
for (const item of hanging)
|
|
113
|
+
item.staff = reached.staff;
|
|
114
|
+
carried = reached.staff ?? carried;
|
|
115
|
+
}
|
|
116
|
+
hanging = bar.filter((item) => item.across === 'start');
|
|
117
|
+
return bar;
|
|
118
|
+
});
|
|
119
|
+
return written;
|
|
120
|
+
}
|
|
121
|
+
/** The highest a part may reach and still be written on the bass staff alone: E4, two ledger lines up. */
|
|
122
|
+
export const BASS_ONLY_MAX = 64;
|
|
123
|
+
/** The lowest a part may reach and still be written on the treble staff alone: G3, two ledger lines down. */
|
|
124
|
+
export const TREBLE_ONLY_MIN = 55;
|
|
125
|
+
/**
|
|
126
|
+
* The staves a part needs: one when every note it plays sits within two ledger
|
|
127
|
+
* lines of a single staff, both otherwise.
|
|
128
|
+
*
|
|
129
|
+
* A grand staff whose other half is a whole track of rests is furniture: the
|
|
130
|
+
* triangle carries the bass in every one of the twenty tracks, and in
|
|
131
|
+
* Ragnarok's canyon it reaches D#4 at most — a ledger line and a half above the
|
|
132
|
+
* bass staff. Where both single staves would do, the part's middle decides.
|
|
133
|
+
*/
|
|
134
|
+
export function partStaves(notes) {
|
|
135
|
+
if (notes.length === 0)
|
|
136
|
+
return ['treble'];
|
|
137
|
+
let low = Infinity;
|
|
138
|
+
let high = -Infinity;
|
|
139
|
+
for (const note of notes) {
|
|
140
|
+
low = Math.min(low, note.midi);
|
|
141
|
+
high = Math.max(high, note.midi);
|
|
142
|
+
}
|
|
143
|
+
const bass = high <= BASS_ONLY_MAX;
|
|
144
|
+
const treble = low >= TREBLE_ONLY_MIN;
|
|
145
|
+
if (bass && treble)
|
|
146
|
+
return [chooseClef(notes)];
|
|
147
|
+
if (bass)
|
|
148
|
+
return ['bass'];
|
|
149
|
+
if (treble)
|
|
150
|
+
return ['treble'];
|
|
151
|
+
return ['treble', 'bass'];
|
|
152
|
+
}
|
|
153
|
+
const DYNAMICS = ['pp', 'p', 'mp', 'mf', 'f', 'ff'];
|
|
154
|
+
/**
|
|
155
|
+
* The APU's 0..15 volume in six steps.
|
|
156
|
+
*
|
|
157
|
+
* A READING, not a fact: the nibble is linear in output level and a dynamic is
|
|
158
|
+
* a word about loudness, so the steps are even in the nibble and nothing more
|
|
159
|
+
* is claimed for them.
|
|
160
|
+
*/
|
|
161
|
+
export function dynamicOf(volume) {
|
|
162
|
+
const level = Math.max(0, Math.min(15, Math.round(volume)));
|
|
163
|
+
return DYNAMICS[Math.min(DYNAMICS.length - 1, Math.floor((level * DYNAMICS.length) / 16))];
|
|
164
|
+
}
|
|
165
|
+
/** A change of level closer than this, in ticks, is folded into the mark before it. A quarter note. */
|
|
166
|
+
export const DYNAMIC_HOLD = 16;
|
|
167
|
+
/** Onset to onset, notes closer than this can make one shaped figure. An eighth note. */
|
|
168
|
+
export const FIGURE_GAP = 8;
|
|
169
|
+
/**
|
|
170
|
+
* And no closer than this, a sixteenth. Faster is one note's envelope struck
|
|
171
|
+
* again and again — Arctic Caverns' 8 4 2 1 in thirty-seconds on one pitch, an
|
|
172
|
+
* echo — not a figure a player shapes.
|
|
173
|
+
*/
|
|
174
|
+
export const FIGURE_STEP = 4;
|
|
175
|
+
/**
|
|
176
|
+
* Runs of at least three notes, each `FIGURE_STEP` to `FIGURE_GAP` after the one before, whose
|
|
177
|
+
* level moves one way every step and by two or more in all — the 4 3 2 1 under
|
|
178
|
+
* every beat of a fading figure. The six steps of `dynamicOf` cannot show that:
|
|
179
|
+
* 4 and 3 are both p, and the hold folds the pp of 2 and 1 back into it.
|
|
180
|
+
*
|
|
181
|
+
* `notes` must be in order of start.
|
|
182
|
+
*/
|
|
183
|
+
export function shapedFigures(notes, gap = FIGURE_GAP) {
|
|
184
|
+
const figures = [];
|
|
185
|
+
let from = 0;
|
|
186
|
+
while (from < notes.length - 2) {
|
|
187
|
+
const step = Math.sign(notes[from + 1].volume - notes[from].volume);
|
|
188
|
+
let to = from;
|
|
189
|
+
while (step !== 0 &&
|
|
190
|
+
to + 1 < notes.length &&
|
|
191
|
+
notes[to + 1].start - notes[to].start <= gap &&
|
|
192
|
+
notes[to + 1].start - notes[to].start >= FIGURE_STEP &&
|
|
193
|
+
Math.sign(notes[to + 1].volume - notes[to].volume) === step) {
|
|
194
|
+
to++;
|
|
195
|
+
}
|
|
196
|
+
if (to - from >= 2 && Math.abs(notes[to].volume - notes[from].volume) >= 2) {
|
|
197
|
+
figures.push({ from, to, words: step < 0 ? 'dim.' : 'cresc.' });
|
|
198
|
+
from = to + 1;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
from++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return figures;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Two figures `sim.` can stand for: the same levels in the same rhythm. Only
|
|
208
|
+
* that — ragnarok's 7 4 2 1 followed by a 7 4 2 is a figure of its own, with a
|
|
209
|
+
* hairpin of its own.
|
|
210
|
+
*/
|
|
211
|
+
function sameFigure(notes, a, b) {
|
|
212
|
+
if (a.to - a.from !== b.to - b.from)
|
|
213
|
+
return false;
|
|
214
|
+
for (let i = 0; i <= a.to - a.from; i++) {
|
|
215
|
+
if (notes[a.from + i].volume !== notes[b.from + i].volume)
|
|
216
|
+
return false;
|
|
217
|
+
if (i > 0 &&
|
|
218
|
+
notes[a.from + i].start - notes[a.from + i - 1].start !==
|
|
219
|
+
notes[b.from + i].start - notes[b.from + i - 1].start) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Where a voice's level changes, as the marks a score prints.
|
|
227
|
+
*
|
|
228
|
+
* Only at a change, and never two within `DYNAMIC_HOLD`: the driver can move a
|
|
229
|
+
* volume on every note, and a mark under every head is noise. Inside the hold
|
|
230
|
+
* the LOUDER level stands — the accent is what a reader has to see — and a
|
|
231
|
+
* change that comes straight back to the level before it is dropped.
|
|
232
|
+
*
|
|
233
|
+
* A shaped figure (`shapedFigures`) is written as its level and a hairpin under
|
|
234
|
+
* the first, `sim.` under the next one just like it, its dashes running on over
|
|
235
|
+
* the ones after that, until some other mark is printed. A figure that turns straight
|
|
236
|
+
* into one going the other way — wookie hole's 6 4 2 1 2 4 6 8 on one held
|
|
237
|
+
* note — is a level swaying, not a figure, and is read note by note as before.
|
|
238
|
+
* After a hairpin the reader is where it ends, so going back to where it began
|
|
239
|
+
* is written — ragnarok's mp over the 7 after 7 4 2 1 — unless a `sim.` that
|
|
240
|
+
* goes on already says the figure comes round again.
|
|
241
|
+
*/
|
|
242
|
+
export function dynamicMarks(notes, hold = DYNAMIC_HOLD) {
|
|
243
|
+
const sorted = [...notes].sort((a, b) => a.start - b.start);
|
|
244
|
+
const found = shapedFigures(sorted);
|
|
245
|
+
const swaying = (a, b) => !!a && !!b && b.from === a.to + 1 && a.words !== b.words;
|
|
246
|
+
const figureAt = new Map();
|
|
247
|
+
found.forEach((figure, i) => {
|
|
248
|
+
if (!swaying(found[i - 1], figure) && !swaying(figure, found[i + 1])) {
|
|
249
|
+
figureAt.set(figure.from, figure);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
const marks = [];
|
|
253
|
+
// The level the reader is at.
|
|
254
|
+
let heard = null;
|
|
255
|
+
// The figure the last words were about, and whether its `sim.` has been printed.
|
|
256
|
+
let stated = null;
|
|
257
|
+
let similar = false;
|
|
258
|
+
// A plain mark breaks a run of `sim.`; taken back, it gives the run back.
|
|
259
|
+
const broken = new Map();
|
|
260
|
+
const pop = () => {
|
|
261
|
+
const mark = marks.pop();
|
|
262
|
+
const run = broken.get(mark);
|
|
263
|
+
if (run)
|
|
264
|
+
({ stated, similar } = run);
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* The next figure after note `i` is one more like `like`, and nothing between says otherwise —
|
|
268
|
+
* Intruder Excluder's 6 6 before each 6 4 2 1 is the same figure coming round, not news.
|
|
269
|
+
*/
|
|
270
|
+
const starts = [...figureAt.keys()].sort((a, b) => a - b);
|
|
271
|
+
const simGoesOn = (i, like) => {
|
|
272
|
+
const next = starts.find((start) => start > i);
|
|
273
|
+
if (next === undefined || !sameFigure(sorted, like, figureAt.get(next)))
|
|
274
|
+
return false;
|
|
275
|
+
const level = dynamicOf(sorted[i].volume);
|
|
276
|
+
for (let k = i + 1; k < next; k++)
|
|
277
|
+
if (dynamicOf(sorted[k].volume) !== level)
|
|
278
|
+
return false;
|
|
279
|
+
return true;
|
|
280
|
+
};
|
|
281
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
282
|
+
const note = sorted[i];
|
|
283
|
+
const dynamic = dynamicOf(note.volume);
|
|
284
|
+
const figure = figureAt.get(i);
|
|
285
|
+
const last = marks[marks.length - 1];
|
|
286
|
+
if (figure) {
|
|
287
|
+
// `p`, and a beat later `mp` and a hairpin: the figure's mark says it, as the louder.
|
|
288
|
+
// At the same level the mark stays and the hairpin goes on from it, bare.
|
|
289
|
+
if (last &&
|
|
290
|
+
!last.words &&
|
|
291
|
+
note.start - last.tick < hold &&
|
|
292
|
+
DYNAMICS.indexOf(last.dynamic) < DYNAMICS.indexOf(dynamic)) {
|
|
293
|
+
pop();
|
|
294
|
+
// Its level is now the figure's to state.
|
|
295
|
+
heard = marks[marks.length - 1]?.dynamic ?? null;
|
|
296
|
+
}
|
|
297
|
+
const until = sorted[figure.to].start;
|
|
298
|
+
if (stated && sameFigure(sorted, stated, figure)) {
|
|
299
|
+
if (similar)
|
|
300
|
+
marks[marks.length - 1].until = until;
|
|
301
|
+
else
|
|
302
|
+
marks.push({ tick: note.start, dynamic, words: 'sim.', until });
|
|
303
|
+
similar = true;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const mark = { tick: note.start, dynamic, words: figure.words, until };
|
|
307
|
+
if (heard === dynamic)
|
|
308
|
+
mark.bare = true;
|
|
309
|
+
marks.push(mark);
|
|
310
|
+
stated = figure;
|
|
311
|
+
similar = false;
|
|
312
|
+
}
|
|
313
|
+
// The hairpin has taken the reader down (or up) to where the figure ends.
|
|
314
|
+
heard = dynamicOf(sorted[figure.to].volume);
|
|
315
|
+
i = figure.to;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (heard === dynamic)
|
|
319
|
+
continue;
|
|
320
|
+
// Back at the level of a run of `sim.` that goes on: the `sim.` says so already.
|
|
321
|
+
if (stated && dynamic === dynamicOf(sorted[stated.from].volume) && simGoesOn(i, stated)) {
|
|
322
|
+
heard = dynamic;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
heard = dynamic;
|
|
326
|
+
if (last && !last.words && note.start - last.tick < hold) {
|
|
327
|
+
if (DYNAMICS.indexOf(dynamic) > DYNAMICS.indexOf(last.dynamic))
|
|
328
|
+
last.dynamic = dynamic;
|
|
329
|
+
if (marks[marks.length - 2]?.dynamic === last.dynamic)
|
|
330
|
+
pop();
|
|
331
|
+
heard = marks[marks.length - 1].dynamic;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const mark = { tick: note.start, dynamic };
|
|
335
|
+
broken.set(mark, { stated, similar });
|
|
336
|
+
stated = null;
|
|
337
|
+
marks.push(mark);
|
|
338
|
+
}
|
|
339
|
+
return marks;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Where each NES noise period (`$400E`, 0..15) sits on the percussion staff:
|
|
343
|
+
* high noise at the top and low noise at the bottom.
|
|
344
|
+
*/
|
|
345
|
+
const NOISE_LINES = [
|
|
346
|
+
'g/5',
|
|
347
|
+
'g/5',
|
|
348
|
+
'g/5',
|
|
349
|
+
'f/5',
|
|
350
|
+
'f/5',
|
|
351
|
+
'e/5',
|
|
352
|
+
'e/5',
|
|
353
|
+
'd/5',
|
|
354
|
+
'd/5',
|
|
355
|
+
'c/5',
|
|
356
|
+
'b/4',
|
|
357
|
+
'a/4',
|
|
358
|
+
'g/4',
|
|
359
|
+
'f/4',
|
|
360
|
+
'e/4',
|
|
361
|
+
'e/4',
|
|
362
|
+
];
|
|
363
|
+
/**
|
|
364
|
+
* The notehead: a cross for a closed or pedal hat, a circled cross for an open
|
|
365
|
+
* one, a plain head for everything with a skin. The pieces are the ones
|
|
366
|
+
* `@xromdev/drumroll` draws on its kit (`NOISE_DRUM_KINDS`).
|
|
367
|
+
*/
|
|
368
|
+
const NOISE_HEADS = ['x', 'x', 'x', 'x', 'cx', 'cx', '', '', '', '', '', '', '', '', '', ''];
|
|
369
|
+
/** A noise period as a VexFlow key on the percussion staff. Only the low nibble is the period. */
|
|
370
|
+
export function noiseKey(period) {
|
|
371
|
+
const index = period & 0x0f;
|
|
372
|
+
return NOISE_HEADS[index] ? `${NOISE_LINES[index]}/${NOISE_HEADS[index]}` : NOISE_LINES[index];
|
|
373
|
+
}
|
|
374
|
+
/** A vibrato swinging further than this, in semitones, is drawn as its curve rather than a wave. */
|
|
375
|
+
export const WIDE_VIBRATO = 1;
|
|
376
|
+
/**
|
|
377
|
+
* A movement smaller than this, in semitones, is not written at all.
|
|
378
|
+
*
|
|
379
|
+
* 261 of the cartridge's 304 curves are a drift of a fifth of a semitone at the
|
|
380
|
+
* end of a note. The roll draws them as a hairline, which is right for a picture
|
|
381
|
+
* of the sound; on a staff a mark on every one of them would be noise over a
|
|
382
|
+
* third of the notes of some tracks.
|
|
383
|
+
*/
|
|
384
|
+
export const DRIFT_LIMIT = 0.5;
|
|
385
|
+
/** Changes of direction that make a curve a vibrato rather than a slide. */
|
|
386
|
+
const VIBRATO_TURNS = 3;
|
|
387
|
+
/** A swing back smaller than this, in semitones, is the curve's own jitter, not a change of direction. */
|
|
388
|
+
const TURN_MIN = 0.25;
|
|
389
|
+
/** The shallowest vibrato written, in semitones either way: Surf City's is a tenth. */
|
|
390
|
+
const LIGHT_VIBRATO = 0.09;
|
|
391
|
+
/**
|
|
392
|
+
* How many times a curve really changes direction.
|
|
393
|
+
*
|
|
394
|
+
* Counted with hysteresis: a move only turns once the curve has come back
|
|
395
|
+
* `TURN_MIN` from the furthest point it reached. Counting every sign change of
|
|
396
|
+
* the slope read ragnarok's canyon's triangle, bar 15 — a flicker of 0.05
|
|
397
|
+
* semitones and then a steady fall of five to a B — as a vibrato, and it was
|
|
398
|
+
* drawn as a wave where a reader sees a glissando.
|
|
399
|
+
*/
|
|
400
|
+
function turnsOf(values, turnMin = TURN_MIN) {
|
|
401
|
+
let turns = 0;
|
|
402
|
+
let direction = 0;
|
|
403
|
+
let pivot = values[0] ?? 0;
|
|
404
|
+
for (const value of values) {
|
|
405
|
+
const move = value - pivot;
|
|
406
|
+
if (direction === 0) {
|
|
407
|
+
if (Math.abs(move) >= turnMin) {
|
|
408
|
+
direction = Math.sign(move);
|
|
409
|
+
pivot = value;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else if (Math.sign(move) === direction) {
|
|
413
|
+
pivot = value;
|
|
414
|
+
}
|
|
415
|
+
else if (Math.abs(move) >= turnMin) {
|
|
416
|
+
turns++;
|
|
417
|
+
direction = -direction;
|
|
418
|
+
pivot = value;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return turns;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* A note's pitch curve read as notation: a slide to where it arrives, a
|
|
425
|
+
* vibrato, or nothing.
|
|
426
|
+
*
|
|
427
|
+
* Over the twenty tracks that is 33 slides (one to four semitones), 10 vibratos,
|
|
428
|
+
* and the drifts left out — the same split the Battletoads music tooling
|
|
429
|
+
* makes between a slide and a vibrato.
|
|
430
|
+
*/
|
|
431
|
+
/** How far into a note a strike may reach and still be read as its attack, 0..1. */
|
|
432
|
+
/**
|
|
433
|
+
* Alien 3's kick takes three frames of a seventeen-frame bass note — 18% — and at 15% the
|
|
434
|
+
* vibrato after it was still read with the kick in it.
|
|
435
|
+
*/
|
|
436
|
+
export const STRIKE_SHARE = 0.25;
|
|
437
|
+
/**
|
|
438
|
+
* The curve without the strike it opens with, if it opens with one: a pitch
|
|
439
|
+
* away from the note at its first point that comes back to it within
|
|
440
|
+
* `STRIKE_SHARE` of the note. That is how a sound driver hits a note — a
|
|
441
|
+
* triangle bass kicked from an octave up, a frame of a deep thump — and it is
|
|
442
|
+
* timbre, not a movement of the pitch: Alien 3's vibrato on a long G3 read six
|
|
443
|
+
* semitones deep with the kick in front of it, and was drawn as a curve.
|
|
444
|
+
*/
|
|
445
|
+
function withoutStrike(curve) {
|
|
446
|
+
if (Math.abs(curve[0][1]) < DRIFT_LIMIT)
|
|
447
|
+
return curve;
|
|
448
|
+
const back = curve.findIndex(([, semitones]) => Math.abs(semitones) < 0.05);
|
|
449
|
+
if (back <= 0 || curve[back][0] > STRIKE_SHARE)
|
|
450
|
+
return curve;
|
|
451
|
+
return curve.slice(back);
|
|
452
|
+
}
|
|
453
|
+
export function pitchEffect(whole, ticks) {
|
|
454
|
+
const curve = whole.length >= 2 ? withoutStrike(whole) : whole;
|
|
455
|
+
if (curve.length < 2)
|
|
456
|
+
return null;
|
|
457
|
+
const values = curve.map(([, semitones]) => semitones);
|
|
458
|
+
const peak = Math.max(...values.map(Math.abs));
|
|
459
|
+
if (peak < DRIFT_LIMIT) {
|
|
460
|
+
/*
|
|
461
|
+
* Under half a semitone a curve is not written — unless it SWINGS. Surf City wobbles a tenth of
|
|
462
|
+
* a semitone and Intermission a sixth, both audible; wookie hole's
|
|
463
|
+
* and ragnarok's marks, which a looser rule also calls vibrato, are one-way drifts at the end
|
|
464
|
+
* of a two-tick note and stay unwritten. Turns tell the two apart, counted against the curve's
|
|
465
|
+
* OWN depth: a fixed grain either misses a tenth-of-a-semitone swing or counts a drift's jitter.
|
|
466
|
+
*/
|
|
467
|
+
return peak >= LIGHT_VIBRATO && turnsOf(values, peak / 2) >= VIBRATO_TURNS
|
|
468
|
+
? { kind: 'vibrato', depth: peak, ticks }
|
|
469
|
+
: null;
|
|
470
|
+
}
|
|
471
|
+
if (turnsOf(values) >= VIBRATO_TURNS) {
|
|
472
|
+
const depth = Math.max(...values.map(Math.abs));
|
|
473
|
+
return depth > WIDE_VIBRATO
|
|
474
|
+
? { kind: 'vibrato', depth, ticks, curve }
|
|
475
|
+
: { kind: 'vibrato', depth, ticks };
|
|
476
|
+
}
|
|
477
|
+
const end = values[values.length - 1];
|
|
478
|
+
if (Math.abs(end) < DRIFT_LIMIT)
|
|
479
|
+
return null;
|
|
480
|
+
const moving = values.findIndex((semitones) => Math.abs(semitones) >= 0.1);
|
|
481
|
+
const from = curve[Math.max(0, moving - 1)][0];
|
|
482
|
+
return { kind: 'slide', semitones: Math.round(end * 10) / 10, from, ticks };
|
|
483
|
+
}
|
|
484
|
+
/** The most a note's pitch may move on its own and still be a bend: a tone and a half. */
|
|
485
|
+
export const BEND_MAX = 3;
|
|
486
|
+
/** Ticks between a slide's end and the next note that still count as the slide arriving in it. */
|
|
487
|
+
const GLISS_GAP = 1;
|
|
488
|
+
/**
|
|
489
|
+
* Each note's effect, read against the note AFTER it in the part.
|
|
490
|
+
*
|
|
491
|
+
* A curve does not say whether the pitch is sliding somewhere or bending on the
|
|
492
|
+
* spot; the music around it does. A slide that ends on the pitch the very next
|
|
493
|
+
* note is written at arrives in that note — a glissando, as in the title music's
|
|
494
|
+
* G4 up to A4. One that goes nowhere written is the note itself bending:
|
|
495
|
+
* ragnarok's canyon, pulse 1, bar 5, a D#4 pulled up 1.3 semitones and then left
|
|
496
|
+
* for an A3 — a guitarist's bend, which a reader said at once, where "gliss." had
|
|
497
|
+
* been printed. Past `BEND_MAX` nothing bends that far, and the sweeps of fifteen
|
|
498
|
+
* to twenty-eight semitones that open a few tracks stay lines.
|
|
499
|
+
*
|
|
500
|
+
* Except when the note after it comes straight back to the pitch the curve left:
|
|
501
|
+
* that is a bend and its release, however far it went. Clinger Winger, pulse 1,
|
|
502
|
+
* an E2 pushed up six and a half semitones over four bars and then struck again
|
|
503
|
+
* as E2 — a reader heard a bend where a line marked "gliss." had been drawn.
|
|
504
|
+
* Across the whole ROM this reads that one note differently and no other.
|
|
505
|
+
*/
|
|
506
|
+
export function effectsOf(notes) {
|
|
507
|
+
const ordered = [...notes].sort((a, b) => a.start - b.start);
|
|
508
|
+
const effects = new Map();
|
|
509
|
+
ordered.forEach((note, index) => {
|
|
510
|
+
const effect = note.bend ? pitchEffect(note.bend, note.ticks) : null;
|
|
511
|
+
if (!effect)
|
|
512
|
+
return;
|
|
513
|
+
if (effect.kind === 'slide') {
|
|
514
|
+
const next = ordered[index + 1];
|
|
515
|
+
const follows = next !== undefined && next.start - (note.start + note.ticks) <= GLISS_GAP;
|
|
516
|
+
const arrives = follows && Math.abs(next.midi - (note.midi + effect.semitones)) <= 0.5;
|
|
517
|
+
const returns = follows && next.midi === note.midi;
|
|
518
|
+
if (!arrives && (returns || Math.abs(effect.semitones) <= BEND_MAX)) {
|
|
519
|
+
effects.set(note.start, { ...effect, kind: 'bend' });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
effects.set(note.start, { ...effect, arrives });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
effects.set(note.start, effect);
|
|
526
|
+
});
|
|
527
|
+
return effects;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* A bend's size, in semitones, to one decimal — the arrow says which way.
|
|
531
|
+
*
|
|
532
|
+
* Always the number. The guitar's words (½ for a semitone, "full" for a tone)
|
|
533
|
+
* were printed for sizes near a step and the number for the rest, so one page
|
|
534
|
+
* said "½" over a bend of 0.9 semitones and "1.3" over the next: two systems
|
|
535
|
+
* side by side, and a "½" read as half a semitone.
|
|
536
|
+
*/
|
|
537
|
+
export function bendLabel(semitones) {
|
|
538
|
+
return Math.abs(semitones).toFixed(1);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* How much louder than BOTH its neighbours a note has to start to be written as
|
|
542
|
+
* accented, in nibble steps.
|
|
543
|
+
*
|
|
544
|
+
* Three is the step the tracks themselves use: the driver holds a level and
|
|
545
|
+
* drops or lifts the next note by 3 — ragnarok's canyon, bar 25, alternates 4
|
|
546
|
+
* and 7 through the bar, and 7 against 4 is what the ear hears as the accent.
|
|
547
|
+
*/
|
|
548
|
+
export const ACCENT_STEP = 3;
|
|
549
|
+
/**
|
|
550
|
+
* The notes a voice leans on: louder than the note before it by `ACCENT_STEP`,
|
|
551
|
+
* no quieter than the note after it, and NOT on a beat.
|
|
552
|
+
*
|
|
553
|
+
* Off the beat is the whole point. The driver moves the volume constantly, and
|
|
554
|
+
* a mark on every rise would put one under a third of the heads of some tracks
|
|
555
|
+
* while saying nothing a reader cannot see from the dynamics. What a reader
|
|
556
|
+
* cannot see is a voice that leans between the beats: ragnarok's canyon, bar 25,
|
|
557
|
+
* plays sixteenths at 9 9 7 7 4 7 4 7 4 2 7 7 4 7 4 9 — every beat of the bar
|
|
558
|
+
* but the first is a 4, and the 7s fall in the gaps. On paper that is an even
|
|
559
|
+
* run of sixteenths; in the ears it is a syncopation, and accents are what make
|
|
560
|
+
* the two agree.
|
|
561
|
+
*
|
|
562
|
+
* Read on the notes as the staff has them — after `voicePhase` has taken out a
|
|
563
|
+
* channel's authored delay — or a voice written a tick late would count as
|
|
564
|
+
* off the beat throughout.
|
|
565
|
+
*/
|
|
566
|
+
export function accentedStarts(notes, beat = TICKS_PER_QUARTER, step = ACCENT_STEP) {
|
|
567
|
+
const sounded = [...notes]
|
|
568
|
+
.filter((note) => note.volume !== undefined)
|
|
569
|
+
.sort((a, b) => a.start - b.start);
|
|
570
|
+
const starts = new Set();
|
|
571
|
+
sounded.forEach((note, index) => {
|
|
572
|
+
const before = sounded[index - 1];
|
|
573
|
+
const after = sounded[index + 1];
|
|
574
|
+
if (!before || !after)
|
|
575
|
+
return;
|
|
576
|
+
if (note.start % beat === 0)
|
|
577
|
+
return;
|
|
578
|
+
if (note.volume - before.volume < step)
|
|
579
|
+
return;
|
|
580
|
+
if (note.volume < after.volume)
|
|
581
|
+
return;
|
|
582
|
+
starts.add(note.start);
|
|
583
|
+
});
|
|
584
|
+
return starts;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Beam groups that follow the ACCENTS rather than the beat, for a passage that
|
|
588
|
+
* is written in one pulse and played in another.
|
|
589
|
+
*
|
|
590
|
+
* Ragnarok's canyon from bar 25 is the case, and it is a case, not a rule:
|
|
591
|
+
* beats 2, 3 and 4 of every bar sit at volume 4 while the notes between them
|
|
592
|
+
* are at 7, so beaming by the quarter draws four even groups over a bar whose
|
|
593
|
+
* weight lands in five uneven ones — 3 2 3 3 2, each beginning on a note the
|
|
594
|
+
* ROM leans on. Tried as a general rule over the cartridge it made beams of 14,
|
|
595
|
+
* 16 and 32 heads elsewhere (wookie hole, Arctic Caverns), which is why the
|
|
596
|
+
* passage asks for it by tick.
|
|
597
|
+
*
|
|
598
|
+
* Numbers a group per head from `from` on: a new one at every accent, and one
|
|
599
|
+
* after the bar's LOUDEST head when what follows is quieter — the figure's own
|
|
600
|
+
* peak, which ends a group rather than joining the run under it. Bar 25 is the
|
|
601
|
+
* one that needs the second rule: bars 26 to 28 open on the two heads a beam
|
|
602
|
+
* has already carried over the bar line, while 25 opens on the same pair with
|
|
603
|
+
* nothing to carry them, and without it they were swallowed by the five heads
|
|
604
|
+
* up to the first accent.
|
|
605
|
+
*/
|
|
606
|
+
export function accentBeamGroups(bars,
|
|
607
|
+
/** The passage the rule covers, in the staff's ticks: `[from, to)`. */
|
|
608
|
+
{ from, to },
|
|
609
|
+
/** Each head's volume by its start tick — the levels the groups are read from. */
|
|
610
|
+
levels) {
|
|
611
|
+
let group = 0;
|
|
612
|
+
return bars.map((items) => {
|
|
613
|
+
const within = (item) => item.start >= from && item.start < to;
|
|
614
|
+
const heads = items.filter((item) => item.midi !== null && within(item));
|
|
615
|
+
/*
|
|
616
|
+
* A voice with nothing to lean on keeps the beat. The rule has cuts only where a voice pushes,
|
|
617
|
+
* and the triangle holds one level right through this passage: numbered along with the others
|
|
618
|
+
* it had no cut at all, and its bar came out under a single beam from end to end.
|
|
619
|
+
*/
|
|
620
|
+
if (!heads.some((item) => item.accent))
|
|
621
|
+
return items;
|
|
622
|
+
const peak = Math.max(...heads.map((item) => levels.get(item.start) ?? 0), 0);
|
|
623
|
+
let previous = null;
|
|
624
|
+
return items.map((item) => {
|
|
625
|
+
if (!within(item) || item.midi === null)
|
|
626
|
+
return item;
|
|
627
|
+
const level = levels.get(item.start);
|
|
628
|
+
if (item.accent || (previous !== null && previous >= peak && (level ?? 0) < previous))
|
|
629
|
+
group++;
|
|
630
|
+
previous = level ?? previous;
|
|
631
|
+
return { ...item, beamGroup: group };
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/** The most notes a beam is carried across a bar line onto — the figure, and no more. */
|
|
636
|
+
export const CROSS_BAR_RUN = 4;
|
|
637
|
+
/**
|
|
638
|
+
* Figures a bar line cuts in half: a run that starts on the bar's last note and
|
|
639
|
+
* carries the same level into the next bar.
|
|
640
|
+
*
|
|
641
|
+
* Beams do not cross bar lines, and for good reason — the bar line is what a
|
|
642
|
+
* reader counts from. The exception earns itself when the figure is plainly one
|
|
643
|
+
* gesture and the bar line falls inside it: ragnarok's canyon, bars 25 to 28,
|
|
644
|
+
* ends each bar on an accented E3 and opens the next with D3 E3 at the same
|
|
645
|
+
* level 9, the run around it sitting at 7 and 4. Three heads, one gesture, and
|
|
646
|
+
* written bar by bar it reads as a stray sixteenth and a pair.
|
|
647
|
+
*
|
|
648
|
+
* Narrow on purpose: the last note must be ACCENTED (see `accentedStarts`), the
|
|
649
|
+
* run must be unbroken, every note must hold the same volume, and it stops after
|
|
650
|
+
* `CROSS_BAR_RUN` heads. Over the whole cartridge that is three places, all of
|
|
651
|
+
* them this figure. Loosened to "the level holds" alone it caught 148, some of
|
|
652
|
+
* them 240 notes long — a beam over half a track.
|
|
653
|
+
*
|
|
654
|
+
* Returns the start tick of each note in a crossing run, mapped to its part in
|
|
655
|
+
* it: the `start` is the note the beam leaves the bar on, the `join`s are the
|
|
656
|
+
* heads it reaches on the other side.
|
|
657
|
+
*/
|
|
658
|
+
export function crossBarRuns(notes, grid, accents = accentedStarts(notes)) {
|
|
659
|
+
const sounded = [...notes]
|
|
660
|
+
.filter((note) => note.volume !== undefined)
|
|
661
|
+
.sort((a, b) => a.start - b.start);
|
|
662
|
+
const runs = new Map();
|
|
663
|
+
sounded.forEach((note, index) => {
|
|
664
|
+
if (note.ticks >= TICKS_PER_QUARTER || !accents.has(note.start))
|
|
665
|
+
return;
|
|
666
|
+
const next = sounded[index + 1];
|
|
667
|
+
if (!next || next.start !== note.start + note.ticks)
|
|
668
|
+
return;
|
|
669
|
+
if (barAt(grid, next.start) === barAt(grid, note.start))
|
|
670
|
+
return;
|
|
671
|
+
if (next.volume !== note.volume)
|
|
672
|
+
return;
|
|
673
|
+
const chain = [note];
|
|
674
|
+
for (let at = index + 1; at < sounded.length && chain.length < CROSS_BAR_RUN; at++) {
|
|
675
|
+
const head = sounded[at];
|
|
676
|
+
const before = chain[chain.length - 1];
|
|
677
|
+
if (head.start !== before.start + before.ticks || head.volume !== note.volume)
|
|
678
|
+
break;
|
|
679
|
+
chain.push(head);
|
|
680
|
+
}
|
|
681
|
+
if (chain.length < 2)
|
|
682
|
+
return;
|
|
683
|
+
runs.set(note.start, 'start');
|
|
684
|
+
for (const head of chain.slice(1))
|
|
685
|
+
runs.set(head.start, 'join');
|
|
686
|
+
});
|
|
687
|
+
return runs;
|
|
688
|
+
}
|
|
689
|
+
/** Whether an effect is drawn along its note — and so has to carry on where the note does. */
|
|
690
|
+
export function drawnAlong(effect, measureTicks = MEASURE_TICKS) {
|
|
691
|
+
return (effect.kind === 'slide' ||
|
|
692
|
+
(effect.kind === 'bend' && effect.ticks >= measureTicks) ||
|
|
693
|
+
(effect.kind === 'vibrato' && effect.curve !== undefined));
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Puts what each note carries on its HEAD — the first piece of it, never a
|
|
697
|
+
* piece tied on after a bar line, which would mark one note twice: its effect,
|
|
698
|
+
* grace notes, accent, echo, level, and its part in a beam across the bar line.
|
|
699
|
+
* The pieces tied on after a head get a `carry` instead, when the effect is
|
|
700
|
+
* drawn along the note.
|
|
701
|
+
*
|
|
702
|
+
* In place, and in one walk: the bars are the ones `buildScore` has just made,
|
|
703
|
+
* and copying every head once for each thing it might carry was a fifth of the
|
|
704
|
+
* time a score took to work out. The marks go on in the order above, so a
|
|
705
|
+
* head's fields read the same whichever of them it has.
|
|
706
|
+
*/
|
|
707
|
+
function attachHeads(bars, marks) {
|
|
708
|
+
const effects = marks.effects?.size ? marks.effects : null;
|
|
709
|
+
const graces = marks.graces?.size ? marks.graces : null;
|
|
710
|
+
const accents = marks.accents?.size ? marks.accents : null;
|
|
711
|
+
const echoes = marks.echoes?.size ? marks.echoes : null;
|
|
712
|
+
const levels = marks.levels?.size ? marks.levels : null;
|
|
713
|
+
const across = marks.across?.size ? marks.across : null;
|
|
714
|
+
const staccatos = marks.staccatos?.size ? marks.staccatos : null;
|
|
715
|
+
const chords = marks.chords?.size ? marks.chords : null;
|
|
716
|
+
if (!effects && !graces && !accents && !echoes && !levels && !across && !staccatos && !chords) {
|
|
717
|
+
return bars;
|
|
718
|
+
}
|
|
719
|
+
let active = null;
|
|
720
|
+
for (const items of bars) {
|
|
721
|
+
for (const plain of items) {
|
|
722
|
+
const item = plain;
|
|
723
|
+
const head = item.midi !== null && !item.tieStop;
|
|
724
|
+
if (effects) {
|
|
725
|
+
if (head && effects.has(item.start)) {
|
|
726
|
+
const effect = effects.get(item.start);
|
|
727
|
+
active = drawnAlong(effect) ? { start: item.start, midi: item.midi, effect } : null;
|
|
728
|
+
item.effect = effect;
|
|
729
|
+
}
|
|
730
|
+
else if (active && item.tieStop && item.midi === active.midi) {
|
|
731
|
+
item.carry = active;
|
|
732
|
+
}
|
|
733
|
+
else if (!item.tieStop) {
|
|
734
|
+
active = null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (!head)
|
|
738
|
+
continue;
|
|
739
|
+
if (graces?.has(item.start))
|
|
740
|
+
item.grace = graces.get(item.start);
|
|
741
|
+
if (accents?.has(item.start))
|
|
742
|
+
item.accent = true;
|
|
743
|
+
if (echoes?.has(item.start))
|
|
744
|
+
item.echo = true;
|
|
745
|
+
if (levels?.has(item.start))
|
|
746
|
+
item.volume = levels.get(item.start);
|
|
747
|
+
if (across?.has(item.start))
|
|
748
|
+
item.across = across.get(item.start);
|
|
749
|
+
if (staccatos?.has(item.start))
|
|
750
|
+
item.staccato = true;
|
|
751
|
+
if (chords?.has(item.start))
|
|
752
|
+
item.chord = chords.get(item.start);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return bars;
|
|
756
|
+
}
|
|
757
|
+
/** Each note's own APU level by where it starts — what `attachHeads` puts on its head. */
|
|
758
|
+
function levelsOf(notes) {
|
|
759
|
+
const levels = new Map();
|
|
760
|
+
for (const note of notes)
|
|
761
|
+
if (note.volume !== undefined)
|
|
762
|
+
levels.set(note.start, note.volume);
|
|
763
|
+
return levels;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* The key each bar of a score is written in.
|
|
767
|
+
*
|
|
768
|
+
* A change takes effect from the bar its tick falls in — the tick is moved onto
|
|
769
|
+
* the staff's clock by `shift` first — so it stays with the music when the bar
|
|
770
|
+
* line or the metre is read differently. Volkmire's Inferno Race turns from D
|
|
771
|
+
* minor to D major on tick 870, which is bar 29 of its 2/4 score.
|
|
772
|
+
*/
|
|
773
|
+
export function barKeys(barCount, shift, grid, opening, changes = []) {
|
|
774
|
+
const names = Array.from({ length: barCount }, () => opening);
|
|
775
|
+
for (const change of changes) {
|
|
776
|
+
const from = Math.max(0, barAt(grid, change.tick + shift));
|
|
777
|
+
for (let bar = from; bar < names.length; bar++)
|
|
778
|
+
names[bar] = change.key;
|
|
779
|
+
}
|
|
780
|
+
return names;
|
|
781
|
+
}
|
|
782
|
+
/** The part id the percussion staff carries. */
|
|
783
|
+
export const DRUMS_PART = 'drums';
|
|
784
|
+
/**
|
|
785
|
+
* The whole score, worked out once.
|
|
786
|
+
*
|
|
787
|
+
* `options.offset` is the tick the bar lines are counted from and
|
|
788
|
+
* `options.measureTicks` the length of a bar; any other notation view of the
|
|
789
|
+
* same piece should use the same two, so they all bar the music the same way.
|
|
790
|
+
*/
|
|
791
|
+
export function buildScore(parts, drums, totalTicks, { offset = 0, measureTicks = MEASURE_TICKS, mergePickup = false, accentBeams, oddBars = [], alignVoices = true, } = {}) {
|
|
792
|
+
const { grid, shift } = barring(offset, measureTicks, mergePickup, oddBars);
|
|
793
|
+
const end = totalTicks + shift;
|
|
794
|
+
const barCount = barCountOf(grid, end);
|
|
795
|
+
const barsOf = (notes) => staffMeasures(staffItems(notes, end, grid), end, grid).map((bar) => bar.items);
|
|
796
|
+
const phases = {};
|
|
797
|
+
const moved = (notes, id) => {
|
|
798
|
+
const phase = alignVoices && notes.length > 0 ? voicePhase(notes, offset, measureTicks) : 0;
|
|
799
|
+
if (id !== undefined)
|
|
800
|
+
phases[id] = phase;
|
|
801
|
+
return shiftNotes(notes, shift - phase);
|
|
802
|
+
};
|
|
803
|
+
const staves = [];
|
|
804
|
+
const dynamics = {};
|
|
805
|
+
for (const part of parts) {
|
|
806
|
+
/*
|
|
807
|
+
* Notes struck together are one head with a chord on its stem: the highest — the line a
|
|
808
|
+
* reader follows — is the head the rhythm, the staff and the marks are read from, and it
|
|
809
|
+
* holds as long as the longest of them. A part with one note at a time is untouched.
|
|
810
|
+
*/
|
|
811
|
+
const together = new Map();
|
|
812
|
+
for (const note of part.notes)
|
|
813
|
+
together.set(note.start, [...(together.get(note.start) ?? []), note]);
|
|
814
|
+
const groups = [...together.values()];
|
|
815
|
+
const leads = groups.map((group) => group.length === 1
|
|
816
|
+
? group[0]
|
|
817
|
+
: {
|
|
818
|
+
...group.reduce((top, note) => (note.midi > top.midi ? note : top)),
|
|
819
|
+
ticks: Math.max(...group.map((note) => note.ticks)),
|
|
820
|
+
});
|
|
821
|
+
const notes = moved(leads, part.id);
|
|
822
|
+
const pitchChords = new Map();
|
|
823
|
+
groups.forEach((group, index) => {
|
|
824
|
+
if (group.length < 2)
|
|
825
|
+
return;
|
|
826
|
+
const others = [...new Set(group.map((note) => note.midi))]
|
|
827
|
+
.filter((midi) => midi !== leads[index].midi)
|
|
828
|
+
.sort((a, b) => a - b);
|
|
829
|
+
if (others.length)
|
|
830
|
+
pitchChords.set(notes[index].start, others);
|
|
831
|
+
});
|
|
832
|
+
const range = pitchChords.size
|
|
833
|
+
? [...notes, ...[...pitchChords.values()].flat().map((midi) => ({ midi }))]
|
|
834
|
+
: notes;
|
|
835
|
+
const kinds = part.staves ? [...part.staves] : partStaves(range);
|
|
836
|
+
// Read over the whole part: the note after a slide may be on the other staff.
|
|
837
|
+
const effects = effectsOf(notes);
|
|
838
|
+
/*
|
|
839
|
+
* Accents only inside the passage read by its accents (`accentBeams`) — a
|
|
840
|
+
* reading by ear, made for ragnarok's canyon, bars 25 to 28. Let loose over
|
|
841
|
+
* the cartridge the rule marked every loud note off the beat, and Arctic
|
|
842
|
+
* Caverns' echo — every second note far quieter than the one before, an
|
|
843
|
+
* imitation of an echo — came out as a column of accents it does not have.
|
|
844
|
+
*/
|
|
845
|
+
const accents = accentBeams
|
|
846
|
+
? new Set([...accentedStarts(notes)].filter((start) => start >= accentBeams.from + shift && start < accentBeams.to + shift))
|
|
847
|
+
: new Set();
|
|
848
|
+
const across = crossBarRuns(notes, grid, accents);
|
|
849
|
+
const heads = () => {
|
|
850
|
+
const written = attachHeads(barsOf(notes), {
|
|
851
|
+
effects,
|
|
852
|
+
graces: new Map(notes.filter((note) => note.grace?.length).map((note) => [note.start, note.grace])),
|
|
853
|
+
accents,
|
|
854
|
+
echoes: new Set(notes.filter((note) => note.echo).map((note) => note.start)),
|
|
855
|
+
levels: levelsOf(notes),
|
|
856
|
+
across,
|
|
857
|
+
staccatos: new Set(notes.filter((note) => note.staccato).map((note) => note.start)),
|
|
858
|
+
chords: pitchChords,
|
|
859
|
+
});
|
|
860
|
+
if (!accentBeams)
|
|
861
|
+
return written;
|
|
862
|
+
return accentBeamGroups(written, { from: accentBeams.from + shift, to: accentBeams.to + shift }, levelsOf(notes));
|
|
863
|
+
};
|
|
864
|
+
if (kinds.length === 1) {
|
|
865
|
+
staves.push({ partId: part.id, kind: kinds[0], bars: heads() });
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
const voice = crossStaffVoice(heads());
|
|
869
|
+
const on = (staff) => voice.map((items) => items.filter((item) => item.staff === staff));
|
|
870
|
+
staves.push({
|
|
871
|
+
partId: part.id,
|
|
872
|
+
kind: 'treble',
|
|
873
|
+
bars: on('treble'),
|
|
874
|
+
voiceBars: voice,
|
|
875
|
+
});
|
|
876
|
+
staves.push({
|
|
877
|
+
partId: part.id,
|
|
878
|
+
kind: 'bass',
|
|
879
|
+
bars: on('bass'),
|
|
880
|
+
follows: true,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
const loud = notes.filter((note) => note.volume !== undefined);
|
|
884
|
+
dynamics[part.id] = dynamicMarks(loud);
|
|
885
|
+
}
|
|
886
|
+
if (drums.length > 0) {
|
|
887
|
+
/*
|
|
888
|
+
* Hits at the same moment are one chord: the longest is the head the staff's rhythm is
|
|
889
|
+
* written from, the others stand on its stem. A staff of one voice wrote them one after
|
|
890
|
+
* another, and every hat over a kick pushed the rest of the bar a value late.
|
|
891
|
+
*/
|
|
892
|
+
const groups = new Map();
|
|
893
|
+
for (const hit of drums)
|
|
894
|
+
groups.set(hit.start, [...(groups.get(hit.start) ?? []), hit]);
|
|
895
|
+
const leads = [...groups.values()].map((group) => group.reduce((longest, hit) => (hit.ticks > longest.ticks ? hit : longest)));
|
|
896
|
+
const others = [...groups.values()].map((group, index) => [
|
|
897
|
+
...new Set(group
|
|
898
|
+
.filter((hit) => hit !== leads[index])
|
|
899
|
+
.map((hit) => hit.period & 0x0f)
|
|
900
|
+
.filter((period) => period !== (leads[index].period & 0x0f))),
|
|
901
|
+
]);
|
|
902
|
+
const hits = moved(leads.map((hit) => ({
|
|
903
|
+
start: hit.start,
|
|
904
|
+
ticks: hit.ticks,
|
|
905
|
+
midi: hit.period & 0x0f,
|
|
906
|
+
volume: hit.volume,
|
|
907
|
+
})));
|
|
908
|
+
const chords = new Map();
|
|
909
|
+
hits.forEach((hit, index) => {
|
|
910
|
+
if (others[index]?.length)
|
|
911
|
+
chords.set(hit.start, others[index]);
|
|
912
|
+
});
|
|
913
|
+
/*
|
|
914
|
+
* A drum is struck once. What is left of its slot after the first head —
|
|
915
|
+
* the rest of a long value, or the part carried over a bar line — is
|
|
916
|
+
* written as rest: tied on a percussion staff it reads as a roll, and as a
|
|
917
|
+
* second head it reads as a second hit.
|
|
918
|
+
*/
|
|
919
|
+
const bars = attachHeads(barsOf(hits).map((items) => items.map((item) => item.tieStop
|
|
920
|
+
? { ...item, midi: null, tieStart: false, tieStop: false }
|
|
921
|
+
: { ...item, tieStart: false })), { levels: levelsOf(hits), chords });
|
|
922
|
+
staves.push({ partId: DRUMS_PART, kind: 'percussion', bars });
|
|
923
|
+
}
|
|
924
|
+
return { shift, grid, barCount, staves, dynamics, phases };
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=score.js.map
|