audio-mixer-engine 0.3.4 → 0.4.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.
@@ -1,579 +1,579 @@
1
- import stick4csUrl from '../assets/stick-4cs.mp3';
2
- import stick4dUrl from '../assets/stick-4d.mp3';
3
-
4
- /**
5
- * Abstract AudioEngine - Part-centric audio synthesis for audio mixers
6
- * This class provides the contract for creating and managing musical parts
7
- */
8
- export default class AudioEngine {
9
- /**
10
- * Create a new AudioEngine instance
11
- * @param {AudioContext} audioContext - Web Audio API context
12
- * @param {Object} options - Engine-specific options
13
- */
14
- constructor(audioContext, options = {}) {
15
- if (new.target === AudioEngine) {
16
- throw new Error('AudioEngine is abstract and cannot be instantiated directly');
17
- }
18
-
19
- this.audioContext = audioContext;
20
- this.options = options;
21
- this.isInitialized = false;
22
- this.channels = new WeakMap(); // ChannelHandle -> internal channel data
23
- this.activeChannels = new Set(); // Track active channels for cleanup
24
- }
25
-
26
- /**
27
- * Initialize the audio engine - load soundfont and set up synthesis
28
- * @param {string|ArrayBuffer} soundfontData - Path to soundfont or binary data
29
- * @returns {Promise<void>}
30
- */
31
- async initialize(soundfontData) {
32
- throw new Error('initialize() must be implemented by subclass');
33
- }
34
-
35
- /**
36
- * Create a new channel for a musical part
37
- * @param {string} partId - Unique identifier for this part (e.g., 'soprano', 'piano')
38
- * @param {Object} options - Channel configuration
39
- * @param {string|number} [options.instrument] - Initial instrument
40
- * @param {number} [options.initialVolume=1.0] - Initial volume (0.0-1.0)
41
- * @returns {ChannelHandle} Handle object for controlling this channel
42
- */
43
- createChannel(partId, options = {}) {
44
- throw new Error('createChannel() must be implemented by subclass');
45
- }
46
-
47
-
48
- /**
49
- * Stop all notes on all channels
50
- */
51
- allSoundsOff() {
52
- throw new Error('allSoundsOff() must be implemented by subclass');
53
- }
54
-
55
- /**
56
- * Play a metronome tick sound using audio buffers (default implementation)
57
- * @param {number} audioTime - Absolute audio context time for the tick
58
- * @param {boolean} isAccent - Whether this is an accent beat (downbeat)
59
- * @param {number} volume - Volume level (0.0-1.0)
60
- * @returns {Promise<void>}
61
- */
62
- async playMetronomeTick(audioTime, isAccent, volume) {
63
- try {
64
- // Ensure metronome buffers are loaded
65
- await this._ensureMetronomeBuffersLoaded();
66
-
67
- const buffer = isAccent ? this.accentTickBuffer : this.regularTickBuffer;
68
- if (!buffer) {
69
- console.warn('Metronome buffer not available');
70
- return;
71
- }
72
-
73
- // Create buffer source
74
- const source = this.audioContext.createBufferSource();
75
- source.buffer = buffer;
76
-
77
- // Create gain node for volume control
78
- const gainNode = this.audioContext.createGain();
79
- gainNode.gain.value = volume;
80
-
81
- // Connect: source -> gain -> metronome output
82
- source.connect(gainNode);
83
- const metronomeOutput = this.getMetronomeOutput();
84
- if (metronomeOutput) {
85
- gainNode.connect(metronomeOutput);
86
- } else {
87
- // Fallback to main destination if no metronome output
88
- gainNode.connect(this.audioContext.destination);
89
- }
90
-
91
- // Start playback at specified time
92
- const startTime = Math.max(audioTime, this.audioContext.currentTime);
93
- source.start(startTime);
94
-
95
- } catch (error) {
96
- console.warn('Buffer metronome playback failed:', error);
97
- // Silently continue - metronome failure shouldn't break playback
98
- }
99
- }
100
-
101
- /**
102
- * Get the audio output node for metronome sounds (default implementation)
103
- * This allows the mixer to route metronome audio separately
104
- * @returns {AudioNode|null} Metronome output node or null if not available
105
- */
106
- getMetronomeOutput() {
107
- // Initialize metronome output if not already created
108
- if (!this._metronomeOutput) {
109
- this._metronomeOutput = this.audioContext.createGain();
110
- this._metronomeOutput.gain.value = 1.0;
111
- this._metronomeOutput.connect(this.audioContext.destination);
112
- }
113
- return this._metronomeOutput;
114
- }
115
-
116
- /**
117
- * Ensure metronome audio buffers are loaded
118
- * @private
119
- */
120
- async _ensureMetronomeBuffersLoaded() {
121
- // Skip loading if buffers already exist
122
- if (this.regularTickBuffer && this.accentTickBuffer) {
123
- return;
124
- }
125
-
126
- try {
127
- // Try to load tick sound files if available
128
- if (typeof fetch !== 'undefined') {
129
- const [regularResponse, accentResponse] = await Promise.all([
130
- fetch(stick4csUrl),
131
- fetch(stick4dUrl)
132
- ]);
133
-
134
- const [regularBuffer, accentBuffer] = await Promise.all([
135
- regularResponse.arrayBuffer(),
136
- accentResponse.arrayBuffer()
137
- ]);
138
-
139
- const [regularAudioBuffer, accentAudioBuffer] = await Promise.all([
140
- this.audioContext.decodeAudioData(regularBuffer),
141
- this.audioContext.decodeAudioData(accentBuffer)
142
- ]);
143
-
144
- this.regularTickBuffer = regularAudioBuffer;
145
- this.accentTickBuffer = accentAudioBuffer;
146
- return;
147
- }
148
- } catch (error) {
149
- console.warn('Failed to load metronome sounds:', error);
150
- }
151
-
152
- // Create silent fallback buffers (useful for testing or when files are missing)
153
- this.regularTickBuffer = this.audioContext.createBuffer(2, 1024, this.audioContext.sampleRate);
154
- this.accentTickBuffer = this.audioContext.createBuffer(2, 1024, this.audioContext.sampleRate);
155
- }
156
-
157
- /**
158
- * Get list of all active channel handles
159
- * @returns {Array<ChannelHandle>} Array of active channel handles
160
- */
161
- getActiveChannels() {
162
- return Array.from(this.activeChannels);
163
- }
164
-
165
- /**
166
- * Clean up all resources and disconnect audio nodes
167
- */
168
- destroy() {
169
- // Stop all sounds
170
- this.allSoundsOff();
171
-
172
- // Clean up metronome resources
173
- if (this._metronomeOutput) {
174
- this._metronomeOutput.disconnect();
175
- this._metronomeOutput = null;
176
- }
177
- this.regularTickBuffer = null;
178
- this.accentTickBuffer = null;
179
-
180
- // Clear tracking collections
181
- this.activeChannels.clear();
182
-
183
- this.isInitialized = false;
184
- }
185
-
186
- /**
187
- * Validate that the engine is initialized
188
- * @protected
189
- */
190
- _validateInitialized() {
191
- if (!this.isInitialized) {
192
- throw new Error('AudioEngine not initialized. Call initialize() first.');
193
- }
194
- }
195
-
196
- /**
197
- * Register a new channel handle (called by subclasses)
198
- * @param {ChannelHandle} handle - Channel handle to register
199
- * @protected
200
- */
201
- _registerChannel(handle) {
202
- this.activeChannels.add(handle);
203
- }
204
-
205
- /**
206
- * Unregister a channel handle (called when channel is destroyed)
207
- * @param {ChannelHandle} handle - Channel handle to unregister
208
- * @protected
209
- */
210
- _unregisterChannel(handle) {
211
- this.activeChannels.delete(handle);
212
- this.channels.delete(handle);
213
- }
214
- }
215
-
216
- /**
217
- * ChannelHandle - Interface for controlling one musical part
218
- * Each instance represents one musical part (soprano, piano, etc.)
219
- */
220
- export class ChannelHandle {
221
- constructor(engine, partId, options = {}) {
222
- if (new.target === ChannelHandle) {
223
- throw new Error('ChannelHandle is abstract and cannot be instantiated directly');
224
- }
225
-
226
- this.engine = engine;
227
- this.partId = partId;
228
- this.options = { initialVolume: 1.0, ...options };
229
- this.isDestroyed = false;
230
-
231
- // Reference counting for overlapping notes
232
- this.noteRefCounts = new Map(); // pitch -> count
233
- this.scheduledEvents = new Map(); // eventId -> timeoutId
234
- this.activeNotes = new Set(); // Set of currently playing pitches
235
- }
236
-
237
- /**
238
- * Get the output audio node for this channel
239
- * This node can be connected to gain controls, analyzers, etc.
240
- * @returns {AudioNode} Output node (typically a GainNode)
241
- */
242
- getOutputNode() {
243
- throw new Error('getOutputNode() must be implemented by subclass');
244
- }
245
-
246
- /**
247
- * Start a note with reference counting (handles overlaps automatically)
248
- * @param {number} pitch - MIDI pitch (0-127)
249
- * @param {number} velocity - Note velocity (0-127)
250
- */
251
- noteOn(pitch, velocity) {
252
- this._validateActive();
253
-
254
- const currentCount = this.noteRefCounts.get(pitch) || 0;
255
-
256
- // Always increment reference count and start the note
257
- this.noteRefCounts.set(pitch, currentCount + 1);
258
- this._actualNoteOn(pitch, velocity);
259
-
260
- // Add to active notes if not already playing
261
- if (currentCount === 0) {
262
- this.activeNotes.add(pitch);
263
- }
264
- }
265
-
266
- /**
267
- * Stop a note with reference counting (only stops when count reaches 0)
268
- * @param {number} pitch - MIDI pitch (0-127)
269
- */
270
- noteOff(pitch) {
271
- this._validateActive();
272
-
273
- const currentCount = this.noteRefCounts.get(pitch) || 0;
274
-
275
- if (currentCount <= 0) {
276
- return; // No notes to stop
277
- }
278
-
279
- const newCount = currentCount - 1;
280
- this.noteRefCounts.set(pitch, newCount);
281
-
282
- // Only actually stop the note when reference count reaches 0
283
- if (newCount === 0) {
284
- this._actualNoteOff(pitch);
285
- this.activeNotes.delete(pitch);
286
- this.noteRefCounts.delete(pitch);
287
- }
288
- }
289
-
290
- /**
291
- * Schedule a note with automatic timing adjustment
292
- * @param {number} startTime - Absolute audio context time when note should start
293
- * @param {number} pitch - MIDI pitch (0-127)
294
- * @param {number} velocity - Note velocity (0-127)
295
- * @param {number} duration - Note duration in seconds
296
- * @returns {string} Event ID for cancellation
297
- */
298
- playNote(startTime, pitch, velocity, duration) {
299
- this._validateActive();
300
-
301
- const currentTime = this.engine.audioContext.currentTime;
302
- const eventId = `${this.partId}_${startTime}_${pitch}_${Date.now()}`;
303
-
304
- // Adjust timing if start time is in the past
305
- let adjustedStartTime = startTime;
306
- let adjustedDuration = duration;
307
-
308
- if (startTime < currentTime) {
309
- const timePassed = currentTime - startTime;
310
- adjustedStartTime = currentTime;
311
- adjustedDuration = Math.max(0, duration - timePassed);
312
- }
313
-
314
- // If duration is too short after adjustment, skip this note
315
- if (adjustedDuration <= 0) {
316
- return eventId;
317
- }
318
-
319
- // Schedule note on
320
- const noteOnDelay = Math.max(0, (adjustedStartTime - currentTime) * 1000);
321
- const noteOnTimeoutId = setTimeout(() => {
322
- this.noteOn(pitch, velocity);
323
- this.scheduledEvents.delete(`${eventId}_on`);
324
- }, noteOnDelay);
325
-
326
- // Schedule note off
327
- const noteOffDelay = noteOnDelay + (adjustedDuration * 1000);
328
- const noteOffTimeoutId = setTimeout(() => {
329
- this.noteOff(pitch);
330
- this.scheduledEvents.delete(`${eventId}_off`);
331
- }, noteOffDelay);
332
-
333
- // Track both events for cancellation
334
- this.scheduledEvents.set(`${eventId}_on`, noteOnTimeoutId);
335
- this.scheduledEvents.set(`${eventId}_off`, noteOffTimeoutId);
336
-
337
- return eventId;
338
- }
339
-
340
- /**
341
- * Stop all notes on this channel
342
- */
343
- allNotesOff() {
344
- this._validateActive();
345
-
346
- // Cancel all scheduled events
347
- this.scheduledEvents.forEach(timeoutId => {
348
- clearTimeout(timeoutId);
349
- });
350
- this.scheduledEvents.clear();
351
-
352
- // Stop all actively playing notes
353
- this.activeNotes.forEach(pitch => {
354
- this._actualNoteOff(pitch);
355
- });
356
-
357
- // Reset all state
358
- this.noteRefCounts.clear();
359
- this.activeNotes.clear();
360
- }
361
-
362
- /**
363
- * Actually start a note (implemented by subclass)
364
- * @param {number} pitch - MIDI pitch (0-127)
365
- * @param {number} velocity - Note velocity (0-127)
366
- * @protected
367
- */
368
- _actualNoteOn(pitch, velocity) {
369
- throw new Error('_actualNoteOn() must be implemented by subclass');
370
- }
371
-
372
- /**
373
- * Actually stop a note (implemented by subclass)
374
- * @param {number} pitch - MIDI pitch (0-127)
375
- * @protected
376
- */
377
- _actualNoteOff(pitch) {
378
- throw new Error('_actualNoteOff() must be implemented by subclass');
379
- }
380
-
381
- /**
382
- * Change the instrument for this channel
383
- * @param {string|number} instrument - Instrument name or program number
384
- * @returns {Promise<void>}
385
- */
386
- async setInstrument(instrument) {
387
- throw new Error('setInstrument() must be implemented by subclass');
388
- }
389
-
390
- /**
391
- * Get current instrument for this channel
392
- * @returns {string|number} Current instrument
393
- */
394
- getInstrument() {
395
- throw new Error('getInstrument() must be implemented by subclass');
396
- }
397
-
398
- /**
399
- * Set volume for this channel (affects the internal channel volume)
400
- * Note: External volume control should use the output node from getOutputNode()
401
- * @param {number} volume - Volume level (0.0-1.0)
402
- */
403
- setVolume(volume) {
404
- throw new Error('setVolume() must be implemented by subclass');
405
- }
406
-
407
- /**
408
- * Get current volume for this channel
409
- * @returns {number} Current volume (0.0-1.0)
410
- */
411
- getVolume() {
412
- throw new Error('getVolume() must be implemented by subclass');
413
- }
414
-
415
- /**
416
- * Get the part ID for this channel
417
- * @returns {string} Part identifier
418
- */
419
- getPartId() {
420
- return this.partId;
421
- }
422
-
423
- /**
424
- * Check if this channel is still active
425
- * @returns {boolean} True if channel is active
426
- */
427
- isActive() {
428
- return !this.isDestroyed && this.engine.isInitialized && this.engine.activeChannels.has(this);
429
- }
430
-
431
- /**
432
- * Destroy this channel and clean up resources
433
- */
434
- destroy() {
435
- if (!this.isDestroyed) {
436
- this.allNotesOff();
437
-
438
- const outputNode = this.getOutputNode();
439
- if (outputNode) {
440
- outputNode.disconnect();
441
- }
442
-
443
- // Clear reference counting state
444
- this.noteRefCounts.clear();
445
- this.scheduledEvents.clear();
446
- this.activeNotes.clear();
447
-
448
- this.engine._unregisterChannel(this);
449
- this.isDestroyed = true;
450
- }
451
- }
452
-
453
- /**
454
- * Validate that this channel is still active
455
- * @protected
456
- */
457
- _validateActive() {
458
- if (this.isDestroyed) {
459
- throw new Error('Channel has been destroyed');
460
- }
461
- if (!this.engine.isInitialized) {
462
- throw new Error('AudioEngine is not initialized');
463
- }
464
- }
465
- }
466
-
467
- const MidiInstrumentNumbers = {
468
- // Piano family (0-7)
469
- 'piano': 0, 'bright_piano': 1, 'electric_grand': 2, 'honky_tonk': 3,
470
- 'electric_piano_1': 4, 'electric_piano_2': 5, 'harpsichord': 6, 'clavinet': 7,
471
-
472
- // Chromatic percussion (8-15)
473
- 'celesta': 8, 'glockenspiel': 9, 'music_box': 10, 'vibraphone': 11,
474
- 'marimba': 12, 'xylophone': 13, 'tubular_bells': 14, 'dulcimer': 15,
475
-
476
- // Organ (16-23)
477
- 'drawbar_organ': 16, 'percussive_organ': 17, 'rock_organ': 18, 'church_organ': 19,
478
- 'reed_organ': 20, 'accordion': 21, 'harmonica': 22, 'tango_accordion': 23,
479
- 'organ': 19,
480
-
481
- // Guitar (24-31)
482
- 'nylon_guitar': 24, 'steel_guitar': 25, 'electric_guitar_jazz': 26, 'electric_guitar_clean': 27,
483
- 'electric_guitar_muted': 28, 'overdriven_guitar': 29, 'distortion_guitar': 30, 'guitar_harmonics': 31,
484
- 'guitar': 24,
485
-
486
- // Bass (32-39)
487
- 'acoustic_bass': 32, 'electric_bass_finger': 33, 'electric_bass_pick': 34, 'fretless_bass': 35,
488
- 'slap_bass_1': 36, 'slap_bass_2': 37, 'synth_bass_1': 38, 'synth_bass_2': 39,
489
- 'bass': 32,
490
-
491
- // Strings (40-47)
492
- 'violin': 40, 'viola': 41, 'cello': 42, 'contrabass': 43,
493
- 'tremolo_strings': 44, 'pizzicato_strings': 45, 'orchestral_harp': 46, 'timpani': 47,
494
- 'strings': 48, 'strings_ensemble': 48,
495
-
496
- // Ensemble (48-55)
497
- 'slow_strings': 49, 'synth_strings_1': 50, 'synth_strings_2': 51,
498
- 'choir_aahs': 52, 'voice_oohs': 53, 'synth_voice': 54, 'orchestra_hit': 55,
499
-
500
- // Brass (56-63)
501
- 'trumpet': 56, 'trombone': 57, 'tuba': 58, 'muted_trumpet': 59,
502
- 'french_horn': 60, 'brass_section': 61, 'synth_brass_1': 62, 'synth_brass_2': 63,
503
-
504
- // Reed (64-71)
505
- 'soprano_sax': 64, 'alto_sax': 65, 'tenor_sax': 66, 'baritone_sax': 67,
506
- 'oboe': 68, 'english_horn': 69, 'bassoon': 70, 'clarinet': 71,
507
- 'saxophone': 64,
508
-
509
- // Pipe (72-79)
510
- 'piccolo': 72, 'flute': 73, 'recorder': 74, 'pan_flute': 75,
511
- 'blown_bottle': 76, 'shakuhachi': 77, 'whistle': 78, 'ocarina': 79,
512
-
513
- // Synth lead (80-87)
514
- 'lead_1_square': 80, 'lead_2_sawtooth': 81, 'lead_3_calliope': 82, 'lead_4_chiff': 83,
515
- 'lead_5_charang': 84, 'lead_6_voice': 85, 'lead_7_fifths': 86, 'lead_8_bass': 87,
516
-
517
- // Synth pad (88-95)
518
- 'pad_1_new_age': 88, 'pad_2_warm': 89, 'pad_3_polysynth': 90, 'pad_4_choir': 91,
519
- 'pad_5_bowed': 92, 'pad_6_metallic': 93, 'pad_7_halo': 94, 'pad_8_sweep': 95,
520
-
521
- // Synth effects (96-103)
522
- 'fx_1_rain': 96, 'fx_2_soundtrack': 97, 'fx_3_crystal': 98, 'fx_4_atmosphere': 99,
523
- 'fx_5_brightness': 100, 'fx_6_goblins': 101, 'fx_7_echoes': 102, 'fx_8_sci_fi': 103,
524
-
525
- // Ethnic (104-111)
526
- 'sitar': 104, 'banjo': 105, 'shamisen': 106, 'koto': 107,
527
- 'kalimba': 108, 'bag_pipe': 109, 'fiddle': 110, 'shanai': 111,
528
-
529
- // Percussive (112-119)
530
- 'tinkle_bell': 112, 'agogo': 113, 'steel_drums': 114, 'woodblock': 115,
531
- 'taiko_drum': 116, 'melodic_tom': 117, 'synth_drum': 118, 'reverse_cymbal': 119,
532
-
533
- // Sound effects (120-127)
534
- 'guitar_fret_noise': 120, 'breath_noise': 121, 'seashore': 122, 'bird_tweet': 123,
535
- 'telephone_ring': 124, 'helicopter': 125, 'applause': 126, 'gunshot': 127
536
- };
537
-
538
- const MidiInstrumentNames = Object.entries(MidiInstrumentNumbers)
539
- .reduce((o,[k,v])=>{
540
- o[v]=k;
541
- return o;
542
- },{})
543
-
544
- /**
545
- * Utility class for common audio engine operations
546
- */
547
- export class AudioEngineUtils {
548
- /**
549
- * Map common instrument names to MIDI program numbers
550
- * @param {string|number} instrument - Instrument name or program number
551
- * @returns {number} MIDI program number
552
- */
553
- static getInstrumentProgram(instrument) {
554
- if (typeof instrument === 'number') return instrument;
555
- const program = MidiInstrumentNumbers[instrument.toLowerCase()];
556
- return program !== undefined ? program : 0; // Default to Piano
557
- }
558
-
559
- /**
560
- * Get instrument name from MIDI program number (for display purposes)
561
- * @param {number} programNumber - MIDI program number (0-127)
562
- * @returns {string} Instrument name or fallback
563
- */
564
- static getProgramName(programNumber) {
565
-
566
- return MidiInstrumentNames[programNumber] || `Program ${programNumber}`;
567
- }
568
-
569
- /**
570
- * Generate a unique note ID
571
- * @param {number} channel - MIDI channel
572
- * @param {number} pitch - MIDI pitch
573
- * @param {number} startTime - Start time
574
- * @returns {string} Unique note ID
575
- */
576
- static generateNoteId(channel, pitch, startTime) {
577
- return `${channel}_${pitch}_${Math.round(startTime)}`;
578
- }
1
+ import stick4csUrl from '../assets/stick-4cs.mp3';
2
+ import stick4dUrl from '../assets/stick-4d.mp3';
3
+
4
+ /**
5
+ * Abstract AudioEngine - Part-centric audio synthesis for audio mixers
6
+ * This class provides the contract for creating and managing musical parts
7
+ */
8
+ export default class AudioEngine {
9
+ /**
10
+ * Create a new AudioEngine instance
11
+ * @param {AudioContext} audioContext - Web Audio API context
12
+ * @param {Object} options - Engine-specific options
13
+ */
14
+ constructor(audioContext, options = {}) {
15
+ if (new.target === AudioEngine) {
16
+ throw new Error('AudioEngine is abstract and cannot be instantiated directly');
17
+ }
18
+
19
+ this.audioContext = audioContext;
20
+ this.options = options;
21
+ this.isInitialized = false;
22
+ this.channels = new WeakMap(); // ChannelHandle -> internal channel data
23
+ this.activeChannels = new Set(); // Track active channels for cleanup
24
+ }
25
+
26
+ /**
27
+ * Initialize the audio engine - load soundfont and set up synthesis
28
+ * @param {string|ArrayBuffer} soundfontData - Path to soundfont or binary data
29
+ * @returns {Promise<void>}
30
+ */
31
+ async initialize(soundfontData) {
32
+ throw new Error('initialize() must be implemented by subclass');
33
+ }
34
+
35
+ /**
36
+ * Create a new channel for a musical part
37
+ * @param {string} partId - Unique identifier for this part (e.g., 'soprano', 'piano')
38
+ * @param {Object} options - Channel configuration
39
+ * @param {string|number} [options.instrument] - Initial instrument
40
+ * @param {number} [options.initialVolume=1.0] - Initial volume (0.0-1.0)
41
+ * @returns {ChannelHandle} Handle object for controlling this channel
42
+ */
43
+ createChannel(partId, options = {}) {
44
+ throw new Error('createChannel() must be implemented by subclass');
45
+ }
46
+
47
+
48
+ /**
49
+ * Stop all notes on all channels
50
+ */
51
+ allSoundsOff() {
52
+ throw new Error('allSoundsOff() must be implemented by subclass');
53
+ }
54
+
55
+ /**
56
+ * Play a metronome tick sound using audio buffers (default implementation)
57
+ * @param {number} audioTime - Absolute audio context time for the tick
58
+ * @param {boolean} isAccent - Whether this is an accent beat (downbeat)
59
+ * @param {number} volume - Volume level (0.0-1.0)
60
+ * @returns {Promise<void>}
61
+ */
62
+ async playMetronomeTick(audioTime, isAccent, volume) {
63
+ try {
64
+ // Ensure metronome buffers are loaded
65
+ await this._ensureMetronomeBuffersLoaded();
66
+
67
+ const buffer = isAccent ? this.accentTickBuffer : this.regularTickBuffer;
68
+ if (!buffer) {
69
+ console.warn('Metronome buffer not available');
70
+ return;
71
+ }
72
+
73
+ // Create buffer source
74
+ const source = this.audioContext.createBufferSource();
75
+ source.buffer = buffer;
76
+
77
+ // Create gain node for volume control
78
+ const gainNode = this.audioContext.createGain();
79
+ gainNode.gain.value = volume;
80
+
81
+ // Connect: source -> gain -> metronome output
82
+ source.connect(gainNode);
83
+ const metronomeOutput = this.getMetronomeOutput();
84
+ if (metronomeOutput) {
85
+ gainNode.connect(metronomeOutput);
86
+ } else {
87
+ // Fallback to main destination if no metronome output
88
+ gainNode.connect(this.audioContext.destination);
89
+ }
90
+
91
+ // Start playback at specified time
92
+ const startTime = Math.max(audioTime, this.audioContext.currentTime);
93
+ source.start(startTime);
94
+
95
+ } catch (error) {
96
+ console.warn('Buffer metronome playback failed:', error);
97
+ // Silently continue - metronome failure shouldn't break playback
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Get the audio output node for metronome sounds (default implementation)
103
+ * This allows the mixer to route metronome audio separately
104
+ * @returns {AudioNode|null} Metronome output node or null if not available
105
+ */
106
+ getMetronomeOutput() {
107
+ // Initialize metronome output if not already created
108
+ if (!this._metronomeOutput) {
109
+ this._metronomeOutput = this.audioContext.createGain();
110
+ this._metronomeOutput.gain.value = 1.0;
111
+ this._metronomeOutput.connect(this.audioContext.destination);
112
+ }
113
+ return this._metronomeOutput;
114
+ }
115
+
116
+ /**
117
+ * Ensure metronome audio buffers are loaded
118
+ * @private
119
+ */
120
+ async _ensureMetronomeBuffersLoaded() {
121
+ // Skip loading if buffers already exist
122
+ if (this.regularTickBuffer && this.accentTickBuffer) {
123
+ return;
124
+ }
125
+
126
+ try {
127
+ // Try to load tick sound files if available
128
+ if (typeof fetch !== 'undefined') {
129
+ const [regularResponse, accentResponse] = await Promise.all([
130
+ fetch(stick4csUrl),
131
+ fetch(stick4dUrl)
132
+ ]);
133
+
134
+ const [regularBuffer, accentBuffer] = await Promise.all([
135
+ regularResponse.arrayBuffer(),
136
+ accentResponse.arrayBuffer()
137
+ ]);
138
+
139
+ const [regularAudioBuffer, accentAudioBuffer] = await Promise.all([
140
+ this.audioContext.decodeAudioData(regularBuffer),
141
+ this.audioContext.decodeAudioData(accentBuffer)
142
+ ]);
143
+
144
+ this.regularTickBuffer = regularAudioBuffer;
145
+ this.accentTickBuffer = accentAudioBuffer;
146
+ return;
147
+ }
148
+ } catch (error) {
149
+ console.warn('Failed to load metronome sounds:', error);
150
+ }
151
+
152
+ // Create silent fallback buffers (useful for testing or when files are missing)
153
+ this.regularTickBuffer = this.audioContext.createBuffer(2, 1024, this.audioContext.sampleRate);
154
+ this.accentTickBuffer = this.audioContext.createBuffer(2, 1024, this.audioContext.sampleRate);
155
+ }
156
+
157
+ /**
158
+ * Get list of all active channel handles
159
+ * @returns {Array<ChannelHandle>} Array of active channel handles
160
+ */
161
+ getActiveChannels() {
162
+ return Array.from(this.activeChannels);
163
+ }
164
+
165
+ /**
166
+ * Clean up all resources and disconnect audio nodes
167
+ */
168
+ destroy() {
169
+ // Stop all sounds
170
+ this.allSoundsOff();
171
+
172
+ // Clean up metronome resources
173
+ if (this._metronomeOutput) {
174
+ this._metronomeOutput.disconnect();
175
+ this._metronomeOutput = null;
176
+ }
177
+ this.regularTickBuffer = null;
178
+ this.accentTickBuffer = null;
179
+
180
+ // Clear tracking collections
181
+ this.activeChannels.clear();
182
+
183
+ this.isInitialized = false;
184
+ }
185
+
186
+ /**
187
+ * Validate that the engine is initialized
188
+ * @protected
189
+ */
190
+ _validateInitialized() {
191
+ if (!this.isInitialized) {
192
+ throw new Error('AudioEngine not initialized. Call initialize() first.');
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Register a new channel handle (called by subclasses)
198
+ * @param {ChannelHandle} handle - Channel handle to register
199
+ * @protected
200
+ */
201
+ _registerChannel(handle) {
202
+ this.activeChannels.add(handle);
203
+ }
204
+
205
+ /**
206
+ * Unregister a channel handle (called when channel is destroyed)
207
+ * @param {ChannelHandle} handle - Channel handle to unregister
208
+ * @protected
209
+ */
210
+ _unregisterChannel(handle) {
211
+ this.activeChannels.delete(handle);
212
+ this.channels.delete(handle);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * ChannelHandle - Interface for controlling one musical part
218
+ * Each instance represents one musical part (soprano, piano, etc.)
219
+ */
220
+ export class ChannelHandle {
221
+ constructor(engine, partId, options = {}) {
222
+ if (new.target === ChannelHandle) {
223
+ throw new Error('ChannelHandle is abstract and cannot be instantiated directly');
224
+ }
225
+
226
+ this.engine = engine;
227
+ this.partId = partId;
228
+ this.options = { initialVolume: 1.0, ...options };
229
+ this.isDestroyed = false;
230
+
231
+ // Reference counting for overlapping notes
232
+ this.noteRefCounts = new Map(); // pitch -> count
233
+ this.scheduledEvents = new Map(); // eventId -> timeoutId
234
+ this.activeNotes = new Set(); // Set of currently playing pitches
235
+ }
236
+
237
+ /**
238
+ * Get the output audio node for this channel
239
+ * This node can be connected to gain controls, analyzers, etc.
240
+ * @returns {AudioNode} Output node (typically a GainNode)
241
+ */
242
+ getOutputNode() {
243
+ throw new Error('getOutputNode() must be implemented by subclass');
244
+ }
245
+
246
+ /**
247
+ * Start a note with reference counting (handles overlaps automatically)
248
+ * @param {number} pitch - MIDI pitch (0-127)
249
+ * @param {number} velocity - Note velocity (0-127)
250
+ */
251
+ noteOn(pitch, velocity) {
252
+ this._validateActive();
253
+
254
+ const currentCount = this.noteRefCounts.get(pitch) || 0;
255
+
256
+ // Always increment reference count and start the note
257
+ this.noteRefCounts.set(pitch, currentCount + 1);
258
+ this._actualNoteOn(pitch, velocity);
259
+
260
+ // Add to active notes if not already playing
261
+ if (currentCount === 0) {
262
+ this.activeNotes.add(pitch);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Stop a note with reference counting (only stops when count reaches 0)
268
+ * @param {number} pitch - MIDI pitch (0-127)
269
+ */
270
+ noteOff(pitch) {
271
+ this._validateActive();
272
+
273
+ const currentCount = this.noteRefCounts.get(pitch) || 0;
274
+
275
+ if (currentCount <= 0) {
276
+ return; // No notes to stop
277
+ }
278
+
279
+ const newCount = currentCount - 1;
280
+ this.noteRefCounts.set(pitch, newCount);
281
+
282
+ // Only actually stop the note when reference count reaches 0
283
+ if (newCount === 0) {
284
+ this._actualNoteOff(pitch);
285
+ this.activeNotes.delete(pitch);
286
+ this.noteRefCounts.delete(pitch);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Schedule a note with automatic timing adjustment
292
+ * @param {number} startTime - Absolute audio context time when note should start
293
+ * @param {number} pitch - MIDI pitch (0-127)
294
+ * @param {number} velocity - Note velocity (0-127)
295
+ * @param {number} duration - Note duration in seconds
296
+ * @returns {string} Event ID for cancellation
297
+ */
298
+ playNote(startTime, pitch, velocity, duration) {
299
+ this._validateActive();
300
+
301
+ const currentTime = this.engine.audioContext.currentTime;
302
+ const eventId = `${this.partId}_${startTime}_${pitch}_${Date.now()}`;
303
+
304
+ // Adjust timing if start time is in the past
305
+ let adjustedStartTime = startTime;
306
+ let adjustedDuration = duration;
307
+
308
+ if (startTime < currentTime) {
309
+ const timePassed = currentTime - startTime;
310
+ adjustedStartTime = currentTime;
311
+ adjustedDuration = Math.max(0, duration - timePassed);
312
+ }
313
+
314
+ // If duration is too short after adjustment, skip this note
315
+ if (adjustedDuration <= 0) {
316
+ return eventId;
317
+ }
318
+
319
+ // Schedule note on
320
+ const noteOnDelay = Math.max(0, (adjustedStartTime - currentTime) * 1000);
321
+ const noteOnTimeoutId = setTimeout(() => {
322
+ this.noteOn(pitch, velocity);
323
+ this.scheduledEvents.delete(`${eventId}_on`);
324
+ }, noteOnDelay);
325
+
326
+ // Schedule note off
327
+ const noteOffDelay = noteOnDelay + (adjustedDuration * 1000);
328
+ const noteOffTimeoutId = setTimeout(() => {
329
+ this.noteOff(pitch);
330
+ this.scheduledEvents.delete(`${eventId}_off`);
331
+ }, noteOffDelay);
332
+
333
+ // Track both events for cancellation
334
+ this.scheduledEvents.set(`${eventId}_on`, noteOnTimeoutId);
335
+ this.scheduledEvents.set(`${eventId}_off`, noteOffTimeoutId);
336
+
337
+ return eventId;
338
+ }
339
+
340
+ /**
341
+ * Stop all notes on this channel
342
+ */
343
+ allNotesOff() {
344
+ this._validateActive();
345
+
346
+ // Cancel all scheduled events
347
+ this.scheduledEvents.forEach(timeoutId => {
348
+ clearTimeout(timeoutId);
349
+ });
350
+ this.scheduledEvents.clear();
351
+
352
+ // Stop all actively playing notes
353
+ this.activeNotes.forEach(pitch => {
354
+ this._actualNoteOff(pitch);
355
+ });
356
+
357
+ // Reset all state
358
+ this.noteRefCounts.clear();
359
+ this.activeNotes.clear();
360
+ }
361
+
362
+ /**
363
+ * Actually start a note (implemented by subclass)
364
+ * @param {number} pitch - MIDI pitch (0-127)
365
+ * @param {number} velocity - Note velocity (0-127)
366
+ * @protected
367
+ */
368
+ _actualNoteOn(pitch, velocity) {
369
+ throw new Error('_actualNoteOn() must be implemented by subclass');
370
+ }
371
+
372
+ /**
373
+ * Actually stop a note (implemented by subclass)
374
+ * @param {number} pitch - MIDI pitch (0-127)
375
+ * @protected
376
+ */
377
+ _actualNoteOff(pitch) {
378
+ throw new Error('_actualNoteOff() must be implemented by subclass');
379
+ }
380
+
381
+ /**
382
+ * Change the instrument for this channel
383
+ * @param {string|number} instrument - Instrument name or program number
384
+ * @returns {Promise<void>}
385
+ */
386
+ async setInstrument(instrument) {
387
+ throw new Error('setInstrument() must be implemented by subclass');
388
+ }
389
+
390
+ /**
391
+ * Get current instrument for this channel
392
+ * @returns {string|number} Current instrument
393
+ */
394
+ getInstrument() {
395
+ throw new Error('getInstrument() must be implemented by subclass');
396
+ }
397
+
398
+ /**
399
+ * Set volume for this channel (affects the internal channel volume)
400
+ * Note: External volume control should use the output node from getOutputNode()
401
+ * @param {number} volume - Volume level (0.0-1.0)
402
+ */
403
+ setVolume(volume) {
404
+ throw new Error('setVolume() must be implemented by subclass');
405
+ }
406
+
407
+ /**
408
+ * Get current volume for this channel
409
+ * @returns {number} Current volume (0.0-1.0)
410
+ */
411
+ getVolume() {
412
+ throw new Error('getVolume() must be implemented by subclass');
413
+ }
414
+
415
+ /**
416
+ * Get the part ID for this channel
417
+ * @returns {string} Part identifier
418
+ */
419
+ getPartId() {
420
+ return this.partId;
421
+ }
422
+
423
+ /**
424
+ * Check if this channel is still active
425
+ * @returns {boolean} True if channel is active
426
+ */
427
+ isActive() {
428
+ return !this.isDestroyed && this.engine.isInitialized && this.engine.activeChannels.has(this);
429
+ }
430
+
431
+ /**
432
+ * Destroy this channel and clean up resources
433
+ */
434
+ destroy() {
435
+ if (!this.isDestroyed) {
436
+ this.allNotesOff();
437
+
438
+ const outputNode = this.getOutputNode();
439
+ if (outputNode) {
440
+ outputNode.disconnect();
441
+ }
442
+
443
+ // Clear reference counting state
444
+ this.noteRefCounts.clear();
445
+ this.scheduledEvents.clear();
446
+ this.activeNotes.clear();
447
+
448
+ this.engine._unregisterChannel(this);
449
+ this.isDestroyed = true;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Validate that this channel is still active
455
+ * @protected
456
+ */
457
+ _validateActive() {
458
+ if (this.isDestroyed) {
459
+ throw new Error('Channel has been destroyed');
460
+ }
461
+ if (!this.engine.isInitialized) {
462
+ throw new Error('AudioEngine is not initialized');
463
+ }
464
+ }
465
+ }
466
+
467
+ const MidiInstrumentNumbers = {
468
+ // Piano family (0-7)
469
+ 'piano': 0, 'bright_piano': 1, 'electric_grand': 2, 'honky_tonk': 3,
470
+ 'electric_piano_1': 4, 'electric_piano_2': 5, 'harpsichord': 6, 'clavinet': 7,
471
+
472
+ // Chromatic percussion (8-15)
473
+ 'celesta': 8, 'glockenspiel': 9, 'music_box': 10, 'vibraphone': 11,
474
+ 'marimba': 12, 'xylophone': 13, 'tubular_bells': 14, 'dulcimer': 15,
475
+
476
+ // Organ (16-23)
477
+ 'drawbar_organ': 16, 'percussive_organ': 17, 'rock_organ': 18, 'church_organ': 19,
478
+ 'reed_organ': 20, 'accordion': 21, 'harmonica': 22, 'tango_accordion': 23,
479
+ 'organ': 19,
480
+
481
+ // Guitar (24-31)
482
+ 'nylon_guitar': 24, 'steel_guitar': 25, 'electric_guitar_jazz': 26, 'electric_guitar_clean': 27,
483
+ 'electric_guitar_muted': 28, 'overdriven_guitar': 29, 'distortion_guitar': 30, 'guitar_harmonics': 31,
484
+ 'guitar': 24,
485
+
486
+ // Bass (32-39)
487
+ 'acoustic_bass': 32, 'electric_bass_finger': 33, 'electric_bass_pick': 34, 'fretless_bass': 35,
488
+ 'slap_bass_1': 36, 'slap_bass_2': 37, 'synth_bass_1': 38, 'synth_bass_2': 39,
489
+ 'bass': 32,
490
+
491
+ // Strings (40-47)
492
+ 'violin': 40, 'viola': 41, 'cello': 42, 'contrabass': 43,
493
+ 'tremolo_strings': 44, 'pizzicato_strings': 45, 'orchestral_harp': 46, 'timpani': 47,
494
+ 'strings': 48, 'strings_ensemble': 48,
495
+
496
+ // Ensemble (48-55)
497
+ 'slow_strings': 49, 'synth_strings_1': 50, 'synth_strings_2': 51,
498
+ 'choir_aahs': 52, 'voice_oohs': 53, 'synth_voice': 54, 'orchestra_hit': 55,
499
+
500
+ // Brass (56-63)
501
+ 'trumpet': 56, 'trombone': 57, 'tuba': 58, 'muted_trumpet': 59,
502
+ 'french_horn': 60, 'brass_section': 61, 'synth_brass_1': 62, 'synth_brass_2': 63,
503
+
504
+ // Reed (64-71)
505
+ 'soprano_sax': 64, 'alto_sax': 65, 'tenor_sax': 66, 'baritone_sax': 67,
506
+ 'oboe': 68, 'english_horn': 69, 'bassoon': 70, 'clarinet': 71,
507
+ 'saxophone': 64,
508
+
509
+ // Pipe (72-79)
510
+ 'piccolo': 72, 'flute': 73, 'recorder': 74, 'pan_flute': 75,
511
+ 'blown_bottle': 76, 'shakuhachi': 77, 'whistle': 78, 'ocarina': 79,
512
+
513
+ // Synth lead (80-87)
514
+ 'lead_1_square': 80, 'lead_2_sawtooth': 81, 'lead_3_calliope': 82, 'lead_4_chiff': 83,
515
+ 'lead_5_charang': 84, 'lead_6_voice': 85, 'lead_7_fifths': 86, 'lead_8_bass': 87,
516
+
517
+ // Synth pad (88-95)
518
+ 'pad_1_new_age': 88, 'pad_2_warm': 89, 'pad_3_polysynth': 90, 'pad_4_choir': 91,
519
+ 'pad_5_bowed': 92, 'pad_6_metallic': 93, 'pad_7_halo': 94, 'pad_8_sweep': 95,
520
+
521
+ // Synth effects (96-103)
522
+ 'fx_1_rain': 96, 'fx_2_soundtrack': 97, 'fx_3_crystal': 98, 'fx_4_atmosphere': 99,
523
+ 'fx_5_brightness': 100, 'fx_6_goblins': 101, 'fx_7_echoes': 102, 'fx_8_sci_fi': 103,
524
+
525
+ // Ethnic (104-111)
526
+ 'sitar': 104, 'banjo': 105, 'shamisen': 106, 'koto': 107,
527
+ 'kalimba': 108, 'bag_pipe': 109, 'fiddle': 110, 'shanai': 111,
528
+
529
+ // Percussive (112-119)
530
+ 'tinkle_bell': 112, 'agogo': 113, 'steel_drums': 114, 'woodblock': 115,
531
+ 'taiko_drum': 116, 'melodic_tom': 117, 'synth_drum': 118, 'reverse_cymbal': 119,
532
+
533
+ // Sound effects (120-127)
534
+ 'guitar_fret_noise': 120, 'breath_noise': 121, 'seashore': 122, 'bird_tweet': 123,
535
+ 'telephone_ring': 124, 'helicopter': 125, 'applause': 126, 'gunshot': 127
536
+ };
537
+
538
+ const MidiInstrumentNames = Object.entries(MidiInstrumentNumbers)
539
+ .reduce((o,[k,v])=>{
540
+ o[v]=k;
541
+ return o;
542
+ },{})
543
+
544
+ /**
545
+ * Utility class for common audio engine operations
546
+ */
547
+ export class AudioEngineUtils {
548
+ /**
549
+ * Map common instrument names to MIDI program numbers
550
+ * @param {string|number} instrument - Instrument name or program number
551
+ * @returns {number} MIDI program number
552
+ */
553
+ static getInstrumentProgram(instrument) {
554
+ if (typeof instrument === 'number') return instrument;
555
+ const program = MidiInstrumentNumbers[instrument.toLowerCase()];
556
+ return program !== undefined ? program : 0; // Default to Piano
557
+ }
558
+
559
+ /**
560
+ * Get instrument name from MIDI program number (for display purposes)
561
+ * @param {number} programNumber - MIDI program number (0-127)
562
+ * @returns {string} Instrument name or fallback
563
+ */
564
+ static getProgramName(programNumber) {
565
+
566
+ return MidiInstrumentNames[programNumber] || `Program ${programNumber}`;
567
+ }
568
+
569
+ /**
570
+ * Generate a unique note ID
571
+ * @param {number} channel - MIDI channel
572
+ * @param {number} pitch - MIDI pitch
573
+ * @param {number} startTime - Start time
574
+ * @returns {string} Unique note ID
575
+ */
576
+ static generateNoteId(channel, pitch, startTime) {
577
+ return `${channel}_${pitch}_${Math.round(startTime)}`;
578
+ }
579
579
  }