@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.
Files changed (159) hide show
  1. package/CLAUDE.md +335 -0
  2. package/dist/core.d.ts +33 -0
  3. package/dist/core.d.ts.map +1 -0
  4. package/dist/core.js +33 -0
  5. package/dist/core.js.map +1 -0
  6. package/dist/engine.d.ts +103 -0
  7. package/dist/engine.d.ts.map +1 -0
  8. package/dist/engine.js +2 -0
  9. package/dist/engine.js.map +1 -0
  10. package/dist/mocks/index.d.ts +46 -0
  11. package/dist/mocks/index.d.ts.map +1 -0
  12. package/dist/mocks/index.js +93 -0
  13. package/dist/mocks/index.js.map +1 -0
  14. package/dist/player.d.ts +81 -0
  15. package/dist/player.d.ts.map +1 -0
  16. package/dist/player.js +209 -0
  17. package/dist/player.js.map +1 -0
  18. package/dist/rn/audio/offline-render.d.ts +29 -0
  19. package/dist/rn/audio/offline-render.d.ts.map +1 -0
  20. package/dist/rn/audio/offline-render.js +134 -0
  21. package/dist/rn/audio/offline-render.js.map +1 -0
  22. package/dist/rn/index.d.ts +50 -0
  23. package/dist/rn/index.d.ts.map +1 -0
  24. package/dist/rn/index.js +39 -0
  25. package/dist/rn/index.js.map +1 -0
  26. package/dist/rn/playback/audio-api.d.ts +93 -0
  27. package/dist/rn/playback/audio-api.d.ts.map +1 -0
  28. package/dist/rn/playback/audio-api.js +24 -0
  29. package/dist/rn/playback/audio-api.js.map +1 -0
  30. package/dist/rn/playback/expression-table.d.ts +22 -0
  31. package/dist/rn/playback/expression-table.d.ts.map +1 -0
  32. package/dist/rn/playback/expression-table.js +926 -0
  33. package/dist/rn/playback/expression-table.js.map +1 -0
  34. package/dist/rn/playback/expression.d.ts +48 -0
  35. package/dist/rn/playback/expression.d.ts.map +1 -0
  36. package/dist/rn/playback/expression.js +120 -0
  37. package/dist/rn/playback/expression.js.map +1 -0
  38. package/dist/rn/playback/gm-pack-name.d.ts +40 -0
  39. package/dist/rn/playback/gm-pack-name.d.ts.map +1 -0
  40. package/dist/rn/playback/gm-pack-name.js +61 -0
  41. package/dist/rn/playback/gm-pack-name.js.map +1 -0
  42. package/dist/rn/playback/pack-library.d.ts +44 -0
  43. package/dist/rn/playback/pack-library.d.ts.map +1 -0
  44. package/dist/rn/playback/pack-library.js +85 -0
  45. package/dist/rn/playback/pack-library.js.map +1 -0
  46. package/dist/rn/playback/pack-names.json +130 -0
  47. package/dist/rn/playback/sample-engine.d.ts +216 -0
  48. package/dist/rn/playback/sample-engine.d.ts.map +1 -0
  49. package/dist/rn/playback/sample-engine.js +669 -0
  50. package/dist/rn/playback/sample-engine.js.map +1 -0
  51. package/dist/rn/playback/sample-pack.d.ts +71 -0
  52. package/dist/rn/playback/sample-pack.d.ts.map +1 -0
  53. package/dist/rn/playback/sample-pack.js +112 -0
  54. package/dist/rn/playback/sample-pack.js.map +1 -0
  55. package/dist/rn/playback/sustain-loop.d.ts +24 -0
  56. package/dist/rn/playback/sustain-loop.d.ts.map +1 -0
  57. package/dist/rn/playback/sustain-loop.js +111 -0
  58. package/dist/rn/playback/sustain-loop.js.map +1 -0
  59. package/dist/rn/playback/voice-plan.d.ts +60 -0
  60. package/dist/rn/playback/voice-plan.d.ts.map +1 -0
  61. package/dist/rn/playback/voice-plan.js +38 -0
  62. package/dist/rn/playback/voice-plan.js.map +1 -0
  63. package/dist/shared/bus.d.ts +73 -0
  64. package/dist/shared/bus.d.ts.map +1 -0
  65. package/dist/shared/bus.js +83 -0
  66. package/dist/shared/bus.js.map +1 -0
  67. package/dist/shared/midi.d.ts +18 -0
  68. package/dist/shared/midi.d.ts.map +1 -0
  69. package/dist/shared/midi.js +25 -0
  70. package/dist/shared/midi.js.map +1 -0
  71. package/dist/shared/mix.d.ts +75 -0
  72. package/dist/shared/mix.d.ts.map +1 -0
  73. package/dist/shared/mix.js +115 -0
  74. package/dist/shared/mix.js.map +1 -0
  75. package/dist/shared/note-queue.d.ts +43 -0
  76. package/dist/shared/note-queue.d.ts.map +1 -0
  77. package/dist/shared/note-queue.js +54 -0
  78. package/dist/shared/note-queue.js.map +1 -0
  79. package/dist/shared/plan.d.ts +34 -0
  80. package/dist/shared/plan.d.ts.map +1 -0
  81. package/dist/shared/plan.js +134 -0
  82. package/dist/shared/plan.js.map +1 -0
  83. package/dist/shared/pump-window.d.ts +38 -0
  84. package/dist/shared/pump-window.d.ts.map +1 -0
  85. package/dist/shared/pump-window.js +13 -0
  86. package/dist/shared/pump-window.js.map +1 -0
  87. package/dist/shared/render-events.d.ts +25 -0
  88. package/dist/shared/render-events.d.ts.map +1 -0
  89. package/dist/shared/render-events.js +79 -0
  90. package/dist/shared/render-events.js.map +1 -0
  91. package/dist/shared/sounding-set.d.ts +45 -0
  92. package/dist/shared/sounding-set.d.ts.map +1 -0
  93. package/dist/shared/sounding-set.js +81 -0
  94. package/dist/shared/sounding-set.js.map +1 -0
  95. package/dist/shared/test-plan.d.ts +36 -0
  96. package/dist/shared/test-plan.d.ts.map +1 -0
  97. package/dist/shared/test-plan.js +83 -0
  98. package/dist/shared/test-plan.js.map +1 -0
  99. package/dist/singleton.d.ts +23 -0
  100. package/dist/singleton.d.ts.map +1 -0
  101. package/dist/singleton.js +26 -0
  102. package/dist/singleton.js.map +1 -0
  103. package/dist/types.d.ts +84 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/web/audio/offline-synth.d.ts +24 -0
  108. package/dist/web/audio/offline-synth.d.ts.map +1 -0
  109. package/dist/web/audio/offline-synth.js +92 -0
  110. package/dist/web/audio/offline-synth.js.map +1 -0
  111. package/dist/web/audio/soundfont-render.d.ts +31 -0
  112. package/dist/web/audio/soundfont-render.d.ts.map +1 -0
  113. package/dist/web/audio/soundfont-render.js +130 -0
  114. package/dist/web/audio/soundfont-render.js.map +1 -0
  115. package/dist/web/audio/synth-types.d.ts +24 -0
  116. package/dist/web/audio/synth-types.d.ts.map +1 -0
  117. package/dist/web/audio/synth-types.js +2 -0
  118. package/dist/web/audio/synth-types.js.map +1 -0
  119. package/dist/web/index.d.ts +40 -0
  120. package/dist/web/index.d.ts.map +1 -0
  121. package/dist/web/index.js +59 -0
  122. package/dist/web/index.js.map +1 -0
  123. package/dist/web/playback/channel-allocator.d.ts +54 -0
  124. package/dist/web/playback/channel-allocator.d.ts.map +1 -0
  125. package/dist/web/playback/channel-allocator.js +72 -0
  126. package/dist/web/playback/channel-allocator.js.map +1 -0
  127. package/dist/web/playback/click.d.ts +9 -0
  128. package/dist/web/playback/click.d.ts.map +1 -0
  129. package/dist/web/playback/click.js +61 -0
  130. package/dist/web/playback/click.js.map +1 -0
  131. package/dist/web/playback/clock.d.ts +36 -0
  132. package/dist/web/playback/clock.d.ts.map +1 -0
  133. package/dist/web/playback/clock.js +64 -0
  134. package/dist/web/playback/clock.js.map +1 -0
  135. package/dist/web/playback/governor.d.ts +45 -0
  136. package/dist/web/playback/governor.d.ts.map +1 -0
  137. package/dist/web/playback/governor.js +33 -0
  138. package/dist/web/playback/governor.js.map +1 -0
  139. package/dist/web/playback/quiet-stub-notices.d.ts +55 -0
  140. package/dist/web/playback/quiet-stub-notices.d.ts.map +1 -0
  141. package/dist/web/playback/quiet-stub-notices.js +93 -0
  142. package/dist/web/playback/quiet-stub-notices.js.map +1 -0
  143. package/dist/web/playback/soundfont-engine.d.ts +281 -0
  144. package/dist/web/playback/soundfont-engine.d.ts.map +1 -0
  145. package/dist/web/playback/soundfont-engine.js +735 -0
  146. package/dist/web/playback/soundfont-engine.js.map +1 -0
  147. package/dist/web/playback/soundfont-loader.d.ts +30 -0
  148. package/dist/web/playback/soundfont-loader.d.ts.map +1 -0
  149. package/dist/web/playback/soundfont-loader.js +68 -0
  150. package/dist/web/playback/soundfont-loader.js.map +1 -0
  151. package/dist/web/playback/synth-host.d.ts +142 -0
  152. package/dist/web/playback/synth-host.d.ts.map +1 -0
  153. package/dist/web/playback/synth-host.js +293 -0
  154. package/dist/web/playback/synth-host.js.map +1 -0
  155. package/package.json +18 -3
  156. package/dist/index.d.ts +0 -14
  157. package/dist/index.d.ts.map +0 -1
  158. package/dist/index.js +0 -2
  159. package/dist/index.js.map +0 -1
@@ -0,0 +1,735 @@
1
+ import { allocateChannels, CHANNELS_PER_INSTANCE } from './channel-allocator.js';
2
+ import { PlaybackClock } from './clock.js';
3
+ import { NoteQueue } from '../../shared/note-queue.js';
4
+ import { SoundingSet } from '../../shared/sounding-set.js';
5
+ import { scheduleClick } from './click.js';
6
+ import { Governor } from './governor.js';
7
+ import { loadSoundfont, openSoundfontCache } from './soundfont-loader.js';
8
+ /** How often the pump runs. */
9
+ const PUMP_INTERVAL_MS = 50;
10
+ /**
11
+ * How far ahead the sequencer is kept filled.
12
+ *
13
+ * Deep enough that a main-thread stall of any plausible length lands entirely
14
+ * inside already-queued audio. This used to be a 200ms window plus a 200ms
15
+ * grace, past which a note was skipped outright — so a stall over ~400ms
16
+ * silently dropped every note inside it. A 200-track notation redraw measures
17
+ * 119ms, which made three unlucky frames enough.
18
+ *
19
+ * Timing is the worklet's job now. The pump only keeps the buffer full, which
20
+ * is a job that tolerates being late.
21
+ */
22
+ const HORIZON_SECONDS = 4;
23
+ /**
24
+ * The most notes handed to the sequencer in one tick.
25
+ *
26
+ * Each is a `postMessage` to the worklet, so without this a dense score's first
27
+ * tick would post thousands at once. Whatever is left waits 50ms, which is
28
+ * nothing against a four-second horizon.
29
+ */
30
+ const MAX_EVENTS_PER_REFILL = 512;
31
+ const POSITION_TICK_INTERVAL_MS = 1000 / 30;
32
+ const CC_VOLUME = 7;
33
+ const CC_PAN = 10;
34
+ const MAX_CC = 127;
35
+ // `CHANNELS_PER_INSTANCE` is imported from the allocator rather than restated
36
+ // here. A second copy of it went stale the moment the allocator moved to 256:
37
+ // `pickAuditionChannel` searched only channels 15..0, found every one of them
38
+ // taken on a sixteen-track score, and handed auditions a channel a track owned.
39
+ /** General MIDI's drum channel, which an audition must not borrow. */
40
+ const GM_PERCUSSION_CHANNEL = 9;
41
+ /**
42
+ * How much of the progress bar the download accounts for.
43
+ *
44
+ * The rest is the synth digesting the font, which is the longer half and
45
+ * reports nothing. Measured at roughly five seconds against a download that is
46
+ * near-instant on a fast connection, so this is deliberately conservative — a
47
+ * bar that reaches the end and then waits is worse than one that stops early.
48
+ */
49
+ const FETCH_SHARE = 0.5;
50
+ /** A pump failing every tick must not also flood the console twenty times a second. */
51
+ const MAX_REPORTED_TICK_FAILURES = 3;
52
+ /** Percent granularity: finer changes are not worth a store update. */
53
+ function roughly(fraction) {
54
+ return fraction === null ? null : Math.round(fraction * 100);
55
+ }
56
+ export class SoundfontPlaybackEngine {
57
+ queue = new NoteQueue();
58
+ governor;
59
+ /** When the pump was last expected to run, for measuring how late it is. */
60
+ nextPumpDueAt = null;
61
+ clock;
62
+ deps;
63
+ context = null;
64
+ plan = null;
65
+ /** Replaced on every `load`. The default is 120 BPM at ppq 480, as before. */
66
+ tempo = {
67
+ ticksToSeconds: (tick) => tick / 960,
68
+ secondsToTicks: (seconds) => seconds * 960,
69
+ };
70
+ tracks = new Map();
71
+ observer = null;
72
+ stopPump = null;
73
+ sounding = new SoundingSet();
74
+ /**
75
+ * When the last note of the loaded score finishes.
76
+ *
77
+ * The transport used to know the piece was over by the note queue being
78
+ * exhausted *and* no note-offs being outstanding. Releases now happen in the
79
+ * worklet, so there is nothing outstanding to count — this is what stops
80
+ * playback ending on the last note's attack instead of its release.
81
+ */
82
+ lastNoteEndSeconds = 0;
83
+ loopRange = null;
84
+ lastReportedAt = 0;
85
+ initialized = false;
86
+ /** In-flight bring-up, so concurrent callers share one attempt. */
87
+ initializing = null;
88
+ loadState = { status: 'idle' };
89
+ /** Counted so a pump that fails every tick reports once, not sixty times a second. */
90
+ tickFailures = 0;
91
+ /**
92
+ * Where playback should begin once the pump actually runs.
93
+ *
94
+ * The clock is *not* started by `play()`. Starting it there loses the opening
95
+ * of the piece: the first pump tick can be seconds late while the main thread
96
+ * finishes worklet and soundfont setup, and by then the clock says the music
97
+ * is already seconds in — so every note before that point is past its grace
98
+ * window and gets skipped. Measured at 2.3s of lost opening on a real load.
99
+ * Anchoring on the first tick instead means the piece starts where it should,
100
+ * however busy the thread was.
101
+ */
102
+ startAtSeconds = null;
103
+ metronomeEnabled = false;
104
+ /** Click positions in ticks, with a cursor, mirroring how notes are drained. */
105
+ clicks = [];
106
+ clickCursor = 0;
107
+ /**
108
+ * Clicks already placed on the audio graph, so the transport can take them
109
+ * back.
110
+ *
111
+ * They are scheduled a whole horizon ahead and do not go through the synth,
112
+ * so `allSoundOff` says nothing to them: pausing used to leave the room
113
+ * ticking for four seconds after the music stopped.
114
+ */
115
+ pendingClicks = [];
116
+ /** Audition voices are held on their own channel so they never disturb a track. */
117
+ auditionHeld = new Map();
118
+ /** How many synths the current score's channel assignment needs. */
119
+ instanceCount = 1;
120
+ tempoMultiplier = 1;
121
+ /**
122
+ * The channel auditions borrow — recomputed per score so it is one no track owns.
123
+ *
124
+ * It used to be channel 15 flat. That channel belongs to a track on any score
125
+ * with fifteen pitched parts, so tapping a key selected the audition's program
126
+ * on it and left that part playing the wrong instrument until the next edit
127
+ * reloaded the score.
128
+ */
129
+ auditionChannel = { instance: 0, channel: 15, needsDrumTypeSwitch: false };
130
+ constructor(deps) {
131
+ this.deps = {
132
+ ...deps,
133
+ loadFont: deps.loadFont ??
134
+ (async (url, onProgress) => loadSoundfont(url, { cache: await openSoundfontCache(), onProgress })),
135
+ startPump: deps.startPump ??
136
+ ((tick, ms) => {
137
+ const id = setInterval(tick, ms);
138
+ return () => clearInterval(id);
139
+ }),
140
+ };
141
+ this.clock = new PlaybackClock(() => this.now());
142
+ // The governor's only knob is interpolation order; see governor.ts for why
143
+ // polyphony is not a second rung.
144
+ this.governor = new Governor({ onChange: (order) => this.deps.host.setInterpolation(order) });
145
+ }
146
+ now() {
147
+ if (this.deps.now)
148
+ return this.deps.now();
149
+ return this.context?.currentTime ?? 0;
150
+ }
151
+ secondsForTick(tick) {
152
+ return this.tempo.ticksToSeconds(tick);
153
+ }
154
+ /**
155
+ * Brings up the audio context and the synth, once.
156
+ *
157
+ * Resuming comes first, and the guard after it is the whole point. A browser
158
+ * starts an `AudioContext` **suspended** unless it was created during a user
159
+ * gesture, and a suspended context never runs its `AudioWorklet`. The synth's
160
+ * font load round-trips through that worklet, so attempting it on a suspended
161
+ * context does not fail — it simply never returns. That is what happened:
162
+ * `load` ran at load time, long before any click, and left a
163
+ * promise pending for good. Every later `play()` awaited the same promise,
164
+ * so the transport sat at "stopped" with nothing in the console to explain it.
165
+ *
166
+ * So when the context cannot be got running, this does the cheap part and
167
+ * returns. `play()` and `noteOn()` are user gestures and will get it running.
168
+ */
169
+ async initialize() {
170
+ this.initializing ??= this.bringUp().finally(() => {
171
+ // Cleared either way: a failed or deferred attempt must be retryable from
172
+ // the next gesture rather than cached as the answer forever.
173
+ this.initializing = null;
174
+ });
175
+ return this.initializing;
176
+ }
177
+ async bringUp() {
178
+ if (this.initialized)
179
+ return;
180
+ this.context ??= this.deps.createContext?.() ?? new AudioContext();
181
+ await this.resumeContext();
182
+ if (!this.contextCanRun())
183
+ return;
184
+ try {
185
+ this.reportLoad({ status: 'loading', fraction: 0 });
186
+ const soundfont = await this.deps.loadFont(this.deps.fontUrl, ({ loaded, total }) => {
187
+ // Fetching is the measurable half. Held below 1 so the bar does not sit
188
+ // at "done" through the seconds of decoding that follow.
189
+ this.reportLoad({ status: 'loading', fraction: total > 0 ? (loaded / total) * FETCH_SHARE : null });
190
+ });
191
+ // Handing the font to fluidsynth takes seconds and reports nothing along
192
+ // the way, so this is the honest answer: busy, no idea how long.
193
+ this.reportLoad({ status: 'loading', fraction: null });
194
+ await this.deps.host.init(this.context, {
195
+ fluidsynthModuleUrl: this.deps.moduleUrls.fluidsynth,
196
+ workletModuleUrl: this.deps.moduleUrls.worklet,
197
+ soundfont,
198
+ // What the score already loaded needs, not a fixed one: a score with
199
+ // more parts than a single synth can address puts tracks on instance 1,
200
+ // and a synth that was never opened plays them silently.
201
+ instanceCount: this.instanceCount,
202
+ });
203
+ this.initialized = true;
204
+ // The score may have arrived while the context was still suspended; the
205
+ // host has not been told about it yet.
206
+ if (this.plan)
207
+ this.applyPlanToHost(this.plan.tracks);
208
+ this.reportLoad({ status: 'ready' });
209
+ }
210
+ catch (error) {
211
+ // Reported rather than swallowed: a font that will not load is the
212
+ // difference between silence-with-a-reason and silence.
213
+ this.reportLoad({ status: 'failed', message: error instanceof Error ? error.message : String(error) });
214
+ throw error;
215
+ }
216
+ }
217
+ /** Only on change, so a progress stream does not become a store-update stream. */
218
+ reportLoad(state) {
219
+ if (state.status === this.loadState.status &&
220
+ (state.status !== 'loading' ||
221
+ this.loadState.status !== 'loading' ||
222
+ roughly(state.fraction) === roughly(this.loadState.fraction))) {
223
+ return;
224
+ }
225
+ this.loadState = state;
226
+ this.observer?.onLoadStateChange?.(state);
227
+ }
228
+ /** Best effort: without a user gesture behind it this is allowed to fail. */
229
+ async resumeContext() {
230
+ const context = this.context;
231
+ if (!context?.resume || context.state === 'running')
232
+ return;
233
+ try {
234
+ await context.resume();
235
+ }
236
+ catch {
237
+ // No gesture yet. `contextCanRun` will see it and defer the rest.
238
+ }
239
+ }
240
+ /** A stub context in a test has no `state`; only a real suspended one blocks. */
241
+ contextCanRun() {
242
+ const state = this.context?.state;
243
+ return state === undefined || state === 'running';
244
+ }
245
+ async load(plan) {
246
+ this.plan = plan;
247
+ this.tempo = plan.tempo;
248
+ const notes = plan.notes;
249
+ this.queue.load(notes);
250
+ this.lastNoteEndSeconds = notes.reduce((end, n) => Math.max(end, this.secondsForTick(n.tick + n.durTicks)), 0);
251
+ this.sounding.load(notes, (tick) => this.secondsForTick(tick));
252
+ this.clicks = plan.clicks;
253
+ this.clickCursor = 0;
254
+ this.seek(0);
255
+ const { assignments, instanceCount } = allocateChannels(plan.tracks.map((t) => ({ id: t.id, isPercussion: t.isPercussion })));
256
+ this.instanceCount = instanceCount;
257
+ this.tracks = new Map();
258
+ for (const track of plan.tracks) {
259
+ const assignment = assignments.get(track.id);
260
+ if (!assignment)
261
+ continue;
262
+ this.tracks.set(track.id, { assignment, volume: track.volume, muted: track.muted, solo: track.solo });
263
+ }
264
+ this.auditionChannel = this.pickAuditionChannel();
265
+ // Only once there is a synth to tell. Before the fix above this ran inline,
266
+ // which is what dragged the whole synth bring-up into score loading.
267
+ if (this.initialized) {
268
+ // A score can grow past what the open synths can address, and the tracks
269
+ // beyond them are silent until this resolves.
270
+ await this.deps.host.ensureInstances(this.instanceCount);
271
+ this.applyPlanToHost(plan.tracks);
272
+ }
273
+ else
274
+ void this.initialize();
275
+ }
276
+ /**
277
+ * A channel no track owns, searched from the top down because tracks fill
278
+ * from the bottom.
279
+ *
280
+ * Channel 9 is skipped: it is General MIDI's drum channel, so borrowing it
281
+ * would make every audition a drum hit. Percussion auditions get their drum
282
+ * type switched on instead, which is the same route a second drum track takes.
283
+ *
284
+ * Where a score owns every channel of every open instance there is nothing to
285
+ * borrow, and it falls back to sharing the last one — an audition that
286
+ * disturbs one track's program beats a keyboard that makes no sound.
287
+ */
288
+ pickAuditionChannel() {
289
+ const owned = new Set([...this.tracks.values()].map((t) => `${t.assignment.instance}:${t.assignment.channel}`));
290
+ for (let instance = 0; instance < this.instanceCount; instance += 1) {
291
+ for (let channel = CHANNELS_PER_INSTANCE - 1; channel >= 0; channel -= 1) {
292
+ if (channel === GM_PERCUSSION_CHANNEL)
293
+ continue;
294
+ if (!owned.has(`${instance}:${channel}`)) {
295
+ return { instance, channel, needsDrumTypeSwitch: false };
296
+ }
297
+ }
298
+ }
299
+ return { instance: 0, channel: CHANNELS_PER_INSTANCE - 1, needsDrumTypeSwitch: false };
300
+ }
301
+ /** Tells the host each track's program, percussion flag, pan and level. */
302
+ applyPlanToHost(tracks) {
303
+ for (const track of tracks) {
304
+ const assignment = this.tracks.get(track.id)?.assignment;
305
+ if (!assignment)
306
+ continue;
307
+ if (assignment.needsDrumTypeSwitch || track.isPercussion) {
308
+ // General MIDI selects the drum kit with a program change on the drum
309
+ // channel, so the track's program is its kit — Room, TR-808, Jazz.
310
+ this.deps.host.setChannelPercussion(assignment.instance, assignment.channel, track.midiProgram);
311
+ }
312
+ else {
313
+ // A soundfont needs only the GM program number. The program-versus-name
314
+ // rule the old engine used existed to choose among hand-built synth
315
+ // voices and has no meaning here.
316
+ this.deps.host.programSelect(assignment.instance, assignment.channel, track.midiProgram);
317
+ }
318
+ this.deps.host.controlChange(assignment.instance, assignment.channel, CC_PAN, panToCc(track.pan));
319
+ }
320
+ this.deps.host.setTrackCount(tracks.length);
321
+ this.applyTrackLevels();
322
+ }
323
+ /** Volume, mute and solo all resolve to one CC7 value per channel. */
324
+ applyTrackLevels() {
325
+ const anySolo = [...this.tracks.values()].some((t) => t.solo);
326
+ for (const state of this.tracks.values()) {
327
+ const audible = anySolo ? state.solo : !state.muted;
328
+ const value = audible ? Math.round(state.volume * MAX_CC) : 0;
329
+ this.deps.host.controlChange(state.assignment.instance, state.assignment.channel, CC_VOLUME, value);
330
+ }
331
+ }
332
+ /**
333
+ * Starts the transport — reporting "playing" only once it is true.
334
+ *
335
+ * The first press of Play has to load a 23MB soundfont and hand it to
336
+ * fluidsynth, which takes seconds. This used to report "playing" up front so
337
+ * the button would not look dead through that wait, on the reasoning that it
338
+ * cost nothing in accuracy: the clock does not start until the pump's first
339
+ * tick, so no part of the piece is skipped however long the load takes.
340
+ *
341
+ * It cost something in accuracy elsewhere. The **caret** does not follow the
342
+ * engine's position while playing — it interpolates, dead-reckoning from the
343
+ * last 30Hz report with elapsed real time, because those reports arrive in
344
+ * clumps. During the load there are no reports at all, so the caret projected
345
+ * forward from a standing start and glided silently through several bars,
346
+ * then snapped back when the music finally began at the beginning. Two
347
+ * separately-sound optimisations that could not both be right.
348
+ *
349
+ * So the transport now says what is true, and `PlaybackLoadState` — which the
350
+ * bar renders as "Preparing instruments 45%" — says why the wait is happening.
351
+ * That indicator did not exist when the early report was written; it is what
352
+ * makes honesty affordable here.
353
+ *
354
+ * Only a cold engine waits. Once the synth is up this reports synchronously,
355
+ * which is every press after the first.
356
+ */
357
+ async play(fromTick) {
358
+ if (!this.plan)
359
+ return;
360
+ if (fromTick !== undefined)
361
+ this.seek(fromTick);
362
+ this.startAtSeconds = this.clock.positionSeconds;
363
+ this.nextPumpDueAt = null;
364
+ const ready = this.initialized;
365
+ if (ready)
366
+ this.observer?.onStateChange('playing');
367
+ await this.initialize();
368
+ // Pausing or stopping during the load clears this, and is the user
369
+ // changing their mind: do not start playing underneath them.
370
+ if (!this.initialized || this.startAtSeconds === null)
371
+ return;
372
+ if (!ready)
373
+ this.observer?.onStateChange('playing');
374
+ this.startPump();
375
+ }
376
+ pause() {
377
+ this.startAtSeconds = null;
378
+ this.clock.pause();
379
+ this.endPump();
380
+ this.seek(this.tickForSeconds(this.clock.positionSeconds));
381
+ this.observer?.onStateChange('paused');
382
+ }
383
+ stop() {
384
+ this.startAtSeconds = null;
385
+ this.clock.stop();
386
+ this.endPump();
387
+ this.deps.host.allSoundOff();
388
+ this.cancelPendingClicks();
389
+ this.clearSounding(0);
390
+ this.queue.seekToTick(0);
391
+ this.clickCursor = 0;
392
+ this.observer?.onPositionTick(0);
393
+ this.observer?.onStateChange('stopped');
394
+ }
395
+ seek(tick) {
396
+ const seconds = this.secondsForTick(tick);
397
+ this.clock.seek(seconds);
398
+ this.queue.seekToTick(tick);
399
+ this.clickCursor = this.clicks.findIndex((c) => c.tick >= tick);
400
+ if (this.clickCursor < 0)
401
+ this.clickCursor = this.clicks.length;
402
+ this.deps.host.allSoundOff();
403
+ this.cancelPendingClicks();
404
+ this.clearSounding(seconds);
405
+ // Report where we landed. Nothing else writes the store's position —
406
+ // `PlaybackController.seek` only forwards here — so a seek made while
407
+ // stopped (which is what clicking the sheet is) left the caret at
408
+ // whatever tick it was already showing.
409
+ //
410
+ // Clearing the throttle baseline first is not tidiness: `report` gates on
411
+ // the distance from the last reported position, so after a backward seek
412
+ // that distance is negative and every report is suppressed until playback
413
+ // climbs back past the old position. Jumping from bar 60 to bar 1 froze
414
+ // the caret for the whole of those 59 bars.
415
+ this.lastReportedAt = Number.NEGATIVE_INFINITY;
416
+ this.report(seconds);
417
+ }
418
+ /**
419
+ * Changes playback speed, rebuilding what is already queued.
420
+ *
421
+ * Every note in the sequencer carries a delay worked out at the speed that
422
+ * was in force when it was posted, and the horizon is four seconds deep — so
423
+ * on its own a speed change moved the caret at once and left the music at the
424
+ * old tempo for a whole horizon, then jumped. Re-seeking where we stand drops
425
+ * that backlog, and the next tick refills it at the new speed. The gap is one
426
+ * pump interval.
427
+ */
428
+ setTempoMultiplier(multiplier) {
429
+ if (multiplier === this.tempoMultiplier)
430
+ return;
431
+ this.tempoMultiplier = multiplier;
432
+ // Banks the position first, so the seek below reads where we actually are
433
+ // rather than a position rescaled retroactively.
434
+ this.clock.setRate(multiplier);
435
+ if (this.stopPump)
436
+ this.seek(this.tickForSeconds(this.clock.positionSeconds));
437
+ }
438
+ setLoop(range) {
439
+ this.loopRange = range;
440
+ }
441
+ setTrackMute(trackId, muted) {
442
+ const state = this.tracks.get(trackId);
443
+ if (!state)
444
+ return;
445
+ state.muted = muted;
446
+ this.applyTrackLevels();
447
+ }
448
+ setTrackSolo(trackId, solo) {
449
+ const state = this.tracks.get(trackId);
450
+ if (!state)
451
+ return;
452
+ state.solo = solo;
453
+ this.applyTrackLevels();
454
+ }
455
+ /**
456
+ * Re-reads the mix off `score` and pushes it, scheduling nothing.
457
+ *
458
+ * `setTrackMute`/`setTrackSolo` covered two of the four mix properties.
459
+ * Volume was read once, in `load`, and pan only in `applyPlanToHost` —
460
+ * so with the playback edit lock in place, where a mix change deliberately
461
+ * does *not* reload, moving a fader mid-playback moved the fader and left the
462
+ * sound where it was.
463
+ *
464
+ * A track the engine does not know is skipped rather than throwing: a mix
465
+ * change cannot add a track (that would be content), so an unknown id means
466
+ * the engine is holding an older plan and the next `load` settles it.
467
+ */
468
+ applyMix(tracks) {
469
+ for (const track of tracks) {
470
+ const state = this.tracks.get(track.id);
471
+ if (!state)
472
+ continue;
473
+ state.volume = track.volume;
474
+ state.muted = track.muted;
475
+ state.solo = track.solo;
476
+ this.deps.host.controlChange(state.assignment.instance, state.assignment.channel, CC_PAN, panToCc(track.pan));
477
+ }
478
+ this.applyTrackLevels();
479
+ }
480
+ setMetronome(enabled) {
481
+ this.metronomeEnabled = enabled;
482
+ // Switching it off has to reach the horizon that is already queued, or the
483
+ // click carries on for four seconds after the user silenced it.
484
+ if (!enabled)
485
+ this.cancelPendingClicks();
486
+ }
487
+ setMasterVolume(volume) {
488
+ this.deps.host.setMasterVolume(volume);
489
+ }
490
+ noteOn(midi, voice) {
491
+ // Tapping a key is a user gesture, so it is also a chance to get the synth
492
+ // up — otherwise the keyboard stays silent until the first press of Play.
493
+ // The tap that starts the load does not sound; there is nothing to sound on
494
+ // yet, and the alternative is a keyboard that does nothing at all.
495
+ if (!this.initialized)
496
+ void this.initialize();
497
+ // Auditioning must not disturb the transport, so it borrows a channel no
498
+ // track owns rather than routing through a track's.
499
+ const assignment = this.auditionChannel;
500
+ if (voice.isPercussion) {
501
+ // `voice.program` is the kit, resolved by the caller, so this sounds the
502
+ // kit the track actually plays rather than whatever Standard it had.
503
+ this.deps.host.setChannelPercussion(assignment.instance, assignment.channel, voice.program);
504
+ }
505
+ else {
506
+ // Switches the channel back off drums first, if the last audition was one.
507
+ this.deps.host.programSelect(assignment.instance, assignment.channel, voice.program);
508
+ }
509
+ this.deps.host.noteOn(assignment.instance, assignment.channel, midi, 100);
510
+ this.auditionHeld.set(midi, assignment);
511
+ }
512
+ noteOff(midi) {
513
+ const assignment = this.auditionHeld.get(midi);
514
+ if (!assignment)
515
+ return;
516
+ this.auditionHeld.delete(midi);
517
+ this.deps.host.noteOff(assignment.instance, assignment.channel, midi);
518
+ }
519
+ setObserver(observer) {
520
+ this.observer = observer;
521
+ }
522
+ dispose() {
523
+ this.endPump();
524
+ this.cancelPendingClicks();
525
+ this.deps.host.dispose();
526
+ this.context?.close?.();
527
+ this.context = null;
528
+ this.plan = null;
529
+ this.observer = null;
530
+ this.initialized = false;
531
+ this.clearSounding(0);
532
+ }
533
+ // ---- the pump ------------------------------------------------------------
534
+ startPump() {
535
+ if (this.stopPump)
536
+ return;
537
+ this.stopPump = this.deps.startPump(() => this.tick(), PUMP_INTERVAL_MS);
538
+ this.tick();
539
+ }
540
+ endPump() {
541
+ this.stopPump?.();
542
+ this.stopPump = null;
543
+ }
544
+ tick() {
545
+ // Guarded because the pump is a bare interval callback: a throw in here
546
+ // escapes into nothing, the interval keeps firing, and every later tick
547
+ // throws at the same place — playback stops dead with a clean console and
548
+ // a transport still reporting "playing". Whatever else goes wrong, it
549
+ // should not go wrong invisibly.
550
+ try {
551
+ this.dispatchTick();
552
+ }
553
+ catch (error) {
554
+ this.tickFailures += 1;
555
+ if (this.tickFailures <= MAX_REPORTED_TICK_FAILURES) {
556
+ console.error('Playback pump failed', error);
557
+ }
558
+ }
559
+ }
560
+ dispatchTick() {
561
+ this.measureLateness();
562
+ if (this.startAtSeconds !== null) {
563
+ this.clock.start(this.startAtSeconds);
564
+ this.startAtSeconds = null;
565
+ }
566
+ const position = this.clock.positionSeconds;
567
+ this.applyLoop(position);
568
+ const speed = this.playbackSpeed;
569
+ const untilTick = this.horizonTick(position + HORIZON_SECONDS * speed);
570
+ const due = this.queue.drainUntil(untilTick, MAX_EVENTS_PER_REFILL);
571
+ for (const note of due) {
572
+ const state = this.tracks.get(note.trackId);
573
+ if (!state)
574
+ continue;
575
+ const { instance, channel } = state.assignment;
576
+ const atSeconds = this.secondsForTick(note.tick);
577
+ const endSeconds = this.secondsForTick(note.tick + note.durTicks);
578
+ this.deps.host.noteAt(instance, channel, note.midi, clampVelocity(note.velocity),
579
+ // Clamped to zero in `noteAt`: a note whose moment passed during a
580
+ // stall sounds at once rather than being dropped, because the sequencer
581
+ // still holds its release.
582
+ (atSeconds - position) / speed, (endSeconds - atSeconds) / speed);
583
+ }
584
+ this.pumpMetronome(position, untilTick);
585
+ this.report(position);
586
+ // A loop never ends the transport: its range may well run past the last
587
+ // note, and stopping there would end playback mid-loop.
588
+ if (!this.loopRange &&
589
+ this.queue.exhausted &&
590
+ this.clock.isRunning &&
591
+ position >= this.lastNoteEndSeconds) {
592
+ this.stop();
593
+ }
594
+ }
595
+ /**
596
+ * The far edge of what this tick may schedule, in ticks.
597
+ *
598
+ * Clamped to just inside a loop, because the wrap only happens on the pump
599
+ * tick *after* the end is reached: without this a note or click a few
600
+ * milliseconds past the loop end was already in the sequencer and sounded
601
+ * before the loop came round, on every pass. The end tick itself belongs to
602
+ * the next pass, where it is the loop's own first beat.
603
+ */
604
+ horizonTick(untilSeconds) {
605
+ const tick = this.tickForSeconds(untilSeconds);
606
+ if (!this.loopRange)
607
+ return tick;
608
+ return Math.min(tick, this.loopRange.endTick - 1);
609
+ }
610
+ /**
611
+ * How late this frame ran against when it was due.
612
+ *
613
+ * Measured on the audio clock rather than wall time, because that is the
614
+ * clock the audio is rendered against and the one that matters if the thread
615
+ * is being starved.
616
+ */
617
+ measureLateness() {
618
+ const now = this.now();
619
+ const expected = this.nextPumpDueAt;
620
+ this.nextPumpDueAt = now + PUMP_INTERVAL_MS / 1000;
621
+ if (expected === null)
622
+ return; // first frame has nothing to be late against
623
+ this.governor.record(Math.max(0, now - expected));
624
+ }
625
+ /**
626
+ * Clicks due in this window, scheduled ahead at their exact moment.
627
+ *
628
+ * Unlike notes, these can be placed precisely: an oscillator started with
629
+ * `start(at)` sounds at that sample however busy the main thread is, so the
630
+ * click stays steady even when the pump runs late.
631
+ */
632
+ pumpMetronome(position, untilTick) {
633
+ const now = this.now();
634
+ // Forget the ones that have finished sounding, so the list tracks what is
635
+ // still cancellable rather than growing for the length of the piece.
636
+ if (this.pendingClicks.length > 0) {
637
+ this.pendingClicks = this.pendingClicks.filter((click) => click.endsAt > now);
638
+ }
639
+ if (!this.metronomeEnabled || !this.context)
640
+ return;
641
+ while (this.clickCursor < this.clicks.length) {
642
+ const click = this.clicks[this.clickCursor];
643
+ if (click.tick > untilTick)
644
+ break;
645
+ const atSeconds = this.secondsForTick(click.tick);
646
+ this.clickCursor += 1;
647
+ if (atSeconds < position)
648
+ continue; // already gone by; do not stack it up
649
+ this.pendingClicks.push(scheduleClick(this.context, this.context.destination, now + Math.max(0, (atSeconds - position) / this.playbackSpeed), click.accent));
650
+ }
651
+ }
652
+ /** Takes back every click still queued — a stop, a seek, or the switch going off. */
653
+ cancelPendingClicks() {
654
+ if (this.pendingClicks.length === 0)
655
+ return;
656
+ const at = this.now();
657
+ for (const click of this.pendingClicks)
658
+ click.cancel(at);
659
+ this.pendingClicks = [];
660
+ }
661
+ /** Inverse of `secondsForTick`, for turning the lookahead window back into ticks. */
662
+ tickForSeconds(seconds) {
663
+ return this.tempo.secondsToTicks(seconds);
664
+ }
665
+ get playbackSpeed() {
666
+ return this.tempoMultiplier > 0 ? this.tempoMultiplier : 1;
667
+ }
668
+ applyLoop(position) {
669
+ if (!this.loopRange)
670
+ return;
671
+ if (position < this.secondsForTick(this.loopRange.endTick))
672
+ return;
673
+ this.seek(this.loopRange.startTick);
674
+ this.clock.start(this.secondsForTick(this.loopRange.startTick));
675
+ }
676
+ /**
677
+ * Position and active notes, both at 30Hz.
678
+ *
679
+ * The engine this replaces called `onActiveNotes` on every note-on *and*
680
+ * note-off — some 11,500 store updates for a three-minute piece, on the same
681
+ * thread the scheduler runs on. Coalescing is what keeps that work off the
682
+ * pump's back.
683
+ */
684
+ report(position) {
685
+ const sounding = this.sounding.advanceTo(position);
686
+ if (sounding)
687
+ this.observer?.onActiveNotes(sounding);
688
+ const nowMs = position * 1000;
689
+ /*
690
+ Throttled forwards only. Going *backwards* is always due.
691
+
692
+ The baseline is a playback position, not a wall clock, so any jump back
693
+ down the piece makes the distance from it negative — and negative is
694
+ smaller than the interval, so every report is suppressed until playback
695
+ climbs back past where it had previously reached. `seek` knew this and
696
+ cleared the baseline itself, but stopping does not go through `seek`: it
697
+ rewinds the queue directly, so replaying after the transport reached the
698
+ end left the baseline at the end of the piece. Nothing was ever due
699
+ again — the music played, and the caret sat at bar 1 for the whole of it
700
+ with the sheet never following.
701
+
702
+ Fixed here rather than by clearing the baseline in `stop` too, because
703
+ that is the version that has now been missed twice. A backward jump is
704
+ the *reason* to report, whoever caused it: a stop, a seek, or a loop
705
+ wrapping to its start.
706
+ */
707
+ const sinceLastReport = nowMs - this.lastReportedAt;
708
+ if (sinceLastReport >= 0 && sinceLastReport < POSITION_TICK_INTERVAL_MS)
709
+ return;
710
+ this.lastReportedAt = nowMs;
711
+ this.observer?.onPositionTick(Math.max(0, Math.round(this.tickForSeconds(position))));
712
+ }
713
+ /** Drops the sounding set and says so, for a seek, stop or teardown. */
714
+ clearSounding(atSeconds) {
715
+ const had = this.sounding.size > 0;
716
+ this.sounding.reset(atSeconds);
717
+ if (had)
718
+ this.observer?.onActiveNotes([]);
719
+ }
720
+ }
721
+ /**
722
+ * A note velocity as MIDI will take it.
723
+ *
724
+ * The floor is 1, not 0: a note-on at velocity zero *is* a note-off, so a value
725
+ * that rounds to nothing is a note that silently does not sound. The offline
726
+ * renderer has always clamped this way, and an export that sounds a note
727
+ * playback drops is the drift `shared/` exists to prevent.
728
+ */
729
+ function clampVelocity(velocity) {
730
+ return Math.max(1, Math.min(MAX_CC, Math.round(velocity)));
731
+ }
732
+ function panToCc(pan) {
733
+ return Math.round(((Math.min(1, Math.max(-1, pan)) + 1) / 2) * MAX_CC);
734
+ }
735
+ //# sourceMappingURL=soundfont-engine.js.map