@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,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
|