polyphon 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 +374 -2
- package/README.zh-TW.md +375 -0
- package/dist/engine/automation.d.ts +24 -0
- package/dist/engine/automation.d.ts.map +1 -0
- package/dist/engine/automation.js +55 -0
- package/dist/engine/automation.js.map +1 -0
- package/dist/engine/compile.d.ts +36 -0
- package/dist/engine/compile.d.ts.map +1 -0
- package/dist/engine/compile.js +103 -0
- package/dist/engine/compile.js.map +1 -0
- package/dist/engine/jukebox.d.ts +113 -0
- package/dist/engine/jukebox.d.ts.map +1 -0
- package/dist/engine/jukebox.js +287 -0
- package/dist/engine/jukebox.js.map +1 -0
- package/dist/engine/mixer.d.ts +29 -0
- package/dist/engine/mixer.d.ts.map +1 -0
- package/dist/engine/mixer.js +141 -0
- package/dist/engine/mixer.js.map +1 -0
- package/dist/engine/output.d.ts +14 -0
- package/dist/engine/output.d.ts.map +1 -0
- package/dist/engine/output.js +51 -0
- package/dist/engine/output.js.map +1 -0
- package/dist/engine/playback.d.ts +97 -0
- package/dist/engine/playback.d.ts.map +1 -0
- package/dist/engine/playback.js +311 -0
- package/dist/engine/playback.js.map +1 -0
- package/dist/engine/prepare.d.ts +6 -0
- package/dist/engine/prepare.d.ts.map +1 -0
- package/dist/engine/prepare.js +12 -0
- package/dist/engine/prepare.js.map +1 -0
- package/dist/engine/render.d.ts +14 -0
- package/dist/engine/render.d.ts.map +1 -0
- package/dist/engine/render.js +60 -0
- package/dist/engine/render.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/instruments/bowed.d.ts +357 -0
- package/dist/instruments/bowed.d.ts.map +1 -0
- package/dist/instruments/bowed.js +502 -0
- package/dist/instruments/bowed.js.map +1 -0
- package/dist/instruments/builtin.d.ts +261 -0
- package/dist/instruments/builtin.d.ts.map +1 -0
- package/dist/instruments/builtin.js +160 -0
- package/dist/instruments/builtin.js.map +1 -0
- package/dist/instruments/definition.d.ts +46 -0
- package/dist/instruments/definition.d.ts.map +1 -0
- package/dist/instruments/definition.js +5 -0
- package/dist/instruments/definition.js.map +1 -0
- package/dist/instruments/dsp/ambience.d.ts +17 -0
- package/dist/instruments/dsp/ambience.d.ts.map +1 -0
- package/dist/instruments/dsp/ambience.js +146 -0
- package/dist/instruments/dsp/ambience.js.map +1 -0
- package/dist/instruments/dsp/core.d.ts +22 -0
- package/dist/instruments/dsp/core.d.ts.map +1 -0
- package/dist/instruments/dsp/core.js +100 -0
- package/dist/instruments/dsp/core.js.map +1 -0
- package/dist/instruments/dsp/drums.d.ts +112 -0
- package/dist/instruments/dsp/drums.d.ts.map +1 -0
- package/dist/instruments/dsp/drums.js +566 -0
- package/dist/instruments/dsp/drums.js.map +1 -0
- package/dist/instruments/dsp/modal.d.ts +30 -0
- package/dist/instruments/dsp/modal.d.ts.map +1 -0
- package/dist/instruments/dsp/modal.js +54 -0
- package/dist/instruments/dsp/modal.js.map +1 -0
- package/dist/instruments/dsp/piano.d.ts +9 -0
- package/dist/instruments/dsp/piano.d.ts.map +1 -0
- package/dist/instruments/dsp/piano.js +230 -0
- package/dist/instruments/dsp/piano.js.map +1 -0
- package/dist/instruments/dsp/pluck.d.ts +53 -0
- package/dist/instruments/dsp/pluck.d.ts.map +1 -0
- package/dist/instruments/dsp/pluck.js +195 -0
- package/dist/instruments/dsp/pluck.js.map +1 -0
- package/dist/instruments/dsp/reverb.d.ts +16 -0
- package/dist/instruments/dsp/reverb.d.ts.map +1 -0
- package/dist/instruments/dsp/reverb.js +47 -0
- package/dist/instruments/dsp/reverb.js.map +1 -0
- package/dist/instruments/dsp/staccato.d.ts +18 -0
- package/dist/instruments/dsp/staccato.d.ts.map +1 -0
- package/dist/instruments/dsp/staccato.js +147 -0
- package/dist/instruments/dsp/staccato.js.map +1 -0
- package/dist/instruments/play.d.ts +21 -0
- package/dist/instruments/play.d.ts.map +1 -0
- package/dist/instruments/play.js +140 -0
- package/dist/instruments/play.js.map +1 -0
- package/dist/instruments/registry.d.ts +63 -0
- package/dist/instruments/registry.d.ts.map +1 -0
- package/dist/instruments/registry.js +50 -0
- package/dist/instruments/registry.js.map +1 -0
- package/dist/instruments/samples.d.ts +30 -0
- package/dist/instruments/samples.d.ts.map +1 -0
- package/dist/instruments/samples.js +109 -0
- package/dist/instruments/samples.js.map +1 -0
- package/dist/instruments/voices.d.ts +76 -0
- package/dist/instruments/voices.d.ts.map +1 -0
- package/dist/instruments/voices.js +1061 -0
- package/dist/instruments/voices.js.map +1 -0
- package/dist/midi.d.ts +15 -0
- package/dist/midi.d.ts.map +1 -0
- package/dist/midi.js +280 -0
- package/dist/midi.js.map +1 -0
- package/dist/notation/format.d.ts +12 -0
- package/dist/notation/format.d.ts.map +1 -0
- package/dist/notation/format.js +102 -0
- package/dist/notation/format.js.map +1 -0
- package/dist/notation/harmony.d.ts +83 -0
- package/dist/notation/harmony.d.ts.map +1 -0
- package/dist/notation/harmony.js +191 -0
- package/dist/notation/harmony.js.map +1 -0
- package/dist/notation/melody.d.ts +25 -0
- package/dist/notation/melody.d.ts.map +1 -0
- package/dist/notation/melody.js +138 -0
- package/dist/notation/melody.js.map +1 -0
- package/dist/notation/part.d.ts +34 -0
- package/dist/notation/part.d.ts.map +1 -0
- package/dist/notation/part.js +15 -0
- package/dist/notation/part.js.map +1 -0
- package/dist/notation/transform.d.ts +28 -0
- package/dist/notation/transform.d.ts.map +1 -0
- package/dist/notation/transform.js +67 -0
- package/dist/notation/transform.js.map +1 -0
- package/dist/song.d.ts +140 -0
- package/dist/song.d.ts.map +1 -0
- package/dist/song.js +45 -0
- package/dist/song.js.map +1 -0
- package/dist/tempo.d.ts +36 -0
- package/dist/tempo.d.ts.map +1 -0
- package/dist/tempo.js +81 -0
- package/dist/tempo.js.map +1 -0
- package/dist/theory.d.ts +70 -0
- package/dist/theory.d.ts.map +1 -0
- package/dist/theory.js +183 -0
- package/dist/theory.js.map +1 -0
- package/dist/transcribe.d.ts +81 -0
- package/dist/transcribe.d.ts.map +1 -0
- package/dist/transcribe.js +433 -0
- package/dist/transcribe.js.map +1 -0
- package/package.json +57 -4
- package/src/engine/automation.ts +65 -0
- package/src/engine/compile.ts +124 -0
- package/src/engine/jukebox.ts +322 -0
- package/src/engine/mixer.ts +166 -0
- package/src/engine/output.ts +59 -0
- package/src/engine/playback.ts +349 -0
- package/src/engine/prepare.ts +15 -0
- package/src/engine/render.ts +71 -0
- package/src/index.ts +28 -0
- package/src/instruments/bowed.ts +660 -0
- package/src/instruments/builtin.ts +180 -0
- package/src/instruments/definition.ts +52 -0
- package/src/instruments/dsp/ambience.ts +142 -0
- package/src/instruments/dsp/core.ts +97 -0
- package/src/instruments/dsp/drums.ts +565 -0
- package/src/instruments/dsp/modal.ts +76 -0
- package/src/instruments/dsp/piano.ts +226 -0
- package/src/instruments/dsp/pluck.ts +240 -0
- package/src/instruments/dsp/reverb.ts +56 -0
- package/src/instruments/dsp/staccato.ts +141 -0
- package/src/instruments/play.ts +151 -0
- package/src/instruments/registry.ts +100 -0
- package/src/instruments/samples.ts +119 -0
- package/src/instruments/voices.ts +1168 -0
- package/src/midi.ts +285 -0
- package/src/notation/format.ts +96 -0
- package/src/notation/harmony.ts +233 -0
- package/src/notation/melody.ts +133 -0
- package/src/notation/part.ts +42 -0
- package/src/notation/transform.ts +78 -0
- package/src/song.ts +170 -0
- package/src/tempo.ts +108 -0
- package/src/theory.ts +208 -0
- package/src/transcribe.ts +487 -0
package/src/midi.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Read Standard MIDI Files into songs: notes, tempo changes, time signature, sustain pedal, volume and pan.
|
|
2
|
+
// General MIDI programs pick instruments from the built-in orchestra; channel 10 is the drum kit.
|
|
3
|
+
|
|
4
|
+
import type { BuiltInInstrument } from './instruments/builtin.ts';
|
|
5
|
+
import { stepsPerBar, type Meter, type Note } from './notation/part.ts';
|
|
6
|
+
import type { Song, Track } from './song.ts';
|
|
7
|
+
import type { TempoChange } from './tempo.ts';
|
|
8
|
+
|
|
9
|
+
export interface MidiImportOptions {
|
|
10
|
+
/** Song title (default: the file's first track name, or 'Imported MIDI') */
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a Standard MIDI File (format 0 or 1) into a song that plays once.
|
|
16
|
+
* Notes stay on the file's beat grid and its tempo changes become the song's, so every event still lands at its real time.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const song = songFromMidi(await file.arrayBuffer(), { title: file.name });
|
|
20
|
+
* jukebox.play(song);
|
|
21
|
+
*/
|
|
22
|
+
export function songFromMidi(data: ArrayBuffer | Uint8Array, options: MidiImportOptions = {}): Song {
|
|
23
|
+
const file = parseMidi(data instanceof Uint8Array ? data : new Uint8Array(data));
|
|
24
|
+
const step = (tick: number) => (tick / file.ppq) * 4;
|
|
25
|
+
const signature = file.tracks.flatMap((t) => t.events).find((e) => e.kind === 'meter');
|
|
26
|
+
const meter: Meter = signature ? `${signature.value}/${signature.denominator}` : 16;
|
|
27
|
+
const { bpm, tempo } = tempoChanges(file, meter);
|
|
28
|
+
|
|
29
|
+
const groups = new Map<string, { name: string; instrument: BuiltInInstrument; gain: number; pan: number; notes: Note[] }>();
|
|
30
|
+
let end = 0;
|
|
31
|
+
file.tracks.forEach((track, ti) => {
|
|
32
|
+
const program = new Array<number>(16).fill(0);
|
|
33
|
+
const volume = new Array<number | undefined>(16);
|
|
34
|
+
const pan = new Array<number | undefined>(16);
|
|
35
|
+
const pedal = new Array<boolean>(16).fill(false);
|
|
36
|
+
const sounding = new Map<string, { tick: number; velocity: number; program: number }[]>();
|
|
37
|
+
const held = new Map<number, { key: string; start: { tick: number; velocity: number; program: number } }[]>();
|
|
38
|
+
const finish = (key: string, start: { tick: number; velocity: number; program: number }, tick: number) => {
|
|
39
|
+
const [channel, pitch] = key.split('|').map(Number);
|
|
40
|
+
const drum = channel === 9 ? DRUMS[pitch] : undefined;
|
|
41
|
+
if (channel === 9 && !drum) return;
|
|
42
|
+
const instrument = drum ? drum[0] : GM_PROGRAMS[start.program];
|
|
43
|
+
const id = `${ti}|${channel}|${instrument}`;
|
|
44
|
+
let g = groups.get(id);
|
|
45
|
+
if (!g) {
|
|
46
|
+
const label = drum ? drum[1] : track.name || GM_NAMES[Math.floor(start.program / 8)];
|
|
47
|
+
g = { name: label, instrument, gain: (volume[channel] ?? 100) / 100, pan: ((pan[channel] ?? 64) - 64) / 64, notes: [] };
|
|
48
|
+
groups.set(id, g);
|
|
49
|
+
}
|
|
50
|
+
const time = step(start.tick);
|
|
51
|
+
const duration = Math.max(0.25, step(tick) - time);
|
|
52
|
+
g.notes.push({ time, duration, pitches: drum && !drum[2] ? [] : [pitch], velocity: start.velocity / 127 });
|
|
53
|
+
end = Math.max(end, time + duration);
|
|
54
|
+
};
|
|
55
|
+
for (const e of track.events) {
|
|
56
|
+
if (e.kind === 'program') program[e.channel] = e.value;
|
|
57
|
+
else if (e.kind === 'control' && e.control === 7 && volume[e.channel] === undefined) volume[e.channel] = e.value;
|
|
58
|
+
else if (e.kind === 'control' && e.control === 10 && pan[e.channel] === undefined) pan[e.channel] = e.value;
|
|
59
|
+
else if (e.kind === 'control' && e.control === 64) {
|
|
60
|
+
const down = e.value >= 64;
|
|
61
|
+
if (pedal[e.channel] && !down) for (const h of held.get(e.channel)?.splice(0) ?? []) finish(h.key, h.start, e.tick);
|
|
62
|
+
pedal[e.channel] = down;
|
|
63
|
+
} else if (e.kind === 'noteOn') {
|
|
64
|
+
const key = `${e.channel}|${e.pitch}`;
|
|
65
|
+
// A key struck again while the pedal holds the old note: the old note ends here
|
|
66
|
+
const h = held.get(e.channel);
|
|
67
|
+
const i = h?.findIndex((x) => x.key === key) ?? -1;
|
|
68
|
+
if (h && i >= 0) finish(key, h.splice(i, 1)[0].start, e.tick);
|
|
69
|
+
const list = sounding.get(key) ?? [];
|
|
70
|
+
list.push({ tick: e.tick, velocity: e.velocity, program: program[e.channel] });
|
|
71
|
+
sounding.set(key, list);
|
|
72
|
+
} else if (e.kind === 'noteOff') {
|
|
73
|
+
const key = `${e.channel}|${e.pitch}`;
|
|
74
|
+
const start = sounding.get(key)?.shift();
|
|
75
|
+
if (!start) continue;
|
|
76
|
+
if (pedal[e.channel] && e.channel !== 9) {
|
|
77
|
+
const h = held.get(e.channel) ?? [];
|
|
78
|
+
h.push({ key, start });
|
|
79
|
+
held.set(e.channel, h);
|
|
80
|
+
} else finish(key, start, e.tick);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Notes still sounding or held at the end of the track
|
|
84
|
+
const last = track.events.length ? track.events[track.events.length - 1].tick : 0;
|
|
85
|
+
for (const [key, list] of sounding) for (const start of list) finish(key, start, last);
|
|
86
|
+
for (const h of held.values()) for (const x of h) finish(x.key, x.start, last);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const duration = Math.ceil(end);
|
|
90
|
+
const noteCount = [...groups.values()].reduce((n, g) => n + g.notes.length, 0);
|
|
91
|
+
const tracks: Track[] = [...groups.values()].map((g) => ({
|
|
92
|
+
name: g.name,
|
|
93
|
+
instrument: g.instrument,
|
|
94
|
+
part: { notes: g.notes.sort((a, b) => a.time - b.time), duration },
|
|
95
|
+
gain: g.gain,
|
|
96
|
+
pan: g.pan,
|
|
97
|
+
repeat: false,
|
|
98
|
+
}));
|
|
99
|
+
if (!tracks.length) throw new Error('The MIDI file has no notes');
|
|
100
|
+
const title = options.title ?? (file.tracks.find((t) => t.name)?.name || 'Imported MIDI');
|
|
101
|
+
return {
|
|
102
|
+
title,
|
|
103
|
+
description: `Imported from MIDI: ${tracks.length} tracks, ${noteCount} notes.`,
|
|
104
|
+
bpm,
|
|
105
|
+
...(tempo.length ? { tempo } : {}),
|
|
106
|
+
meter,
|
|
107
|
+
loop: false,
|
|
108
|
+
tracks,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ───────────────────────── Reading the file ─────────────────────────
|
|
113
|
+
|
|
114
|
+
type MidiEvent =
|
|
115
|
+
| { kind: 'noteOn' | 'noteOff'; tick: number; channel: number; pitch: number; velocity: number }
|
|
116
|
+
| { kind: 'control'; tick: number; channel: number; control: number; value: number }
|
|
117
|
+
| { kind: 'program'; tick: number; channel: number; value: number }
|
|
118
|
+
| { kind: 'tempo'; tick: number; value: number }
|
|
119
|
+
| { kind: 'meter'; tick: number; value: number; denominator: number };
|
|
120
|
+
|
|
121
|
+
interface MidiFile {
|
|
122
|
+
ppq: number;
|
|
123
|
+
tracks: { name: string; events: MidiEvent[] }[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseMidi(b: Uint8Array): MidiFile {
|
|
127
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
128
|
+
const text = (at: number, n: number) => String.fromCharCode(...b.subarray(at, at + n));
|
|
129
|
+
if (text(0, 4) !== 'MThd') throw new Error('Not a MIDI file');
|
|
130
|
+
const count = view.getUint16(10);
|
|
131
|
+
const ppq = view.getUint16(12);
|
|
132
|
+
if (ppq & 0x8000) throw new Error('SMPTE-timed MIDI files are not supported');
|
|
133
|
+
const tracks: MidiFile['tracks'] = [];
|
|
134
|
+
let at = 8 + view.getUint32(4);
|
|
135
|
+
for (let t = 0; t < count && at < b.length; t++) {
|
|
136
|
+
const length = view.getUint32(at + 4);
|
|
137
|
+
if (text(at, 4) === 'MTrk') tracks.push(parseTrack(b, at + 8, at + 8 + length));
|
|
138
|
+
at += 8 + length;
|
|
139
|
+
}
|
|
140
|
+
return { ppq, tracks };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseTrack(b: Uint8Array, from: number, to: number): MidiFile['tracks'][number] {
|
|
144
|
+
const events: MidiEvent[] = [];
|
|
145
|
+
let name = '';
|
|
146
|
+
let i = from;
|
|
147
|
+
let tick = 0;
|
|
148
|
+
let status = 0;
|
|
149
|
+
const varLen = () => {
|
|
150
|
+
let v = 0;
|
|
151
|
+
for (;;) {
|
|
152
|
+
const c = b[i++];
|
|
153
|
+
v = (v << 7) | (c & 0x7f);
|
|
154
|
+
if (!(c & 0x80)) return v;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
while (i < to) {
|
|
158
|
+
tick += varLen();
|
|
159
|
+
if (b[i] & 0x80) status = b[i++];
|
|
160
|
+
if (status === 0xff) {
|
|
161
|
+
const type = b[i++];
|
|
162
|
+
const length = varLen();
|
|
163
|
+
const d = b.subarray(i, i + length);
|
|
164
|
+
if (type === 0x51) events.push({ kind: 'tempo', tick, value: (d[0] << 16) | (d[1] << 8) | d[2] });
|
|
165
|
+
else if (type === 0x58) events.push({ kind: 'meter', tick, value: d[0], denominator: 2 ** d[1] });
|
|
166
|
+
else if (type === 0x03 && !name) name = new TextDecoder().decode(d).trim();
|
|
167
|
+
i += length;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (status === 0xf0 || status === 0xf7) {
|
|
171
|
+
i += varLen();
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const channel = status & 0x0f;
|
|
175
|
+
switch (status & 0xf0) {
|
|
176
|
+
case 0x90:
|
|
177
|
+
case 0x80: {
|
|
178
|
+
const pitch = b[i++];
|
|
179
|
+
const velocity = b[i++];
|
|
180
|
+
events.push({ kind: (status & 0xf0) === 0x90 && velocity > 0 ? 'noteOn' : 'noteOff', tick, channel, pitch, velocity });
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case 0xb0:
|
|
184
|
+
events.push({ kind: 'control', tick, channel, control: b[i++], value: b[i++] });
|
|
185
|
+
break;
|
|
186
|
+
case 0xc0:
|
|
187
|
+
events.push({ kind: 'program', tick, channel, value: b[i++] });
|
|
188
|
+
break;
|
|
189
|
+
case 0xd0:
|
|
190
|
+
i += 1;
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
i += 2;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { name, events };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The opening tempo (default 120) and the changes after it, at the bars where they fall (fractional when mid-bar) */
|
|
200
|
+
function tempoChanges(file: MidiFile, meter: Meter): { bpm: number; tempo: TempoChange[] } {
|
|
201
|
+
const bar = stepsPerBar(meter);
|
|
202
|
+
let bpm = 120;
|
|
203
|
+
const tempo: TempoChange[] = [];
|
|
204
|
+
const events = file.tracks.flatMap((t) => t.events).filter((e) => e.kind === 'tempo');
|
|
205
|
+
for (const e of events.sort((a, b) => a.tick - b.tick)) {
|
|
206
|
+
const value = Math.round(60e8 / e.value) / 100;
|
|
207
|
+
if (e.tick === 0) bpm = value;
|
|
208
|
+
else if (value !== (tempo.at(-1)?.bpm ?? bpm)) tempo.push({ bar: 1 + (e.tick / file.ppq) * 4 / bar, bpm: value });
|
|
209
|
+
}
|
|
210
|
+
return { bpm, tempo };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ───────────────────────── General MIDI ─────────────────────────
|
|
214
|
+
|
|
215
|
+
/** Instrument for each of the 128 General MIDI programs, one family of eight per line */
|
|
216
|
+
const GM_PROGRAMS: BuiltInInstrument[] = [
|
|
217
|
+
'piano', 'piano', 'piano', 'piano', 'epiano', 'epiano', 'dulcimer', 'piano',
|
|
218
|
+
'celesta', 'glock', 'musicbox', 'vibes', 'marimba', 'marimba', 'chime', 'dulcimer',
|
|
219
|
+
'organ', 'organ', 'organ', 'organFull', 'organ', 'accordion', 'accordion', 'accordion',
|
|
220
|
+
'nylon', 'steel', 'steel', 'steel', 'muteGtr', 'powerGtr', 'powerGtr', 'steel',
|
|
221
|
+
'pizzBass', 'bass', 'bass', 'bass', 'bass', 'bass', 'synthBass', 'synthBass',
|
|
222
|
+
'violin', 'violin', 'cello', 'contrabass', 'strings', 'pizz', 'harp', 'timpani',
|
|
223
|
+
'strings', 'strPad', 'strPad', 'strPad', 'choir', 'choirOo', 'choirO', 'brassStab',
|
|
224
|
+
'trumpet', 'trombone', 'tuba', 'trumpet', 'horn', 'trumpets', 'trumpets', 'horns',
|
|
225
|
+
'oboe', 'clarinet', 'clarinet', 'bassoon', 'oboe', 'oboe', 'bassoon', 'clarinet',
|
|
226
|
+
'flute', 'flute', 'whistle', 'panflute', 'ocarina', 'panflute', 'whistle', 'ocarina',
|
|
227
|
+
'synthLead', 'synthLead', 'synthLead', 'synthLead', 'synthLead', 'synthLead', 'synthLead', 'synthLead',
|
|
228
|
+
'warmPad', 'warmPad', 'warmPad', 'choirOo', 'strPad', 'glassPad', 'airPad', 'darkPad',
|
|
229
|
+
'glassPad', 'glassPad', 'airPad', 'glassPad', 'glassPad', 'darkPad', 'airPad', 'glassPad',
|
|
230
|
+
'lute', 'steel', 'lute', 'harp', 'kalimba', 'oboe', 'fiddle', 'oboe',
|
|
231
|
+
'glock', 'woodblock', 'steelpan', 'woodblock', 'taiko', 'tom', 'tom', 'swell',
|
|
232
|
+
'glassPad', 'glassPad', 'glassPad', 'glassPad', 'glassPad', 'glassPad', 'glassPad', 'glassPad',
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
/** Display names of the General MIDI families (eight programs each) */
|
|
236
|
+
const GM_NAMES = ['Piano', 'Chromatic percussion', 'Organ', 'Guitar', 'Bass', 'Strings', 'Ensemble', 'Brass', 'Reed', 'Pipe', 'Synth lead', 'Synth pad', 'Synth effects', 'Ethnic', 'Percussive', 'Sound effects'];
|
|
237
|
+
|
|
238
|
+
/** General MIDI drum keys: [instrument, mixer name, keeps its pitch] */
|
|
239
|
+
const DRUMS: Record<number, [BuiltInInstrument, string, boolean]> = {
|
|
240
|
+
35: ['kick', 'Kick', false],
|
|
241
|
+
36: ['kick', 'Kick', false],
|
|
242
|
+
37: ['rim', 'Rim', false],
|
|
243
|
+
38: ['snare', 'Snare', false],
|
|
244
|
+
39: ['clap', 'Clap', false],
|
|
245
|
+
40: ['snare', 'Snare', false],
|
|
246
|
+
41: ['tom', 'Toms', true],
|
|
247
|
+
42: ['hat', 'Hi-hat', false],
|
|
248
|
+
43: ['tom', 'Toms', true],
|
|
249
|
+
44: ['hat', 'Hi-hat', false],
|
|
250
|
+
45: ['tom', 'Toms', true],
|
|
251
|
+
46: ['ohat', 'Open hi-hat', false],
|
|
252
|
+
47: ['tom', 'Toms', true],
|
|
253
|
+
48: ['tom', 'Toms', true],
|
|
254
|
+
49: ['crash', 'Crash', false],
|
|
255
|
+
50: ['tom', 'Toms', true],
|
|
256
|
+
51: ['ride', 'Ride', false],
|
|
257
|
+
52: ['crash', 'Crash', false],
|
|
258
|
+
53: ['ride', 'Ride', false],
|
|
259
|
+
54: ['tamb', 'Tambourine', false],
|
|
260
|
+
55: ['crash', 'Crash', false],
|
|
261
|
+
56: ['woodblock', 'Cowbell', true],
|
|
262
|
+
57: ['crash', 'Crash', false],
|
|
263
|
+
58: ['shaker', 'Shaker', false],
|
|
264
|
+
59: ['ride', 'Ride', false],
|
|
265
|
+
60: ['darbuka', 'Bongos', true],
|
|
266
|
+
61: ['darbuka', 'Bongos', true],
|
|
267
|
+
62: ['darbuka', 'Congas', true],
|
|
268
|
+
63: ['darbuka', 'Congas', true],
|
|
269
|
+
64: ['darbuka', 'Congas', true],
|
|
270
|
+
65: ['tom', 'Timbales', true],
|
|
271
|
+
66: ['tom', 'Timbales', true],
|
|
272
|
+
67: ['woodblock', 'Agogo', true],
|
|
273
|
+
68: ['woodblock', 'Agogo', true],
|
|
274
|
+
69: ['shaker', 'Shaker', false],
|
|
275
|
+
70: ['shaker', 'Shaker', false],
|
|
276
|
+
73: ['shaker', 'Shaker', false],
|
|
277
|
+
74: ['shaker', 'Shaker', false],
|
|
278
|
+
75: ['clave', 'Claves', false],
|
|
279
|
+
76: ['woodblock', 'Wood block', true],
|
|
280
|
+
77: ['woodblock', 'Wood block', true],
|
|
281
|
+
78: ['darbuka', 'Cuica', true],
|
|
282
|
+
79: ['darbuka', 'Cuica', true],
|
|
283
|
+
80: ['triangle', 'Triangle', false],
|
|
284
|
+
81: ['triangle', 'Triangle', false],
|
|
285
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Parts written back as melody strings, the notation melody() reads: for notes that came from elsewhere (a voice, a MIDI file).
|
|
2
|
+
|
|
3
|
+
import { midi } from '../theory.ts';
|
|
4
|
+
import { stepsPerBar, VELOCITY, type Meter, type Note, type Part } from './part.ts';
|
|
5
|
+
|
|
6
|
+
const SHARPS = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
7
|
+
const FLATS = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
|
8
|
+
/** Without a key signature: each black key as it is most often written (noteName's spelling) */
|
|
9
|
+
const PLAIN = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'];
|
|
10
|
+
/** Keys whose signatures have flats; the others but C major and A minor have sharps */
|
|
11
|
+
const FLAT_KEYS = new Set(['F', 'Bb', 'Eb', 'Ab', 'Db', 'Gb', 'Cb'].map((t) => `${t} major`).concat(['D', 'G', 'C', 'F', 'Bb', 'Eb', 'Ab'].map((t) => `${t} minor`)));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write a part as a melody string, with a bar line after every bar; a note that crosses a bar line is tied over it. Black keys are
|
|
15
|
+
* spelled as the key signature of `key` has them ('Eb major' writes Bb, 'E major' writes D#), or as noteName() does without one.
|
|
16
|
+
* The notes must follow one another: notes that start together become a chord, and must end together.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* formatMelody(melody('C5:4 E5 G5 C6 | B5:12 r:4 |')); // 'C5:4 E5 G5 C6 | B5:12 r:4 |'
|
|
20
|
+
* formatMelody(part, '3/4', 'F major');
|
|
21
|
+
*/
|
|
22
|
+
export function formatMelody(part: Part, meter: Meter = 16, key?: string): string {
|
|
23
|
+
const bar = stepsPerBar(meter);
|
|
24
|
+
const name = speller(key);
|
|
25
|
+
const tokens: string[] = [];
|
|
26
|
+
/** The length in force: a token that leaves its length out reuses it */
|
|
27
|
+
let length: number | undefined;
|
|
28
|
+
let time = 0;
|
|
29
|
+
|
|
30
|
+
/** Write a note (or a rest, without pitches) from `time`, splitting it at bar lines */
|
|
31
|
+
const write = (pitches: string | null, duration: number, mark = '', glide = false) => {
|
|
32
|
+
const end = time + duration;
|
|
33
|
+
for (let first = true; time < end - 1e-9; first = false) {
|
|
34
|
+
const barEnd = (Math.floor(time / bar + 1e-9) + 1) * bar;
|
|
35
|
+
const piece = Math.min(end, barEnd) - time;
|
|
36
|
+
const last = time + piece >= end - 1e-9;
|
|
37
|
+
if (pitches === null && Math.abs(piece - bar) < 1e-9) tokens.push('R');
|
|
38
|
+
else {
|
|
39
|
+
const written = length !== undefined && Math.abs(piece - length) < 1e-9 ? '' : `:${formatLength(piece)}`;
|
|
40
|
+
const body = pitches === null ? 'r' : `${first ? mark : ''}${pitches}`;
|
|
41
|
+
tokens.push(`${body}${written}${pitches !== null && last && glide ? '>' : ''}${pitches !== null && !last ? '~' : ''}`);
|
|
42
|
+
length = piece;
|
|
43
|
+
}
|
|
44
|
+
time += piece;
|
|
45
|
+
if (Math.abs(time - barEnd) < 1e-9) tokens.push('|');
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (const chord of chords(part.notes)) {
|
|
50
|
+
const [{ time: start, duration, velocity, glide }] = chord;
|
|
51
|
+
if (start < time - 1e-9) throw new Error(`Notes overlap at step ${+start.toFixed(3)}: a melody string holds one line`);
|
|
52
|
+
if (start > time) write(null, start - time);
|
|
53
|
+
const mark = velocity >= (VELOCITY.accent + VELOCITY.normal) / 2 ? '!' : velocity <= (VELOCITY.normal + VELOCITY.ghost) / 2 ? '?' : '';
|
|
54
|
+
write(chord.flatMap((n) => n.pitches).map(name).join('+'), duration, mark, glide);
|
|
55
|
+
}
|
|
56
|
+
if (part.duration > time) write(null, part.duration - time);
|
|
57
|
+
return tokens.join(' ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Notes grouped by when they start, in order; a group must end together */
|
|
61
|
+
function chords(notes: readonly Note[]): Note[][] {
|
|
62
|
+
const groups: Note[][] = [];
|
|
63
|
+
for (const note of [...notes].sort((a, b) => a.time - b.time)) {
|
|
64
|
+
const group = groups[groups.length - 1];
|
|
65
|
+
if (group && Math.abs(group[0].time - note.time) < 1e-9) {
|
|
66
|
+
if (Math.abs(group[0].duration - note.duration) > 1e-9) throw new Error(`Notes starting together at step ${+note.time.toFixed(3)} must end together`);
|
|
67
|
+
group.push(note);
|
|
68
|
+
} else groups.push([note]);
|
|
69
|
+
}
|
|
70
|
+
return groups;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A length in steps as the notation writes it: 4, or 4/3 for a triplet eighth */
|
|
74
|
+
function formatLength(steps: number): string {
|
|
75
|
+
for (let denominator = 1; denominator <= 16; denominator++) {
|
|
76
|
+
const numerator = steps * denominator;
|
|
77
|
+
if (Math.abs(numerator - Math.round(numerator)) < 1e-6) return denominator === 1 ? `${Math.round(numerator)}` : `${Math.round(numerator)}/${denominator}`;
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`A length of ${steps} steps cannot be written`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Note names in a key: its signature's sharps or flats */
|
|
83
|
+
function speller(key?: string): (pitch: number) => string {
|
|
84
|
+
const names = [...PLAIN];
|
|
85
|
+
if (key) {
|
|
86
|
+
const m = /^([A-Ga-g])([#b]?)\s+(major|minor)$/i.exec(key.trim());
|
|
87
|
+
if (!m) throw new Error(`Unrecognized key: ${key} (write it as 'Eb major' or 'F# minor')`);
|
|
88
|
+
const [tonic, mode] = [`${m[1].toUpperCase()}${m[2]}`, m[3].toLowerCase()];
|
|
89
|
+
const plain = (tonic === 'C' && mode === 'major') || (tonic === 'A' && mode === 'minor');
|
|
90
|
+
if (!plain) names.splice(0, 12, ...(FLAT_KEYS.has(`${tonic} ${mode}`) ? FLATS : SHARPS));
|
|
91
|
+
// A minor key raises its sixth and seventh on the way up, with sharps or naturals: G# in A minor, C# in D minor
|
|
92
|
+
const root = midi(`${tonic}4`) % 12;
|
|
93
|
+
if (mode === 'minor') for (const degree of [9, 11]) names[(root + degree) % 12] = SHARPS[(root + degree) % 12];
|
|
94
|
+
}
|
|
95
|
+
return (pitch) => `${names[((pitch % 12) + 12) % 12]}${Math.floor(pitch / 12) - 1}`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Chord progressions, and the parts that grow out of them: pads, arpeggios, bass lines, sustained roots and strums.
|
|
2
|
+
|
|
3
|
+
import { midi, parseChord, voiceChord, type ChordInfo, type Pitch } from '../theory.ts';
|
|
4
|
+
import { stepsPerBar, VELOCITY, type Meter, type Note, type Part } from './part.ts';
|
|
5
|
+
|
|
6
|
+
/** A chord placed in time (steps) */
|
|
7
|
+
export interface Chord extends ChordInfo {
|
|
8
|
+
time: number;
|
|
9
|
+
duration: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a chord progression. Bars are separated by |, and the chords in a bar share it equally;
|
|
14
|
+
* lengths can also be written out ('C:12 G:4'). '%' holds the previous chord for one more bar.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* chords('Am | F | C G | E7')
|
|
18
|
+
* chords('Dm:12 A7:4 | %')
|
|
19
|
+
*/
|
|
20
|
+
export function chords(source: string, meter: Meter = 16): Chord[] {
|
|
21
|
+
const bar = stepsPerBar(meter);
|
|
22
|
+
const out: Chord[] = [];
|
|
23
|
+
let t = 0;
|
|
24
|
+
for (const cell of source.split('|')) {
|
|
25
|
+
const toks = cell.trim().split(/\s+/).filter(Boolean);
|
|
26
|
+
if (!toks.length) continue;
|
|
27
|
+
const explicit = toks.some((x) => x.includes(':'));
|
|
28
|
+
const each = bar / toks.length;
|
|
29
|
+
for (const tok of toks) {
|
|
30
|
+
const [sym, len] = tok.split(':');
|
|
31
|
+
const d = explicit ? Number(len ?? 0) || each : each;
|
|
32
|
+
if (sym === '%') {
|
|
33
|
+
if (out.length) out[out.length - 1].duration += d;
|
|
34
|
+
} else out.push({ ...parseChord(sym), time: t, duration: d });
|
|
35
|
+
t += d;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Total length of a progression, in steps */
|
|
42
|
+
export const chordsDuration = (progression: Chord[]): number => {
|
|
43
|
+
const last = progression[progression.length - 1];
|
|
44
|
+
return last ? last.time + last.duration : 0;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface PadOptions {
|
|
48
|
+
/** The register the voicing centers on (default C4) */
|
|
49
|
+
center?: Pitch;
|
|
50
|
+
/** Notes per chord (default: every chord tone) */
|
|
51
|
+
size?: number;
|
|
52
|
+
velocity?: number;
|
|
53
|
+
/** Steps of silence before each chord change */
|
|
54
|
+
gap?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Sustained chords (pads, string sections, choirs, organ), voiced to move smoothly */
|
|
58
|
+
export function pad(progression: Chord[], { center = 60, size, velocity = VELOCITY.normal, gap = 0 }: PadOptions = {}): Part {
|
|
59
|
+
let prev: number[] | undefined;
|
|
60
|
+
const notes = progression.map((c) => {
|
|
61
|
+
const m = voiceChord(c, { center, previous: prev, size });
|
|
62
|
+
prev = m;
|
|
63
|
+
return { time: c.time, duration: c.duration - gap, pitches: m, velocity };
|
|
64
|
+
});
|
|
65
|
+
return { notes, duration: chordsDuration(progression) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ArpeggioOptions {
|
|
69
|
+
/** Steps per pattern character (default 2: eighth notes) */
|
|
70
|
+
step?: number;
|
|
71
|
+
/** The register the voicing centers on (default E4) */
|
|
72
|
+
center?: Pitch;
|
|
73
|
+
/** Notes per chord voicing (default: every chord tone) */
|
|
74
|
+
size?: number;
|
|
75
|
+
velocity?: number;
|
|
76
|
+
/** Accent every Nth note */
|
|
77
|
+
accent?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Arpeggio: the pattern picks chord tones by digit (0 is the lowest; past the number of chord tones it climbs an octave);
|
|
82
|
+
* '.' is a rest, '-' holds the previous note. The pattern restarts on every chord.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* arpeggio(chords('C | Am'), '0 1 2 1')
|
|
86
|
+
* arpeggio(chords('C | Am'), '0-12 3.21', { step: 1, center: 'C5' })
|
|
87
|
+
*/
|
|
88
|
+
export function arpeggio(progression: Chord[], pattern: string, { step = 2, center = 64, size, velocity = VELOCITY.normal, accent }: ArpeggioOptions = {}): Part {
|
|
89
|
+
const pat = pattern.replace(/\s+/g, '');
|
|
90
|
+
const notes: Note[] = [];
|
|
91
|
+
let prev: number[] | undefined;
|
|
92
|
+
for (const c of progression) {
|
|
93
|
+
const vc = voiceChord(c, { center, previous: prev, size });
|
|
94
|
+
prev = vc;
|
|
95
|
+
let k = 0;
|
|
96
|
+
for (let s = 0; s < c.duration - 1e-6; s += step, k++) {
|
|
97
|
+
const ch = pat[k % pat.length];
|
|
98
|
+
if (ch === '.') continue;
|
|
99
|
+
if (ch === '-') {
|
|
100
|
+
if (notes.length) notes[notes.length - 1].duration += step;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const idx = parseInt(ch, 36);
|
|
104
|
+
const n = vc.length;
|
|
105
|
+
const m = vc[idx % n] + 12 * Math.floor(idx / n);
|
|
106
|
+
const acc = accent && k % accent === 0 ? 1.15 : 1;
|
|
107
|
+
notes.push({ time: c.time + s, duration: Math.min(step, c.duration - s), pitches: [m], velocity: Math.min(1, velocity * acc) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { notes, duration: chordsDuration(progression) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What to stack above the bass: prefer a perfect fifth; if there is none (diminished chord, inversion, passing bass), look in turn for a sixth, a fourth, a third */
|
|
114
|
+
const ABOVE = [7, 8, 9, 5, 4, 3, 6];
|
|
115
|
+
|
|
116
|
+
/** The chord's bass note, placed within a fifth below to a fifth above the register */
|
|
117
|
+
function bassNote(c: Chord, register: number): number {
|
|
118
|
+
let low = register + ((c.bass - (register % 12) + 12) % 12);
|
|
119
|
+
if (low > register + 7) low -= 12;
|
|
120
|
+
return low;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RootsOptions {
|
|
124
|
+
/** Where the bass sits: notes land within a fifth below to a fifth above this pitch (default C2) */
|
|
125
|
+
register?: Pitch;
|
|
126
|
+
/** Stack one more chord tone on top (usually an open fifth) */
|
|
127
|
+
fifth?: boolean;
|
|
128
|
+
velocity?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Sustained bass notes: one per chord, held for the whole chord; a repeated bass note continues without re-attacking */
|
|
132
|
+
export function roots(progression: Chord[], { register = 36, fifth = false, velocity = 0.8 }: RootsOptions = {}): Part {
|
|
133
|
+
const reg = midi(register);
|
|
134
|
+
const notes: Note[] = [];
|
|
135
|
+
for (const c of progression) {
|
|
136
|
+
// Passing bass (a slash bass that is not a chord tone): just follows the bass, with no fifth stacked (stacking it would clash with the chord)
|
|
137
|
+
const pcs = new Set(c.intervals.map((i) => (c.root + i) % 12));
|
|
138
|
+
const low = bassNote(c, reg);
|
|
139
|
+
const m = [low];
|
|
140
|
+
if (fifth && pcs.has(c.bass)) {
|
|
141
|
+
const up = ABOVE.find((k) => pcs.has((c.bass + k) % 12));
|
|
142
|
+
if (up !== undefined) m.push(low + up);
|
|
143
|
+
}
|
|
144
|
+
const last = notes[notes.length - 1];
|
|
145
|
+
if (last && last.pitches.join() === m.join() && last.time + last.duration === c.time) last.duration += c.duration;
|
|
146
|
+
else notes.push({ time: c.time, duration: c.duration, pitches: m, velocity });
|
|
147
|
+
}
|
|
148
|
+
return { notes, duration: chordsDuration(progression) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface BasslineOptions {
|
|
152
|
+
/** Where the bass sits: the chord's bass lands within a fifth below to a fifth above this pitch (default C2) */
|
|
153
|
+
register?: Pitch;
|
|
154
|
+
velocity?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Bass line: the rhythm is a rhythm string that repeats every bar ('-' holds), and the line lists the scale degrees used in turn:
|
|
159
|
+
* 1 the chord's bass, 3 the chord's third, 5 the fifth, 8 an octave up, -8 an octave down, b7 / 7 / 2 / 4 / 6, b2 (Phrygian), -5 / -4 below.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* bassline(chords('C | Am | F | G'), 'x.x.x.x.x.x.x.x.', '1 5 8 5')
|
|
163
|
+
*/
|
|
164
|
+
export function bassline(progression: Chord[], rhythm: string, line = '1', { register = 36, velocity = VELOCITY.normal }: BasslineOptions = {}): Part {
|
|
165
|
+
const rh = rhythm.replace(/[\s|]/g, '');
|
|
166
|
+
const steps = line.trim().split(/\s+/);
|
|
167
|
+
const reg = midi(register);
|
|
168
|
+
const notes: Note[] = [];
|
|
169
|
+
let li = 0;
|
|
170
|
+
for (const c of progression) {
|
|
171
|
+
const low = bassNote(c, reg);
|
|
172
|
+
// Other degrees are counted from the root, placed at the nearest position above the bass
|
|
173
|
+
const rootAbove = low + ((c.root - c.bass + 12) % 12);
|
|
174
|
+
for (let s = 0; s < c.duration; s++) {
|
|
175
|
+
const ch = rh[(c.time + s) % rh.length];
|
|
176
|
+
if (ch === '-') {
|
|
177
|
+
if (notes.length) notes[notes.length - 1].duration += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (ch !== 'x' && ch !== 'X' && ch !== 'o') continue;
|
|
181
|
+
const deg = steps[li++ % steps.length];
|
|
182
|
+
let m: number;
|
|
183
|
+
if (deg === '1') m = low;
|
|
184
|
+
else if (deg === '8') m = low + 12;
|
|
185
|
+
else if (deg === '-8') m = low - 12;
|
|
186
|
+
else {
|
|
187
|
+
m = rootAbove + degreeOffset(deg, c);
|
|
188
|
+
if (!deg.startsWith('-')) while (m < low) m += 12;
|
|
189
|
+
}
|
|
190
|
+
notes.push({ time: c.time + s, duration: 1, pitches: [m], velocity: ch === 'X' ? 1 : ch === 'o' ? 0.5 : velocity });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { notes, duration: chordsDuration(progression) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function degreeOffset(deg: string, c: ChordInfo): number {
|
|
197
|
+
const fifth = c.intervals.includes(6) && !c.intervals.includes(7) ? 6 : c.intervals.includes(8) ? 8 : 7;
|
|
198
|
+
const map: Record<string, number> = { '1': 0, b2: 1, '2': 2, b3: 3, '3': c.third || 4, '4': 5, b5: 6, '5': fifth, b6: 8, '6': 9, b7: 10, '7': 11, '8': 12, '10': 12 + (c.third || 4), '-5': -5, '-4': -7, '-b7': -2, '-7': -1, '-8': -12 };
|
|
199
|
+
const v = map[deg];
|
|
200
|
+
if (v === undefined) throw new Error(`Unrecognized bass scale degree: ${deg}`);
|
|
201
|
+
return v;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface StrumOptions {
|
|
205
|
+
/** The register the voicing centers on (default Bb3) */
|
|
206
|
+
center?: Pitch;
|
|
207
|
+
/** Strings per strum (default 5) */
|
|
208
|
+
size?: number;
|
|
209
|
+
/** Seconds between strings (default 0.014) */
|
|
210
|
+
spread?: number;
|
|
211
|
+
velocity?: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Strummed chords: the rhythm repeats every bar, x down-stroke, X heavy, o soft, '-' holds */
|
|
215
|
+
export function strum(progression: Chord[], rhythm: string, { center = 58, size = 5, spread = 0.014, velocity = VELOCITY.normal }: StrumOptions = {}): Part {
|
|
216
|
+
const rh = rhythm.replace(/[\s|]/g, '');
|
|
217
|
+
const notes: Note[] = [];
|
|
218
|
+
let prev: number[] | undefined;
|
|
219
|
+
for (const c of progression) {
|
|
220
|
+
const vc = voiceChord(c, { center, previous: prev, size });
|
|
221
|
+
prev = vc;
|
|
222
|
+
for (let s = 0; s < c.duration; s++) {
|
|
223
|
+
const ch = rh[(c.time + s) % rh.length];
|
|
224
|
+
if (ch === '-') {
|
|
225
|
+
if (notes.length) notes[notes.length - 1].duration += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (ch !== 'x' && ch !== 'X' && ch !== 'o') continue;
|
|
229
|
+
notes.push({ time: c.time + s, duration: 1, pitches: vc, velocity: ch === 'X' ? 1 : ch === 'o' ? 0.5 : velocity, strum: spread });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { notes, duration: chordsDuration(progression) };
|
|
233
|
+
}
|