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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Melodies and rhythms written as short strings.
|
|
2
|
+
//
|
|
3
|
+
// Melody:
|
|
4
|
+
// 'E5:4 D5:2 C5:2 | B4:8 r:8 |' note:length in sixteenth-note steps, r is a rest, | is a bar line (each bar's length is checked)
|
|
5
|
+
// 'E5:4 D5 C5 B4' leave out the length to reuse the previous one
|
|
6
|
+
// 'C4+E4+G4:16' a chord (pitches sounding together)
|
|
7
|
+
// '!E5:4 ?E5:2' ! accent, ? soft (ghost note)
|
|
8
|
+
// 'E5:4> G5:4' > glide into the next note (instruments that support glides)
|
|
9
|
+
// 'G4:8~ | G4:4' ~ ties into the next note of the same pitch, across bar lines too
|
|
10
|
+
// 'E5:4/3' fractional length (a triplet eighth note is 4/3)
|
|
11
|
+
// '(E5:2 D5:2)*4 R*2' parenthesized groups repeat; R is a full bar of rest; . is the same as r
|
|
12
|
+
//
|
|
13
|
+
// Rhythm: one character per step. X accent, x normal, o soft, . or - rest; spaces and | are ignored.
|
|
14
|
+
|
|
15
|
+
import { midi, type Pitch } from '../theory.ts';
|
|
16
|
+
import { stepsPerBar, VELOCITY, type Meter, type Note, type Part } from './part.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a melody string.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* melody('C5:4 E5 G5 C6 | B5:8 G5:8 |')
|
|
23
|
+
* melody('G4:6 A4:2 B4:4 | D5:12 |', '3/4')
|
|
24
|
+
*/
|
|
25
|
+
export function melody(source: string, meter: Meter = 16): Part {
|
|
26
|
+
const bar = stepsPerBar(meter);
|
|
27
|
+
const notes: Note[] = [];
|
|
28
|
+
let t = 0;
|
|
29
|
+
let dur = 4;
|
|
30
|
+
let barStart = 0;
|
|
31
|
+
let barNo = 1;
|
|
32
|
+
let lastR = false;
|
|
33
|
+
/** The note a ~ holds on into the next one */
|
|
34
|
+
let tied: Note | undefined;
|
|
35
|
+
for (const raw of expandRepeats(source).split(/\s+/)) {
|
|
36
|
+
if (!raw) continue;
|
|
37
|
+
if (raw === '|') {
|
|
38
|
+
// R is a complete bar by itself, so a bar line right after it needs no further check (the "R*3 |" style)
|
|
39
|
+
if (lastR && Math.abs(t - barStart) < 1e-6) {
|
|
40
|
+
lastR = false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (Math.abs(t - barStart - bar) > 1e-6) throw new Error(`Bar ${barNo} is ${+(t - barStart).toFixed(3)} steps long, expected ${bar}: ...${source.slice(0, 60)}`);
|
|
44
|
+
barStart = t;
|
|
45
|
+
barNo++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (raw === 'R') {
|
|
49
|
+
if (tied) throw new Error(`A tie (~) must lead into the same note, not a rest: ${raw}`);
|
|
50
|
+
const atBar = Math.abs(t - barStart) < 1e-6;
|
|
51
|
+
t += bar;
|
|
52
|
+
if (atBar) {
|
|
53
|
+
barStart = t;
|
|
54
|
+
barNo++;
|
|
55
|
+
}
|
|
56
|
+
lastR = atBar;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
lastR = false;
|
|
60
|
+
let tok = raw;
|
|
61
|
+
let v: number = VELOCITY.normal;
|
|
62
|
+
if (tok[0] === '!') {
|
|
63
|
+
v = VELOCITY.accent;
|
|
64
|
+
tok = tok.slice(1);
|
|
65
|
+
} else if (tok[0] === '?') {
|
|
66
|
+
v = VELOCITY.ghost;
|
|
67
|
+
tok = tok.slice(1);
|
|
68
|
+
}
|
|
69
|
+
const tie = tok.endsWith('~');
|
|
70
|
+
if (tie) tok = tok.slice(0, -1);
|
|
71
|
+
let glide = false;
|
|
72
|
+
if (tok.endsWith('>')) {
|
|
73
|
+
glide = true;
|
|
74
|
+
tok = tok.slice(0, -1);
|
|
75
|
+
}
|
|
76
|
+
const [pitch, len] = tok.split(':');
|
|
77
|
+
if (len) {
|
|
78
|
+
const [a, b] = len.split('/');
|
|
79
|
+
dur = b ? Number(a) / Number(b) : Number(a);
|
|
80
|
+
if (!(dur > 0)) throw new Error(`Invalid duration: ${raw}`);
|
|
81
|
+
}
|
|
82
|
+
if (pitch === 'r' || pitch === '.') {
|
|
83
|
+
if (tied || tie) throw new Error(`A tie (~) joins two notes of the same pitch, not a rest: ${raw}`);
|
|
84
|
+
} else if (tied) {
|
|
85
|
+
if (pitch.split('+').map(midi).join() !== tied.pitches.join()) throw new Error(`A tie (~) must lead into the same pitch: ${raw}`);
|
|
86
|
+
tied.duration += dur;
|
|
87
|
+
if (glide) tied.glide = true;
|
|
88
|
+
} else notes.push({ time: t, duration: dur, pitches: pitch.split('+').map(midi), velocity: v, glide: glide || undefined });
|
|
89
|
+
tied = tie ? notes[notes.length - 1] : undefined;
|
|
90
|
+
t += dur;
|
|
91
|
+
}
|
|
92
|
+
if (tied) throw new Error('A tie (~) at the end of a melody has no note to lead into');
|
|
93
|
+
return { notes, duration: t };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function expandRepeats(src: string): string {
|
|
97
|
+
let s = src.replace(/\|/g, ' | ');
|
|
98
|
+
// Expand parentheses from the inside out
|
|
99
|
+
for (let guard = 0; guard < 50 && s.includes('('); guard++) {
|
|
100
|
+
s = s.replace(/\(([^()]*)\)\*(\d+)/g, (_, body: string, n: string) => Array(Number(n)).fill(body).join(' '));
|
|
101
|
+
s = s.replace(/\(([^()]*)\)(?!\*)/g, (_, body: string) => body);
|
|
102
|
+
}
|
|
103
|
+
// Repeat a single token: R*4, E5:2*3
|
|
104
|
+
s = s.replace(/(\S+)\*(\d+)/g, (_, tok: string, n: string) => Array(Number(n)).fill(tok).join(' '));
|
|
105
|
+
return s;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface RhythmOptions {
|
|
109
|
+
/** Steps per character (default 1: each character is a sixteenth note) */
|
|
110
|
+
step?: number;
|
|
111
|
+
/** The pitch every hit plays (tuned drums, or any instrument); default: unpitched */
|
|
112
|
+
pitch?: Pitch;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a rhythm: one character per step. X accent, x normal, o soft, . or - rest.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* rhythm('X...x...X..xx...') // a kick pattern
|
|
120
|
+
* rhythm('x.x.x.x.', { pitch: 'G2' }) // a pitched pulse
|
|
121
|
+
*/
|
|
122
|
+
export function rhythm(pattern: string, { step = 1, pitch }: RhythmOptions = {}): Part {
|
|
123
|
+
const m = pitch === undefined ? undefined : midi(pitch);
|
|
124
|
+
const notes: Note[] = [];
|
|
125
|
+
let t = 0;
|
|
126
|
+
for (const ch of pattern) {
|
|
127
|
+
if (ch === ' ' || ch === '|' || ch === '\n') continue;
|
|
128
|
+
const v = ch === 'X' ? VELOCITY.accent : ch === 'x' ? VELOCITY.normal : ch === 'o' ? VELOCITY.ghost : 0;
|
|
129
|
+
if (v) notes.push({ time: t, duration: step, pitches: m === undefined ? [] : [m], velocity: v });
|
|
130
|
+
t += step;
|
|
131
|
+
}
|
|
132
|
+
return { notes, duration: t };
|
|
133
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The data every notation function produces: a Part is a list of notes on a grid of sixteenth-note steps.
|
|
2
|
+
// It is plain data, so any code that returns { notes, duration } can feed a track.
|
|
3
|
+
|
|
4
|
+
/** One note (or chord) in a part. Times are in steps: one step is a sixteenth note. */
|
|
5
|
+
export interface Note {
|
|
6
|
+
/** Start, in steps from the start of the part */
|
|
7
|
+
time: number;
|
|
8
|
+
/** Length, in steps */
|
|
9
|
+
duration: number;
|
|
10
|
+
/** MIDI pitches sounding together; unpitched drums may leave this empty */
|
|
11
|
+
pitches: number[];
|
|
12
|
+
/** Loudness 0-1 */
|
|
13
|
+
velocity: number;
|
|
14
|
+
/** Glide into the next note (instruments that support glides) */
|
|
15
|
+
glide?: boolean;
|
|
16
|
+
/** Strum a chord: delay in seconds between successive pitches */
|
|
17
|
+
strum?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Part {
|
|
21
|
+
notes: Note[];
|
|
22
|
+
/** Total length, in steps */
|
|
23
|
+
duration: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A time signature, or the number of sixteenth-note steps in one bar.
|
|
28
|
+
* '4/4' = 16, '3/4' = 12, '6/8' = 12, '7/8' = 14, '5/4' = 20.
|
|
29
|
+
*/
|
|
30
|
+
export type Meter = number | `${number}/${number}`;
|
|
31
|
+
|
|
32
|
+
/** Steps in one bar of a meter (default 4/4 = 16) */
|
|
33
|
+
export function stepsPerBar(meter: Meter = 16): number {
|
|
34
|
+
if (typeof meter === 'number') return meter;
|
|
35
|
+
const [beats, unit] = meter.split('/').map(Number);
|
|
36
|
+
const steps = (beats * 16) / unit;
|
|
37
|
+
if (!(steps > 0) || !Number.isFinite(steps)) throw new Error(`Invalid meter: ${meter}`);
|
|
38
|
+
return steps;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Velocities for the three dynamics the notation can write */
|
|
42
|
+
export const VELOCITY = { normal: 0.78, accent: 1, ghost: 0.42 } as const;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Combine and reshape parts. Every function returns a new part; nothing is modified in place.
|
|
2
|
+
|
|
3
|
+
import type { Chord } from './harmony.ts';
|
|
4
|
+
import { melody } from './melody.ts';
|
|
5
|
+
import { stepsPerBar, type Meter, type Note, type Part } from './part.ts';
|
|
6
|
+
|
|
7
|
+
const asPart = (p: Part | string): Part => (typeof p === 'string' ? melody(p) : p);
|
|
8
|
+
|
|
9
|
+
/** Play parts one after another (strings are read as 4/4 melodies) */
|
|
10
|
+
export function sequence(...parts: (Part | string)[]): Part {
|
|
11
|
+
const notes: Note[] = [];
|
|
12
|
+
let t = 0;
|
|
13
|
+
for (const p of parts.map(asPart)) {
|
|
14
|
+
for (const n of p.notes) notes.push({ ...n, time: n.time + t });
|
|
15
|
+
t += p.duration;
|
|
16
|
+
}
|
|
17
|
+
return { notes, duration: t };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Play parts at the same time; the result is as long as the longest */
|
|
21
|
+
export function stack(...parts: Part[]): Part {
|
|
22
|
+
return { notes: parts.flatMap((p) => p.notes).sort((a, b) => a.time - b.time), duration: Math.max(0, ...parts.map((p) => p.duration)) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Play a part n times in a row */
|
|
26
|
+
export function repeat(part: Part, times: number): Part {
|
|
27
|
+
return sequence(...Array<Part>(times).fill(part));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Silence for a number of bars */
|
|
31
|
+
export const rest = (bars: number, meter: Meter = 16): Part => ({ notes: [], duration: bars * stepsPerBar(meter) });
|
|
32
|
+
|
|
33
|
+
/** Shift every pitch by a number of semitones */
|
|
34
|
+
export function transpose(part: Part, semitones: number): Part {
|
|
35
|
+
return mapPitch(part, (m) => m + semitones);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Rewrite every pitch (for mode changes, inversions and so on) */
|
|
39
|
+
export function mapPitch(part: Part, fn: (midi: number) => number): Part {
|
|
40
|
+
return { notes: part.notes.map((n) => ({ ...n, pitches: n.pitches.map(fn) })), duration: part.duration };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Multiply every velocity (0.5 = half as loud; capped at 1) */
|
|
44
|
+
export function scaleVelocity(part: Part, factor: number): Part {
|
|
45
|
+
return { notes: part.notes.map((n) => ({ ...n, velocity: Math.min(1, n.velocity * factor) })), duration: part.duration };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Cut out the steps [from, to); notes that run past the end are shortened */
|
|
49
|
+
export function slice(part: Part, from: number, to: number): Part {
|
|
50
|
+
return {
|
|
51
|
+
notes: part.notes.filter((n) => n.time >= from && n.time < to).map((n) => ({ ...n, time: n.time - from, duration: Math.min(n.duration, to - n.time) })),
|
|
52
|
+
duration: to - from,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The sustain pedal: every note rings on until the next pedal change, or until its key is struck again.
|
|
58
|
+
* Changes are given as a step interval (16: once a bar), a list of steps, or a chord progression (change with each chord).
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* pedal(arpeggio(harmony, '0 1 2 1'), harmony) // re-pedal on every chord
|
|
62
|
+
* pedal(melody('...'), 16) // once a bar
|
|
63
|
+
*/
|
|
64
|
+
export function pedal(part: Part, changes: number | number[] | Chord[]): Part {
|
|
65
|
+
const points =
|
|
66
|
+
typeof changes === 'number'
|
|
67
|
+
? Array.from({ length: Math.ceil(part.duration / changes) }, (_, i) => (i + 1) * changes)
|
|
68
|
+
: changes.map((c) => (typeof c === 'number' ? c : c.time)).sort((a, b) => a - b);
|
|
69
|
+
const nextChange = (t: number) => points.find((p) => p > t + 1e-9) ?? part.duration;
|
|
70
|
+
const nextStrike = (t: number, pitches: number[]) => {
|
|
71
|
+
const later = part.notes.filter((n) => n.time > t + 1e-9 && n.pitches.some((m) => pitches.includes(m)));
|
|
72
|
+
return later.length ? Math.min(...later.map((n) => n.time)) : Infinity;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
notes: part.notes.map((n) => ({ ...n, duration: Math.max(n.duration, Math.min(nextChange(n.time), nextStrike(n.time, n.pitches)) - n.time) })),
|
|
76
|
+
duration: part.duration,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/song.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// A song is plain data: tempo, the room it plays in, and tracks that pair an instrument with a part.
|
|
2
|
+
|
|
3
|
+
import type { Room } from './instruments/dsp/reverb.ts';
|
|
4
|
+
import type { InstrumentName } from './instruments/registry.ts';
|
|
5
|
+
import { melody } from './notation/melody.ts';
|
|
6
|
+
import { stepsPerBar, type Meter, type Part } from './notation/part.ts';
|
|
7
|
+
import { tempoClock, type TempoChange } from './tempo.ts';
|
|
8
|
+
|
|
9
|
+
export interface Song {
|
|
10
|
+
title: string;
|
|
11
|
+
/** One line on the mood and the instrumentation */
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Beats (quarter notes) per minute: the tempo the song starts at */
|
|
14
|
+
bpm: number;
|
|
15
|
+
/** Changes of tempo along the way: at once (a new section) or gliding (ritardando, accelerando) */
|
|
16
|
+
tempo?: TempoChange[];
|
|
17
|
+
/** Dead stops: moments where everything falls silent, the room's echo too, before the music crashes back in */
|
|
18
|
+
silences?: Silence[];
|
|
19
|
+
/** Time signature (default '4/4') */
|
|
20
|
+
meter?: Meter;
|
|
21
|
+
/** Eighth-note swing 0-0.5 (0.33 is roughly a triplet feel) */
|
|
22
|
+
swing?: number;
|
|
23
|
+
/** Sixteenth-note swing 0-0.5 */
|
|
24
|
+
swing16?: number;
|
|
25
|
+
/** The room (default: a medium hall) */
|
|
26
|
+
reverb?: Reverb;
|
|
27
|
+
/** A tempo-synced stereo ping-pong delay that tracks can send to */
|
|
28
|
+
delay?: Delay;
|
|
29
|
+
/** Level of the whole song (linear, default 1) */
|
|
30
|
+
gain?: number;
|
|
31
|
+
/** Mastering: EQ, glue compression and, for loud genres, a limiter */
|
|
32
|
+
master?: Master;
|
|
33
|
+
/** Plays once before the tracks start looping */
|
|
34
|
+
intro?: Track[];
|
|
35
|
+
/** The body of the song; loops unless `loop` is false */
|
|
36
|
+
tracks: Track[];
|
|
37
|
+
/** false: play the body once and end (jingles, fanfares). Default true */
|
|
38
|
+
loop?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Track {
|
|
42
|
+
/** Shown in mixers; defaults to the instrument name */
|
|
43
|
+
name?: string;
|
|
44
|
+
instrument: InstrumentName;
|
|
45
|
+
/** The notes: a part, or a melody string read in the song's meter */
|
|
46
|
+
part: Part | string;
|
|
47
|
+
/** Level (linear, default 1) */
|
|
48
|
+
gain?: number;
|
|
49
|
+
/** -1 left to 1 right */
|
|
50
|
+
pan?: number;
|
|
51
|
+
/** How much goes to the reverb, 0-1 (default 0.25) */
|
|
52
|
+
reverb?: number;
|
|
53
|
+
/** How much goes to the delay, 0-1 (default 0) */
|
|
54
|
+
delay?: number;
|
|
55
|
+
/** Duck under another track's hits, the pumping of house and EDM: that track's name (usually 'Kick'), or how deep and how long too */
|
|
56
|
+
duck?: string | Duck;
|
|
57
|
+
/** Low-pass filter: a cutoff in Hz, or a sweep */
|
|
58
|
+
lowpass?: number | Sweep;
|
|
59
|
+
/** High-pass filter: a cutoff in Hz, or a sweep */
|
|
60
|
+
highpass?: number | Sweep;
|
|
61
|
+
/** Semitones */
|
|
62
|
+
transpose?: number;
|
|
63
|
+
/** Timing looseness: the most a note may land early or late, in seconds (default 0.008, drums 0.003) */
|
|
64
|
+
humanize?: number;
|
|
65
|
+
/** Instrument settings, such as attack, release, bright, glide */
|
|
66
|
+
params?: Record<string, number>;
|
|
67
|
+
/** false: play once at the start of the section instead of repeating to fill it. Default true */
|
|
68
|
+
repeat?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A dead stop from `bar` (counted from 1 at the start of the song, the intro's bars first; fractional to start mid-bar, so 32.75 is the
|
|
73
|
+
* last beat of bar 32) for `beats` quarter notes. Everything falls silent, the reverb and delay tails too, as if the room were switched off,
|
|
74
|
+
* and comes back at full level when it ends. A silence in the body happens on every pass.
|
|
75
|
+
*/
|
|
76
|
+
export interface Silence {
|
|
77
|
+
bar: number;
|
|
78
|
+
beats: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface Reverb extends Room {
|
|
82
|
+
/** Level of the reverb return (default 1) */
|
|
83
|
+
mix?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Delay {
|
|
87
|
+
/** Delay time, in sixteenth-note steps at the song's opening tempo */
|
|
88
|
+
steps: number;
|
|
89
|
+
/** Echo feedback 0-1 (default 0.35) */
|
|
90
|
+
feedback?: number;
|
|
91
|
+
/** Low-pass on the echoes, Hz (default 2800) */
|
|
92
|
+
tone?: number;
|
|
93
|
+
/** Level of the delay return (default 0.8) */
|
|
94
|
+
mix?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Sidechain pumping: at every hit of another track (usually the kick) the level dips, then swells back */
|
|
98
|
+
export interface Duck {
|
|
99
|
+
/** The name of the track whose hits duck this one, in the same section (it pumps even while that track is muted) */
|
|
100
|
+
by: string;
|
|
101
|
+
/** How far the level dips, 0-1 (default 0.7) */
|
|
102
|
+
depth?: number;
|
|
103
|
+
/** Steps until it is back up, at the song's opening tempo (default 4: a beat) */
|
|
104
|
+
release?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A filter sweep, { step: Hz }, counted from the start of the section: the cutoff glides from one to the next (evenly in pitch) and
|
|
109
|
+
* holds before the first and after the last. `{ 0: 400, 128: 400, 256: 18000 }` stays dark for eight bars, then opens over eight more.
|
|
110
|
+
*/
|
|
111
|
+
export type Sweep = Record<number, number>;
|
|
112
|
+
|
|
113
|
+
export interface Master {
|
|
114
|
+
/** Body at 150 Hz, dB */
|
|
115
|
+
warm?: number;
|
|
116
|
+
/** Pulls 1.3 kHz down, dB */
|
|
117
|
+
cut?: number;
|
|
118
|
+
/** Level of the mix, dB */
|
|
119
|
+
trim?: number;
|
|
120
|
+
/** Bus compression that glues the mix together, 0-1 (0.3 gentle, 0.6 firm) */
|
|
121
|
+
glue?: number;
|
|
122
|
+
/** Master loud: dB pushed into a brickwall limiter that keeps the peaks under -1 dBFS, between the samples too (EDM: about 6-10). The song then bypasses the gentle limiter other songs share */
|
|
123
|
+
loud?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const DEFAULT_REVERB: Reverb = { size: 1.8, damp: 0.45 };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Declare a song. It returns the song unchanged; it exists so editors can type-check and autocomplete it.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* export default defineSong({
|
|
133
|
+
* title: 'Morning Market',
|
|
134
|
+
* bpm: 112,
|
|
135
|
+
* tracks: [
|
|
136
|
+
* { instrument: 'marimba', part: 'C5:2 E5 G5 E5 | D5:4 B4:4 G4:8 |' },
|
|
137
|
+
* { instrument: 'pizzBass', part: bassline(chords('C | G'), 'x...x...x...x...', '1 5') },
|
|
138
|
+
* ],
|
|
139
|
+
* });
|
|
140
|
+
*/
|
|
141
|
+
export function defineSong(song: Song): Song {
|
|
142
|
+
return song;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A track's name: its own, or its instrument's */
|
|
146
|
+
export function trackName(track: Track): string {
|
|
147
|
+
return track.name ?? track.instrument;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The part a track plays (melody strings are read in the song's meter) */
|
|
151
|
+
export function trackPart(track: Track, meter: Meter): Part {
|
|
152
|
+
return typeof track.part === 'string' ? melody(track.part, meter) : track.part;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Length of a section whose tracks have these parts: the longest, rounded up to whole bars (at least one bar) */
|
|
156
|
+
export function sectionSteps(parts: Part[], meter: Meter): number {
|
|
157
|
+
const bar = stepsPerBar(meter);
|
|
158
|
+
const longest = Math.max(bar, ...parts.map((p) => p.duration));
|
|
159
|
+
return Math.ceil(longest / bar - 1e-6) * bar;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** How long the intro and one pass of the body last, in seconds */
|
|
163
|
+
export function songDuration(song: Song): { intro: number; loop: number } {
|
|
164
|
+
const meter = song.meter ?? 16;
|
|
165
|
+
const steps = (tracks: Track[]) => sectionSteps(tracks.map((t) => trackPart(t, meter)), meter);
|
|
166
|
+
const clock = tempoClock(song);
|
|
167
|
+
const introSteps = song.intro?.length ? steps(song.intro) : 0;
|
|
168
|
+
const intro = clock.seconds(introSteps);
|
|
169
|
+
return { intro, loop: clock.seconds(introSteps + steps(song.tracks)) - intro };
|
|
170
|
+
}
|
package/src/tempo.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Tempo through a song: changes at once (a new section) or gradually (ritardando, accelerando), and the clock that turns positions in
|
|
2
|
+
// the score into seconds.
|
|
3
|
+
|
|
4
|
+
import { stepsPerBar, type Meter } from './notation/part.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A change of tempo at the start of a bar, counted from 1 at the start of the song (the intro's bars first, then the body's).
|
|
8
|
+
* `{ bar: 90, bpm: 66 }` is a new tempo from bar 90; `{ bar: 120, bpm: 80, over: 4 }` slows down evenly to 80 across bars 120 to 123.
|
|
9
|
+
* A bar may be fractional to change mid-bar: 12.5 is halfway through bar 12.
|
|
10
|
+
*/
|
|
11
|
+
export interface TempoChange {
|
|
12
|
+
bar: number;
|
|
13
|
+
/** Beats (quarter notes) per minute */
|
|
14
|
+
bpm: number;
|
|
15
|
+
/** How many bars the change takes, gliding evenly from the tempo before it (default 0: at once) */
|
|
16
|
+
over?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A song's tempo through its score: positions in steps from the start of the song, as seconds and back */
|
|
20
|
+
export interface TempoClock {
|
|
21
|
+
/** Seconds from the start of the song to a step */
|
|
22
|
+
seconds(step: number): number;
|
|
23
|
+
/** The step the song has reached after `seconds` */
|
|
24
|
+
step(seconds: number): number;
|
|
25
|
+
/** Beats per minute at a step */
|
|
26
|
+
bpm(step: number): number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A stretch of the score from `step` to the next span: the tempo starts at `bpm` and glides by `slope` per step (0: holds) */
|
|
30
|
+
interface Span {
|
|
31
|
+
step: number;
|
|
32
|
+
bpm: number;
|
|
33
|
+
/** Seconds from the start of the song */
|
|
34
|
+
seconds: number;
|
|
35
|
+
/** Change in bpm per step */
|
|
36
|
+
slope: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Seconds a step lasts at a tempo: 60 / bpm / 4 */
|
|
40
|
+
const stepSeconds = (bpm: number) => 15 / bpm;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The clock of a song (its `bpm`, `tempo` and `meter`). Each pass of a looping body plays the same changes: the body's steps follow
|
|
44
|
+
* the intro's, so a body step's time within its pass is `seconds(introSteps + step) - seconds(introSteps)`.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const clock = tempoClock({ bpm: 120, meter: '3/4', tempo: [{ bar: 9, bpm: 60 }] });
|
|
48
|
+
* clock.seconds(8 * 12 + 4); // 12 + 0.25 * 4 = 13: eight bars at 120, then a beat at 60
|
|
49
|
+
*/
|
|
50
|
+
export function tempoClock({ bpm, tempo = [], meter }: { bpm: number; tempo?: readonly TempoChange[]; meter?: Meter }): TempoClock {
|
|
51
|
+
const bar = stepsPerBar(meter);
|
|
52
|
+
if (!(bpm > 0)) throw new Error(`Invalid tempo: ${bpm} bpm`);
|
|
53
|
+
const spans: Span[] = [{ step: 0, bpm, seconds: 0, slope: 0 }];
|
|
54
|
+
/** Hold or glide from the end of the last span to `step`, arriving at `target` bpm */
|
|
55
|
+
const to = (step: number, target: number) => {
|
|
56
|
+
const last = spans[spans.length - 1];
|
|
57
|
+
if (step <= last.step) return;
|
|
58
|
+
last.slope = (target - last.bpm) / (step - last.step);
|
|
59
|
+
spans.push({ step, bpm: target, seconds: last.seconds + within(last, step - last.step), slope: 0 });
|
|
60
|
+
};
|
|
61
|
+
for (const change of [...tempo].sort((a, b) => a.bar - b.bar)) {
|
|
62
|
+
const start = (change.bar - 1) * bar;
|
|
63
|
+
const end = start + (change.over ?? 0) * bar;
|
|
64
|
+
const before = spans[spans.length - 1];
|
|
65
|
+
if (!(change.bpm > 0) || !(change.bar >= 1) || !(end >= start)) throw new Error(`Invalid tempo change: ${JSON.stringify(change)}`);
|
|
66
|
+
if (start < before.step - 1e-9) throw new Error(`The tempo change at bar ${change.bar} starts before the change before it has finished`);
|
|
67
|
+
to(start, before.bpm);
|
|
68
|
+
if (end > start) to(end, change.bpm);
|
|
69
|
+
else spans[spans.length - 1].bpm = change.bpm;
|
|
70
|
+
}
|
|
71
|
+
/** The last span starting at or before `value` (a MIDI file's rubato can make thousands) */
|
|
72
|
+
const spanAt = (key: 'step' | 'seconds', value: number) => {
|
|
73
|
+
let lo = 0;
|
|
74
|
+
let hi = spans.length - 1;
|
|
75
|
+
while (lo < hi) {
|
|
76
|
+
const mid = (lo + hi + 1) >> 1;
|
|
77
|
+
if (spans[mid][key] <= value) lo = mid;
|
|
78
|
+
else hi = mid - 1;
|
|
79
|
+
}
|
|
80
|
+
return spans[lo];
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
seconds: (step) => {
|
|
84
|
+
const span = spanAt('step', step);
|
|
85
|
+
return span.seconds + within(span, step - span.step);
|
|
86
|
+
},
|
|
87
|
+
step: (seconds) => {
|
|
88
|
+
const span = spanAt('seconds', seconds);
|
|
89
|
+
return span.step + stepsWithin(span, seconds - span.seconds);
|
|
90
|
+
},
|
|
91
|
+
bpm: (step) => {
|
|
92
|
+
const span = spanAt('step', step);
|
|
93
|
+
return span.bpm + span.slope * Math.max(0, step - span.step);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Seconds for the first `steps` of a span: the tempo glides linearly, so the time is the integral of 15 / bpm(step) */
|
|
99
|
+
function within({ bpm, slope }: Span, steps: number): number {
|
|
100
|
+
if (Math.abs(slope) < 1e-12 || steps <= 0) return steps * stepSeconds(bpm);
|
|
101
|
+
return (15 / slope) * Math.log(1 + (slope * steps) / bpm);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Steps a span covers in its first `seconds` (the inverse of `within`) */
|
|
105
|
+
function stepsWithin({ bpm, slope }: Span, seconds: number): number {
|
|
106
|
+
if (Math.abs(slope) < 1e-12 || seconds <= 0) return seconds / stepSeconds(bpm);
|
|
107
|
+
return (bpm / slope) * Math.expm1((slope * seconds) / 15);
|
|
108
|
+
}
|