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,59 @@
|
|
|
1
|
+
// The final stage every song goes through, live or rendered: the level songs are balanced for, then a gentle limiter.
|
|
2
|
+
// A song mastered loud (`master.loud`) ends in a stage of its own instead, a brickwall limiter, and goes around this one.
|
|
3
|
+
|
|
4
|
+
import type { Song } from '../song.ts';
|
|
5
|
+
|
|
6
|
+
/** Songs are mixed to sound right at this bus level */
|
|
7
|
+
export const MIX_LEVEL = 0.36;
|
|
8
|
+
|
|
9
|
+
/** Level and limiter in front of a destination; returns the stage's input */
|
|
10
|
+
export function createOutputStage(ctx: BaseAudioContext, destination: AudioNode): GainNode {
|
|
11
|
+
const level = ctx.createGain();
|
|
12
|
+
level.gain.value = MIX_LEVEL;
|
|
13
|
+
const limiter = ctx.createDynamicsCompressor();
|
|
14
|
+
limiter.threshold.value = -14;
|
|
15
|
+
limiter.ratio.value = 4;
|
|
16
|
+
level.connect(limiter).connect(destination);
|
|
17
|
+
return level;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Songs mastered loud end in a limiter of their own, so they skip the output stage */
|
|
21
|
+
export function mastersItself(song: Song): boolean {
|
|
22
|
+
return song.master?.loud !== undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The loudest a song mastered loud peaks, dBFS: the clipper's oversampling overshoots it by about 0.25 dB between the samples, which stays under -1 */
|
|
26
|
+
const CEILING = -1.5;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The final stage of a song mastered loud: `loud` dB of drive into a brickwall limiter (songs peak around full scale before the
|
|
30
|
+
* bus level, so that is about how much their loudest moments are turned down), then a soft clipper that rounds off whatever gets
|
|
31
|
+
* past it, under the ceiling. Returns its input.
|
|
32
|
+
*/
|
|
33
|
+
export function createLoudStage(ctx: BaseAudioContext, loud: number, destination: AudioNode): GainNode {
|
|
34
|
+
const drive = ctx.createGain();
|
|
35
|
+
drive.gain.value = dB(loud);
|
|
36
|
+
const limiter = new DynamicsCompressorNode(ctx, { threshold: CEILING, knee: 0, ratio: 20, attack: 0.001, release: 0.06 });
|
|
37
|
+
// Take back the makeup gain Web Audio adds, (1 / the gain at 0 dBFS) ^ 0.6; and halve the signal, since a WaveShaper reads
|
|
38
|
+
// inputs from -1 to 1 and the clipper's curve covers peaks up to 6 dB over full scale
|
|
39
|
+
const trim = ctx.createGain();
|
|
40
|
+
trim.gain.value = dB(0.6 * CEILING * (1 - 1 / 20)) / 2;
|
|
41
|
+
const clipper = ctx.createWaveShaper();
|
|
42
|
+
clipper.curve = softClip(dB(CEILING - 2), dB(CEILING));
|
|
43
|
+
clipper.oversample = '4x';
|
|
44
|
+
drive.connect(limiter).connect(trim).connect(clipper).connect(destination);
|
|
45
|
+
return drive;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** WaveShaper curve over inputs -2 to 2: straight up to `knee`, then bending smoothly over towards `ceiling` */
|
|
49
|
+
function softClip(knee: number, ceiling: number): Float32Array<ArrayBuffer> {
|
|
50
|
+
const room = ceiling - knee;
|
|
51
|
+
return Float32Array.from({ length: 4097 }, (_, i) => {
|
|
52
|
+
const x = (i / 2048 - 1) * 2;
|
|
53
|
+
const a = Math.abs(x);
|
|
54
|
+
return a <= knee ? x : Math.sign(x) * (knee + room * Math.tanh((a - knee) / room));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Decibels to a gain */
|
|
59
|
+
const dB = (db: number): number => Math.pow(10, db / 20);
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// A song being played (one playback). Notes are scheduled ahead of time in small windows (call schedule() regularly, as the Jukebox does),
|
|
2
|
+
// so timing stays sample-accurate while nothing is computed during playback except starting buffers and voices.
|
|
3
|
+
|
|
4
|
+
import { getInstrument } from '../instruments/registry.ts';
|
|
5
|
+
import { playNote } from '../instruments/play.ts';
|
|
6
|
+
import { stepsPerBar } from '../notation/part.ts';
|
|
7
|
+
import { DEFAULT_REVERB, trackName, type Song, type Track } from '../song.ts';
|
|
8
|
+
import { tempoClock, type TempoClock } from '../tempo.ts';
|
|
9
|
+
import { pump, sweep } from './automation.ts';
|
|
10
|
+
import { compileSection, type CompiledSection, type ScheduledNote } from './compile.ts';
|
|
11
|
+
import { createMixer, type Strip } from './mixer.ts';
|
|
12
|
+
|
|
13
|
+
export interface PlaybackOptions {
|
|
14
|
+
/** When to start, in context seconds (default: 80 ms from now) */
|
|
15
|
+
at?: number;
|
|
16
|
+
/** Start this many seconds into the song (default 0) */
|
|
17
|
+
from?: number;
|
|
18
|
+
/** Fade in over this many seconds (default 0) */
|
|
19
|
+
fadeIn?: number;
|
|
20
|
+
/** How many times the body plays before the song ends (default: once if `song.loop` is false, otherwise forever) */
|
|
21
|
+
loops?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One track of a playing song, for mixers and meters */
|
|
25
|
+
export interface PlaybackTrack {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly instrument: string;
|
|
28
|
+
readonly section: 'intro' | 'loop';
|
|
29
|
+
/** The track's signal after its level, filters and ducking, before pan and effects (connect an AnalyserNode here for a meter) */
|
|
30
|
+
readonly output: AudioNode;
|
|
31
|
+
muted: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where a playback is in its song */
|
|
35
|
+
export interface SongPosition {
|
|
36
|
+
/** Seconds since the start of the song */
|
|
37
|
+
seconds: number;
|
|
38
|
+
section: 'intro' | 'loop';
|
|
39
|
+
/** Bar within the section, from 1 */
|
|
40
|
+
bar: number;
|
|
41
|
+
/** How many passes of the body have completed */
|
|
42
|
+
loop: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const INTRO = 0;
|
|
46
|
+
const BODY = 1;
|
|
47
|
+
|
|
48
|
+
export class Playback {
|
|
49
|
+
readonly song: Song;
|
|
50
|
+
readonly context: BaseAudioContext;
|
|
51
|
+
/** The song's output node */
|
|
52
|
+
readonly output: GainNode;
|
|
53
|
+
readonly tracks: readonly PlaybackTrack[];
|
|
54
|
+
/** Seconds */
|
|
55
|
+
readonly introDuration: number;
|
|
56
|
+
/** Seconds for one pass of the body */
|
|
57
|
+
readonly loopDuration: number;
|
|
58
|
+
/** Seconds from start to end (Infinity when the body loops forever) */
|
|
59
|
+
readonly duration: number;
|
|
60
|
+
/** Context time the song started (or would have started, when playing from a later point) */
|
|
61
|
+
readonly startTime: number;
|
|
62
|
+
/** Resolves when a song with an end reaches it (never resolves if stopped first) */
|
|
63
|
+
readonly finished: Promise<void>;
|
|
64
|
+
/** The song's tempo: steps from the start of the song (the intro's, then one pass of the body's) as seconds, and back */
|
|
65
|
+
readonly clock: TempoClock;
|
|
66
|
+
|
|
67
|
+
private readonly sections: [CompiledSection, CompiledSection];
|
|
68
|
+
private readonly strips: [Strip[], Strip[]];
|
|
69
|
+
/** Closes for the song's silences */
|
|
70
|
+
private readonly gate: GainNode;
|
|
71
|
+
/** Each section's silences, [from, to] in steps from its start */
|
|
72
|
+
private readonly silences: [[number, number][], [number, number][]];
|
|
73
|
+
/** Where each section starts on the clock, in steps */
|
|
74
|
+
private readonly base: [number, number];
|
|
75
|
+
private readonly loops: number;
|
|
76
|
+
private sec = INTRO;
|
|
77
|
+
private idx = 0;
|
|
78
|
+
/** Context time where the current section starts */
|
|
79
|
+
private secStart: number;
|
|
80
|
+
/** Completed passes of the body */
|
|
81
|
+
private passes = 0;
|
|
82
|
+
private state: 'playing' | 'ended' | 'stopped' = 'playing';
|
|
83
|
+
private resolveFinished!: () => void;
|
|
84
|
+
|
|
85
|
+
constructor(context: BaseAudioContext, destination: AudioNode, song: Song, options: PlaybackOptions = {}) {
|
|
86
|
+
this.context = context;
|
|
87
|
+
this.song = song;
|
|
88
|
+
const meter = song.meter ?? 16;
|
|
89
|
+
this.clock = tempoClock(song);
|
|
90
|
+
this.loops = Math.max(1, options.loops ?? (song.loop === false ? 1 : Infinity));
|
|
91
|
+
this.sections = [song.intro?.length ? compileSection(song.intro, meter) : { duration: 0, notes: [], ducks: [], desks: [] }, compileSection(song.tracks, meter)];
|
|
92
|
+
const introSteps = this.sections[INTRO].duration;
|
|
93
|
+
this.base = [0, introSteps];
|
|
94
|
+
this.introDuration = this.clock.seconds(introSteps);
|
|
95
|
+
this.loopDuration = this.clock.seconds(introSteps + this.sections[BODY].duration) - this.introDuration;
|
|
96
|
+
this.duration = this.introDuration + this.loops * this.loopDuration;
|
|
97
|
+
this.finished = new Promise((resolve) => (this.resolveFinished = resolve));
|
|
98
|
+
|
|
99
|
+
const mixer = createMixer(context, song, destination);
|
|
100
|
+
this.output = mixer.output;
|
|
101
|
+
this.gate = mixer.gate;
|
|
102
|
+
this.strips = mixer.strips;
|
|
103
|
+
this.silences = [INTRO, BODY].map((s) => {
|
|
104
|
+
const { duration } = this.sections[s];
|
|
105
|
+
return (song.silences ?? [])
|
|
106
|
+
.map(({ bar, beats }): [number, number] => {
|
|
107
|
+
const from = (bar - 1) * stepsPerBar(meter) - this.base[s];
|
|
108
|
+
return [from, Math.min(duration, from + 4 * beats)];
|
|
109
|
+
})
|
|
110
|
+
.filter(([from, to]) => from >= 0 && from < duration && to > from);
|
|
111
|
+
}) as [[number, number][], [number, number][]];
|
|
112
|
+
this.tracks = [...trackHandles(song.intro ?? [], mixer.strips[INTRO], 'intro'), ...trackHandles(song.tracks, mixer.strips[BODY], 'loop')];
|
|
113
|
+
|
|
114
|
+
const at = options.at ?? context.currentTime + 0.08;
|
|
115
|
+
const fadeIn = options.fadeIn ?? 0;
|
|
116
|
+
this.output.gain.setValueAtTime(fadeIn > 0 ? 0 : 1, context.currentTime);
|
|
117
|
+
if (fadeIn > 0) {
|
|
118
|
+
this.output.gain.setValueAtTime(0, at);
|
|
119
|
+
this.output.gain.linearRampToValueAtTime(1, at + fadeIn);
|
|
120
|
+
}
|
|
121
|
+
const from = Math.min(Math.max(0, options.from ?? 0), this.duration - 0.001);
|
|
122
|
+
this.secStart = at;
|
|
123
|
+
if (from > 0) this.seek(from, at);
|
|
124
|
+
else this.automate(0);
|
|
125
|
+
this.startTime = at - from;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Still producing notes (not stopped, not past its end) */
|
|
129
|
+
get playing(): boolean {
|
|
130
|
+
return this.state === 'playing';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Schedule every note that starts before `until` (context seconds). Call this regularly, a little ahead of the clock. */
|
|
134
|
+
schedule(until: number): void {
|
|
135
|
+
if (this.state !== 'playing') return;
|
|
136
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
137
|
+
const sec = this.sections[this.sec];
|
|
138
|
+
while (this.idx < sec.notes.length) {
|
|
139
|
+
const n = sec.notes[this.idx];
|
|
140
|
+
const at = this.timeOf(n.time);
|
|
141
|
+
if (at >= until) return;
|
|
142
|
+
this.fire(this.sec, n, at);
|
|
143
|
+
this.duckUnder(n.track, at);
|
|
144
|
+
this.idx++;
|
|
145
|
+
}
|
|
146
|
+
const end = this.secStart + (this.sec === INTRO ? this.introDuration : this.loopDuration);
|
|
147
|
+
if (end >= until) return;
|
|
148
|
+
if (this.sec === BODY) {
|
|
149
|
+
if (this.passes + 1 >= this.loops) {
|
|
150
|
+
this.end(end);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.passes++;
|
|
154
|
+
}
|
|
155
|
+
// Next section: after the intro comes the body; the body repeats
|
|
156
|
+
const again = this.sec === BODY;
|
|
157
|
+
this.secStart = end;
|
|
158
|
+
this.sec = BODY;
|
|
159
|
+
this.idx = 0;
|
|
160
|
+
this.automate(0, again);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Where the song is at a context time (default: now) */
|
|
165
|
+
position(time = this.context.currentTime): SongPosition {
|
|
166
|
+
const sectionStart = this.sec === INTRO ? 0 : this.introDuration + this.passes * this.loopDuration;
|
|
167
|
+
const seconds = Math.min(this.duration, Math.max(0, sectionStart + time - this.secStart));
|
|
168
|
+
const bar = (step: number) => Math.floor(step / stepsPerBar(this.song.meter) + 1e-6) + 1;
|
|
169
|
+
if (seconds < this.introDuration) return { seconds, section: 'intro', bar: bar(this.clock.step(seconds)), loop: 0 };
|
|
170
|
+
const inBody = seconds - this.introDuration;
|
|
171
|
+
const loop = Math.min(Math.floor(inBody / this.loopDuration), this.loops - 1);
|
|
172
|
+
const step = this.clock.step(this.introDuration + inBody - loop * this.loopDuration) - this.base[BODY];
|
|
173
|
+
return { seconds, section: 'loop', bar: bar(step), loop };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Fade out and release */
|
|
177
|
+
stop(fade = 0.8): void {
|
|
178
|
+
if (this.state === 'stopped') return;
|
|
179
|
+
this.state = 'stopped';
|
|
180
|
+
const now = this.context.currentTime;
|
|
181
|
+
const g = this.output.gain;
|
|
182
|
+
g.cancelScheduledValues(now);
|
|
183
|
+
g.setValueAtTime(g.value, now);
|
|
184
|
+
g.linearRampToValueAtTime(0, now + fade);
|
|
185
|
+
onClock(this.context, now + fade + 0.3, () => this.output.disconnect());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Context time of a step in the current section, with swing applied to on-grid steps */
|
|
189
|
+
private timeOf(step: number): number {
|
|
190
|
+
const { swing, swing16 } = this.song;
|
|
191
|
+
let s = step;
|
|
192
|
+
const i = Math.floor(step + 1e-6);
|
|
193
|
+
if (Math.abs(step - i) < 1e-6) {
|
|
194
|
+
if (swing && i % 4 === 2) s += swing * 2;
|
|
195
|
+
else if (swing16 && i % 2 === 1) s += swing16;
|
|
196
|
+
}
|
|
197
|
+
return this.at(s);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Context time of a step in the current section, on the grid */
|
|
201
|
+
private at(step: number): number {
|
|
202
|
+
const base = this.base[this.sec];
|
|
203
|
+
return this.secStart + this.clock.seconds(base + step) - this.clock.seconds(base);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Seconds between two steps of a section */
|
|
207
|
+
private span(section: number, from: number, to: number): number {
|
|
208
|
+
const base = this.base[section];
|
|
209
|
+
return this.clock.seconds(base + to) - this.clock.seconds(base + from);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private fire(section: number, n: ScheduledNote, at: number): void {
|
|
213
|
+
const track = this.trackOf(section, n.track);
|
|
214
|
+
const def = getInstrument(track.instrument);
|
|
215
|
+
const drum = def.kind === 'sample' && (def.variants ?? 0) > 0;
|
|
216
|
+
const humanize = track.humanize ?? (drum ? 0.003 : 0.008);
|
|
217
|
+
const t = Math.max(this.context.currentTime + 0.005, at + (Math.random() - 0.5) * humanize);
|
|
218
|
+
const vel = Math.min(1, n.velocity * (1 + (Math.random() - 0.5) * 0.1));
|
|
219
|
+
const dur = this.span(section, n.time, n.time + n.duration);
|
|
220
|
+
const transpose = track.transpose ?? 0;
|
|
221
|
+
const desk = this.sections[section].desks[n.track];
|
|
222
|
+
const pitches = n.pitches.length ? n.pitches : [0];
|
|
223
|
+
pitches.forEach((m, k) => {
|
|
224
|
+
playNote(track.instrument, {
|
|
225
|
+
ctx: this.context,
|
|
226
|
+
out: this.strips[section][n.track].input,
|
|
227
|
+
t: t + (n.strum ? k * n.strum : 0),
|
|
228
|
+
dur,
|
|
229
|
+
m: m ? m + transpose : 0,
|
|
230
|
+
vel,
|
|
231
|
+
bends: n.bends?.map(([s, bm]) => [this.span(section, n.time, n.time + s), bm + transpose] as [number, number]),
|
|
232
|
+
params: track.params,
|
|
233
|
+
desk,
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Jump to `seconds` into the song; sustained notes already sounding at that point are picked up */
|
|
239
|
+
private seek(seconds: number, at: number): void {
|
|
240
|
+
let into = seconds;
|
|
241
|
+
if (seconds >= this.introDuration) {
|
|
242
|
+
this.passes = Math.floor((seconds - this.introDuration) / this.loopDuration);
|
|
243
|
+
into = seconds - this.introDuration - this.passes * this.loopDuration;
|
|
244
|
+
this.sec = BODY;
|
|
245
|
+
}
|
|
246
|
+
const base = this.base[this.sec];
|
|
247
|
+
const offset = this.clock.step(this.clock.seconds(base) + into) - base;
|
|
248
|
+
this.secStart = at - into;
|
|
249
|
+
const { notes, ducks } = this.sections[this.sec];
|
|
250
|
+
/** The last hit of each track that others duck under */
|
|
251
|
+
const hits = new Map<number, number>();
|
|
252
|
+
while (this.idx < notes.length && notes[this.idx].time < offset - 1e-9) {
|
|
253
|
+
const n = notes[this.idx++];
|
|
254
|
+
if (ducks[n.track].length) hits.set(n.track, n.time);
|
|
255
|
+
const left = n.time + n.duration - offset;
|
|
256
|
+
if (left > 0.5 && sustains(this.trackOf(this.sec, n.track))) {
|
|
257
|
+
const into = offset - n.time;
|
|
258
|
+
const bends = n.bends?.filter(([s]) => s > into).map(([s, m]) => [s - into, m] as [number, number]);
|
|
259
|
+
this.fire(this.sec, { ...n, time: offset, duration: left, bends }, at);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Pick up the pumping, the filter sweeps and any silence where they would be by now
|
|
263
|
+
for (const [track, time] of hits) this.duckUnder(track, this.timeOf(time), at);
|
|
264
|
+
this.automate(offset);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A track's hit at `hit` (context seconds): the tracks that duck under it pump (picked up at `from` when the hit is past) */
|
|
268
|
+
private duckUnder(track: number, hit: number, from = hit): void {
|
|
269
|
+
for (const k of this.sections[this.sec].ducks[track]) pump(this.strips[this.sec][k].pump!, hit, from);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A pass of the current section begins (`from` steps in; `again` when it follows a pass of the same section): send its filters along
|
|
274
|
+
* their sweeps and close the gate for its silences. The gate shuts in 10 ms (a hard cut clicks) and is open again a few milliseconds
|
|
275
|
+
* before the music returns, so a note humanized early keeps its attack
|
|
276
|
+
*/
|
|
277
|
+
private automate(from: number, again = false): void {
|
|
278
|
+
const { duration } = this.sections[this.sec];
|
|
279
|
+
for (const strip of this.strips[this.sec]) for (const s of strip.sweeps) sweep(s.cutoff, s.points, from, duration, (step) => this.at(step), again);
|
|
280
|
+
const gate = this.gate.gain;
|
|
281
|
+
for (const [a, b] of this.silences[this.sec]) {
|
|
282
|
+
if (b <= from) continue;
|
|
283
|
+
const start = this.at(Math.max(a, from));
|
|
284
|
+
const end = this.at(b);
|
|
285
|
+
if (a < from) gate.setValueAtTime(0, start);
|
|
286
|
+
else {
|
|
287
|
+
gate.setValueAtTime(1, start);
|
|
288
|
+
gate.linearRampToValueAtTime(0, start + 0.01);
|
|
289
|
+
}
|
|
290
|
+
gate.setValueAtTime(0, end - 0.009);
|
|
291
|
+
gate.linearRampToValueAtTime(1, end - 0.005);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private trackOf(section: number, index: number): Track {
|
|
296
|
+
return section === INTRO ? this.song.intro![index] : this.song.tracks[index];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A song with an end has played its last section: release after the reverb tail */
|
|
300
|
+
private end(at: number): void {
|
|
301
|
+
this.state = 'ended';
|
|
302
|
+
onClock(this.context, at, () => this.resolveFinished());
|
|
303
|
+
onClock(this.context, at + (this.song.reverb ?? DEFAULT_REVERB).size + 1.5, () => this.output.disconnect());
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run `fn` when the context's own clock reaches `time`. A wall-clock timer would drift from the audio: an offline render runs
|
|
309
|
+
* faster or slower than real time, and a suspended context does not advance at all.
|
|
310
|
+
*/
|
|
311
|
+
function onClock(ctx: BaseAudioContext, time: number, fn: () => void): void {
|
|
312
|
+
const tick = ctx.createConstantSource();
|
|
313
|
+
const silent = ctx.createGain();
|
|
314
|
+
silent.gain.value = 0;
|
|
315
|
+
tick.connect(silent).connect(ctx.destination);
|
|
316
|
+
tick.onended = () => {
|
|
317
|
+
tick.disconnect();
|
|
318
|
+
silent.disconnect();
|
|
319
|
+
fn();
|
|
320
|
+
};
|
|
321
|
+
tick.start(ctx.currentTime);
|
|
322
|
+
tick.stop(Math.max(ctx.currentTime, time));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Instruments whose notes can be picked up mid-way when seeking (pads, bowed strings, ambience), as opposed to struck ones */
|
|
326
|
+
function sustains(track: Track): boolean {
|
|
327
|
+
const def = getInstrument(track.instrument);
|
|
328
|
+
return def.kind === 'live' || !!def.bow || !!def.loop;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function trackHandles(tracks: Track[], strips: Strip[], section: 'intro' | 'loop'): PlaybackTrack[] {
|
|
332
|
+
return tracks.map((track, i) => {
|
|
333
|
+
const { input, output } = strips[i];
|
|
334
|
+
let muted = false;
|
|
335
|
+
return {
|
|
336
|
+
name: trackName(track),
|
|
337
|
+
instrument: track.instrument,
|
|
338
|
+
section,
|
|
339
|
+
output,
|
|
340
|
+
get muted() {
|
|
341
|
+
return muted;
|
|
342
|
+
},
|
|
343
|
+
set muted(value: boolean) {
|
|
344
|
+
muted = value;
|
|
345
|
+
input.gain.setTargetAtTime(value ? 0 : (track.gain ?? 1), input.context.currentTime, 0.015);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Render every sample a song needs before it plays, so playback never has to compute audio.
|
|
2
|
+
|
|
3
|
+
import { prepareSamples, prepareSamplesSync } from '../instruments/samples.ts';
|
|
4
|
+
import type { Song } from '../song.ts';
|
|
5
|
+
import { sampleRequests } from './compile.ts';
|
|
6
|
+
|
|
7
|
+
/** Render a song's samples in the background (yields to the main thread every 12 ms) */
|
|
8
|
+
export function prepareSong(context: BaseAudioContext, song: Song): Promise<void> {
|
|
9
|
+
return prepareSamples(context, sampleRequests(song));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Render a song's samples right now (for short jingles and offline rendering) */
|
|
13
|
+
export function prepareSongSync(context: BaseAudioContext, song: Song): void {
|
|
14
|
+
prepareSamplesSync(context, sampleRequests(song));
|
|
15
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Offline rendering: play a song into a buffer as fast as the machine allows (for exporting audio files, tests and measurements).
|
|
2
|
+
|
|
3
|
+
import { songDuration, type Song } from '../song.ts';
|
|
4
|
+
import { createOutputStage, mastersItself } from './output.ts';
|
|
5
|
+
import { Playback } from './playback.ts';
|
|
6
|
+
import { prepareSongSync } from './prepare.ts';
|
|
7
|
+
|
|
8
|
+
export interface RenderOptions {
|
|
9
|
+
/** Seconds to render (default: the intro, `loops` passes of the body, and 3 s for the tail to ring out) */
|
|
10
|
+
duration?: number;
|
|
11
|
+
/** How many passes of the body (default 1) */
|
|
12
|
+
loops?: number;
|
|
13
|
+
/** Default 44100 */
|
|
14
|
+
sampleRate?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Seconds of music scheduled at a time while rendering, as live playback schedules a little ahead of the clock: scheduled all at once,
|
|
19
|
+
* every note of a long song would sit in the audio graph from the start, and the render would take quadratic time
|
|
20
|
+
*/
|
|
21
|
+
const WINDOW = 1;
|
|
22
|
+
|
|
23
|
+
/** Render a song to a stereo AudioBuffer, through the same output stage as live playback (browsers only: needs OfflineAudioContext) */
|
|
24
|
+
export async function renderSong(song: Song, { duration, loops = 1, sampleRate = 44100 }: RenderOptions = {}): Promise<AudioBuffer> {
|
|
25
|
+
if (typeof OfflineAudioContext === 'undefined') throw new Error('renderSong needs OfflineAudioContext (a browser)');
|
|
26
|
+
const { intro, loop } = songDuration(song);
|
|
27
|
+
const length = duration ?? intro + loops * loop + 3;
|
|
28
|
+
const ctx = new OfflineAudioContext(2, Math.ceil(length * sampleRate), sampleRate);
|
|
29
|
+
prepareSongSync(ctx, song);
|
|
30
|
+
const out = mastersItself(song) ? ctx.destination : createOutputStage(ctx, ctx.destination);
|
|
31
|
+
const playback = new Playback(ctx, out, song, { at: 0.05, loops });
|
|
32
|
+
// Suspend the render once a window to schedule the next one
|
|
33
|
+
playback.schedule(2 * WINDOW);
|
|
34
|
+
for (let t = WINDOW; t < length; t += WINDOW) {
|
|
35
|
+
void ctx.suspend(t).then(() => {
|
|
36
|
+
playback.schedule(t + 2 * WINDOW);
|
|
37
|
+
void ctx.resume();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return ctx.startRendering();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Encode an AudioBuffer as a 16-bit PCM WAV file */
|
|
44
|
+
export function encodeWav(buffer: AudioBuffer): ArrayBuffer {
|
|
45
|
+
const channels = buffer.numberOfChannels;
|
|
46
|
+
const frames = buffer.length;
|
|
47
|
+
const bytes = frames * channels * 2;
|
|
48
|
+
const out = new DataView(new ArrayBuffer(44 + bytes));
|
|
49
|
+
const text = (at: number, s: string) => [...s].forEach((c, i) => out.setUint8(at + i, c.charCodeAt(0)));
|
|
50
|
+
text(0, 'RIFF');
|
|
51
|
+
out.setUint32(4, 36 + bytes, true);
|
|
52
|
+
text(8, 'WAVE');
|
|
53
|
+
text(12, 'fmt ');
|
|
54
|
+
out.setUint32(16, 16, true);
|
|
55
|
+
out.setUint16(20, 1, true);
|
|
56
|
+
out.setUint16(22, channels, true);
|
|
57
|
+
out.setUint32(24, buffer.sampleRate, true);
|
|
58
|
+
out.setUint32(28, buffer.sampleRate * channels * 2, true);
|
|
59
|
+
out.setUint16(32, channels * 2, true);
|
|
60
|
+
out.setUint16(34, 16, true);
|
|
61
|
+
text(36, 'data');
|
|
62
|
+
out.setUint32(40, bytes, true);
|
|
63
|
+
const data = Array.from({ length: channels }, (_, c) => buffer.getChannelData(c));
|
|
64
|
+
for (let i = 0, at = 44; i < frames; i++) {
|
|
65
|
+
for (let c = 0; c < channels; c++, at += 2) {
|
|
66
|
+
const x = Math.max(-1, Math.min(1, data[c][i]));
|
|
67
|
+
out.setInt16(at, x < 0 ? x * 0x8000 : x * 0x7fff, true);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out.buffer;
|
|
71
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Polyphon: music for the browser, written as code.
|
|
2
|
+
//
|
|
3
|
+
// Write melody, rhythm, chords, pad, arpeggio, bassline, roots, strum, and the part transforms
|
|
4
|
+
// Declare defineSong: tempo, room, tracks (instrument + part), mixing and mastering
|
|
5
|
+
// Play Jukebox: crossfades, preloading, ducking, pause and seek
|
|
6
|
+
// Export renderSong + encodeWav
|
|
7
|
+
// Import songFromMidi, and transcribe for a sung melody
|
|
8
|
+
// Extend registerInstrument
|
|
9
|
+
|
|
10
|
+
export { melody, rhythm, type RhythmOptions } from './notation/melody.ts';
|
|
11
|
+
export { formatMelody } from './notation/format.ts';
|
|
12
|
+
export { chords, chordsDuration, pad, arpeggio, roots, bassline, strum, type Chord, type PadOptions, type ArpeggioOptions, type RootsOptions, type BasslineOptions, type StrumOptions } from './notation/harmony.ts';
|
|
13
|
+
export { sequence, stack, repeat, rest, transpose, mapPitch, scaleVelocity, slice, pedal } from './notation/transform.ts';
|
|
14
|
+
export { stepsPerBar, VELOCITY, type Note, type Part, type Meter } from './notation/part.ts';
|
|
15
|
+
export { midi, frequency, noteName, parseChord, voiceChord, scaleDegree, remapToMode, romanChord, MODES, type Pitch, type Mode, type ChordInfo, type VoicingOptions } from './theory.ts';
|
|
16
|
+
|
|
17
|
+
export { defineSong, songDuration, DEFAULT_REVERB, type Song, type Track, type Reverb, type Delay, type Master, type Duck, type Sweep, type Silence } from './song.ts';
|
|
18
|
+
export { tempoClock, type TempoChange, type TempoClock } from './tempo.ts';
|
|
19
|
+
|
|
20
|
+
export { Jukebox, type JukeboxOptions, type JukeboxState, type PlayOptions, type DuckOptions, type SongSource } from './engine/jukebox.ts';
|
|
21
|
+
export { Playback, type PlaybackOptions, type PlaybackTrack, type SongPosition } from './engine/playback.ts';
|
|
22
|
+
export { prepareSong, prepareSongSync } from './engine/prepare.ts';
|
|
23
|
+
export { renderSong, encodeWav, type RenderOptions } from './engine/render.ts';
|
|
24
|
+
export { compileSection, type CompiledSection, type ScheduledNote } from './engine/compile.ts';
|
|
25
|
+
export { songFromMidi, type MidiImportOptions } from './midi.ts';
|
|
26
|
+
export { PitchTracker, transcribe, type PitchFrame, type TranscribeOptions, type Transcription } from './transcribe.ts';
|
|
27
|
+
|
|
28
|
+
export { registerInstrument, instrumentNames, type InstrumentName, type CustomInstrument, type CustomSampledInstrument, type CustomSynthInstrument, type SynthNote } from './instruments/registry.ts';
|