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.
Files changed (174) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -2
  3. package/README.zh-TW.md +375 -0
  4. package/dist/engine/automation.d.ts +24 -0
  5. package/dist/engine/automation.d.ts.map +1 -0
  6. package/dist/engine/automation.js +55 -0
  7. package/dist/engine/automation.js.map +1 -0
  8. package/dist/engine/compile.d.ts +36 -0
  9. package/dist/engine/compile.d.ts.map +1 -0
  10. package/dist/engine/compile.js +103 -0
  11. package/dist/engine/compile.js.map +1 -0
  12. package/dist/engine/jukebox.d.ts +113 -0
  13. package/dist/engine/jukebox.d.ts.map +1 -0
  14. package/dist/engine/jukebox.js +287 -0
  15. package/dist/engine/jukebox.js.map +1 -0
  16. package/dist/engine/mixer.d.ts +29 -0
  17. package/dist/engine/mixer.d.ts.map +1 -0
  18. package/dist/engine/mixer.js +141 -0
  19. package/dist/engine/mixer.js.map +1 -0
  20. package/dist/engine/output.d.ts +14 -0
  21. package/dist/engine/output.d.ts.map +1 -0
  22. package/dist/engine/output.js +51 -0
  23. package/dist/engine/output.js.map +1 -0
  24. package/dist/engine/playback.d.ts +97 -0
  25. package/dist/engine/playback.d.ts.map +1 -0
  26. package/dist/engine/playback.js +311 -0
  27. package/dist/engine/playback.js.map +1 -0
  28. package/dist/engine/prepare.d.ts +6 -0
  29. package/dist/engine/prepare.d.ts.map +1 -0
  30. package/dist/engine/prepare.js +12 -0
  31. package/dist/engine/prepare.js.map +1 -0
  32. package/dist/engine/render.d.ts +14 -0
  33. package/dist/engine/render.d.ts.map +1 -0
  34. package/dist/engine/render.js +60 -0
  35. package/dist/engine/render.js.map +1 -0
  36. package/dist/index.d.ts +17 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +25 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/instruments/bowed.d.ts +357 -0
  41. package/dist/instruments/bowed.d.ts.map +1 -0
  42. package/dist/instruments/bowed.js +502 -0
  43. package/dist/instruments/bowed.js.map +1 -0
  44. package/dist/instruments/builtin.d.ts +261 -0
  45. package/dist/instruments/builtin.d.ts.map +1 -0
  46. package/dist/instruments/builtin.js +160 -0
  47. package/dist/instruments/builtin.js.map +1 -0
  48. package/dist/instruments/definition.d.ts +46 -0
  49. package/dist/instruments/definition.d.ts.map +1 -0
  50. package/dist/instruments/definition.js +5 -0
  51. package/dist/instruments/definition.js.map +1 -0
  52. package/dist/instruments/dsp/ambience.d.ts +17 -0
  53. package/dist/instruments/dsp/ambience.d.ts.map +1 -0
  54. package/dist/instruments/dsp/ambience.js +146 -0
  55. package/dist/instruments/dsp/ambience.js.map +1 -0
  56. package/dist/instruments/dsp/core.d.ts +22 -0
  57. package/dist/instruments/dsp/core.d.ts.map +1 -0
  58. package/dist/instruments/dsp/core.js +100 -0
  59. package/dist/instruments/dsp/core.js.map +1 -0
  60. package/dist/instruments/dsp/drums.d.ts +112 -0
  61. package/dist/instruments/dsp/drums.d.ts.map +1 -0
  62. package/dist/instruments/dsp/drums.js +566 -0
  63. package/dist/instruments/dsp/drums.js.map +1 -0
  64. package/dist/instruments/dsp/modal.d.ts +30 -0
  65. package/dist/instruments/dsp/modal.d.ts.map +1 -0
  66. package/dist/instruments/dsp/modal.js +54 -0
  67. package/dist/instruments/dsp/modal.js.map +1 -0
  68. package/dist/instruments/dsp/piano.d.ts +9 -0
  69. package/dist/instruments/dsp/piano.d.ts.map +1 -0
  70. package/dist/instruments/dsp/piano.js +230 -0
  71. package/dist/instruments/dsp/piano.js.map +1 -0
  72. package/dist/instruments/dsp/pluck.d.ts +53 -0
  73. package/dist/instruments/dsp/pluck.d.ts.map +1 -0
  74. package/dist/instruments/dsp/pluck.js +195 -0
  75. package/dist/instruments/dsp/pluck.js.map +1 -0
  76. package/dist/instruments/dsp/reverb.d.ts +16 -0
  77. package/dist/instruments/dsp/reverb.d.ts.map +1 -0
  78. package/dist/instruments/dsp/reverb.js +47 -0
  79. package/dist/instruments/dsp/reverb.js.map +1 -0
  80. package/dist/instruments/dsp/staccato.d.ts +18 -0
  81. package/dist/instruments/dsp/staccato.d.ts.map +1 -0
  82. package/dist/instruments/dsp/staccato.js +147 -0
  83. package/dist/instruments/dsp/staccato.js.map +1 -0
  84. package/dist/instruments/play.d.ts +21 -0
  85. package/dist/instruments/play.d.ts.map +1 -0
  86. package/dist/instruments/play.js +140 -0
  87. package/dist/instruments/play.js.map +1 -0
  88. package/dist/instruments/registry.d.ts +63 -0
  89. package/dist/instruments/registry.d.ts.map +1 -0
  90. package/dist/instruments/registry.js +50 -0
  91. package/dist/instruments/registry.js.map +1 -0
  92. package/dist/instruments/samples.d.ts +30 -0
  93. package/dist/instruments/samples.d.ts.map +1 -0
  94. package/dist/instruments/samples.js +109 -0
  95. package/dist/instruments/samples.js.map +1 -0
  96. package/dist/instruments/voices.d.ts +76 -0
  97. package/dist/instruments/voices.d.ts.map +1 -0
  98. package/dist/instruments/voices.js +1061 -0
  99. package/dist/instruments/voices.js.map +1 -0
  100. package/dist/midi.d.ts +15 -0
  101. package/dist/midi.d.ts.map +1 -0
  102. package/dist/midi.js +280 -0
  103. package/dist/midi.js.map +1 -0
  104. package/dist/notation/format.d.ts +12 -0
  105. package/dist/notation/format.d.ts.map +1 -0
  106. package/dist/notation/format.js +102 -0
  107. package/dist/notation/format.js.map +1 -0
  108. package/dist/notation/harmony.d.ts +83 -0
  109. package/dist/notation/harmony.d.ts.map +1 -0
  110. package/dist/notation/harmony.js +191 -0
  111. package/dist/notation/harmony.js.map +1 -0
  112. package/dist/notation/melody.d.ts +25 -0
  113. package/dist/notation/melody.d.ts.map +1 -0
  114. package/dist/notation/melody.js +138 -0
  115. package/dist/notation/melody.js.map +1 -0
  116. package/dist/notation/part.d.ts +34 -0
  117. package/dist/notation/part.d.ts.map +1 -0
  118. package/dist/notation/part.js +15 -0
  119. package/dist/notation/part.js.map +1 -0
  120. package/dist/notation/transform.d.ts +28 -0
  121. package/dist/notation/transform.d.ts.map +1 -0
  122. package/dist/notation/transform.js +67 -0
  123. package/dist/notation/transform.js.map +1 -0
  124. package/dist/song.d.ts +140 -0
  125. package/dist/song.d.ts.map +1 -0
  126. package/dist/song.js +45 -0
  127. package/dist/song.js.map +1 -0
  128. package/dist/tempo.d.ts +36 -0
  129. package/dist/tempo.d.ts.map +1 -0
  130. package/dist/tempo.js +81 -0
  131. package/dist/tempo.js.map +1 -0
  132. package/dist/theory.d.ts +70 -0
  133. package/dist/theory.d.ts.map +1 -0
  134. package/dist/theory.js +183 -0
  135. package/dist/theory.js.map +1 -0
  136. package/dist/transcribe.d.ts +81 -0
  137. package/dist/transcribe.d.ts.map +1 -0
  138. package/dist/transcribe.js +433 -0
  139. package/dist/transcribe.js.map +1 -0
  140. package/package.json +57 -4
  141. package/src/engine/automation.ts +65 -0
  142. package/src/engine/compile.ts +124 -0
  143. package/src/engine/jukebox.ts +322 -0
  144. package/src/engine/mixer.ts +166 -0
  145. package/src/engine/output.ts +59 -0
  146. package/src/engine/playback.ts +349 -0
  147. package/src/engine/prepare.ts +15 -0
  148. package/src/engine/render.ts +71 -0
  149. package/src/index.ts +28 -0
  150. package/src/instruments/bowed.ts +660 -0
  151. package/src/instruments/builtin.ts +180 -0
  152. package/src/instruments/definition.ts +52 -0
  153. package/src/instruments/dsp/ambience.ts +142 -0
  154. package/src/instruments/dsp/core.ts +97 -0
  155. package/src/instruments/dsp/drums.ts +565 -0
  156. package/src/instruments/dsp/modal.ts +76 -0
  157. package/src/instruments/dsp/piano.ts +226 -0
  158. package/src/instruments/dsp/pluck.ts +240 -0
  159. package/src/instruments/dsp/reverb.ts +56 -0
  160. package/src/instruments/dsp/staccato.ts +141 -0
  161. package/src/instruments/play.ts +151 -0
  162. package/src/instruments/registry.ts +100 -0
  163. package/src/instruments/samples.ts +119 -0
  164. package/src/instruments/voices.ts +1168 -0
  165. package/src/midi.ts +285 -0
  166. package/src/notation/format.ts +96 -0
  167. package/src/notation/harmony.ts +233 -0
  168. package/src/notation/melody.ts +133 -0
  169. package/src/notation/part.ts +42 -0
  170. package/src/notation/transform.ts +78 -0
  171. package/src/song.ts +170 -0
  172. package/src/tempo.ts +108 -0
  173. package/src/theory.ts +208 -0
  174. package/src/transcribe.ts +487 -0
@@ -0,0 +1,65 @@
1
+ // Mixer automation, scheduled ahead like the notes: sidechain pumping (a track dips at every hit of another, then swells back)
2
+ // and filter sweeps through a section.
3
+
4
+ import type { Sweep } from '../song.ts';
5
+
6
+ /** A ducking track's gain, and how it pumps */
7
+ export interface Pump {
8
+ gain: AudioParam;
9
+ /** How far it dips, 0-1 */
10
+ depth: number;
11
+ /** Seconds until it is back up */
12
+ release: number;
13
+ }
14
+
15
+ /** The dip starts this far ahead of the hit, so the level is down when the hit lands (seconds) */
16
+ const LEAD = 0.004;
17
+
18
+ /** Level of a ducking track `t` seconds after a hit: down for the first quarter of the release, then swelling back (95% there at the end) */
19
+ export function duckLevel(depth: number, release: number, t: number): number {
20
+ const swell = release / 4;
21
+ return 1 - depth * Math.exp(-Math.max(0, t - swell) / swell);
22
+ }
23
+
24
+ /** Pump at a hit (context seconds). A hit before `from`, when a playback starts mid-swell, is picked up where it would be by then */
25
+ export function pump({ gain, depth, release }: Pump, hit: number, from = hit): void {
26
+ const swell = release / 4;
27
+ if (from > hit) gain.setValueAtTime(duckLevel(depth, release, from - hit), from);
28
+ else gain.setTargetAtTime(1 - depth, hit - LEAD, LEAD / 3);
29
+ gain.setTargetAtTime(1, Math.max(from, hit + swell), swell);
30
+ }
31
+
32
+ /** A sweep's breakpoints as [step, Hz], in order */
33
+ export function breakpoints(sweep: Sweep): [number, number][] {
34
+ return Object.entries(sweep)
35
+ .map(([step, hz]): [number, number] => [Number(step), Math.max(10, hz)])
36
+ .sort((a, b) => a[0] - b[0]);
37
+ }
38
+
39
+ /** A sweep's cutoff at a step: held before the first breakpoint and after the last, gliding exponentially (evenly in pitch) between */
40
+ export function cutoffAt(points: [number, number][], step: number): number {
41
+ const i = points.findIndex(([s]) => s > step);
42
+ if (i === 0) return points[0][1];
43
+ if (i < 0) return points[points.length - 1][1];
44
+ const [s0, f0] = points[i - 1];
45
+ const [s1, f1] = points[i];
46
+ return f0 * Math.pow(f1 / f0, (step - s0) / (s1 - s0));
47
+ }
48
+
49
+ /** How long a sweep takes to get back to its start when its section repeats (seconds) */
50
+ const GLIDE = 0.02;
51
+
52
+ /**
53
+ * Schedule one pass of a sweep through a section that lasts `steps`, from `from` steps in; `time` gives the context time of a step.
54
+ * A pass that follows another (`again`) glides back from where the last one ended: a filter retuned in one jump while it carries
55
+ * sound thumps.
56
+ */
57
+ export function sweep(cutoff: AudioParam, points: [number, number][], from: number, steps: number, time: (step: number) => number, again = false): void {
58
+ const at = time(from);
59
+ if (again) {
60
+ cutoff.setValueAtTime(cutoffAt(points, steps), at);
61
+ cutoff.exponentialRampToValueAtTime(cutoffAt(points, from), at + GLIDE);
62
+ } else cutoff.setValueAtTime(cutoffAt(points, from), at);
63
+ const knots = new Set(points.map(([s]) => Math.min(s, steps)).filter((s) => s > from));
64
+ for (const s of knots) cutoff.exponentialRampToValueAtTime(cutoffAt(points, s), time(s));
65
+ }
@@ -0,0 +1,124 @@
1
+ // Compile a song's sections into flat, time-sorted note lists, and list the samples a song needs rendered.
2
+
3
+ import { DESKS } from '../instruments/bowed.ts';
4
+ import type { SampledInstrument } from '../instruments/definition.ts';
5
+ import { getInstrument } from '../instruments/registry.ts';
6
+ import { bowLayers, type SampleRequest } from '../instruments/samples.ts';
7
+ import type { Meter } from '../notation/part.ts';
8
+ import { sectionSteps, trackName, trackPart, type Song, type Track } from '../song.ts';
9
+
10
+ /** A note ready to schedule */
11
+ export interface ScheduledNote {
12
+ /** Steps from the start of the section */
13
+ time: number;
14
+ duration: number;
15
+ pitches: number[];
16
+ velocity: number;
17
+ /** Index of the track in its section */
18
+ track: number;
19
+ /** Glides merged into this note: [steps after its start, target MIDI pitch] */
20
+ bends?: [number, number][];
21
+ strum?: number;
22
+ }
23
+
24
+ export interface CompiledSection {
25
+ /** Length in steps (whole bars) */
26
+ duration: number;
27
+ notes: ScheduledNote[];
28
+ /** For each track, the tracks that duck under its hits */
29
+ ducks: number[][];
30
+ /** For each track, the desk it plays at (see deskList) */
31
+ desks: number[];
32
+ }
33
+
34
+ /** Compile one section into sorted notes; shorter tracks repeat to fill the whole section */
35
+ export function compileSection(tracks: Song['tracks'], meter: Meter): CompiledSection {
36
+ const parts = tracks.map((t) => trackPart(t, meter));
37
+ const duration = sectionSteps(parts, meter);
38
+ const notes: ScheduledNote[] = [];
39
+ tracks.forEach((track, ti) => {
40
+ const p = parts[ti];
41
+ if (!p.duration) return;
42
+ const reps = track.repeat === false ? 1 : Math.max(1, Math.floor(duration / p.duration + 1e-6));
43
+ const src = [...p.notes].sort((a, b) => a.time - b.time);
44
+ const glides = getInstrument(track.instrument).glide ?? false;
45
+ for (let r = 0; r < reps; r++) {
46
+ const off = r * p.duration;
47
+ for (let i = 0; i < src.length; i++) {
48
+ const n = src[i];
49
+ if (n.time + off >= duration) break;
50
+ const c: ScheduledNote = { time: n.time + off, duration: n.duration, pitches: n.pitches, velocity: n.velocity, track: ti, strum: n.strum };
51
+ // Glide: merge the following notes in, turning them into pitch changes of the same voice
52
+ if (glides && n.glide) {
53
+ c.bends = [];
54
+ let j = i;
55
+ while (src[j]?.glide && src[j + 1]) {
56
+ const next = src[j + 1];
57
+ c.bends.push([next.time - n.time, next.pitches[0]]);
58
+ c.duration = next.time + next.duration - n.time;
59
+ j++;
60
+ }
61
+ i = j;
62
+ }
63
+ notes.push(c);
64
+ }
65
+ }
66
+ });
67
+ notes.sort((a, b) => a.time - b.time);
68
+ return { duration, notes, ducks: duckLists(tracks), desks: deskList(tracks) };
69
+ }
70
+
71
+ /** For each track, the tracks that duck under its hits (a pad with `duck: 'Kick'` is on the kick's list) */
72
+ function duckLists(tracks: Track[]): number[][] {
73
+ const ducks = tracks.map((): number[] => []);
74
+ tracks.forEach((track, i) => {
75
+ if (!track.duck) return;
76
+ const by = typeof track.duck === 'string' ? track.duck : track.duck.by;
77
+ const triggers = tracks.flatMap((t, k) => (k !== i && trackName(t) === by ? [k] : []));
78
+ if (!triggers.length) throw new Error(`"${trackName(track)}" ducks under "${by}", but no other track in its section is called that`);
79
+ for (const k of triggers) ducks[k].push(i);
80
+ });
81
+ return ducks;
82
+ }
83
+
84
+ /**
85
+ * For each track, the desk it plays at. Tracks of one section that share a bowed instrument (or its samples: strPad plays the strings') are
86
+ * different players, as the desks of a string section are: the first sits at desk 0 and sounds as a lone track always has, the next at desk 1
87
+ * with its own tuning, vibrato and bow (see DESKS), then desk 2, and round again. Other tracks are at desk 0.
88
+ */
89
+ export function deskList(tracks: Track[]): number[] {
90
+ const seated = new Map<string, number>();
91
+ return tracks.map((track) => {
92
+ const def = getInstrument(track.instrument);
93
+ if (def.kind !== 'sample' || !def.bow) return 0;
94
+ const k = seated.get(def.bow.name) ?? 0;
95
+ seated.set(def.bow.name, k + 1);
96
+ return k % DESKS.length;
97
+ });
98
+ }
99
+
100
+ /** Every sample a song will play, so they can be rendered before it starts */
101
+ export function sampleRequests(song: Song): SampleRequest[] {
102
+ const out = new Map<string, SampleRequest>();
103
+ const meter = song.meter ?? 16;
104
+ for (const tracks of [song.intro ?? [], song.tracks]) {
105
+ const desks = deskList(tracks);
106
+ tracks.forEach((track, ti) => {
107
+ const def = getInstrument(track.instrument);
108
+ if (def.kind !== 'sample') return;
109
+ const desk = desks[ti];
110
+ for (const n of trackPart(track, meter).notes) {
111
+ for (const m of n.pitches.length ? n.pitches : [0]) {
112
+ const midi = m + (track.transpose ?? 0);
113
+ if (def.bow) {
114
+ // Bowed strings: only the velocity layers that will be used, at the track's desk
115
+ for (const layer of bowLayers(def, n.velocity, track.params?.bright)) out.set(`${track.instrument}|${midi}|${layer}|${desk}`, request(def, track.instrument, midi, layer, desk));
116
+ } else out.set(`${track.instrument}|${midi}`, request(def, track.instrument, midi));
117
+ }
118
+ }
119
+ });
120
+ }
121
+ return [...out.values()];
122
+ }
123
+
124
+ const request = (def: SampledInstrument, name: string, midi: number, layer?: number, desk?: number): SampleRequest => ({ def, name, midi, layer, desk });
@@ -0,0 +1,322 @@
1
+ // The Jukebox: the one object a game needs. It owns the clock, crossfades between songs, prepares samples ahead of time,
2
+ // and handles the browser's audio unlock. One song plays at a time; jingles can be layered on top.
3
+
4
+ import type { Song } from '../song.ts';
5
+ import { createOutputStage, mastersItself } from './output.ts';
6
+ import { Playback } from './playback.ts';
7
+ import { prepareSong, prepareSongSync } from './prepare.ts';
8
+
9
+ /** A song, or a function that loads one (`() => import('./songs/title.ts')` works: a module's default export is used) */
10
+ export type SongSource = Song | (() => Promise<Song | { default: Song }>);
11
+
12
+ export interface JukeboxOptions {
13
+ /** Use this AudioContext (default: one is created on first use) */
14
+ context?: AudioContext;
15
+ /** Where the music goes (default: the speakers) */
16
+ destination?: AudioNode;
17
+ /** Songs that can be played by name */
18
+ songs?: Record<string, SongSource>;
19
+ /** 0-1 (default 1) */
20
+ volume?: number;
21
+ }
22
+
23
+ export interface PlayOptions {
24
+ /** Seconds to fade the previous song out (default 0.9) */
25
+ fadeOut?: number;
26
+ /** Seconds to fade this song in (default: 0.05 if it has an intro, otherwise 1.2) */
27
+ fadeIn?: number;
28
+ /** Start this many seconds into the song */
29
+ from?: number;
30
+ /** How many times the body plays before the song ends (default: forever, or once if `song.loop` is false) */
31
+ loops?: number;
32
+ }
33
+
34
+ export interface DuckOptions {
35
+ /** Seconds to reach the ducked level (default 0: instantly) */
36
+ attack?: number;
37
+ /** Seconds to stay there (default 0) */
38
+ hold?: number;
39
+ /** Seconds to come back (default 0.35) */
40
+ release?: number;
41
+ }
42
+
43
+ export type JukeboxState = 'idle' | 'loading' | 'playing' | 'paused';
44
+
45
+ /** How far ahead notes are scheduled: a little while visible, much more in a background tab (whose timers are throttled to about once a second) */
46
+ const AHEAD = 0.25;
47
+ const AHEAD_HIDDEN = 1.6;
48
+
49
+ export class Jukebox {
50
+ private ctx: AudioContext | null;
51
+ private readonly destination?: AudioNode;
52
+ private readonly songs: Record<string, SongSource>;
53
+ private readonly loaded = new Map<SongSource, Promise<Song>>();
54
+ /** The music buses, each a volume and a duck gain: [into the output stage, around it for songs mastered loud] */
55
+ private buses: { volume: GainNode; duck: GainNode }[] = [];
56
+ private volumeLevel: number;
57
+ private timer: ReturnType<typeof setInterval> | null = null;
58
+ /** Incremented whenever the current song changes: a song still loading only starts if nothing replaced it */
59
+ private token = 0;
60
+ /** The song asked for last (it may still be loading) */
61
+ private wanted: SongSource | null = null;
62
+ private pending: Promise<Playback | null> | null = null;
63
+ private song: Song | null = null;
64
+ /** The loop count the current song was asked to play with */
65
+ private loops: number | undefined;
66
+ private active: Playback | null = null;
67
+ private pausedAt: number | null = null;
68
+ /** Mute states to carry into the next playback of the same song (after a seek or a pause) */
69
+ private carry = new Set<string>();
70
+ /** Preloads queue up behind each other, so they never compete with each other for the main thread */
71
+ private preloads: Promise<unknown> = Promise.resolve();
72
+
73
+ constructor(options: JukeboxOptions = {}) {
74
+ this.ctx = options.context ?? null;
75
+ this.destination = options.destination;
76
+ this.songs = options.songs ?? {};
77
+ this.volumeLevel = options.volume ?? 1;
78
+ }
79
+
80
+ /** The audio context (created on first use; it starts on the first click, tap or key press if the browser requires one) */
81
+ get context(): AudioContext {
82
+ if (!this.ctx) this.ctx = new AudioContext();
83
+ if (!this.buses.length) this.connect(this.ctx);
84
+ return this.ctx;
85
+ }
86
+
87
+ /** The song playing, paused, or about to start (null when idle) */
88
+ get current(): Song | null {
89
+ return this.song;
90
+ }
91
+
92
+ /** The current playback, for positions, durations and per-track mixing (a new one starts after every seek or resume) */
93
+ get playback(): Playback | null {
94
+ return this.active;
95
+ }
96
+
97
+ get state(): JukeboxState {
98
+ if (this.pausedAt !== null) return 'paused';
99
+ if (this.pending) return 'loading';
100
+ return this.active?.playing ? 'playing' : 'idle';
101
+ }
102
+
103
+ /** Seconds into the current song */
104
+ get position(): number {
105
+ if (this.pausedAt !== null) return this.pausedAt;
106
+ return this.active ? this.active.position().seconds : 0;
107
+ }
108
+
109
+ get volume(): number {
110
+ return this.volumeLevel;
111
+ }
112
+
113
+ set volume(value: number) {
114
+ this.volumeLevel = Math.min(1, Math.max(0, value));
115
+ if (this.ctx) for (const { volume } of this.buses) volume.gain.setTargetAtTime(this.volumeLevel, this.ctx.currentTime, 0.02);
116
+ }
117
+
118
+ /**
119
+ * Play a song (by name, or a song / loader), crossfading from the one before.
120
+ * Asking for the song that is already playing does nothing; asking for a paused one resumes it.
121
+ * Resolves with the playback once it starts, or null if another song was asked for before this one was ready.
122
+ */
123
+ play(song: string | SongSource, options: PlayOptions = {}): Promise<Playback | null> {
124
+ const source = this.resolve(song);
125
+ if (source === this.wanted) {
126
+ if (this.pausedAt !== null) {
127
+ this.resume();
128
+ return Promise.resolve(this.active);
129
+ }
130
+ if (this.pending) return this.pending;
131
+ if (this.active?.playing) return Promise.resolve(this.active);
132
+ }
133
+ this.halt(options.fadeOut ?? 0.9);
134
+ this.carry = new Set();
135
+ this.wanted = source;
136
+ this.loops = options.loops;
137
+ this.pending = this.begin(source, this.token, options);
138
+ return this.pending;
139
+ }
140
+
141
+ /** Fade the music out and go silent */
142
+ stop(fade = 0.9): void {
143
+ this.halt(fade);
144
+ this.wanted = null;
145
+ this.song = null;
146
+ if (this.timer !== null) clearInterval(this.timer);
147
+ this.timer = null;
148
+ }
149
+
150
+ /** Pause the current song (resume() continues from the same point) */
151
+ pause(): void {
152
+ if (!this.active?.playing) return;
153
+ const at = this.active.position().seconds;
154
+ this.carry = mutedTracks(this.active);
155
+ this.halt(0.12);
156
+ this.pausedAt = at;
157
+ }
158
+
159
+ /** Continue a paused song */
160
+ resume(): void {
161
+ if (this.pausedAt === null || !this.song) return;
162
+ const from = this.pausedAt;
163
+ this.pausedAt = null;
164
+ this.start(this.song, { fadeIn: 0.05, from });
165
+ }
166
+
167
+ /** Jump to a point in the current song (seconds) */
168
+ seek(seconds: number): void {
169
+ if (this.pausedAt !== null) {
170
+ this.pausedAt = Math.max(0, seconds);
171
+ return;
172
+ }
173
+ if (!this.song || !this.active) return;
174
+ this.carry = mutedTracks(this.active);
175
+ this.active.stop(0.12);
176
+ this.start(this.song, { fadeIn: 0.08, from: seconds });
177
+ }
178
+
179
+ /** Render a song's samples ahead of time, so playing it later starts without a wait */
180
+ preload(song: string | SongSource): Promise<void> {
181
+ const source = this.resolve(song);
182
+ const ctx = this.context;
183
+ const job = this.preloads.then(() => this.load(source)).then((def) => prepareSong(ctx, def));
184
+ this.preloads = job.catch(() => undefined);
185
+ return job;
186
+ }
187
+
188
+ /** Play a short song once on top of whatever is playing (victory jingles, fanfares). Its samples are rendered on the spot. */
189
+ playOnce(song: Song): Playback {
190
+ const ctx = this.context;
191
+ prepareSongSync(ctx, song);
192
+ const active = new Playback(ctx, this.busFor(song), song, { at: ctx.currentTime + 0.03, loops: 1 });
193
+ active.schedule(active.startTime + active.duration);
194
+ return active;
195
+ }
196
+
197
+ /**
198
+ * Lower the music for a moment, for example under a big sound effect or a line of dialogue.
199
+ *
200
+ * @example
201
+ * jukebox.duck(-10); // a hit: drop 10 dB, recover over 0.35 s
202
+ * jukebox.duck(-30, { attack: 0.12, hold: 4, release: 0.6 }); // near silence for a cutscene beat
203
+ */
204
+ duck(db: number, { attack = 0, hold = 0, release = 0.35 }: DuckOptions = {}): void {
205
+ if (!this.ctx) return;
206
+ const t = this.ctx.currentTime;
207
+ const level = Math.pow(10, db / 20);
208
+ for (const { duck } of this.buses) {
209
+ const g = duck.gain;
210
+ g.cancelScheduledValues(t);
211
+ g.setValueAtTime(attack > 0 ? g.value : level, t);
212
+ if (attack > 0) g.linearRampToValueAtTime(level, t + attack);
213
+ g.setValueAtTime(level, t + attack + hold);
214
+ g.linearRampToValueAtTime(1, t + attack + hold + release);
215
+ }
216
+ }
217
+
218
+ /** Bring ducked music back now */
219
+ unduck(release = 0.3): void {
220
+ if (!this.ctx) return;
221
+ const t = this.ctx.currentTime;
222
+ for (const { duck } of this.buses) {
223
+ const g = duck.gain;
224
+ g.cancelScheduledValues(t);
225
+ g.setValueAtTime(g.value, t);
226
+ g.linearRampToValueAtTime(1, t + release);
227
+ }
228
+ }
229
+
230
+ /** Resume the audio context; call from a click or key handler if you want sound to start on that exact gesture */
231
+ unlock(): Promise<void> {
232
+ return this.context.resume();
233
+ }
234
+
235
+ private async begin(source: SongSource, token: number, options: PlayOptions): Promise<Playback | null> {
236
+ try {
237
+ const ctx = this.context;
238
+ const song = await this.load(source);
239
+ if (token !== this.token) return null;
240
+ this.song = song;
241
+ await prepareSong(ctx, song);
242
+ if (token !== this.token) return null;
243
+ return this.start(song, { fadeIn: options.fadeIn ?? (song.intro?.length ? 0.05 : 1.2), from: options.from });
244
+ } finally {
245
+ if (token === this.token) this.pending = null;
246
+ }
247
+ }
248
+
249
+ private start(song: Song, options: { fadeIn: number; from?: number }): Playback {
250
+ const ctx = this.context;
251
+ const active = new Playback(ctx, this.busFor(song), song, { ...options, loops: this.loops, at: ctx.currentTime + 0.08 });
252
+ for (const t of active.tracks) if (this.carry.has(`${t.section}|${t.name}`)) t.muted = true;
253
+ this.carry = new Set();
254
+ this.active = active;
255
+ this.runClock();
256
+ return active;
257
+ }
258
+
259
+ /** Fade out what is playing and cancel anything still loading; the current song is remembered (for resume and seek) */
260
+ private halt(fade: number): void {
261
+ this.token++;
262
+ this.pending = null;
263
+ this.pausedAt = null;
264
+ this.active?.stop(fade);
265
+ this.active = null;
266
+ }
267
+
268
+ private resolve(song: string | SongSource): SongSource {
269
+ if (typeof song !== 'string') return song;
270
+ const source = this.songs[song];
271
+ if (!source) throw new Error(`Unknown song "${song}". Known: ${Object.keys(this.songs).join(', ') || '(none)'}`);
272
+ return source;
273
+ }
274
+
275
+ private load(source: SongSource): Promise<Song> {
276
+ if (typeof source !== 'function') return Promise.resolve(source);
277
+ let p = this.loaded.get(source);
278
+ if (!p) {
279
+ p = source().then((m) => ('default' in m ? m.default : m));
280
+ p.catch(() => this.loaded.delete(source));
281
+ this.loaded.set(source, p);
282
+ }
283
+ return p;
284
+ }
285
+
286
+ /** Where a song goes in: songs mastered loud have their own limiter, so they go around the output stage */
287
+ private busFor(song: Song): GainNode {
288
+ return this.buses[mastersItself(song) ? 1 : 0].volume;
289
+ }
290
+
291
+ /** Build the music buses (volume → duck → output stage, and volume → duck straight out) and arrange for the context to start on the first user gesture */
292
+ private connect(ctx: AudioContext): void {
293
+ const out = this.destination ?? ctx.destination;
294
+ this.buses = [createOutputStage(ctx, out), out].map((to) => {
295
+ const volume = ctx.createGain();
296
+ volume.gain.value = this.volumeLevel;
297
+ const duck = ctx.createGain();
298
+ volume.connect(duck).connect(to);
299
+ return { volume, duck };
300
+ });
301
+ if (ctx.state !== 'suspended' || typeof window === 'undefined') return;
302
+ const events = ['pointerdown', 'keydown', 'touchend'] as const;
303
+ const unlock = () => void ctx.resume();
304
+ for (const e of events) window.addEventListener(e, unlock, { capture: true });
305
+ ctx.addEventListener('statechange', () => {
306
+ if (ctx.state === 'running') for (const e of events) window.removeEventListener(e, unlock, { capture: true });
307
+ });
308
+ }
309
+
310
+ private runClock(): void {
311
+ if (this.timer !== null) return;
312
+ this.timer = setInterval(() => {
313
+ if (!this.active || !this.ctx) return;
314
+ const hidden = typeof document !== 'undefined' && document.hidden;
315
+ this.active.schedule(this.ctx.currentTime + (hidden ? AHEAD_HIDDEN : AHEAD));
316
+ }, 25);
317
+ }
318
+ }
319
+
320
+ function mutedTracks(active: Playback): Set<string> {
321
+ return new Set(active.tracks.filter((t) => t.muted).map((t) => `${t.section}|${t.name}`));
322
+ }
@@ -0,0 +1,166 @@
1
+ // Each song brings its own mixing console: per-track level, filters, ducking, pan and effect sends, a convolution reverb, a ping-pong delay
2
+ // and the mastering (EQ, glue compression, and a limiter of its own for songs mastered loud).
3
+ //
4
+ // track input ── filters ── duck ─┬─ pan ───────────────────────┐
5
+ // ├─ reverb send ── reverb ─────┤
6
+ // └─ delay send ─── delay ──────┼── master ── EQ ── glue ── (loud stage) ── gate ── output
7
+ // └→ reverb │
8
+
9
+ import { impulse, type Room } from '../instruments/dsp/reverb.ts';
10
+ import { DEFAULT_REVERB, type Song, type Track } from '../song.ts';
11
+ import { breakpoints, cutoffAt, type Pump } from './automation.ts';
12
+ import { createLoudStage } from './output.ts';
13
+
14
+ export interface Mixer {
15
+ /** The song's output (its fades are applied here) */
16
+ output: GainNode;
17
+ /** Closes for the song's silences: after the reverb and delay, so their tails stop dead too */
18
+ gate: GainNode;
19
+ /** One strip per track, per section: [intro tracks, body tracks] */
20
+ strips: [Strip[], Strip[]];
21
+ }
22
+
23
+ /** A track's channel on the console */
24
+ export interface Strip {
25
+ /** Where its notes go: the track's level (and mute) */
26
+ input: GainNode;
27
+ /** After its filters and ducking, before pan and effects */
28
+ output: AudioNode;
29
+ /** Its duck gain, when it ducks under another track */
30
+ pump?: Pump;
31
+ /** Its filters' cutoffs that sweep, with their breakpoints */
32
+ sweeps: { cutoff: AudioParam; points: [number, number][] }[];
33
+ }
34
+
35
+ /** Q of the two stages of a 24 dB/octave filter, in dB as Web Audio reads it for low- and high-pass: Butterworth, with a touch of resonance */
36
+ const FILTER_Q = [-5.3, 4];
37
+
38
+ export function createMixer(ctx: BaseAudioContext, song: Song, destination: AudioNode): Mixer {
39
+ // The delay and the ducking keep time with the opening tempo
40
+ const stepSeconds = 60 / song.bpm / 4;
41
+ const output = ctx.createGain();
42
+ output.connect(destination);
43
+ const gate = ctx.createGain();
44
+ gate.connect(output);
45
+ const master = createMaster(ctx, song, gate);
46
+ const reverb = createReverb(ctx, song, master);
47
+ const delay = song.delay ? createDelay(ctx, song.delay, stepSeconds, master, reverb) : null;
48
+ const strip = (track: Track): Strip => {
49
+ const input = ctx.createGain();
50
+ input.gain.value = track.gain ?? 1;
51
+ let out: AudioNode = input;
52
+ const sweeps: Strip['sweeps'] = [];
53
+ for (const type of ['lowpass', 'highpass'] as const) {
54
+ const setting = track[type];
55
+ if (setting === undefined) continue;
56
+ const points = breakpoints(typeof setting === 'number' ? { 0: setting } : setting);
57
+ for (const q of FILTER_Q) {
58
+ const f = biquad(ctx, type, cutoffAt(points, 0), q);
59
+ out = out.connect(f);
60
+ if (points.length > 1) sweeps.push({ cutoff: f.frequency, points });
61
+ }
62
+ }
63
+ let pump: Pump | undefined;
64
+ if (track.duck) {
65
+ const duck = ctx.createGain();
66
+ out = out.connect(duck);
67
+ const { depth = 0.7, release = 4 } = typeof track.duck === 'string' ? {} : track.duck;
68
+ pump = { gain: duck.gain, depth, release: release * stepSeconds };
69
+ }
70
+ const pan = ctx.createStereoPanner();
71
+ pan.pan.value = track.pan ?? 0;
72
+ out.connect(pan).connect(master);
73
+ send(ctx, out, reverb, track.reverb ?? 0.25);
74
+ if (delay && track.delay) send(ctx, out, delay, track.delay);
75
+ return { input, output: out, pump, sweeps };
76
+ };
77
+ return { output, gate, strips: [(song.intro ?? []).map(strip), song.tracks.map(strip)] };
78
+ }
79
+
80
+ function send(ctx: BaseAudioContext, from: AudioNode, to: AudioNode, amount: number): void {
81
+ const g = ctx.createGain();
82
+ g.gain.value = amount;
83
+ from.connect(g).connect(to);
84
+ }
85
+
86
+ /** Master bus: overall level, then (each only when asked for) the mastering EQ, glue compression and the loud stage */
87
+ function createMaster(ctx: BaseAudioContext, song: Song, output: AudioNode): GainNode {
88
+ const master = ctx.createGain();
89
+ const m = song.master ?? {};
90
+ master.gain.value = (song.gain ?? 1) * Math.pow(10, (m.trim ?? 0) / 20);
91
+ let out: AudioNode = master;
92
+ if (m.warm || m.cut) {
93
+ // Cut the rumble below 30 Hz first, so adding body does not eat the dynamics
94
+ const hp = biquad(ctx, 'highpass', 30, 0.6);
95
+ const warm = biquad(ctx, 'peaking', 150, 0.7, m.warm ?? 0);
96
+ const cut = biquad(ctx, 'peaking', 1300, 0.9, -(m.cut ?? 0));
97
+ out = out.connect(hp).connect(warm).connect(cut);
98
+ }
99
+ // Glue: the more of it, the lower the threshold and the higher the ratio; the slow attack lets the drums' hits through
100
+ if (m.glue) out = out.connect(new DynamicsCompressorNode(ctx, { threshold: -4 - 12 * m.glue, knee: 6, ratio: 2 + 2 * m.glue, attack: 0.02, release: 0.25 }));
101
+ out.connect(m.loud === undefined ? output : createLoudStage(ctx, m.loud, output));
102
+ return master;
103
+ }
104
+
105
+ function biquad(ctx: BaseAudioContext, type: BiquadFilterType, frequency: number, q: number, gain = 0): BiquadFilterNode {
106
+ const f = ctx.createBiquadFilter();
107
+ f.type = type;
108
+ f.frequency.value = frequency;
109
+ f.Q.value = q;
110
+ f.gain.value = gain;
111
+ return f;
112
+ }
113
+
114
+ /** Convolution reverb with the song's room; returns its input */
115
+ function createReverb(ctx: BaseAudioContext, song: Song, master: AudioNode): ConvolverNode {
116
+ const room = song.reverb ?? DEFAULT_REVERB;
117
+ const rev = ctx.createConvolver();
118
+ rev.normalize = false;
119
+ rev.buffer = impulseBuffer(ctx, room);
120
+ const ret = ctx.createGain();
121
+ ret.gain.value = room.mix ?? 1;
122
+ rev.connect(ret).connect(master);
123
+ return rev;
124
+ }
125
+
126
+ /** Tempo-synced ping-pong delay (left, then right); its echoes also feed a little into the reverb. Returns its input */
127
+ function createDelay(ctx: BaseAudioContext, d: NonNullable<Song['delay']>, stepSeconds: number, master: AudioNode, reverb: AudioNode): GainNode {
128
+ const time = Math.min(1.9, d.steps * stepSeconds);
129
+ const input = ctx.createGain();
130
+ const left = ctx.createDelay(2);
131
+ const right = ctx.createDelay(2);
132
+ left.delayTime.value = time;
133
+ right.delayTime.value = time;
134
+ const tone = biquad(ctx, 'lowpass', d.tone ?? 2800, 1);
135
+ const feedback = ctx.createGain();
136
+ feedback.gain.value = d.feedback ?? 0.35;
137
+ const ret = ctx.createGain();
138
+ ret.gain.value = d.mix ?? 0.8;
139
+ const panL = ctx.createStereoPanner();
140
+ const panR = ctx.createStereoPanner();
141
+ panL.pan.value = -0.7;
142
+ panR.pan.value = 0.7;
143
+ input.connect(tone).connect(left);
144
+ left.connect(panL).connect(ret);
145
+ left.connect(right);
146
+ right.connect(panR).connect(ret);
147
+ right.connect(feedback).connect(left);
148
+ ret.connect(master);
149
+ send(ctx, ret, reverb, 0.3);
150
+ return input;
151
+ }
152
+
153
+ const irCache = new Map<string, AudioBuffer>();
154
+ /** Impulse response of a room (each room is computed once per sample rate) */
155
+ export function impulseBuffer(ctx: BaseAudioContext, room: Room): AudioBuffer {
156
+ const key = `${ctx.sampleRate}|${room.size}|${room.damp}|${room.preDelay}|${room.early}|${room.bloom}`;
157
+ let b = irCache.get(key);
158
+ if (!b) {
159
+ const [l, r] = impulse(ctx.sampleRate, room);
160
+ b = ctx.createBuffer(2, l.length, ctx.sampleRate);
161
+ b.getChannelData(0).set(l);
162
+ b.getChannelData(1).set(r);
163
+ irCache.set(key, b);
164
+ }
165
+ return b;
166
+ }