@sudobility/music_player 0.1.0 → 0.1.2
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/CLAUDE.md +335 -0
- package/dist/core.d.ts +33 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +33 -0
- package/dist/core.js.map +1 -0
- package/dist/engine.d.ts +103 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +2 -0
- package/dist/engine.js.map +1 -0
- package/dist/mocks/index.d.ts +46 -0
- package/dist/mocks/index.d.ts.map +1 -0
- package/dist/mocks/index.js +93 -0
- package/dist/mocks/index.js.map +1 -0
- package/dist/player.d.ts +81 -0
- package/dist/player.d.ts.map +1 -0
- package/dist/player.js +209 -0
- package/dist/player.js.map +1 -0
- package/dist/rn/audio/offline-render.d.ts +29 -0
- package/dist/rn/audio/offline-render.d.ts.map +1 -0
- package/dist/rn/audio/offline-render.js +134 -0
- package/dist/rn/audio/offline-render.js.map +1 -0
- package/dist/rn/index.d.ts +50 -0
- package/dist/rn/index.d.ts.map +1 -0
- package/dist/rn/index.js +39 -0
- package/dist/rn/index.js.map +1 -0
- package/dist/rn/playback/audio-api.d.ts +93 -0
- package/dist/rn/playback/audio-api.d.ts.map +1 -0
- package/dist/rn/playback/audio-api.js +24 -0
- package/dist/rn/playback/audio-api.js.map +1 -0
- package/dist/rn/playback/expression-table.d.ts +22 -0
- package/dist/rn/playback/expression-table.d.ts.map +1 -0
- package/dist/rn/playback/expression-table.js +926 -0
- package/dist/rn/playback/expression-table.js.map +1 -0
- package/dist/rn/playback/expression.d.ts +48 -0
- package/dist/rn/playback/expression.d.ts.map +1 -0
- package/dist/rn/playback/expression.js +120 -0
- package/dist/rn/playback/expression.js.map +1 -0
- package/dist/rn/playback/gm-pack-name.d.ts +40 -0
- package/dist/rn/playback/gm-pack-name.d.ts.map +1 -0
- package/dist/rn/playback/gm-pack-name.js +61 -0
- package/dist/rn/playback/gm-pack-name.js.map +1 -0
- package/dist/rn/playback/pack-library.d.ts +44 -0
- package/dist/rn/playback/pack-library.d.ts.map +1 -0
- package/dist/rn/playback/pack-library.js +85 -0
- package/dist/rn/playback/pack-library.js.map +1 -0
- package/dist/rn/playback/pack-names.json +130 -0
- package/dist/rn/playback/sample-engine.d.ts +216 -0
- package/dist/rn/playback/sample-engine.d.ts.map +1 -0
- package/dist/rn/playback/sample-engine.js +669 -0
- package/dist/rn/playback/sample-engine.js.map +1 -0
- package/dist/rn/playback/sample-pack.d.ts +71 -0
- package/dist/rn/playback/sample-pack.d.ts.map +1 -0
- package/dist/rn/playback/sample-pack.js +112 -0
- package/dist/rn/playback/sample-pack.js.map +1 -0
- package/dist/rn/playback/sustain-loop.d.ts +24 -0
- package/dist/rn/playback/sustain-loop.d.ts.map +1 -0
- package/dist/rn/playback/sustain-loop.js +111 -0
- package/dist/rn/playback/sustain-loop.js.map +1 -0
- package/dist/rn/playback/voice-plan.d.ts +60 -0
- package/dist/rn/playback/voice-plan.d.ts.map +1 -0
- package/dist/rn/playback/voice-plan.js +38 -0
- package/dist/rn/playback/voice-plan.js.map +1 -0
- package/dist/shared/bus.d.ts +73 -0
- package/dist/shared/bus.d.ts.map +1 -0
- package/dist/shared/bus.js +83 -0
- package/dist/shared/bus.js.map +1 -0
- package/dist/shared/midi.d.ts +18 -0
- package/dist/shared/midi.d.ts.map +1 -0
- package/dist/shared/midi.js +25 -0
- package/dist/shared/midi.js.map +1 -0
- package/dist/shared/mix.d.ts +75 -0
- package/dist/shared/mix.d.ts.map +1 -0
- package/dist/shared/mix.js +115 -0
- package/dist/shared/mix.js.map +1 -0
- package/dist/shared/note-queue.d.ts +43 -0
- package/dist/shared/note-queue.d.ts.map +1 -0
- package/dist/shared/note-queue.js +54 -0
- package/dist/shared/note-queue.js.map +1 -0
- package/dist/shared/plan.d.ts +34 -0
- package/dist/shared/plan.d.ts.map +1 -0
- package/dist/shared/plan.js +134 -0
- package/dist/shared/plan.js.map +1 -0
- package/dist/shared/pump-window.d.ts +38 -0
- package/dist/shared/pump-window.d.ts.map +1 -0
- package/dist/shared/pump-window.js +13 -0
- package/dist/shared/pump-window.js.map +1 -0
- package/dist/shared/render-events.d.ts +25 -0
- package/dist/shared/render-events.d.ts.map +1 -0
- package/dist/shared/render-events.js +79 -0
- package/dist/shared/render-events.js.map +1 -0
- package/dist/shared/sounding-set.d.ts +45 -0
- package/dist/shared/sounding-set.d.ts.map +1 -0
- package/dist/shared/sounding-set.js +81 -0
- package/dist/shared/sounding-set.js.map +1 -0
- package/dist/shared/test-plan.d.ts +36 -0
- package/dist/shared/test-plan.d.ts.map +1 -0
- package/dist/shared/test-plan.js +83 -0
- package/dist/shared/test-plan.js.map +1 -0
- package/dist/singleton.d.ts +23 -0
- package/dist/singleton.d.ts.map +1 -0
- package/dist/singleton.js +26 -0
- package/dist/singleton.js.map +1 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/web/audio/offline-synth.d.ts +24 -0
- package/dist/web/audio/offline-synth.d.ts.map +1 -0
- package/dist/web/audio/offline-synth.js +92 -0
- package/dist/web/audio/offline-synth.js.map +1 -0
- package/dist/web/audio/soundfont-render.d.ts +31 -0
- package/dist/web/audio/soundfont-render.d.ts.map +1 -0
- package/dist/web/audio/soundfont-render.js +130 -0
- package/dist/web/audio/soundfont-render.js.map +1 -0
- package/dist/web/audio/synth-types.d.ts +24 -0
- package/dist/web/audio/synth-types.d.ts.map +1 -0
- package/dist/web/audio/synth-types.js +2 -0
- package/dist/web/audio/synth-types.js.map +1 -0
- package/dist/web/index.d.ts +40 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +59 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/playback/channel-allocator.d.ts +54 -0
- package/dist/web/playback/channel-allocator.d.ts.map +1 -0
- package/dist/web/playback/channel-allocator.js +72 -0
- package/dist/web/playback/channel-allocator.js.map +1 -0
- package/dist/web/playback/click.d.ts +9 -0
- package/dist/web/playback/click.d.ts.map +1 -0
- package/dist/web/playback/click.js +61 -0
- package/dist/web/playback/click.js.map +1 -0
- package/dist/web/playback/clock.d.ts +36 -0
- package/dist/web/playback/clock.d.ts.map +1 -0
- package/dist/web/playback/clock.js +64 -0
- package/dist/web/playback/clock.js.map +1 -0
- package/dist/web/playback/governor.d.ts +45 -0
- package/dist/web/playback/governor.d.ts.map +1 -0
- package/dist/web/playback/governor.js +33 -0
- package/dist/web/playback/governor.js.map +1 -0
- package/dist/web/playback/quiet-stub-notices.d.ts +55 -0
- package/dist/web/playback/quiet-stub-notices.d.ts.map +1 -0
- package/dist/web/playback/quiet-stub-notices.js +93 -0
- package/dist/web/playback/quiet-stub-notices.js.map +1 -0
- package/dist/web/playback/soundfont-engine.d.ts +281 -0
- package/dist/web/playback/soundfont-engine.d.ts.map +1 -0
- package/dist/web/playback/soundfont-engine.js +735 -0
- package/dist/web/playback/soundfont-engine.js.map +1 -0
- package/dist/web/playback/soundfont-loader.d.ts +30 -0
- package/dist/web/playback/soundfont-loader.d.ts.map +1 -0
- package/dist/web/playback/soundfont-loader.js +68 -0
- package/dist/web/playback/soundfont-loader.js.map +1 -0
- package/dist/web/playback/synth-host.d.ts +142 -0
- package/dist/web/playback/synth-host.d.ts.map +1 -0
- package/dist/web/playback/synth-host.js +293 -0
- package/dist/web/playback/synth-host.js.map +1 -0
- package/package.json +18 -3
- package/dist/index.d.ts +0 -14
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { NoteQueue } from '../../shared/note-queue.js';
|
|
2
|
+
import { SoundingSet } from '../../shared/sounding-set.js';
|
|
3
|
+
import { planDispatch } from '../../shared/pump-window.js';
|
|
4
|
+
import { headroomTrimFor } from '../../shared/mix.js';
|
|
5
|
+
import { gmPackName, percussionPackName } from './gm-pack-name.js';
|
|
6
|
+
import { PackLibrary } from './pack-library.js';
|
|
7
|
+
import { RELEASE_SECONDS, planVoice } from './voice-plan.js';
|
|
8
|
+
import { sustains } from './expression.js';
|
|
9
|
+
import { loadAudioApi } from './audio-api.js';
|
|
10
|
+
export { base64ToBytes } from './pack-library.js';
|
|
11
|
+
import { applySustainLoop } from './sustain-loop.js';
|
|
12
|
+
/** How often the pump looks for notes to schedule. */
|
|
13
|
+
const PUMP_INTERVAL_MS = 50;
|
|
14
|
+
/** How far ahead of the clock notes are handed to the audio graph. */
|
|
15
|
+
const LOOKAHEAD_SECONDS = 0.2;
|
|
16
|
+
/** How late a note may be and still be played rather than skipped. */
|
|
17
|
+
const GRACE_SECONDS = 0.2;
|
|
18
|
+
const POSITION_TICK_INTERVAL_MS = 1000 / 30;
|
|
19
|
+
/** Metronome click pitches, accented downbeat first. */
|
|
20
|
+
const CLICK_HZ = { accent: 1600, normal: 1200 };
|
|
21
|
+
const CLICK_SECONDS = 0.03;
|
|
22
|
+
function defaultFetchPack(url) {
|
|
23
|
+
return fetch(url).then((r) => {
|
|
24
|
+
if (!r.ok)
|
|
25
|
+
throw new Error(`Sample pack ${url} responded ${r.status}`);
|
|
26
|
+
return r.text();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function defaultStartPump(tick, intervalMs) {
|
|
30
|
+
const handle = setInterval(tick, intervalMs);
|
|
31
|
+
return () => clearInterval(handle);
|
|
32
|
+
}
|
|
33
|
+
export class RNSamplePlaybackEngine {
|
|
34
|
+
deps;
|
|
35
|
+
api = null;
|
|
36
|
+
ctx = null;
|
|
37
|
+
master = null;
|
|
38
|
+
plan = null;
|
|
39
|
+
tempo = null;
|
|
40
|
+
queue = new NoteQueue();
|
|
41
|
+
clicks = [];
|
|
42
|
+
clickCursor = 0;
|
|
43
|
+
library = null;
|
|
44
|
+
observer = null;
|
|
45
|
+
state = 'stopped';
|
|
46
|
+
loadState = { status: 'idle' };
|
|
47
|
+
stopPump = null;
|
|
48
|
+
stopPositionTimer = null;
|
|
49
|
+
/** Context time corresponding to `originTick`. */
|
|
50
|
+
originContextTime = 0;
|
|
51
|
+
originTick = 0;
|
|
52
|
+
positionTick = 0;
|
|
53
|
+
tempoMultiplier = 1;
|
|
54
|
+
loop = null;
|
|
55
|
+
masterVolume = 1;
|
|
56
|
+
metronomeOn = false;
|
|
57
|
+
headroom = 1;
|
|
58
|
+
muted = new Set();
|
|
59
|
+
soloed = new Set();
|
|
60
|
+
/**
|
|
61
|
+
* Volume and pan per track, held apart from `plan` because `applyMix` is
|
|
62
|
+
* allowed to change them without the plan being rebuilt — that is the whole
|
|
63
|
+
* reason it exists.
|
|
64
|
+
*/
|
|
65
|
+
mix = new Map();
|
|
66
|
+
strips = new Map();
|
|
67
|
+
voices = [];
|
|
68
|
+
/** Clicks already on the graph, so pause and stop can take them back. */
|
|
69
|
+
pendingClicks = [];
|
|
70
|
+
auditioned = new Map();
|
|
71
|
+
/**
|
|
72
|
+
* What is sounding, read off the clock rather than off the voice list.
|
|
73
|
+
*
|
|
74
|
+
* Deriving it from the voices lit every note the moment it was *scheduled* —
|
|
75
|
+
* a lookahead early — and held it lit through the release tail, so the
|
|
76
|
+
* notation ran ahead of the sound and lagged behind its end. The web engine
|
|
77
|
+
* has always used this; sharing it is what stops the two drifting.
|
|
78
|
+
*/
|
|
79
|
+
sounding = new SoundingSet();
|
|
80
|
+
activeNotes = [];
|
|
81
|
+
constructor(deps = {}) {
|
|
82
|
+
this.deps = {
|
|
83
|
+
loadAudioApi: deps.loadAudioApi ?? loadAudioApi,
|
|
84
|
+
fetchPack: deps.fetchPack ?? defaultFetchPack,
|
|
85
|
+
startPump: deps.startPump ?? defaultStartPump,
|
|
86
|
+
packBase: deps.packBase,
|
|
87
|
+
percussionBase: deps.percussionBase,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// ---- lifecycle ---------------------------------------------------------
|
|
91
|
+
async initialize() {
|
|
92
|
+
if (this.ctx)
|
|
93
|
+
return;
|
|
94
|
+
const api = await this.deps.loadAudioApi();
|
|
95
|
+
const ctx = new api.AudioContext();
|
|
96
|
+
const master = ctx.createGain();
|
|
97
|
+
master.gain.value = this.masterVolume * this.headroom;
|
|
98
|
+
master.connect(ctx.destination);
|
|
99
|
+
this.api = api;
|
|
100
|
+
this.ctx = ctx;
|
|
101
|
+
this.master = master;
|
|
102
|
+
this.library = new PackLibrary({
|
|
103
|
+
fetchPack: this.deps.fetchPack,
|
|
104
|
+
decodeAudioData: (bytes) => api.decodeAudioData(bytes),
|
|
105
|
+
packBase: this.deps.packBase,
|
|
106
|
+
percussionBase: this.deps.percussionBase,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async load(plan) {
|
|
110
|
+
this.stopAllVoices();
|
|
111
|
+
// The strips belong to the tracks of the plan being replaced; keeping them
|
|
112
|
+
// would leave a departed track's fader in the graph and a returning one
|
|
113
|
+
// holding the old score's level.
|
|
114
|
+
this.releaseStrips();
|
|
115
|
+
this.plan = plan;
|
|
116
|
+
this.tempo = plan.tempo;
|
|
117
|
+
this.queue.load(plan.notes);
|
|
118
|
+
this.sounding.load(plan.notes, (tick) => plan.tempo.ticksToSeconds(tick));
|
|
119
|
+
this.clicks = plan.clicks;
|
|
120
|
+
this.clickCursor = 0;
|
|
121
|
+
this.headroom = headroomTrimFor(plan.tracks.length);
|
|
122
|
+
this.applyMasterGain();
|
|
123
|
+
this.mix.clear();
|
|
124
|
+
for (const track of plan.tracks) {
|
|
125
|
+
this.mix.set(track.id, { volume: track.volume, pan: track.pan });
|
|
126
|
+
}
|
|
127
|
+
this.seek(0);
|
|
128
|
+
}
|
|
129
|
+
// ---- sample packs ------------------------------------------------------
|
|
130
|
+
/** The pack name for a track, honouring the percussion/kit distinction. */
|
|
131
|
+
packNameForTrack(trackId) {
|
|
132
|
+
const track = this.plan?.tracks.find((t) => t.id === trackId);
|
|
133
|
+
if (!track)
|
|
134
|
+
return null;
|
|
135
|
+
// The kit-versus-instrument distinction is already resolved into
|
|
136
|
+
// `voiceProgram`/`voiceName` by music_lib, which owns the GM tables.
|
|
137
|
+
return track.isPercussion
|
|
138
|
+
? percussionPackName(track.voiceProgram)
|
|
139
|
+
: gmPackName(track.voiceProgram, track.voiceName);
|
|
140
|
+
}
|
|
141
|
+
/** Loads every pack the score needs, reporting progress across all of them. */
|
|
142
|
+
async ensureScorePacks() {
|
|
143
|
+
const names = new Set();
|
|
144
|
+
for (const track of this.plan?.tracks ?? []) {
|
|
145
|
+
const name = this.packNameForTrack(track.id);
|
|
146
|
+
if (name && !this.library.has(name))
|
|
147
|
+
names.add(name);
|
|
148
|
+
}
|
|
149
|
+
if (names.size === 0) {
|
|
150
|
+
this.reportLoad({ status: 'ready' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.reportLoad({ status: 'loading', fraction: 0 });
|
|
154
|
+
let done = 0;
|
|
155
|
+
try {
|
|
156
|
+
for (const name of names) {
|
|
157
|
+
await this.library.ensure(name);
|
|
158
|
+
done += 1;
|
|
159
|
+
this.reportLoad({ status: 'loading', fraction: done / names.size });
|
|
160
|
+
}
|
|
161
|
+
this.reportLoad({ status: 'ready' });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
this.reportLoad({ status: 'failed', message });
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
reportLoad(state) {
|
|
170
|
+
this.loadState = state;
|
|
171
|
+
this.observer?.onLoadStateChange?.(state);
|
|
172
|
+
}
|
|
173
|
+
// ---- transport ---------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Reports `playing` only once a note can actually sound.
|
|
176
|
+
*
|
|
177
|
+
* The same rule the web engine follows, and for the same reason: the caret
|
|
178
|
+
* dead-reckons from elapsed real time between position reports, so announcing
|
|
179
|
+
* `playing` before the packs are decoded would glide it silently through
|
|
180
|
+
* several bars and snap it back when the music finally started.
|
|
181
|
+
*/
|
|
182
|
+
async play(fromTick) {
|
|
183
|
+
await this.initialize();
|
|
184
|
+
if (fromTick !== undefined)
|
|
185
|
+
this.seek(fromTick);
|
|
186
|
+
await this.ensureScorePacks();
|
|
187
|
+
await this.ctx.resume();
|
|
188
|
+
this.originContextTime = this.ctx.currentTime;
|
|
189
|
+
this.originTick = this.positionTick;
|
|
190
|
+
this.setState('playing');
|
|
191
|
+
// Pressing play while already playing must not leave the first pump
|
|
192
|
+
// running: its handle would be overwritten and nothing could ever stop it.
|
|
193
|
+
this.haltPump();
|
|
194
|
+
this.stopPump = this.deps.startPump(() => this.pump(), PUMP_INTERVAL_MS);
|
|
195
|
+
this.stopPositionTimer = this.deps.startPump(() => this.reportPosition(), POSITION_TICK_INTERVAL_MS);
|
|
196
|
+
this.pump();
|
|
197
|
+
this.reportPosition();
|
|
198
|
+
}
|
|
199
|
+
pause() {
|
|
200
|
+
if (this.state !== 'playing')
|
|
201
|
+
return;
|
|
202
|
+
const tick = this.currentTick();
|
|
203
|
+
this.haltPump();
|
|
204
|
+
this.stopAllVoices();
|
|
205
|
+
this.seek(tick);
|
|
206
|
+
this.setState('paused');
|
|
207
|
+
}
|
|
208
|
+
stop() {
|
|
209
|
+
this.haltPump();
|
|
210
|
+
this.stopAllVoices();
|
|
211
|
+
this.seek(0);
|
|
212
|
+
this.setState('stopped');
|
|
213
|
+
}
|
|
214
|
+
seek(tick) {
|
|
215
|
+
this.positionTick = Math.max(0, tick);
|
|
216
|
+
this.queue.seekToTick(this.positionTick);
|
|
217
|
+
this.clickCursor = this.clicks.findIndex((c) => c.tick >= this.positionTick);
|
|
218
|
+
if (this.clickCursor < 0)
|
|
219
|
+
this.clickCursor = this.clicks.length;
|
|
220
|
+
this.originTick = this.positionTick;
|
|
221
|
+
this.originContextTime = this.ctx?.currentTime ?? 0;
|
|
222
|
+
// A note spanning this moment is deliberately not restored: a seek silences
|
|
223
|
+
// the voices, so colouring it would be a lie. See `sounding-set.ts`.
|
|
224
|
+
this.sounding.reset(this.tempo?.ticksToSeconds(this.positionTick) ?? 0);
|
|
225
|
+
this.setActiveNotes([]);
|
|
226
|
+
this.observer?.onPositionTick(this.positionTick);
|
|
227
|
+
}
|
|
228
|
+
haltPump() {
|
|
229
|
+
this.stopPump?.();
|
|
230
|
+
this.stopPump = null;
|
|
231
|
+
this.stopPositionTimer?.();
|
|
232
|
+
this.stopPositionTimer = null;
|
|
233
|
+
}
|
|
234
|
+
setState(next) {
|
|
235
|
+
if (this.state === next)
|
|
236
|
+
return;
|
|
237
|
+
this.state = next;
|
|
238
|
+
this.observer?.onStateChange(next);
|
|
239
|
+
}
|
|
240
|
+
/** Score tick at the context's current time, from the tempo map and speed. */
|
|
241
|
+
currentTick() {
|
|
242
|
+
if (!this.ctx || !this.tempo)
|
|
243
|
+
return this.positionTick;
|
|
244
|
+
const elapsed = (this.ctx.currentTime - this.originContextTime) * this.tempoMultiplier;
|
|
245
|
+
const originSeconds = this.tempo.ticksToSeconds(this.originTick);
|
|
246
|
+
return this.tempo.secondsToTicks(originSeconds + elapsed);
|
|
247
|
+
}
|
|
248
|
+
secondsForTick(tick) {
|
|
249
|
+
if (!this.tempo || !this.ctx)
|
|
250
|
+
return 0;
|
|
251
|
+
const fromOrigin = (this.tempo.ticksToSeconds(tick) - this.tempo.ticksToSeconds(this.originTick)) /
|
|
252
|
+
this.tempoMultiplier;
|
|
253
|
+
return this.originContextTime + fromOrigin;
|
|
254
|
+
}
|
|
255
|
+
reportPosition() {
|
|
256
|
+
this.positionTick = this.currentTick();
|
|
257
|
+
this.reportSounding();
|
|
258
|
+
this.observer?.onPositionTick(this.positionTick);
|
|
259
|
+
}
|
|
260
|
+
/** The lit set, at the clock's position, and only when it has changed. */
|
|
261
|
+
reportSounding() {
|
|
262
|
+
if (!this.tempo)
|
|
263
|
+
return;
|
|
264
|
+
const notes = this.sounding.advanceTo(this.tempo.ticksToSeconds(this.positionTick));
|
|
265
|
+
if (notes)
|
|
266
|
+
this.setActiveNotes(notes);
|
|
267
|
+
}
|
|
268
|
+
// ---- the pump ----------------------------------------------------------
|
|
269
|
+
pump() {
|
|
270
|
+
if (!this.ctx || this.state !== 'playing')
|
|
271
|
+
return;
|
|
272
|
+
const now = this.ctx.currentTime;
|
|
273
|
+
this.reapVoices(now);
|
|
274
|
+
const horizonTick = this.tickAtContextTime(now + LOOKAHEAD_SECONDS);
|
|
275
|
+
if (this.loop && horizonTick >= this.loop.endTick) {
|
|
276
|
+
// The end tick itself belongs to the next pass, where it is the loop's
|
|
277
|
+
// own first beat — dispatching it here plays it twice at the same moment.
|
|
278
|
+
this.drainThrough(this.loop.endTick - 1, now);
|
|
279
|
+
// Read *before* the seek. `seek` re-anchors the origin on the current
|
|
280
|
+
// moment, so asking afterwards answers "when would the end come round if
|
|
281
|
+
// the loop started this instant" — a whole loop late — and the engine
|
|
282
|
+
// then played nothing at all until real time caught up with its answer.
|
|
283
|
+
const wrapsAt = this.secondsForTick(this.loop.endTick);
|
|
284
|
+
this.seek(this.loop.startTick);
|
|
285
|
+
this.originContextTime = wrapsAt;
|
|
286
|
+
this.originTick = this.loop.startTick;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.drainThrough(horizonTick, now);
|
|
290
|
+
if (this.queue.exhausted && this.voices.length === 0 && !this.loop) {
|
|
291
|
+
this.stop();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
drainThrough(horizonTick, now) {
|
|
295
|
+
const drained = this.queue.drainUntil(horizonTick);
|
|
296
|
+
const { due } = planDispatch({
|
|
297
|
+
notes: drained,
|
|
298
|
+
secondsForTick: (tick) => this.secondsForTick(tick),
|
|
299
|
+
positionSeconds: now,
|
|
300
|
+
graceSeconds: GRACE_SECONDS,
|
|
301
|
+
});
|
|
302
|
+
for (const { note, atSeconds } of due) {
|
|
303
|
+
if (this.audibleTrack(note.trackId))
|
|
304
|
+
this.startNote(note, atSeconds);
|
|
305
|
+
}
|
|
306
|
+
if (this.metronomeOn)
|
|
307
|
+
this.scheduleClicks(horizonTick);
|
|
308
|
+
}
|
|
309
|
+
tickAtContextTime(contextTime) {
|
|
310
|
+
if (!this.tempo)
|
|
311
|
+
return this.positionTick;
|
|
312
|
+
const elapsed = (contextTime - this.originContextTime) * this.tempoMultiplier;
|
|
313
|
+
return this.tempo.secondsToTicks(this.tempo.ticksToSeconds(this.originTick) + elapsed);
|
|
314
|
+
}
|
|
315
|
+
/** Mute/solo, with solo winning: any solo means everything unsoloed is silent. */
|
|
316
|
+
audibleTrack(trackId) {
|
|
317
|
+
if (this.soloed.size > 0)
|
|
318
|
+
return this.soloed.has(trackId);
|
|
319
|
+
return !this.muted.has(trackId);
|
|
320
|
+
}
|
|
321
|
+
startNote(note, atSeconds) {
|
|
322
|
+
const packName = this.packNameForTrack(note.trackId);
|
|
323
|
+
if (!packName)
|
|
324
|
+
return;
|
|
325
|
+
const track = this.plan?.tracks.find((t) => t.id === note.trackId);
|
|
326
|
+
const voicing = this.library?.voice(packName, note.midi, Boolean(track?.isPercussion));
|
|
327
|
+
if (!voicing)
|
|
328
|
+
return;
|
|
329
|
+
const { choice, buffer } = voicing;
|
|
330
|
+
const durationSeconds = this.secondsForTick(note.tick + note.durTicks) - this.secondsForTick(note.tick);
|
|
331
|
+
const program = track?.midiProgram;
|
|
332
|
+
const plan = planVoice({
|
|
333
|
+
atSeconds,
|
|
334
|
+
durationSeconds,
|
|
335
|
+
velocity: note.velocity,
|
|
336
|
+
// Unity here: the track's level lives on its strip, where a fader can
|
|
337
|
+
// still move it after the note has started, and the headroom lives on the
|
|
338
|
+
// master — the same division the web engine and the offline renderer use.
|
|
339
|
+
trackGain: 1,
|
|
340
|
+
choice,
|
|
341
|
+
// Percussion has no melodic expression: a kit's programs address kits,
|
|
342
|
+
// and a drum is a one-shot whose decay is already in the recording.
|
|
343
|
+
program: track?.isPercussion ? undefined : program,
|
|
344
|
+
});
|
|
345
|
+
const voice = this.buildVoiceFromPlan(buffer, plan, program !== undefined && sustains(program), this.stripFor(note.trackId));
|
|
346
|
+
this.voices.push(voice);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The track's gain-and-pan pair, built on first use.
|
|
350
|
+
*
|
|
351
|
+
* Lazily, because a plan can be loaded before there is an audio context to
|
|
352
|
+
* build nodes in — `load` does not await `initialize`.
|
|
353
|
+
*/
|
|
354
|
+
stripFor(trackId) {
|
|
355
|
+
const existing = this.strips.get(trackId);
|
|
356
|
+
if (existing)
|
|
357
|
+
return existing.gain;
|
|
358
|
+
const ctx = this.ctx;
|
|
359
|
+
const gain = ctx.createGain();
|
|
360
|
+
const panner = ctx.createStereoPanner();
|
|
361
|
+
gain.connect(panner);
|
|
362
|
+
panner.connect(this.master);
|
|
363
|
+
const strip = { gain, panner };
|
|
364
|
+
this.strips.set(trackId, strip);
|
|
365
|
+
this.applyStrip(trackId, strip);
|
|
366
|
+
return gain;
|
|
367
|
+
}
|
|
368
|
+
/** Volume, pan, mute and solo, resolved onto one track's strip. */
|
|
369
|
+
applyStrip(trackId, strip) {
|
|
370
|
+
const level = this.mix.get(trackId);
|
|
371
|
+
strip.gain.gain.value = this.audibleTrack(trackId) ? (level?.volume ?? 1) : 0;
|
|
372
|
+
strip.panner.pan.value = Math.max(-1, Math.min(1, level?.pan ?? 0));
|
|
373
|
+
}
|
|
374
|
+
/** Pushes the whole mix, for a fader move or a mute that must land at once. */
|
|
375
|
+
applyAllStrips() {
|
|
376
|
+
for (const [trackId, strip] of this.strips)
|
|
377
|
+
this.applyStrip(trackId, strip);
|
|
378
|
+
}
|
|
379
|
+
releaseStrips() {
|
|
380
|
+
for (const strip of this.strips.values()) {
|
|
381
|
+
strip.gain.disconnect();
|
|
382
|
+
strip.panner.disconnect();
|
|
383
|
+
}
|
|
384
|
+
this.strips.clear();
|
|
385
|
+
}
|
|
386
|
+
applyMasterGain() {
|
|
387
|
+
if (this.master)
|
|
388
|
+
this.master.gain.value = this.masterVolume * this.headroom;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* One voice: source -> [low-pass] -> gain -> master.
|
|
392
|
+
*
|
|
393
|
+
* The filter is only built when this velocity actually calls for one (71 of
|
|
394
|
+
* the 128 instruments never do), because a node per voice is real cost in a
|
|
395
|
+
* dense passage.
|
|
396
|
+
*/
|
|
397
|
+
buildVoiceFromPlan(buffer, plan, mayLoop, destination = this.master) {
|
|
398
|
+
const ctx = this.ctx;
|
|
399
|
+
const source = ctx.createBufferSource();
|
|
400
|
+
source.buffer = buffer;
|
|
401
|
+
// `detune` rather than `playbackRate`: it is already in cents, so no
|
|
402
|
+
// exponential conversion here to get subtly wrong. Packs cover every key,
|
|
403
|
+
// so this is normally 0 and only matters for a pack with gaps.
|
|
404
|
+
if (plan.detuneCents !== 0)
|
|
405
|
+
source.detune.value = plan.detuneCents;
|
|
406
|
+
if (mayLoop)
|
|
407
|
+
applySustainLoop(source, buffer, plan.releaseAt - plan.startAt, plan.sampleMidi);
|
|
408
|
+
const amp = ctx.createGain();
|
|
409
|
+
amp.gain.setValueAtTime(plan.gain, plan.startAt);
|
|
410
|
+
// Hold flat, then release. Linear rather than exponential: exponential
|
|
411
|
+
// ramps cannot reach zero, and the residual is what clicks.
|
|
412
|
+
amp.gain.setValueAtTime(plan.gain, plan.releaseAt);
|
|
413
|
+
amp.gain.linearRampToValueAtTime(0, plan.endAt);
|
|
414
|
+
if (plan.cutoffHz === null) {
|
|
415
|
+
source.connect(amp);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const filter = ctx.createBiquadFilter();
|
|
419
|
+
filter.type = 'lowpass';
|
|
420
|
+
filter.frequency.value = plan.cutoffHz;
|
|
421
|
+
source.connect(filter);
|
|
422
|
+
filter.connect(amp);
|
|
423
|
+
}
|
|
424
|
+
amp.connect(destination);
|
|
425
|
+
source.start(plan.startAt);
|
|
426
|
+
source.stop(plan.endAt);
|
|
427
|
+
return { source, gain: amp, endsAt: plan.endAt };
|
|
428
|
+
}
|
|
429
|
+
/** The flat-envelope form, for the metronome and for auditioning a key. */
|
|
430
|
+
buildVoice(buffer, detuneCents, gain, startAt, releaseAt) {
|
|
431
|
+
return this.buildVoiceFromPlan(buffer, {
|
|
432
|
+
startAt,
|
|
433
|
+
releaseAt,
|
|
434
|
+
endAt: releaseAt + RELEASE_SECONDS,
|
|
435
|
+
gain,
|
|
436
|
+
cutoffHz: null,
|
|
437
|
+
uri: '',
|
|
438
|
+
detuneCents,
|
|
439
|
+
sampleMidi: 0,
|
|
440
|
+
}, false);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Drops voices whose release has finished.
|
|
444
|
+
*
|
|
445
|
+
* Nothing about the *lit* set any more — that comes off the clock. A voice
|
|
446
|
+
* outlives its written note by the instrument's release, which is exactly
|
|
447
|
+
* the discrepancy that made deriving the lit set from here wrong.
|
|
448
|
+
*/
|
|
449
|
+
reapVoices(now) {
|
|
450
|
+
for (let i = this.voices.length - 1; i >= 0; i -= 1) {
|
|
451
|
+
if (this.voices[i].endsAt <= now) {
|
|
452
|
+
this.voices[i].gain.disconnect();
|
|
453
|
+
this.voices.splice(i, 1);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
setActiveNotes(notes) {
|
|
458
|
+
const same = notes.length === this.activeNotes.length &&
|
|
459
|
+
notes.every((n, i) => n.noteId === this.activeNotes[i].noteId);
|
|
460
|
+
if (same)
|
|
461
|
+
return;
|
|
462
|
+
this.activeNotes = notes;
|
|
463
|
+
this.observer?.onActiveNotes(notes);
|
|
464
|
+
}
|
|
465
|
+
stopAllVoices() {
|
|
466
|
+
for (const voice of this.voices) {
|
|
467
|
+
try {
|
|
468
|
+
voice.source.stop();
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// Already stopped; nothing to undo.
|
|
472
|
+
}
|
|
473
|
+
voice.gain.disconnect();
|
|
474
|
+
}
|
|
475
|
+
this.voices.length = 0;
|
|
476
|
+
this.setActiveNotes([]);
|
|
477
|
+
this.cancelPendingClicks();
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Takes back clicks already placed on the graph.
|
|
481
|
+
*
|
|
482
|
+
* An oscillator started with `start(at)` sounds at that moment whatever the
|
|
483
|
+
* transport does next, and a click goes nowhere near a voice — so without
|
|
484
|
+
* this, pausing kept ticking for the length of the lookahead.
|
|
485
|
+
*/
|
|
486
|
+
cancelPendingClicks() {
|
|
487
|
+
if (this.pendingClicks.length === 0)
|
|
488
|
+
return;
|
|
489
|
+
const now = this.ctx?.currentTime ?? 0;
|
|
490
|
+
for (const { osc } of this.pendingClicks) {
|
|
491
|
+
try {
|
|
492
|
+
osc.stop(now);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// Already finished; nothing to take back.
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
this.pendingClicks.length = 0;
|
|
499
|
+
}
|
|
500
|
+
// ---- metronome ---------------------------------------------------------
|
|
501
|
+
scheduleClicks(horizonTick) {
|
|
502
|
+
// Forget the ones that have sounded: only what is still ahead of the clock
|
|
503
|
+
// can be taken back, and a piece is minutes of beats.
|
|
504
|
+
const cutoff = this.ctx.currentTime;
|
|
505
|
+
for (let i = this.pendingClicks.length - 1; i >= 0; i -= 1) {
|
|
506
|
+
if (this.pendingClicks[i].endsAt <= cutoff)
|
|
507
|
+
this.pendingClicks.splice(i, 1);
|
|
508
|
+
}
|
|
509
|
+
while (this.clickCursor < this.clicks.length && this.clicks[this.clickCursor].tick <= horizonTick) {
|
|
510
|
+
const click = this.clicks[this.clickCursor];
|
|
511
|
+
this.clickCursor += 1;
|
|
512
|
+
const at = this.secondsForTick(click.tick);
|
|
513
|
+
if (at < this.ctx.currentTime)
|
|
514
|
+
continue;
|
|
515
|
+
this.scheduleClick(at, click.accent);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* A short sine blip. Synthesised rather than sampled because the packs are
|
|
520
|
+
* instruments and carry no click, and a metronome is one of the few sounds
|
|
521
|
+
* where a plain tone is what is wanted anyway.
|
|
522
|
+
*/
|
|
523
|
+
scheduleClick(at, accent) {
|
|
524
|
+
const ctx = this.ctx;
|
|
525
|
+
const osc = ctx.createOscillator();
|
|
526
|
+
osc.type = 'sine';
|
|
527
|
+
osc.frequency.value = accent ? CLICK_HZ.accent : CLICK_HZ.normal;
|
|
528
|
+
const amp = ctx.createGain();
|
|
529
|
+
amp.gain.setValueAtTime(accent ? 0.5 : 0.3, at);
|
|
530
|
+
amp.gain.linearRampToValueAtTime(0, at + CLICK_SECONDS);
|
|
531
|
+
osc.connect(amp);
|
|
532
|
+
// Straight to the output, past the master — the same routing the web click
|
|
533
|
+
// takes, for the same reason. A click is a monitoring aid rather than part
|
|
534
|
+
// of the music, so the headroom trim has no business making it quieter on a
|
|
535
|
+
// score with more parts. Before the trim moved onto the master this reached
|
|
536
|
+
// the same place by accident.
|
|
537
|
+
amp.connect(ctx.destination);
|
|
538
|
+
osc.start(at);
|
|
539
|
+
osc.stop(at + CLICK_SECONDS);
|
|
540
|
+
this.pendingClicks.push({ osc, endsAt: at + CLICK_SECONDS });
|
|
541
|
+
}
|
|
542
|
+
// ---- settings ----------------------------------------------------------
|
|
543
|
+
setTempoMultiplier(multiplier) {
|
|
544
|
+
// Re-anchor first, or the elapsed time already played would be reinterpreted
|
|
545
|
+
// at the new speed and the position would jump.
|
|
546
|
+
if (this.ctx) {
|
|
547
|
+
this.originTick = this.currentTick();
|
|
548
|
+
this.originContextTime = this.ctx.currentTime;
|
|
549
|
+
}
|
|
550
|
+
this.tempoMultiplier = multiplier;
|
|
551
|
+
}
|
|
552
|
+
setLoop(range) {
|
|
553
|
+
this.loop = range;
|
|
554
|
+
}
|
|
555
|
+
setTrackMute(trackId, muted) {
|
|
556
|
+
if (muted)
|
|
557
|
+
this.muted.add(trackId);
|
|
558
|
+
else
|
|
559
|
+
this.muted.delete(trackId);
|
|
560
|
+
// Every strip, not just this one. Mute is local and solo is global, and
|
|
561
|
+
// pushing the whole mix either way means the audibility rule lives in
|
|
562
|
+
// `applyStrip` alone rather than being restated per setter.
|
|
563
|
+
this.applyAllStrips();
|
|
564
|
+
}
|
|
565
|
+
setTrackSolo(trackId, solo) {
|
|
566
|
+
if (solo)
|
|
567
|
+
this.soloed.add(trackId);
|
|
568
|
+
else
|
|
569
|
+
this.soloed.delete(trackId);
|
|
570
|
+
this.applyAllStrips();
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Re-reads the mix off the tracks it is handed, scheduling nothing.
|
|
574
|
+
*
|
|
575
|
+
* All four properties, as the interface promises. Volume and pan used to be
|
|
576
|
+
* missing here — the engine had no per-track node to put them on, so a fader
|
|
577
|
+
* moved during playback moved the fader and not the sound, exactly the bug
|
|
578
|
+
* `applyMix` exists to fix on the web side. They land on the track's strip,
|
|
579
|
+
* so they reach notes that are already sounding.
|
|
580
|
+
*
|
|
581
|
+
* A track the engine does not know is still recorded: `load` builds its strip
|
|
582
|
+
* later, reading the level from here.
|
|
583
|
+
*/
|
|
584
|
+
applyMix(tracks) {
|
|
585
|
+
this.muted.clear();
|
|
586
|
+
this.soloed.clear();
|
|
587
|
+
for (const track of tracks) {
|
|
588
|
+
if (track.muted)
|
|
589
|
+
this.muted.add(track.id);
|
|
590
|
+
if (track.solo)
|
|
591
|
+
this.soloed.add(track.id);
|
|
592
|
+
this.mix.set(track.id, { volume: track.volume, pan: track.pan });
|
|
593
|
+
}
|
|
594
|
+
this.applyAllStrips();
|
|
595
|
+
}
|
|
596
|
+
setMetronome(enabled) {
|
|
597
|
+
this.metronomeOn = enabled;
|
|
598
|
+
}
|
|
599
|
+
setMasterVolume(volume) {
|
|
600
|
+
this.masterVolume = Math.min(1, Math.max(0, volume));
|
|
601
|
+
this.applyMasterGain();
|
|
602
|
+
}
|
|
603
|
+
// ---- audition ----------------------------------------------------------
|
|
604
|
+
/**
|
|
605
|
+
* Sounds a pitch now and holds it, touching no transport state — no seek, no
|
|
606
|
+
* state change, no active-note report. Fire-and-forget: the caller is a
|
|
607
|
+
* keypress handler and cannot await a pack download.
|
|
608
|
+
*/
|
|
609
|
+
noteOn(midi, auditionVoice) {
|
|
610
|
+
void this.auditionNote(midi, auditionVoice);
|
|
611
|
+
}
|
|
612
|
+
async auditionNote(midi, auditionVoice) {
|
|
613
|
+
await this.initialize();
|
|
614
|
+
// Already resolved by the caller: a percussion `program` addresses a kit,
|
|
615
|
+
// and only music_lib's GM tables know which kit an address falls in.
|
|
616
|
+
const name = auditionVoice.isPercussion
|
|
617
|
+
? percussionPackName(auditionVoice.program)
|
|
618
|
+
: gmPackName(auditionVoice.program, auditionVoice.name);
|
|
619
|
+
await this.library.ensure(name);
|
|
620
|
+
const voicing = this.library.voice(name, midi, auditionVoice.isPercussion);
|
|
621
|
+
if (!voicing)
|
|
622
|
+
return;
|
|
623
|
+
const { choice, buffer } = voicing;
|
|
624
|
+
this.auditioned.get(midi)?.source.stop();
|
|
625
|
+
const now = this.ctx.currentTime;
|
|
626
|
+
// Held until noteOff: released far enough out that an ordinary key press
|
|
627
|
+
// ends it first, and bounded so a lost noteOff cannot ring forever.
|
|
628
|
+
// Unity, not `masterVolume`: this connects to the master, which already
|
|
629
|
+
// carries it. Passing it here again played every audition at the square of
|
|
630
|
+
// the fader — a quarter of the level at half volume.
|
|
631
|
+
const voice = this.buildVoice(buffer, choice.detuneCents, 1, now, now + 30);
|
|
632
|
+
this.auditioned.set(midi, voice);
|
|
633
|
+
}
|
|
634
|
+
noteOff(midi) {
|
|
635
|
+
const voice = this.auditioned.get(midi);
|
|
636
|
+
if (!voice)
|
|
637
|
+
return;
|
|
638
|
+
this.auditioned.delete(midi);
|
|
639
|
+
const now = this.ctx?.currentTime ?? 0;
|
|
640
|
+
voice.gain.gain.cancelScheduledValues(now);
|
|
641
|
+
voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
|
|
642
|
+
voice.gain.gain.linearRampToValueAtTime(0, now + RELEASE_SECONDS);
|
|
643
|
+
try {
|
|
644
|
+
voice.source.stop(now + RELEASE_SECONDS);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// Already stopped.
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// ---- observer / teardown -----------------------------------------------
|
|
651
|
+
setObserver(observer) {
|
|
652
|
+
this.observer = observer;
|
|
653
|
+
observer?.onLoadStateChange?.(this.loadState);
|
|
654
|
+
}
|
|
655
|
+
dispose() {
|
|
656
|
+
this.haltPump();
|
|
657
|
+
this.stopAllVoices();
|
|
658
|
+
this.releaseStrips();
|
|
659
|
+
for (const voice of this.auditioned.values())
|
|
660
|
+
voice.gain.disconnect();
|
|
661
|
+
this.auditioned.clear();
|
|
662
|
+
this.master?.disconnect();
|
|
663
|
+
void this.ctx?.close();
|
|
664
|
+
this.ctx = null;
|
|
665
|
+
this.master = null;
|
|
666
|
+
this.api = null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=sample-engine.js.map
|