@miadi/ava8-measure 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 +174 -0
- package/dist/audio.d.ts +343 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +821 -0
- package/dist/audio.js.map +1 -0
- package/dist/fft.d.ts +51 -0
- package/dist/fft.d.ts.map +1 -0
- package/dist/fft.js +246 -0
- package/dist/fft.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +183 -0
- package/dist/index.js.map +1 -0
- package/dist/midi.d.ts +333 -0
- package/dist/midi.d.ts.map +1 -0
- package/dist/midi.js +651 -0
- package/dist/midi.js.map +1 -0
- package/dist/movement.d.ts +431 -0
- package/dist/movement.d.ts.map +1 -0
- package/dist/movement.js +701 -0
- package/dist/movement.js.map +1 -0
- package/package.json +70 -0
package/dist/midi.js
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* midi.ts — read a *rendered* Standard MIDI File and prove what is inside it.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written, and it must stay hand-written. The Python this is ported from
|
|
5
|
+
* (`atelier_midi.py`) refuses to import `mido`, `pretty_midi` or `music21`
|
|
6
|
+
* because none of them exist on the host the atelier runs on, and a plugin
|
|
7
|
+
* that assumes them fails on the one machine it was built for. The same
|
|
8
|
+
* discipline here costs nothing and buys everything: a decoder you can audit
|
|
9
|
+
* in one sitting, with no dependency that can rot underneath it.
|
|
10
|
+
*
|
|
11
|
+
* The port keeps that promise and adds one of its own — **bytes in, never a
|
|
12
|
+
* path**. `read` takes a `Uint8Array` and uses `DataView`, so the whole
|
|
13
|
+
* measuring surface runs unchanged in a browser. Nothing here touches
|
|
14
|
+
* `node:fs`, and nothing here touches `Buffer`.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS MODULE IS FOR
|
|
17
|
+
* Verification re-reads the rendered artefact, never the source. A note
|
|
18
|
+
* count that comes out right in the generator is not proof; the same count
|
|
19
|
+
* read back out of the .mid that `abc2midi` produced is. Every function
|
|
20
|
+
* answers one question that decided something in the atelier:
|
|
21
|
+
*
|
|
22
|
+
* registers() did a voice land in a register it was told to avoid
|
|
23
|
+
* bandOccupancy() is the singer's band empty (the 45-53 rule)
|
|
24
|
+
* pitchClasses() what mode does the rendered piece actually sit in
|
|
25
|
+
* modePurity() how much of it stays inside the field
|
|
26
|
+
* samePitches() were his notes changed between source and render
|
|
27
|
+
* drumPositions() is the kick really on the floor, eighth by eighth
|
|
28
|
+
* tempos did the mid-tune tempo change survive (a bare Q: does not)
|
|
29
|
+
*
|
|
30
|
+
* UNITS
|
|
31
|
+
* Tick durations are musical durations. Pitch-class and purity shares are
|
|
32
|
+
* weighted in ticks on purpose: they describe the written field, and a tempo
|
|
33
|
+
* change must not reweight it. Anything reported in seconds says so.
|
|
34
|
+
*
|
|
35
|
+
* @packageDocumentation
|
|
36
|
+
*/
|
|
37
|
+
import { parseMode } from "@miadi/ava8-atelier";
|
|
38
|
+
/**
|
|
39
|
+
* Channel index 9 — "channel 10" in ABC, in General MIDI, and in every manual.
|
|
40
|
+
*
|
|
41
|
+
* Note the deliberate divergence from `@miadi/ava8-atelier`, whose
|
|
42
|
+
* `DRUM_CHANNEL` is `10` because it names the channel the way a musician
|
|
43
|
+
* writes it. A decoder reads the wire, and on the wire the nibble is 9.
|
|
44
|
+
*/
|
|
45
|
+
export const DRUM_CHANNEL = 9;
|
|
46
|
+
/** The General MIDI drum names the atelier actually uses, by note number. */
|
|
47
|
+
export const GM_DRUM_NAMES = {
|
|
48
|
+
35: "kick 2", 36: "kick", 37: "side stick", 38: "snare", 39: "clap",
|
|
49
|
+
40: "snare 2", 41: "low tom", 42: "hat closed", 43: "low tom 2",
|
|
50
|
+
44: "hat pedal", 45: "mid tom", 46: "hat open", 47: "mid tom 2",
|
|
51
|
+
48: "high tom", 49: "crash", 50: "high tom 2", 51: "ride",
|
|
52
|
+
54: "tambourine", 56: "cowbell", 57: "crash 2", 59: "ride 2",
|
|
53
|
+
};
|
|
54
|
+
/** Sharp spellings, because a decoder has no key signature to spell against. */
|
|
55
|
+
export const PC_NAMES = [
|
|
56
|
+
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
|
|
57
|
+
];
|
|
58
|
+
/** Sounding length in ticks. A musical duration, never seconds. */
|
|
59
|
+
export function noteDuration(note) {
|
|
60
|
+
return note.endTick - note.startTick;
|
|
61
|
+
}
|
|
62
|
+
const MTHD = [0x4d, 0x54, 0x68, 0x64];
|
|
63
|
+
const MTRK = [0x4d, 0x54, 0x72, 0x6b];
|
|
64
|
+
const TEXT = new TextDecoder("utf-8");
|
|
65
|
+
/** MIDI variable-length quantity. Returns `[value, position after it]`. */
|
|
66
|
+
function varlen(bytes, start) {
|
|
67
|
+
let v = 0;
|
|
68
|
+
let p = start;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const b = byteAt(bytes, p);
|
|
71
|
+
p += 1;
|
|
72
|
+
v = (v << 7) | (b & 0x7f);
|
|
73
|
+
if (!(b & 0x80))
|
|
74
|
+
return [v, p];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Bounds-checked byte read. Runs off the end and it says so, like Python's IndexError. */
|
|
78
|
+
function byteAt(bytes, p) {
|
|
79
|
+
if (p < 0 || p >= bytes.length) {
|
|
80
|
+
throw new RangeError(`MIDI read ran past the end of the file at byte ${p}`);
|
|
81
|
+
}
|
|
82
|
+
return bytes[p];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read a Standard MIDI File into paired notes. This is the only door in.
|
|
86
|
+
*
|
|
87
|
+
* Handles: format 0 and 1 (and 2, read as independent tracks), running status,
|
|
88
|
+
* meta events, sysex (`0xF0` and the `0xF7` escape form), tempo and
|
|
89
|
+
* time-signature maps, unknown chunk types (skipped by their declared length,
|
|
90
|
+
* as the spec instructs), and note-on-with-velocity-0 as note-off.
|
|
91
|
+
*
|
|
92
|
+
* Note-offs are paired FIFO per `(track, channel, pitch)` — pairing on pitch
|
|
93
|
+
* alone silently merges two voices that happen to share a pitch on different
|
|
94
|
+
* channels, and the merge is invisible in the output, which is the worst kind
|
|
95
|
+
* of wrong. Notes left hanging at end of track are closed at the last tick
|
|
96
|
+
* seen and counted in `unclosed`; a non-zero count means the file is malformed
|
|
97
|
+
* and every duration-weighted measure below it is approximate.
|
|
98
|
+
*
|
|
99
|
+
* The sort order is load-bearing, not cosmetic: `(startTick, track, channel,
|
|
100
|
+
* pitch)`. {@link samePitches} re-sorts on top of it and relies on a stable
|
|
101
|
+
* sort to keep the track/channel order under equal pitches, so two readers
|
|
102
|
+
* that disagree here will disagree about whether a file was altered.
|
|
103
|
+
*
|
|
104
|
+
* @param bytes the whole file. Never a path — the library must run in a browser.
|
|
105
|
+
* @param path a label to carry through for reporting. Nothing is opened.
|
|
106
|
+
*/
|
|
107
|
+
export function read(bytes, path = "<bytes>") {
|
|
108
|
+
if (bytes.length < 14 || !MTHD.every((b, i) => bytes[i] === b)) {
|
|
109
|
+
throw new TypeError(`${path}: not a Standard MIDI File (no MThd header)`);
|
|
110
|
+
}
|
|
111
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
112
|
+
const hdrLen = view.getUint32(4, false);
|
|
113
|
+
const format = view.getUint16(8, false);
|
|
114
|
+
const nTracks = view.getUint16(10, false);
|
|
115
|
+
const division = view.getUint16(12, false);
|
|
116
|
+
let p = 8 + hdrLen;
|
|
117
|
+
const notes = [];
|
|
118
|
+
const tempos = [];
|
|
119
|
+
const tsigs = [];
|
|
120
|
+
const names = new Map();
|
|
121
|
+
let unclosed = 0;
|
|
122
|
+
let track = -1;
|
|
123
|
+
while (p + 8 <= bytes.length) {
|
|
124
|
+
const isTrack = MTRK.every((b, i) => bytes[p + i] === b);
|
|
125
|
+
const clen = view.getUint32(p + 4, false);
|
|
126
|
+
p += 8;
|
|
127
|
+
const end = Math.min(p + clen, bytes.length);
|
|
128
|
+
if (!isTrack) {
|
|
129
|
+
// alien chunk: the spec says skip it by its declared length
|
|
130
|
+
p = end;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
track += 1;
|
|
134
|
+
let t = 0;
|
|
135
|
+
let run = null;
|
|
136
|
+
// insertion-ordered, so hanging notes are emitted in the order they opened
|
|
137
|
+
const on = new Map();
|
|
138
|
+
while (p < end) {
|
|
139
|
+
const [delta, afterDelta] = varlen(bytes, p);
|
|
140
|
+
p = afterDelta;
|
|
141
|
+
t += delta;
|
|
142
|
+
if (p >= end)
|
|
143
|
+
break;
|
|
144
|
+
let st = byteAt(bytes, p);
|
|
145
|
+
if (st & 0x80) {
|
|
146
|
+
// any status byte refreshes running status, meta and sysex included.
|
|
147
|
+
// That is what the Python does, and a decoder that "corrects" it
|
|
148
|
+
// silently disagrees with the file the atelier already verified.
|
|
149
|
+
run = st;
|
|
150
|
+
p += 1;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
if (run === null) {
|
|
154
|
+
throw new TypeError(`${path}: running status with no preceding status byte`);
|
|
155
|
+
}
|
|
156
|
+
st = run;
|
|
157
|
+
}
|
|
158
|
+
if (st === 0xff) {
|
|
159
|
+
const mtype = byteAt(bytes, p);
|
|
160
|
+
p += 1;
|
|
161
|
+
const [ln, afterLen] = varlen(bytes, p);
|
|
162
|
+
p = afterLen;
|
|
163
|
+
const payload = bytes.subarray(p, Math.min(p + ln, bytes.length));
|
|
164
|
+
p += ln;
|
|
165
|
+
if (mtype === 0x51 && ln === 3) {
|
|
166
|
+
const us = (payload[0] << 16) | (payload[1] << 8) | payload[2];
|
|
167
|
+
tempos.push({ tick: t, usecPerQuarter: us, bpm: us ? 60_000_000 / us : 0, track });
|
|
168
|
+
}
|
|
169
|
+
else if (mtype === 0x58 && ln >= 4) {
|
|
170
|
+
tsigs.push({
|
|
171
|
+
tick: t,
|
|
172
|
+
numerator: payload[0],
|
|
173
|
+
denominator: 1 << payload[1],
|
|
174
|
+
clocksPerClick: payload[2],
|
|
175
|
+
notated32ndPerQuarter: payload[3],
|
|
176
|
+
track,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else if (mtype === 0x03) {
|
|
180
|
+
names.set(track, TEXT.decode(payload));
|
|
181
|
+
}
|
|
182
|
+
else if (mtype === 0x2f) {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else if (st === 0xf0 || st === 0xf7) {
|
|
187
|
+
const [ln, afterLen] = varlen(bytes, p);
|
|
188
|
+
p = afterLen + ln;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const hi = st & 0xf0;
|
|
192
|
+
const ch = st & 0x0f;
|
|
193
|
+
const nb = hi === 0xc0 || hi === 0xd0 ? 1 : 2;
|
|
194
|
+
const a = byteAt(bytes, p);
|
|
195
|
+
const b2 = nb === 2 && p + 1 < bytes.length ? bytes[p + 1] : 0;
|
|
196
|
+
p += nb;
|
|
197
|
+
const key = ch * 128 + a;
|
|
198
|
+
if (hi === 0x90 && b2 > 0) {
|
|
199
|
+
let q = on.get(key);
|
|
200
|
+
if (!q) {
|
|
201
|
+
q = [];
|
|
202
|
+
on.set(key, q);
|
|
203
|
+
}
|
|
204
|
+
q.push({ tick: t, velocity: b2 });
|
|
205
|
+
}
|
|
206
|
+
else if (hi === 0x80 || (hi === 0x90 && b2 === 0)) {
|
|
207
|
+
const q = on.get(key);
|
|
208
|
+
if (q && q.length) {
|
|
209
|
+
const head = q.shift();
|
|
210
|
+
notes.push({
|
|
211
|
+
startTick: head.tick,
|
|
212
|
+
endTick: t,
|
|
213
|
+
pitch: a,
|
|
214
|
+
velocity: head.velocity,
|
|
215
|
+
channel: ch,
|
|
216
|
+
track,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const [key, q] of on) {
|
|
223
|
+
const ch = Math.floor(key / 128);
|
|
224
|
+
const pitch = key % 128;
|
|
225
|
+
for (const held of q) {
|
|
226
|
+
unclosed += 1;
|
|
227
|
+
notes.push({
|
|
228
|
+
startTick: held.tick,
|
|
229
|
+
endTick: Math.max(held.tick + 1, t),
|
|
230
|
+
pitch,
|
|
231
|
+
velocity: held.velocity,
|
|
232
|
+
channel: ch,
|
|
233
|
+
track,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
p = end;
|
|
238
|
+
}
|
|
239
|
+
notes.sort(compareNotes);
|
|
240
|
+
tempos.sort((x, y) => x.tick - y.tick);
|
|
241
|
+
tsigs.sort((x, y) => x.tick - y.tick);
|
|
242
|
+
return {
|
|
243
|
+
notes,
|
|
244
|
+
division,
|
|
245
|
+
tempos,
|
|
246
|
+
timeSignatures: tsigs,
|
|
247
|
+
format,
|
|
248
|
+
nTracks,
|
|
249
|
+
trackNames: names,
|
|
250
|
+
unclosed,
|
|
251
|
+
path,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The canonical note order: `(startTick, track, channel, pitch)`.
|
|
256
|
+
*
|
|
257
|
+
* Exported because it is a contract, not an implementation detail. Reproduce
|
|
258
|
+
* it wrong and {@link samePitches} reports a file altered that was not, or
|
|
259
|
+
* misses one that was. Ties on `startTick` are broken by track first, which is
|
|
260
|
+
* why a two-voice render reads voice-by-voice inside each chord.
|
|
261
|
+
*/
|
|
262
|
+
export function compareNotes(x, y) {
|
|
263
|
+
return (x.startTick - y.startTick ||
|
|
264
|
+
x.track - y.track ||
|
|
265
|
+
x.channel - y.channel ||
|
|
266
|
+
x.pitch - y.pitch);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Ticks per quarter note, or `null` when the file uses SMPTE timing.
|
|
270
|
+
*
|
|
271
|
+
* A SMPTE division (the 0x8000 bit) carries frames and subframes, not a beat
|
|
272
|
+
* grid, so every bar-relative measure — {@link drumPositions} above all — must
|
|
273
|
+
* refuse rather than guess. Returning `null` is how the refusal is made
|
|
274
|
+
* unmissable at the type level.
|
|
275
|
+
*/
|
|
276
|
+
export function ticksPerBeat(division) {
|
|
277
|
+
if (division & 0x8000)
|
|
278
|
+
return null;
|
|
279
|
+
return division;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Absolute seconds for a tick, walking the tempo map. Reporting only.
|
|
283
|
+
*
|
|
284
|
+
* Used to say *when* something happened out loud, to a human who is listening
|
|
285
|
+
* rather than counting. No musical decision is taken on seconds: they drift
|
|
286
|
+
* with every tempo event, tick durations do not. With no tempo written, the
|
|
287
|
+
* SMF default of 120 bpm applies — which is exactly the trap a bare `Q:` in an
|
|
288
|
+
* ABC body falls into.
|
|
289
|
+
*/
|
|
290
|
+
export function tickToSeconds(tick, division, tempos = []) {
|
|
291
|
+
const tpb = ticksPerBeat(division);
|
|
292
|
+
if (tpb === null) {
|
|
293
|
+
const frames = 256 - ((division >> 8) & 0xff);
|
|
294
|
+
const perFrame = division & 0xff;
|
|
295
|
+
return tick / (frames * perFrame || 1);
|
|
296
|
+
}
|
|
297
|
+
if (tempos.length === 0)
|
|
298
|
+
return (tick * 0.5) / tpb;
|
|
299
|
+
let sec = 0;
|
|
300
|
+
let prevTick = 0;
|
|
301
|
+
let us = 500000;
|
|
302
|
+
for (const tp of tempos) {
|
|
303
|
+
if (tp.tick >= tick)
|
|
304
|
+
break;
|
|
305
|
+
sec += ((tp.tick - prevTick) * us) / 1e6 / tpb;
|
|
306
|
+
prevTick = tp.tick;
|
|
307
|
+
us = tp.usecPerQuarter;
|
|
308
|
+
}
|
|
309
|
+
return sec + ((tick - prevTick) * us) / 1e6 / tpb;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Per-track pitch extent and count, plus the overlap between every pair.
|
|
313
|
+
*
|
|
314
|
+
* Decides: whether a voice went where it was told not to go, and whether two
|
|
315
|
+
* voices meant to stay out of each other's way in fact cross. The atelier
|
|
316
|
+
* reads this before publishing, on the *rendered* file — an ABC window that
|
|
317
|
+
* looks disjoint on paper can still collide once `abc2midi` has chosen an
|
|
318
|
+
* octave, because `clef=treble-8` sounds an octave below what is written.
|
|
319
|
+
*
|
|
320
|
+
* Overlaps come back as an array ordered by `(a, b)` rather than a
|
|
321
|
+
* tuple-keyed map, because JavaScript has no tuple key that survives a round
|
|
322
|
+
* trip through JSON.
|
|
323
|
+
*/
|
|
324
|
+
export function registers(notes) {
|
|
325
|
+
const per = new Map();
|
|
326
|
+
for (const n of notes) {
|
|
327
|
+
let e = per.get(n.track);
|
|
328
|
+
if (!e) {
|
|
329
|
+
e = {
|
|
330
|
+
min: n.pitch,
|
|
331
|
+
max: n.pitch,
|
|
332
|
+
count: 0,
|
|
333
|
+
span: 0,
|
|
334
|
+
channels: [],
|
|
335
|
+
pitches: [],
|
|
336
|
+
channelSet: new Set(),
|
|
337
|
+
pitchSet: new Set(),
|
|
338
|
+
};
|
|
339
|
+
per.set(n.track, e);
|
|
340
|
+
}
|
|
341
|
+
e.min = Math.min(e.min, n.pitch);
|
|
342
|
+
e.max = Math.max(e.max, n.pitch);
|
|
343
|
+
e.count += 1;
|
|
344
|
+
e.channelSet.add(n.channel);
|
|
345
|
+
e.pitchSet.add(n.pitch);
|
|
346
|
+
}
|
|
347
|
+
const tracks = new Map();
|
|
348
|
+
for (const key of [...per.keys()].sort((x, y) => x - y)) {
|
|
349
|
+
const e = per.get(key);
|
|
350
|
+
tracks.set(key, {
|
|
351
|
+
min: e.min,
|
|
352
|
+
max: e.max,
|
|
353
|
+
count: e.count,
|
|
354
|
+
span: e.max - e.min,
|
|
355
|
+
channels: [...e.channelSet].sort((x, y) => x - y),
|
|
356
|
+
pitches: [...e.pitchSet].sort((x, y) => x - y),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const overlaps = [];
|
|
360
|
+
const keys = [...tracks.keys()];
|
|
361
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
362
|
+
for (let j = i + 1; j < keys.length; j += 1) {
|
|
363
|
+
const a = keys[i];
|
|
364
|
+
const b = keys[j];
|
|
365
|
+
const ta = tracks.get(a);
|
|
366
|
+
const tb = tracks.get(b);
|
|
367
|
+
const low = Math.max(ta.min, tb.min);
|
|
368
|
+
const high = Math.min(ta.max, tb.max);
|
|
369
|
+
const bPitches = new Set(tb.pitches);
|
|
370
|
+
overlaps.push({
|
|
371
|
+
a,
|
|
372
|
+
b,
|
|
373
|
+
low,
|
|
374
|
+
high,
|
|
375
|
+
semitones: high >= low ? high - low + 1 : 0,
|
|
376
|
+
sharedPitches: ta.pitches.filter((pc) => bPitches.has(pc)),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return { tracks, overlaps };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* How many notes fall inside a MIDI range, inclusive.
|
|
384
|
+
*
|
|
385
|
+
* This is how "the singer's band is empty" is proven, and it is the only check
|
|
386
|
+
* in the atelier that is pass/fail rather than a number to read. His band was
|
|
387
|
+
* measured on the day: 94.1 % of the park drone lives in MIDI 45-53, so 45-53
|
|
388
|
+
* stays empty in every piece written for him to sing over. Every other measure
|
|
389
|
+
* here reports and lets the human judge; this one has a verdict because the
|
|
390
|
+
* band belongs to a person, not to a threshold someone picked.
|
|
391
|
+
*
|
|
392
|
+
* Returns the count, the share of all notes, the offending notes, and which
|
|
393
|
+
* tracks they came from — naming the track is what makes the correction one
|
|
394
|
+
* edit instead of a hunt.
|
|
395
|
+
*/
|
|
396
|
+
export function bandOccupancy(notes, lo, hi) {
|
|
397
|
+
const all = [...notes];
|
|
398
|
+
const inside = all.filter((n) => n.pitch >= lo && n.pitch <= hi);
|
|
399
|
+
const byTrack = new Map();
|
|
400
|
+
for (const n of inside)
|
|
401
|
+
byTrack.set(n.track, (byTrack.get(n.track) ?? 0) + 1);
|
|
402
|
+
return {
|
|
403
|
+
low: lo,
|
|
404
|
+
high: hi,
|
|
405
|
+
count: inside.length,
|
|
406
|
+
total: all.length,
|
|
407
|
+
share: all.length ? inside.length / all.length : 0,
|
|
408
|
+
empty: inside.length === 0,
|
|
409
|
+
byTrack,
|
|
410
|
+
notes: inside,
|
|
411
|
+
pitches: [...new Set(inside.map((n) => n.pitch))].sort((x, y) => x - y),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Pitch-class histogram weighted by sounding duration, in ticks.
|
|
416
|
+
*
|
|
417
|
+
* Decides the mode. Counting note *events* lies: a drone struck once and held
|
|
418
|
+
* thirty seconds counts as one, and the piece then reads as whatever the busy
|
|
419
|
+
* voice happens to be doing. Weighting by duration is what found MI PHRYGIEN
|
|
420
|
+
* in his Songbird take — the mi2/re#2 beat held thirty seconds *was* the piece.
|
|
421
|
+
*
|
|
422
|
+
* The drum channel is excluded, because a kick is not a pitch class.
|
|
423
|
+
*/
|
|
424
|
+
export function pitchClasses(notes) {
|
|
425
|
+
const ticks = new Array(12).fill(0);
|
|
426
|
+
const events = new Array(12).fill(0);
|
|
427
|
+
for (const n of notes) {
|
|
428
|
+
if (n.channel === DRUM_CHANNEL)
|
|
429
|
+
continue;
|
|
430
|
+
const pc = ((n.pitch % 12) + 12) % 12;
|
|
431
|
+
ticks[pc] = ticks[pc] + Math.max(0, noteDuration(n));
|
|
432
|
+
events[pc] = events[pc] + 1;
|
|
433
|
+
}
|
|
434
|
+
const total = ticks.reduce((s, v) => s + v, 0);
|
|
435
|
+
const byPc = ticks.map((tk, pc) => ({
|
|
436
|
+
ticks: tk,
|
|
437
|
+
share: total ? tk / total : 0,
|
|
438
|
+
events: events[pc],
|
|
439
|
+
}));
|
|
440
|
+
const ranked = Array.from({ length: 12 }, (_, i) => i).sort((x, y) => ticks[y] - ticks[x]);
|
|
441
|
+
return { totalTicks: total, byPc, ranked };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Share of sounding duration that sits inside a given pitch-class set.
|
|
445
|
+
*
|
|
446
|
+
* Decides whether a take stays in the field, and names what leaves it. The
|
|
447
|
+
* number is not a verdict on its own: 21 % outside the white field of the bed
|
|
448
|
+
* was not an error in his Songbird — it was the material the intruder in
|
|
449
|
+
* opus 019 is made of. Report the share, name the strays, let the human read.
|
|
450
|
+
*
|
|
451
|
+
* A mode given as text (`"ddorian"`, `"e phrygian"`, `"Bb-major"`, or a bare
|
|
452
|
+
* `"0,2,4,5,7,9,11"`) is handed to `parseMode` from `@miadi/ava8-atelier`.
|
|
453
|
+
* The atelier owns what a mode *is*; this package only owns what a rendered
|
|
454
|
+
* file *did*. Two copies of that table would eventually disagree, and the
|
|
455
|
+
* disagreement would surface as a purity number nobody could explain.
|
|
456
|
+
*/
|
|
457
|
+
export function modePurity(notes, allowedPcs) {
|
|
458
|
+
const raw = typeof allowedPcs === "string" ? parseMode(allowedPcs) : [...allowedPcs];
|
|
459
|
+
const allowed = new Set(raw.map((p) => (((Math.trunc(p) % 12) + 12) % 12)));
|
|
460
|
+
const h = pitchClasses(notes);
|
|
461
|
+
let inside = 0;
|
|
462
|
+
const outside = [];
|
|
463
|
+
for (let pc = 0; pc < 12; pc += 1) {
|
|
464
|
+
const v = h.byPc[pc];
|
|
465
|
+
if (allowed.has(pc))
|
|
466
|
+
inside += v.ticks;
|
|
467
|
+
else if (v.ticks)
|
|
468
|
+
outside.push({ pc, share: v.share });
|
|
469
|
+
}
|
|
470
|
+
outside.sort((x, y) => y.share - x.share);
|
|
471
|
+
return {
|
|
472
|
+
allowed: [...allowed].sort((x, y) => x - y),
|
|
473
|
+
purity: h.totalTicks ? inside / h.totalTicks : 0,
|
|
474
|
+
insideTicks: inside,
|
|
475
|
+
totalTicks: h.totalTicks,
|
|
476
|
+
outside,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Compare two renders note-for-note **and** as a multiset.
|
|
481
|
+
*
|
|
482
|
+
* This is what proves "his notes are unchanged". Two answers, and they are not
|
|
483
|
+
* the same answer — reporting only one of them would let a real change pass or
|
|
484
|
+
* would condemn an honest one:
|
|
485
|
+
*
|
|
486
|
+
* `noteForNote` same pitches in the same order. A variation that keeps his
|
|
487
|
+
* order — a register split, a re-voicing — passes this.
|
|
488
|
+
* `multiset` same pitches in any order. A mirror or a re-ordering passes
|
|
489
|
+
* only this, and that is then the honest claim to make about
|
|
490
|
+
* it: his material, his order changed.
|
|
491
|
+
*
|
|
492
|
+
* On divergence, `firstDivergence` gives the index and both pitches, which is
|
|
493
|
+
* where to look and nowhere else.
|
|
494
|
+
*
|
|
495
|
+
* The comparison re-sorts by `(startTick, pitch)` on top of the canonical
|
|
496
|
+
* order from {@link read}, and relies on the sort being stable so that equal
|
|
497
|
+
* pitches keep their track and channel order. That is why {@link compareNotes}
|
|
498
|
+
* is a contract.
|
|
499
|
+
*/
|
|
500
|
+
export function samePitches(a, b) {
|
|
501
|
+
const na = a instanceof Uint8Array ? read(a).notes : [...a];
|
|
502
|
+
const nb = b instanceof Uint8Array ? read(b).notes : [...b];
|
|
503
|
+
const byStartThenPitch = (x, y) => x.startTick - y.startTick || x.pitch - y.pitch;
|
|
504
|
+
const pa = [...na].sort(byStartThenPitch).map((n) => n.pitch);
|
|
505
|
+
const pb = [...nb].sort(byStartThenPitch).map((n) => n.pitch);
|
|
506
|
+
const ca = new Map();
|
|
507
|
+
const cb = new Map();
|
|
508
|
+
for (const p of pa)
|
|
509
|
+
ca.set(p, (ca.get(p) ?? 0) + 1);
|
|
510
|
+
for (const p of pb)
|
|
511
|
+
cb.set(p, (cb.get(p) ?? 0) + 1);
|
|
512
|
+
let first = null;
|
|
513
|
+
const n = Math.min(pa.length, pb.length);
|
|
514
|
+
for (let i = 0; i < n; i += 1) {
|
|
515
|
+
if (pa[i] !== pb[i]) {
|
|
516
|
+
first = { index: i, a: pa[i], b: pb[i] };
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (first === null && pa.length !== pb.length) {
|
|
521
|
+
first = { index: n, a: null, b: null };
|
|
522
|
+
}
|
|
523
|
+
const multisetDelta = [];
|
|
524
|
+
for (const pitch of [...new Set([...ca.keys(), ...cb.keys()])].sort((x, y) => x - y)) {
|
|
525
|
+
const delta = (cb.get(pitch) ?? 0) - (ca.get(pitch) ?? 0);
|
|
526
|
+
if (delta !== 0)
|
|
527
|
+
multisetDelta.push({ pitch, delta });
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
countA: pa.length,
|
|
531
|
+
countB: pb.length,
|
|
532
|
+
noteForNote: pa.length === pb.length && pa.every((v, i) => v === pb[i]),
|
|
533
|
+
multiset: multisetDelta.length === 0,
|
|
534
|
+
firstDivergence: first,
|
|
535
|
+
multisetDelta,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** Python's round-half-to-even, because `int(round(x))` decides the slot. */
|
|
539
|
+
function pyRound(x) {
|
|
540
|
+
const f = Math.floor(x);
|
|
541
|
+
const diff = x - f;
|
|
542
|
+
if (diff > 0.5)
|
|
543
|
+
return f + 1;
|
|
544
|
+
if (diff < 0.5)
|
|
545
|
+
return f;
|
|
546
|
+
return f % 2 === 0 ? f : f + 1;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* For channel 10, the eighth-note position of every drum note within its bar.
|
|
550
|
+
*
|
|
551
|
+
* This is what proved four-on-the-floor: the kick reads 0, 2, 4, 6 and nothing
|
|
552
|
+
* else. Reading the ABC would only prove what was *written*; the grid that
|
|
553
|
+
* actually reaches the ear is the one in the rendered file.
|
|
554
|
+
*
|
|
555
|
+
* Bar length follows the time-signature map (numerator x 4/denominator
|
|
556
|
+
* quarters), with 4/4 assumed when the file declares nothing and for anything
|
|
557
|
+
* before the first declaration. `exact` is the unrounded position — a drum
|
|
558
|
+
* that lands on 1.97 instead of 2 is a rounding artefact of the writer, and
|
|
559
|
+
* you want to see that rather than have it quantised away behind your back.
|
|
560
|
+
*
|
|
561
|
+
* Refuses on SMPTE division, which carries no beat grid: there is no honest
|
|
562
|
+
* bar number to return, so it throws instead of inventing one.
|
|
563
|
+
*/
|
|
564
|
+
export function drumPositions(notes, division, timeSignatures = []) {
|
|
565
|
+
const tpb = ticksPerBeat(division);
|
|
566
|
+
if (tpb === null) {
|
|
567
|
+
throw new RangeError("SMPTE division carries no beat grid; bar positions are undefined");
|
|
568
|
+
}
|
|
569
|
+
const eighth = tpb / 2;
|
|
570
|
+
const segs = timeSignatures.length > 0
|
|
571
|
+
? timeSignatures.map((ts) => [ts.tick, ts.numerator, ts.denominator])
|
|
572
|
+
: [[0, 4, 4]];
|
|
573
|
+
if (segs[0][0] !== 0)
|
|
574
|
+
segs.unshift([0, 4, 4]);
|
|
575
|
+
const barOf = (tick) => {
|
|
576
|
+
let segI = 0;
|
|
577
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
578
|
+
if (segs[i][0] <= tick)
|
|
579
|
+
segI = i;
|
|
580
|
+
else
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
const [st, num, den] = segs[segI];
|
|
584
|
+
const barTicks = num * (4 / den) * tpb;
|
|
585
|
+
const off = tick - st;
|
|
586
|
+
let barsBefore = 0;
|
|
587
|
+
for (let k = 0; k < segI; k += 1) {
|
|
588
|
+
barsBefore +=
|
|
589
|
+
Math.max(0, segs[k + 1][0] - segs[k][0]) / (segs[k][1] * (4 / segs[k][2]) * tpb);
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
bar: Math.trunc(barsBefore + Math.floor(off / barTicks)),
|
|
593
|
+
within: off % barTicks,
|
|
594
|
+
barTicks,
|
|
595
|
+
num,
|
|
596
|
+
den,
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
const hits = [];
|
|
600
|
+
for (const n of notes) {
|
|
601
|
+
if (n.channel !== DRUM_CHANNEL)
|
|
602
|
+
continue;
|
|
603
|
+
const { bar, within, barTicks, num, den } = barOf(n.startTick);
|
|
604
|
+
const exact = within / eighth;
|
|
605
|
+
const slots = Math.max(1, pyRound(barTicks / eighth));
|
|
606
|
+
hits.push({
|
|
607
|
+
pitch: n.pitch,
|
|
608
|
+
name: GM_DRUM_NAMES[n.pitch] ?? `midi ${n.pitch}`,
|
|
609
|
+
tick: n.startTick,
|
|
610
|
+
bar,
|
|
611
|
+
eighth: ((pyRound(exact) % slots) + slots) % slots,
|
|
612
|
+
exact,
|
|
613
|
+
eighthsPerBar: barTicks / eighth,
|
|
614
|
+
meter: `${num}/${den}`,
|
|
615
|
+
track: n.track,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
const counts = new Map();
|
|
619
|
+
for (const h of hits) {
|
|
620
|
+
let c = counts.get(h.pitch);
|
|
621
|
+
if (!c) {
|
|
622
|
+
c = new Map();
|
|
623
|
+
counts.set(h.pitch, c);
|
|
624
|
+
}
|
|
625
|
+
c.set(h.eighth, (c.get(h.eighth) ?? 0) + 1);
|
|
626
|
+
}
|
|
627
|
+
const byPitch = new Map();
|
|
628
|
+
const names = new Map();
|
|
629
|
+
for (const pitch of [...counts.keys()].sort((x, y) => x - y)) {
|
|
630
|
+
const c = counts.get(pitch);
|
|
631
|
+
const sorted = new Map();
|
|
632
|
+
for (const slot of [...c.keys()].sort((x, y) => x - y))
|
|
633
|
+
sorted.set(slot, c.get(slot));
|
|
634
|
+
byPitch.set(pitch, sorted);
|
|
635
|
+
names.set(pitch, GM_DRUM_NAMES[pitch] ?? `midi ${pitch}`);
|
|
636
|
+
}
|
|
637
|
+
const fourOnTheFloor = new Map();
|
|
638
|
+
for (const pitch of [35, 36]) {
|
|
639
|
+
const c = counts.get(pitch);
|
|
640
|
+
if (c && c.size) {
|
|
641
|
+
const slots = [...c.keys()].sort((x, y) => x - y);
|
|
642
|
+
fourOnTheFloor.set(pitch, slots.length === 4 && slots[0] === 0 && slots[1] === 2 && slots[2] === 4 && slots[3] === 6);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return { hits, count: hits.length, byPitch, names, fourOnTheFloor };
|
|
646
|
+
}
|
|
647
|
+
/** `60` -> `"C4"`. Sharp spellings, middle C at 60, as every decoder prints it. */
|
|
648
|
+
export function pitchName(p) {
|
|
649
|
+
return `${PC_NAMES[((p % 12) + 12) % 12]}${Math.floor(p / 12) - 1}`;
|
|
650
|
+
}
|
|
651
|
+
//# sourceMappingURL=midi.js.map
|