audio-mixer-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/audio-mixer-engine.cjs.js +1 -0
- package/dist/audio-mixer-engine.es.js +1669 -0
- package/package.json +54 -0
- package/src/assets/stick-4cs.mp3 +0 -0
- package/src/assets/stick-4d.mp3 +0 -0
- package/src/index.js +18 -0
- package/src/lib/audio-engine.js +526 -0
- package/src/lib/beat-mapper.js +155 -0
- package/src/lib/midi-parser.js +718 -0
- package/src/lib/midi-player.js +700 -0
- package/src/lib/playback-manager.js +1257 -0
- package/src/lib/spessasynth-audio-engine.js +310 -0
- package/src/lib/spessasynth-channel-handle.js +151 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import mitt from 'mitt';
|
|
2
|
+
import BeatMapper from './beat-mapper.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Modern MidiPlayer - Part-centric MIDI player
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: This class provides MIDI playback and individual part outputs.
|
|
8
|
+
* For volume/mute/solo controls, you MUST set up external audio routing:
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // 1. Create player
|
|
12
|
+
* const player = new MidiPlayer(engine, instrumentMap, midiData);
|
|
13
|
+
*
|
|
14
|
+
* // 2. Set up external routing for each part (REQUIRED for audio output)
|
|
15
|
+
* const partOutput = player.getPartOutput('soprano');
|
|
16
|
+
* const externalGain = audioContext.createGain();
|
|
17
|
+
* const masterGain = audioContext.createGain();
|
|
18
|
+
*
|
|
19
|
+
* partOutput.connect(externalGain);
|
|
20
|
+
* externalGain.connect(masterGain);
|
|
21
|
+
* masterGain.connect(audioContext.destination);
|
|
22
|
+
*
|
|
23
|
+
* // 3. Control volume via external gain node
|
|
24
|
+
* externalGain.gain.value = 0.5; // 50% volume
|
|
25
|
+
*
|
|
26
|
+
* Uses AudioEngine abstraction for synthesis
|
|
27
|
+
*/
|
|
28
|
+
export default class MidiPlayer {
|
|
29
|
+
/**
|
|
30
|
+
* Create a new MidiPlayer instance
|
|
31
|
+
* @param {AudioEngine} audioEngine - Initialized audio engine instance
|
|
32
|
+
* @param {Object} instrumentMap - Mapping of part names to instrument configurations
|
|
33
|
+
* @param {Object} parsedMidiData - Output from MidiParser
|
|
34
|
+
* @param {Object} [structureMetadata] - Optional score structure for beat mapping
|
|
35
|
+
*/
|
|
36
|
+
constructor(audioEngine, instrumentMap, parsedMidiData, structureMetadata = null) {
|
|
37
|
+
// Validate required parameters
|
|
38
|
+
if (!audioEngine || !audioEngine.isInitialized) {
|
|
39
|
+
throw new Error('Initialized AudioEngine is required');
|
|
40
|
+
}
|
|
41
|
+
if (!parsedMidiData) {
|
|
42
|
+
throw new Error('Parsed MIDI data is required');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Core dependencies
|
|
46
|
+
this.audioEngine = audioEngine;
|
|
47
|
+
this.instrumentMap = instrumentMap || {};
|
|
48
|
+
this.parsedData = parsedMidiData;
|
|
49
|
+
|
|
50
|
+
// Initialize state
|
|
51
|
+
this._isPlaying = false;
|
|
52
|
+
this._currentTime = 0;
|
|
53
|
+
this._totalDuration = 0;
|
|
54
|
+
this.playbackSpeed = 1.0;
|
|
55
|
+
|
|
56
|
+
// Channel management - part-centric approach
|
|
57
|
+
this.partChannels = new Map(); // partName -> ChannelHandle
|
|
58
|
+
this.partOutputs = new Map(); // partName -> GainNode (for external control)
|
|
59
|
+
|
|
60
|
+
// Scheduling and timing
|
|
61
|
+
this.playbackStartTime = 0;
|
|
62
|
+
this.lookAheadTime = 0.05; // 50ms lookahead for scheduling
|
|
63
|
+
this.scheduleInterval = null;
|
|
64
|
+
this.partNotePointers = new Map(); // partName -> next note index to consider
|
|
65
|
+
this.partProgramPointers = new Map(); // partName -> next program change index to consider
|
|
66
|
+
|
|
67
|
+
// Event handling with mitt
|
|
68
|
+
this.eventBus = mitt();
|
|
69
|
+
|
|
70
|
+
// Beat mapping
|
|
71
|
+
this.beatMapper = new BeatMapper();
|
|
72
|
+
this.beats = []; // Will be populated from structure metadata or fallback
|
|
73
|
+
|
|
74
|
+
// Set up audio routing and calculate duration
|
|
75
|
+
this._setupPartChannels();
|
|
76
|
+
this._calculateTotalDuration();
|
|
77
|
+
|
|
78
|
+
// Initialize note and program change pointers
|
|
79
|
+
this._resetNotePointers();
|
|
80
|
+
this._resetProgramPointers();
|
|
81
|
+
|
|
82
|
+
// Generate beat mapping if structure provided
|
|
83
|
+
if (structureMetadata) {
|
|
84
|
+
this.beats = this.beatMapper.mapBeats(parsedMidiData, structureMetadata);
|
|
85
|
+
} else {
|
|
86
|
+
// Generate simple beat mapping from parsed data tempo changes
|
|
87
|
+
this.beats = this._generateSimpleBeatMapping();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ========================================
|
|
92
|
+
// PUBLIC API METHODS (unchanged interface)
|
|
93
|
+
// ========================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Start playback from current position
|
|
97
|
+
*/
|
|
98
|
+
play() {
|
|
99
|
+
if (this._isPlaying) return;
|
|
100
|
+
|
|
101
|
+
this._isPlaying = true;
|
|
102
|
+
this.playbackStartTime = this.audioEngine.audioContext.currentTime - (this._currentTime / this.playbackSpeed);
|
|
103
|
+
|
|
104
|
+
// Reset note and program change pointers based on current time
|
|
105
|
+
this._resetNotePointers();
|
|
106
|
+
this._resetProgramPointers();
|
|
107
|
+
|
|
108
|
+
this._schedulePlayback();
|
|
109
|
+
this._startTimeUpdateLoop();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start playback at a specific audio context time
|
|
114
|
+
* @param {number} startTime - Audio context time when playback should begin
|
|
115
|
+
*/
|
|
116
|
+
playAt(startTime) {
|
|
117
|
+
if (this._isPlaying) return;
|
|
118
|
+
|
|
119
|
+
this._isPlaying = true;
|
|
120
|
+
this.playbackStartTime = startTime - (this._currentTime / this.playbackSpeed);
|
|
121
|
+
|
|
122
|
+
// Reset note and program change pointers based on current time
|
|
123
|
+
this._resetNotePointers();
|
|
124
|
+
this._resetProgramPointers();
|
|
125
|
+
|
|
126
|
+
this._schedulePlayback();
|
|
127
|
+
this._startTimeUpdateLoop();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Pause playback (resumable)
|
|
132
|
+
*/
|
|
133
|
+
pause() {
|
|
134
|
+
if (!this._isPlaying) return;
|
|
135
|
+
|
|
136
|
+
this._isPlaying = false;
|
|
137
|
+
this._stopScheduling();
|
|
138
|
+
this._stopTimeUpdateLoop();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Stop playback and reset to beginning
|
|
143
|
+
*/
|
|
144
|
+
stop() {
|
|
145
|
+
this._isPlaying = false;
|
|
146
|
+
this._currentTime = 0;
|
|
147
|
+
this._stopScheduling();
|
|
148
|
+
this._stopTimeUpdateLoop();
|
|
149
|
+
this._resetNotePointers();
|
|
150
|
+
this._resetProgramPointers();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Skip to a specific time in seconds
|
|
155
|
+
* @param {number} seconds - Time to skip to
|
|
156
|
+
*/
|
|
157
|
+
skipToTime(seconds) {
|
|
158
|
+
seconds = Math.max(0, Math.min(seconds, this._totalDuration));
|
|
159
|
+
|
|
160
|
+
const wasPlaying = this._isPlaying;
|
|
161
|
+
if (wasPlaying) {
|
|
162
|
+
this.pause();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this._currentTime = seconds;
|
|
166
|
+
this._resetNotePointers();
|
|
167
|
+
this._resetProgramPointers();
|
|
168
|
+
|
|
169
|
+
if (wasPlaying) {
|
|
170
|
+
this.play();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Set playback speed
|
|
176
|
+
* @param {number} speed - Speed multiplier (1.0 = normal)
|
|
177
|
+
*/
|
|
178
|
+
setPlaybackSpeed(speed) {
|
|
179
|
+
if (speed <= 0) {
|
|
180
|
+
throw new Error('Playback speed must be greater than 0');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const wasPlaying = this._isPlaying;
|
|
184
|
+
|
|
185
|
+
if (wasPlaying) {
|
|
186
|
+
this.pause();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.playbackSpeed = speed;
|
|
190
|
+
|
|
191
|
+
if (wasPlaying) {
|
|
192
|
+
this.play();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Navigate to a specific bar position
|
|
198
|
+
* @param {number} barNumber - Bar number (1-based)
|
|
199
|
+
* @param {number} repeat - Repeat section number (0-based)
|
|
200
|
+
*/
|
|
201
|
+
setBar(barNumber, repeat = 0) {
|
|
202
|
+
const time = this.getTimeFromBar(barNumber, repeat);
|
|
203
|
+
if (time !== null) {
|
|
204
|
+
this.skipToTime(time);
|
|
205
|
+
this._emitEvent('barChanged', { bar: barNumber, beat: 1, repeat, time });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get time position for a specific bar
|
|
211
|
+
* @param {number} barNumber - Bar number (1-based)
|
|
212
|
+
* @param {number} repeat - Repeat section number (0-based)
|
|
213
|
+
* @returns {number|null} Time in seconds or null if not found
|
|
214
|
+
*/
|
|
215
|
+
getTimeFromBar(barNumber, repeat = 0) {
|
|
216
|
+
const beat = this.beats.find(b =>
|
|
217
|
+
b.bar === barNumber && b.beat === 1 && b.repeat === repeat
|
|
218
|
+
);
|
|
219
|
+
return beat ? beat.time : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get bar information for a specific time position
|
|
224
|
+
* @param {number} time - Time in seconds
|
|
225
|
+
* @returns {Object|null} Bar info object or null
|
|
226
|
+
*/
|
|
227
|
+
getBarFromTime(time) {
|
|
228
|
+
if (!this.beats.length) return null;
|
|
229
|
+
|
|
230
|
+
let currentBeat = null;
|
|
231
|
+
for (let i = this.beats.length - 1; i >= 0; i--) {
|
|
232
|
+
if (this.beats[i].time <= time) {
|
|
233
|
+
currentBeat = this.beats[i];
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return currentBeat;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Stop all sounds immediately
|
|
243
|
+
*/
|
|
244
|
+
allSoundsOff() {
|
|
245
|
+
this.audioEngine.allSoundsOff();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Get the output node for a specific part for external mixer routing
|
|
250
|
+
*
|
|
251
|
+
* IMPORTANT: You MUST connect this output to external gain/analyzer nodes:
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* const partOutput = player.getPartOutput('soprano');
|
|
255
|
+
* const externalGain = audioContext.createGain();
|
|
256
|
+
* const analyzer = audioContext.createAnalyser();
|
|
257
|
+
*
|
|
258
|
+
* partOutput.connect(externalGain);
|
|
259
|
+
* externalGain.connect(analyzer);
|
|
260
|
+
* analyzer.connect(masterGain); // External master gain
|
|
261
|
+
*
|
|
262
|
+
* // Now control volume via external gain:
|
|
263
|
+
* externalGain.gain.value = 0.5; // 50% volume
|
|
264
|
+
*
|
|
265
|
+
* @param {string} partName - Name of the part (e.g., 'soprano', 'alto')
|
|
266
|
+
* @returns {AudioNode|null} Output gain node for this part
|
|
267
|
+
*/
|
|
268
|
+
getPartOutput(partName) {
|
|
269
|
+
return this.partOutputs.get(partName) || null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get the channel handle for a specific part (for direct note control)
|
|
274
|
+
* @param {string} partName - Name of the part
|
|
275
|
+
* @returns {ChannelHandle|null} Channel handle for this part
|
|
276
|
+
*/
|
|
277
|
+
getPartChannel(partName) {
|
|
278
|
+
return this.partChannels.get(partName) || null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
// ========================================
|
|
283
|
+
// STATE ACCESS METHODS (unchanged)
|
|
284
|
+
// ========================================
|
|
285
|
+
|
|
286
|
+
getCurrentTime() {
|
|
287
|
+
if (this._isPlaying) {
|
|
288
|
+
const elapsed = (this.audioEngine.audioContext.currentTime - this.playbackStartTime) * this.playbackSpeed;
|
|
289
|
+
this._currentTime = Math.min(elapsed, this._totalDuration);
|
|
290
|
+
}
|
|
291
|
+
return this._currentTime;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
getTotalDuration() {
|
|
295
|
+
return this._totalDuration;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
isPlaying() {
|
|
299
|
+
return this._isPlaying;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ========================================
|
|
303
|
+
// EVENT HANDLING (mitt-based)
|
|
304
|
+
// ========================================
|
|
305
|
+
|
|
306
|
+
on(event, callback) {
|
|
307
|
+
this.eventBus.on(event, callback);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
off(event, callback) {
|
|
311
|
+
this.eventBus.off(event, callback);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ========================================
|
|
315
|
+
// PART-CENTRIC IMPLEMENTATION METHODS
|
|
316
|
+
// ========================================
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Set up channel handles for each musical part
|
|
320
|
+
* @private
|
|
321
|
+
*/
|
|
322
|
+
_setupPartChannels() {
|
|
323
|
+
Object.keys(this.parsedData.parts).forEach(partName => {
|
|
324
|
+
// Get instrument configuration for this part
|
|
325
|
+
const partData = this.parsedData.parts[partName];
|
|
326
|
+
const instrumentConfig = this.instrumentMap[partName] || {};
|
|
327
|
+
|
|
328
|
+
// Use MIDI program change if available, otherwise fall back to instrumentConfig or default
|
|
329
|
+
const instrument = instrumentConfig.instrument !== undefined
|
|
330
|
+
? instrumentConfig.instrument
|
|
331
|
+
: (partData.defaultInstrument !== undefined ? partData.defaultInstrument : 0);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
// Create channel handle through audio engine
|
|
335
|
+
const channelHandle = this.audioEngine.createChannel(partName, {
|
|
336
|
+
instrument: instrument,
|
|
337
|
+
initialVolume: instrumentConfig.volume || 1.0
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
this.partChannels.set(partName, channelHandle);
|
|
341
|
+
|
|
342
|
+
// Create external gain node for mixer control
|
|
343
|
+
const partGain = this.audioEngine.audioContext.createGain();
|
|
344
|
+
partGain.gain.value = 1.0;
|
|
345
|
+
|
|
346
|
+
// Connect: ChannelHandle -> PartGain
|
|
347
|
+
// Note: The actual audio routing depends on the specific AudioEngine implementation
|
|
348
|
+
// This provides the interface for external volume control, solo/mute, and analysis
|
|
349
|
+
const channelOutput = channelHandle.getOutputNode();
|
|
350
|
+
if (channelOutput) {
|
|
351
|
+
channelOutput.connect(partGain);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.partOutputs.set(partName, partGain);
|
|
355
|
+
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.error(`Failed to create channel for part '${partName}':`, error);
|
|
358
|
+
this._emitEvent('error', error);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Calculate total duration from parsed MIDI data
|
|
365
|
+
* @private
|
|
366
|
+
*/
|
|
367
|
+
_calculateTotalDuration() {
|
|
368
|
+
let maxEndTime = 0;
|
|
369
|
+
|
|
370
|
+
Object.values(this.parsedData.parts).forEach(part => {
|
|
371
|
+
part.notes.forEach(note => {
|
|
372
|
+
if (note.endTime > maxEndTime) {
|
|
373
|
+
maxEndTime = note.endTime;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
this._totalDuration = maxEndTime;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Schedule all notes for playback using simplified approach
|
|
383
|
+
* @private
|
|
384
|
+
*/
|
|
385
|
+
_schedulePlayback() {
|
|
386
|
+
// Stop any existing scheduling
|
|
387
|
+
this._stopScheduling();
|
|
388
|
+
|
|
389
|
+
// Start the simplified scheduling loop
|
|
390
|
+
this._startScheduleLoop();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Start the simplified scheduling loop
|
|
395
|
+
* @private
|
|
396
|
+
*/
|
|
397
|
+
_startScheduleLoop() {
|
|
398
|
+
if (this.scheduleInterval) return;
|
|
399
|
+
|
|
400
|
+
this.scheduleInterval = setInterval(() => {
|
|
401
|
+
if (!this._isPlaying) return;
|
|
402
|
+
|
|
403
|
+
const currentTime = this.audioEngine.audioContext.currentTime;
|
|
404
|
+
const playbackTime = (currentTime - this.playbackStartTime) * this.playbackSpeed;
|
|
405
|
+
const scheduleAheadTime = playbackTime + this.lookAheadTime;
|
|
406
|
+
|
|
407
|
+
// Simple scheduling: advance pointers and schedule upcoming events
|
|
408
|
+
for (const [partName, channelHandle] of this.partChannels) {
|
|
409
|
+
const partData = this.parsedData.parts[partName];
|
|
410
|
+
if (!partData) continue;
|
|
411
|
+
|
|
412
|
+
// Handle program changes
|
|
413
|
+
if (partData.programChanges && partData.programChanges.length > 0) {
|
|
414
|
+
let programIndex = this.partProgramPointers.get(partName) || 0;
|
|
415
|
+
const programChanges = partData.programChanges;
|
|
416
|
+
|
|
417
|
+
// Skip program changes that have already passed
|
|
418
|
+
while (programIndex < programChanges.length && programChanges[programIndex].time < playbackTime) {
|
|
419
|
+
programIndex++;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Apply program changes that should happen within the lookahead window
|
|
423
|
+
while (programIndex < programChanges.length && programChanges[programIndex].time <= scheduleAheadTime) {
|
|
424
|
+
const programChange = programChanges[programIndex];
|
|
425
|
+
channelHandle.setInstrument(programChange.programNumber);
|
|
426
|
+
programIndex++;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Update pointer for this part
|
|
430
|
+
this.partProgramPointers.set(partName, programIndex);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Handle notes
|
|
434
|
+
if (partData.notes) {
|
|
435
|
+
let noteIndex = this.partNotePointers.get(partName) || 0;
|
|
436
|
+
const notes = partData.notes;
|
|
437
|
+
|
|
438
|
+
// Skip notes that have already ended
|
|
439
|
+
while (noteIndex < notes.length && notes[noteIndex].endTime < playbackTime) {
|
|
440
|
+
noteIndex++;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Schedule notes that should start within the lookahead window
|
|
444
|
+
while (noteIndex < notes.length && notes[noteIndex].startTime <= scheduleAheadTime) {
|
|
445
|
+
const note = notes[noteIndex];
|
|
446
|
+
|
|
447
|
+
// Skip zero-duration notes
|
|
448
|
+
if (note.endTime - note.startTime >= 0.01) {
|
|
449
|
+
const audioStartTime = this.playbackStartTime + (note.startTime / this.playbackSpeed);
|
|
450
|
+
const audioDuration = (note.endTime - note.startTime) / this.playbackSpeed;
|
|
451
|
+
|
|
452
|
+
channelHandle.playNote(audioStartTime, note.pitch, note.velocity, audioDuration);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
noteIndex++;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Update pointer for this part
|
|
459
|
+
this.partNotePointers.set(partName, noteIndex);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}, 50); // Schedule every 50ms - less frequent since we're doing less work
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Reset note pointers for all parts based on current playback time
|
|
467
|
+
* @private
|
|
468
|
+
*/
|
|
469
|
+
_resetNotePointers() {
|
|
470
|
+
const currentTime = this._currentTime;
|
|
471
|
+
|
|
472
|
+
for (const [partName] of this.partChannels) {
|
|
473
|
+
const partData = this.parsedData.parts[partName];
|
|
474
|
+
if (!partData || !partData.notes) continue;
|
|
475
|
+
|
|
476
|
+
// Find the first note that hasn't ended yet
|
|
477
|
+
let noteIndex = 0;
|
|
478
|
+
while (noteIndex < partData.notes.length && partData.notes[noteIndex].endTime < currentTime) {
|
|
479
|
+
noteIndex++;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this.partNotePointers.set(partName, noteIndex);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Reset program change pointers for all parts based on current playback time
|
|
488
|
+
* @private
|
|
489
|
+
*/
|
|
490
|
+
_resetProgramPointers() {
|
|
491
|
+
const currentTime = this._currentTime;
|
|
492
|
+
|
|
493
|
+
for (const [partName, channelHandle] of this.partChannels) {
|
|
494
|
+
const partData = this.parsedData.parts[partName];
|
|
495
|
+
if (!partData || !partData.programChanges) {
|
|
496
|
+
this.partProgramPointers.set(partName, 0);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Find the most recent program change before current time
|
|
501
|
+
let programIndex = 0;
|
|
502
|
+
let lastActiveProgram = partData.defaultInstrument;
|
|
503
|
+
|
|
504
|
+
while (programIndex < partData.programChanges.length &&
|
|
505
|
+
partData.programChanges[programIndex].time <= currentTime) {
|
|
506
|
+
lastActiveProgram = partData.programChanges[programIndex].programNumber;
|
|
507
|
+
programIndex++;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Set the current instrument to the most recent program
|
|
511
|
+
channelHandle.setInstrument(lastActiveProgram);
|
|
512
|
+
|
|
513
|
+
this.partProgramPointers.set(partName, programIndex);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Stop scheduling and all channel notes
|
|
519
|
+
* @private
|
|
520
|
+
*/
|
|
521
|
+
_stopScheduling() {
|
|
522
|
+
// Stop the scheduling loop
|
|
523
|
+
if (this.scheduleInterval) {
|
|
524
|
+
clearInterval(this.scheduleInterval);
|
|
525
|
+
this.scheduleInterval = null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Stop all notes on all channels (channels handle their own cleanup)
|
|
529
|
+
this.partChannels.forEach(channelHandle => {
|
|
530
|
+
if (channelHandle.isActive()) {
|
|
531
|
+
channelHandle.allNotesOff();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Start the time update loop for firing timeupdate events
|
|
538
|
+
* @private
|
|
539
|
+
*/
|
|
540
|
+
_startTimeUpdateLoop() {
|
|
541
|
+
this.timeUpdateInterval = setInterval(() => {
|
|
542
|
+
const currentTime = this.getCurrentTime();
|
|
543
|
+
|
|
544
|
+
// Emit timeupdate event
|
|
545
|
+
this._emitEvent('timeupdate', { currentTime });
|
|
546
|
+
|
|
547
|
+
// Emit barChanged event if beat mapping is available
|
|
548
|
+
const barInfo = this.getBarFromTime(currentTime);
|
|
549
|
+
if (barInfo) {
|
|
550
|
+
this._emitEvent('barChanged', barInfo);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Check for end of playback
|
|
554
|
+
if (currentTime >= this._totalDuration) {
|
|
555
|
+
this.stop();
|
|
556
|
+
this._emitEvent('ended', { finalTime: currentTime });
|
|
557
|
+
}
|
|
558
|
+
}, 100); // Update every 100ms
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Stop the time update loop
|
|
563
|
+
* @private
|
|
564
|
+
*/
|
|
565
|
+
_stopTimeUpdateLoop() {
|
|
566
|
+
if (this.timeUpdateInterval) {
|
|
567
|
+
clearInterval(this.timeUpdateInterval);
|
|
568
|
+
this.timeUpdateInterval = null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Emit an event to all registered listeners
|
|
574
|
+
* @param {string} eventType - Type of event
|
|
575
|
+
* @param {any} data - Data to pass to listeners
|
|
576
|
+
* @private
|
|
577
|
+
*/
|
|
578
|
+
_emitEvent(eventType, data) {
|
|
579
|
+
// Get all listeners for this event type and call them individually
|
|
580
|
+
// to ensure one failing listener doesn't prevent others from being called
|
|
581
|
+
const listeners = this.eventBus.all.get(eventType) || [];
|
|
582
|
+
listeners.forEach(listener => {
|
|
583
|
+
try {
|
|
584
|
+
listener(data);
|
|
585
|
+
} catch (error) {
|
|
586
|
+
console.error(`Error in ${eventType} event listener:`, error);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Generate simple beat mapping from MIDI tempo changes when no structure metadata provided
|
|
593
|
+
* @returns {Array} Array of beat mapping objects
|
|
594
|
+
* @private
|
|
595
|
+
*/
|
|
596
|
+
_generateSimpleBeatMapping() {
|
|
597
|
+
// Generate basic beat mapping from MIDI tempo changes
|
|
598
|
+
const beats = [];
|
|
599
|
+
const tempoChanges = this.parsedData.tempoChanges || [];
|
|
600
|
+
const defaultTempo = 120;
|
|
601
|
+
|
|
602
|
+
// Use actual time signature from barStructure instead of hardcoding 4
|
|
603
|
+
const barStructure = this.parsedData.barStructure || [];
|
|
604
|
+
const firstBarData = barStructure[0] || { sig: [4, 4], bpm: defaultTempo };
|
|
605
|
+
const beatsPerBar = firstBarData.sig[0];
|
|
606
|
+
const tempo = Array.isArray(firstBarData.bpm) ? firstBarData.bpm[0] : firstBarData.bpm;
|
|
607
|
+
const secondsPerBeat = 60 / tempo;
|
|
608
|
+
const secondsPerBar = secondsPerBeat * beatsPerBar;
|
|
609
|
+
|
|
610
|
+
if (tempoChanges.length === 0) {
|
|
611
|
+
// No tempo data, create mapping based on total duration and barStructure
|
|
612
|
+
const numBars = Math.max(1, Math.ceil(this._totalDuration / secondsPerBar));
|
|
613
|
+
let currentTime = 0;
|
|
614
|
+
|
|
615
|
+
for (let bar = 1; bar <= numBars; bar++) {
|
|
616
|
+
// Get time signature for this specific bar (not just the first bar)
|
|
617
|
+
const barIndex = Math.max(0, bar - 1);
|
|
618
|
+
const currentBarData = barStructure[barIndex] || firstBarData;
|
|
619
|
+
const currentBeatsPerBar = currentBarData.sig[0];
|
|
620
|
+
const currentTempo = Array.isArray(currentBarData.bpm) ? currentBarData.bpm[0] : currentBarData.bpm;
|
|
621
|
+
const currentSecondsPerBeat = 60 / currentTempo;
|
|
622
|
+
|
|
623
|
+
for (let beat = 1; beat <= currentBeatsPerBar; beat++) {
|
|
624
|
+
beats.push({
|
|
625
|
+
bar,
|
|
626
|
+
beat,
|
|
627
|
+
repeat: 0,
|
|
628
|
+
tempo: currentTempo,
|
|
629
|
+
time: currentTime,
|
|
630
|
+
timeSig: currentBeatsPerBar
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
currentTime += currentSecondsPerBeat;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return beats;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Convert tempo changes to beat array with all beats
|
|
641
|
+
// This is a simplified implementation - full beat mapping requires BeatMapper
|
|
642
|
+
let currentTime = 0;
|
|
643
|
+
let currentBar = 1;
|
|
644
|
+
|
|
645
|
+
for (let i = 0; i < tempoChanges.length; i++) {
|
|
646
|
+
const change = tempoChanges[i];
|
|
647
|
+
const nextChange = tempoChanges[i + 1];
|
|
648
|
+
const endTime = nextChange ? nextChange.time : this._totalDuration;
|
|
649
|
+
const currentBpm = change.tempo || defaultTempo;
|
|
650
|
+
const currentSecondsPerBeat = 60 / currentBpm;
|
|
651
|
+
|
|
652
|
+
// Generate beats from this tempo change to the next
|
|
653
|
+
while (currentTime < endTime) {
|
|
654
|
+
for (let beat = 1; beat <= beatsPerBar && currentTime < endTime; beat++) {
|
|
655
|
+
beats.push({
|
|
656
|
+
bar: currentBar,
|
|
657
|
+
beat,
|
|
658
|
+
repeat: 0,
|
|
659
|
+
tempo: currentBpm,
|
|
660
|
+
time: currentTime,
|
|
661
|
+
timeSig: beatsPerBar
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
currentTime += currentSecondsPerBeat;
|
|
665
|
+
}
|
|
666
|
+
currentBar++;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return beats;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Clean up resources and destroy the player
|
|
675
|
+
*/
|
|
676
|
+
destroy() {
|
|
677
|
+
this.stop();
|
|
678
|
+
|
|
679
|
+
// Destroy all channel handles
|
|
680
|
+
this.partChannels.forEach(channelHandle => {
|
|
681
|
+
channelHandle.destroy();
|
|
682
|
+
});
|
|
683
|
+
this.partChannels.clear();
|
|
684
|
+
|
|
685
|
+
// Disconnect part outputs
|
|
686
|
+
this.partOutputs.forEach(gainNode => {
|
|
687
|
+
gainNode.disconnect();
|
|
688
|
+
});
|
|
689
|
+
this.partOutputs.clear();
|
|
690
|
+
|
|
691
|
+
// Clear note and program change pointers
|
|
692
|
+
this.partNotePointers.clear();
|
|
693
|
+
this.partProgramPointers.clear();
|
|
694
|
+
|
|
695
|
+
// Clear event bus
|
|
696
|
+
this.eventBus.all.clear();
|
|
697
|
+
|
|
698
|
+
// Note: We don't destroy the audioEngine as it might be shared
|
|
699
|
+
}
|
|
700
|
+
}
|