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/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "audio-mixer-engine",
3
+ "version": "0.1.0",
4
+ "description": "Audio engine library for audio mixer applications with MIDI parsing, playback, and synthesis",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "preview": "vite preview",
11
+ "test": "vitest",
12
+ "test:coverage": "NODE_OPTIONS=\"--max-old-space-size=4096\" vitest run --coverage",
13
+ "test:parser": "NODE_OPTIONS=\"--max-old-space-size=4096\" vitest run --coverage tests/unit/lib/midi-parser.test.js"
14
+ },
15
+ "keywords": [
16
+ "audio",
17
+ "midi",
18
+ "choir",
19
+ "music",
20
+ "synthesis",
21
+ "web-audio"
22
+ ],
23
+ "author": "Your Name",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@vue/test-utils": "^2.4.6",
27
+ "mitt": "^3.0.1",
28
+ "spessasynth_lib": "^3.27.8",
29
+ "uuid": "^11.1.0",
30
+ "vite-plugin-vue-devtools": "^8.0.1"
31
+ },
32
+ "devDependencies": {
33
+ "@vitejs/plugin-vue": "^5.2.3",
34
+ "@vitest/coverage-v8": "^3.2.4",
35
+ "jsdom": "^26.0.0",
36
+ "node-web-audio-api": "^1.0.4",
37
+ "vite": "^6.2.4",
38
+ "vitest": "^3.1.1"
39
+ },
40
+ "files": [
41
+ "dist/",
42
+ "src/",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/yourusername/audio-mixer-engine.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/yourusername/audio-mixer-engine/issues"
52
+ },
53
+ "homepage": "https://github.com/yourusername/audio-mixer-engine#readme"
54
+ }
Binary file
Binary file
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Audio Mixer Engine - Main exports
3
+ *
4
+ * This library provides audio synthesis and MIDI playback capabilities
5
+ * specifically designed for audio mixer applications.
6
+ */
7
+
8
+ // Core audio engine
9
+ export { default as AudioEngine, ChannelHandle, AudioEngineUtils } from './lib/audio-engine.js';
10
+
11
+ // SpessaSynth implementation
12
+ export { default as SpessaSynthAudioEngine } from './lib/spessasynth-audio-engine.js';
13
+ export { default as SpessaSynthChannelHandle } from './lib/spessasynth-channel-handle.js';
14
+
15
+ // MIDI processing
16
+ export { default as MidiParser } from './lib/midi-parser.js';
17
+ export { default as BeatMapper } from './lib/beat-mapper.js';
18
+ export { default as MidiPlayer } from './lib/midi-player.js';
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Abstract AudioEngine - Part-centric audio synthesis for audio mixers
3
+ * This class provides the contract for creating and managing musical parts
4
+ */
5
+ export default class AudioEngine {
6
+ /**
7
+ * Create a new AudioEngine instance
8
+ * @param {AudioContext} audioContext - Web Audio API context
9
+ * @param {Object} options - Engine-specific options
10
+ */
11
+ constructor(audioContext, options = {}) {
12
+ if (new.target === AudioEngine) {
13
+ throw new Error('AudioEngine is abstract and cannot be instantiated directly');
14
+ }
15
+
16
+ this.audioContext = audioContext;
17
+ this.options = options;
18
+ this.isInitialized = false;
19
+ this.channels = new WeakMap(); // ChannelHandle -> internal channel data
20
+ this.activeChannels = new Set(); // Track active channels for cleanup
21
+ }
22
+
23
+ /**
24
+ * Initialize the audio engine - load soundfont and set up synthesis
25
+ * @param {string|ArrayBuffer} soundfontData - Path to soundfont or binary data
26
+ * @returns {Promise<void>}
27
+ */
28
+ async initialize(soundfontData) {
29
+ throw new Error('initialize() must be implemented by subclass');
30
+ }
31
+
32
+ /**
33
+ * Create a new channel for a musical part
34
+ * @param {string} partId - Unique identifier for this part (e.g., 'soprano', 'piano')
35
+ * @param {Object} options - Channel configuration
36
+ * @param {string|number} [options.instrument] - Initial instrument
37
+ * @param {number} [options.initialVolume=1.0] - Initial volume (0.0-1.0)
38
+ * @returns {ChannelHandle} Handle object for controlling this channel
39
+ */
40
+ createChannel(partId, options = {}) {
41
+ throw new Error('createChannel() must be implemented by subclass');
42
+ }
43
+
44
+
45
+ /**
46
+ * Stop all notes on all channels
47
+ */
48
+ allSoundsOff() {
49
+ throw new Error('allSoundsOff() must be implemented by subclass');
50
+ }
51
+
52
+ /**
53
+ * Get list of all active channel handles
54
+ * @returns {Array<ChannelHandle>} Array of active channel handles
55
+ */
56
+ getActiveChannels() {
57
+ return Array.from(this.activeChannels);
58
+ }
59
+
60
+ /**
61
+ * Clean up all resources and disconnect audio nodes
62
+ */
63
+ destroy() {
64
+ // Stop all sounds
65
+ this.allSoundsOff();
66
+
67
+ // Clear tracking collections
68
+ this.activeChannels.clear();
69
+
70
+ this.isInitialized = false;
71
+ }
72
+
73
+ /**
74
+ * Validate that the engine is initialized
75
+ * @protected
76
+ */
77
+ _validateInitialized() {
78
+ if (!this.isInitialized) {
79
+ throw new Error('AudioEngine not initialized. Call initialize() first.');
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Register a new channel handle (called by subclasses)
85
+ * @param {ChannelHandle} handle - Channel handle to register
86
+ * @protected
87
+ */
88
+ _registerChannel(handle) {
89
+ this.activeChannels.add(handle);
90
+ }
91
+
92
+ /**
93
+ * Unregister a channel handle (called when channel is destroyed)
94
+ * @param {ChannelHandle} handle - Channel handle to unregister
95
+ * @protected
96
+ */
97
+ _unregisterChannel(handle) {
98
+ this.activeChannels.delete(handle);
99
+ this.channels.delete(handle);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * ChannelHandle - Interface for controlling one musical part
105
+ * Each instance represents one musical part (soprano, piano, etc.)
106
+ */
107
+ export class ChannelHandle {
108
+ constructor(engine, partId, options = {}) {
109
+ if (new.target === ChannelHandle) {
110
+ throw new Error('ChannelHandle is abstract and cannot be instantiated directly');
111
+ }
112
+
113
+ this.engine = engine;
114
+ this.partId = partId;
115
+ this.options = { initialVolume: 1.0, ...options };
116
+ this.isDestroyed = false;
117
+
118
+ // Reference counting for overlapping notes
119
+ this.noteRefCounts = new Map(); // pitch -> count
120
+ this.scheduledEvents = new Map(); // eventId -> timeoutId
121
+ this.activeNotes = new Set(); // Set of currently playing pitches
122
+ }
123
+
124
+ /**
125
+ * Get the output audio node for this channel
126
+ * This node can be connected to gain controls, analyzers, etc.
127
+ * @returns {AudioNode} Output node (typically a GainNode)
128
+ */
129
+ getOutputNode() {
130
+ throw new Error('getOutputNode() must be implemented by subclass');
131
+ }
132
+
133
+ /**
134
+ * Start a note with reference counting (handles overlaps automatically)
135
+ * @param {number} pitch - MIDI pitch (0-127)
136
+ * @param {number} velocity - Note velocity (0-127)
137
+ */
138
+ noteOn(pitch, velocity) {
139
+ this._validateActive();
140
+
141
+ const currentCount = this.noteRefCounts.get(pitch) || 0;
142
+
143
+ // Always increment reference count and start the note
144
+ this.noteRefCounts.set(pitch, currentCount + 1);
145
+ this._actualNoteOn(pitch, velocity);
146
+
147
+ // Add to active notes if not already playing
148
+ if (currentCount === 0) {
149
+ this.activeNotes.add(pitch);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Stop a note with reference counting (only stops when count reaches 0)
155
+ * @param {number} pitch - MIDI pitch (0-127)
156
+ */
157
+ noteOff(pitch) {
158
+ this._validateActive();
159
+
160
+ const currentCount = this.noteRefCounts.get(pitch) || 0;
161
+
162
+ if (currentCount <= 0) {
163
+ return; // No notes to stop
164
+ }
165
+
166
+ const newCount = currentCount - 1;
167
+ this.noteRefCounts.set(pitch, newCount);
168
+
169
+ // Only actually stop the note when reference count reaches 0
170
+ if (newCount === 0) {
171
+ this._actualNoteOff(pitch);
172
+ this.activeNotes.delete(pitch);
173
+ this.noteRefCounts.delete(pitch);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Schedule a note with automatic timing adjustment
179
+ * @param {number} startTime - Absolute audio context time when note should start
180
+ * @param {number} pitch - MIDI pitch (0-127)
181
+ * @param {number} velocity - Note velocity (0-127)
182
+ * @param {number} duration - Note duration in seconds
183
+ * @returns {string} Event ID for cancellation
184
+ */
185
+ playNote(startTime, pitch, velocity, duration) {
186
+ this._validateActive();
187
+
188
+ const currentTime = this.engine.audioContext.currentTime;
189
+ const eventId = `${this.partId}_${startTime}_${pitch}_${Date.now()}`;
190
+
191
+ // Adjust timing if start time is in the past
192
+ let adjustedStartTime = startTime;
193
+ let adjustedDuration = duration;
194
+
195
+ if (startTime < currentTime) {
196
+ const timePassed = currentTime - startTime;
197
+ adjustedStartTime = currentTime;
198
+ adjustedDuration = Math.max(0, duration - timePassed);
199
+ }
200
+
201
+ // If duration is too short after adjustment, skip this note
202
+ if (adjustedDuration <= 0) {
203
+ return eventId;
204
+ }
205
+
206
+ // Schedule note on
207
+ const noteOnDelay = Math.max(0, (adjustedStartTime - currentTime) * 1000);
208
+ const noteOnTimeoutId = setTimeout(() => {
209
+ this.noteOn(pitch, velocity);
210
+ this.scheduledEvents.delete(`${eventId}_on`);
211
+ }, noteOnDelay);
212
+
213
+ // Schedule note off
214
+ const noteOffDelay = noteOnDelay + (adjustedDuration * 1000);
215
+ const noteOffTimeoutId = setTimeout(() => {
216
+ this.noteOff(pitch);
217
+ this.scheduledEvents.delete(`${eventId}_off`);
218
+ }, noteOffDelay);
219
+
220
+ // Track both events for cancellation
221
+ this.scheduledEvents.set(`${eventId}_on`, noteOnTimeoutId);
222
+ this.scheduledEvents.set(`${eventId}_off`, noteOffTimeoutId);
223
+
224
+ return eventId;
225
+ }
226
+
227
+ /**
228
+ * Stop all notes on this channel
229
+ */
230
+ allNotesOff() {
231
+ this._validateActive();
232
+
233
+ // Cancel all scheduled events
234
+ this.scheduledEvents.forEach(timeoutId => {
235
+ clearTimeout(timeoutId);
236
+ });
237
+ this.scheduledEvents.clear();
238
+
239
+ // Stop all actively playing notes
240
+ this.activeNotes.forEach(pitch => {
241
+ this._actualNoteOff(pitch);
242
+ });
243
+
244
+ // Reset all state
245
+ this.noteRefCounts.clear();
246
+ this.activeNotes.clear();
247
+ }
248
+
249
+ /**
250
+ * Actually start a note (implemented by subclass)
251
+ * @param {number} pitch - MIDI pitch (0-127)
252
+ * @param {number} velocity - Note velocity (0-127)
253
+ * @protected
254
+ */
255
+ _actualNoteOn(pitch, velocity) {
256
+ throw new Error('_actualNoteOn() must be implemented by subclass');
257
+ }
258
+
259
+ /**
260
+ * Actually stop a note (implemented by subclass)
261
+ * @param {number} pitch - MIDI pitch (0-127)
262
+ * @protected
263
+ */
264
+ _actualNoteOff(pitch) {
265
+ throw new Error('_actualNoteOff() must be implemented by subclass');
266
+ }
267
+
268
+ /**
269
+ * Change the instrument for this channel
270
+ * @param {string|number} instrument - Instrument name or program number
271
+ * @returns {Promise<void>}
272
+ */
273
+ async setInstrument(instrument) {
274
+ throw new Error('setInstrument() must be implemented by subclass');
275
+ }
276
+
277
+ /**
278
+ * Get current instrument for this channel
279
+ * @returns {string|number} Current instrument
280
+ */
281
+ getInstrument() {
282
+ throw new Error('getInstrument() must be implemented by subclass');
283
+ }
284
+
285
+ /**
286
+ * Set volume for this channel (affects the internal channel volume)
287
+ * Note: External volume control should use the output node from getOutputNode()
288
+ * @param {number} volume - Volume level (0.0-1.0)
289
+ */
290
+ setVolume(volume) {
291
+ throw new Error('setVolume() must be implemented by subclass');
292
+ }
293
+
294
+ /**
295
+ * Get current volume for this channel
296
+ * @returns {number} Current volume (0.0-1.0)
297
+ */
298
+ getVolume() {
299
+ throw new Error('getVolume() must be implemented by subclass');
300
+ }
301
+
302
+ /**
303
+ * Get the part ID for this channel
304
+ * @returns {string} Part identifier
305
+ */
306
+ getPartId() {
307
+ return this.partId;
308
+ }
309
+
310
+ /**
311
+ * Check if this channel is still active
312
+ * @returns {boolean} True if channel is active
313
+ */
314
+ isActive() {
315
+ return !this.isDestroyed && this.engine.isInitialized && this.engine.activeChannels.has(this);
316
+ }
317
+
318
+ /**
319
+ * Destroy this channel and clean up resources
320
+ */
321
+ destroy() {
322
+ if (!this.isDestroyed) {
323
+ this.allNotesOff();
324
+
325
+ const outputNode = this.getOutputNode();
326
+ if (outputNode) {
327
+ outputNode.disconnect();
328
+ }
329
+
330
+ // Clear reference counting state
331
+ this.noteRefCounts.clear();
332
+ this.scheduledEvents.clear();
333
+ this.activeNotes.clear();
334
+
335
+ this.engine._unregisterChannel(this);
336
+ this.isDestroyed = true;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Validate that this channel is still active
342
+ * @protected
343
+ */
344
+ _validateActive() {
345
+ if (this.isDestroyed) {
346
+ throw new Error('Channel has been destroyed');
347
+ }
348
+ if (!this.engine.isInitialized) {
349
+ throw new Error('AudioEngine is not initialized');
350
+ }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Utility class for common audio engine operations
356
+ */
357
+ export class AudioEngineUtils {
358
+ /**
359
+ * Map common instrument names to MIDI program numbers
360
+ * @param {string|number} instrument - Instrument name or program number
361
+ * @returns {number} MIDI program number
362
+ */
363
+ static getInstrumentProgram(instrument) {
364
+ if (typeof instrument === 'number') return instrument;
365
+
366
+ const instrumentMap = {
367
+ // Piano family (0-7)
368
+ 'piano': 0, 'bright_piano': 1, 'electric_grand': 2, 'honky_tonk': 3,
369
+ 'electric_piano_1': 4, 'electric_piano_2': 5, 'harpsichord': 6, 'clavinet': 7,
370
+
371
+ // Chromatic percussion (8-15)
372
+ 'celesta': 8, 'glockenspiel': 9, 'music_box': 10, 'vibraphone': 11,
373
+ 'marimba': 12, 'xylophone': 13, 'tubular_bells': 14, 'dulcimer': 15,
374
+
375
+ // Organ (16-23)
376
+ 'drawbar_organ': 16, 'percussive_organ': 17, 'rock_organ': 18, 'church_organ': 19,
377
+ 'reed_organ': 20, 'accordion': 21, 'harmonica': 22, 'tango_accordion': 23,
378
+ 'organ': 19,
379
+
380
+ // Guitar (24-31)
381
+ 'nylon_guitar': 24, 'steel_guitar': 25, 'electric_guitar_jazz': 26, 'electric_guitar_clean': 27,
382
+ 'electric_guitar_muted': 28, 'overdriven_guitar': 29, 'distortion_guitar': 30, 'guitar_harmonics': 31,
383
+ 'guitar': 24,
384
+
385
+ // Bass (32-39)
386
+ 'acoustic_bass': 32, 'electric_bass_finger': 33, 'electric_bass_pick': 34, 'fretless_bass': 35,
387
+ 'slap_bass_1': 36, 'slap_bass_2': 37, 'synth_bass_1': 38, 'synth_bass_2': 39,
388
+ 'bass': 32,
389
+
390
+ // Strings (40-47)
391
+ 'violin': 40, 'viola': 41, 'cello': 42, 'contrabass': 43,
392
+ 'tremolo_strings': 44, 'pizzicato_strings': 45, 'orchestral_harp': 46, 'timpani': 47,
393
+ 'strings': 48, 'strings_ensemble': 48,
394
+
395
+ // Ensemble (48-55)
396
+ 'slow_strings': 49, 'synth_strings_1': 50, 'synth_strings_2': 51,
397
+ 'choir_aahs': 52, 'voice_oohs': 53, 'synth_voice': 54, 'orchestra_hit': 55,
398
+
399
+ // Brass (56-63)
400
+ 'trumpet': 56, 'trombone': 57, 'tuba': 58, 'muted_trumpet': 59,
401
+ 'french_horn': 60, 'brass_section': 61, 'synth_brass_1': 62, 'synth_brass_2': 63,
402
+
403
+ // Reed (64-71)
404
+ 'soprano_sax': 64, 'alto_sax': 65, 'tenor_sax': 66, 'baritone_sax': 67,
405
+ 'oboe': 68, 'english_horn': 69, 'bassoon': 70, 'clarinet': 71,
406
+ 'saxophone': 64,
407
+
408
+ // Pipe (72-79)
409
+ 'piccolo': 72, 'flute': 73, 'recorder': 74, 'pan_flute': 75,
410
+ 'blown_bottle': 76, 'shakuhachi': 77, 'whistle': 78, 'ocarina': 79,
411
+
412
+ // Synth lead (80-87)
413
+ 'lead_1_square': 80, 'lead_2_sawtooth': 81, 'lead_3_calliope': 82, 'lead_4_chiff': 83,
414
+ 'lead_5_charang': 84, 'lead_6_voice': 85, 'lead_7_fifths': 86, 'lead_8_bass': 87,
415
+
416
+ // Synth pad (88-95)
417
+ 'pad_1_new_age': 88, 'pad_2_warm': 89, 'pad_3_polysynth': 90, 'pad_4_choir': 91,
418
+ 'pad_5_bowed': 92, 'pad_6_metallic': 93, 'pad_7_halo': 94, 'pad_8_sweep': 95,
419
+
420
+ // Synth effects (96-103)
421
+ 'fx_1_rain': 96, 'fx_2_soundtrack': 97, 'fx_3_crystal': 98, 'fx_4_atmosphere': 99,
422
+ 'fx_5_brightness': 100, 'fx_6_goblins': 101, 'fx_7_echoes': 102, 'fx_8_sci_fi': 103,
423
+
424
+ // Ethnic (104-111)
425
+ 'sitar': 104, 'banjo': 105, 'shamisen': 106, 'koto': 107,
426
+ 'kalimba': 108, 'bag_pipe': 109, 'fiddle': 110, 'shanai': 111,
427
+
428
+ // Percussive (112-119)
429
+ 'tinkle_bell': 112, 'agogo': 113, 'steel_drums': 114, 'woodblock': 115,
430
+ 'taiko_drum': 116, 'melodic_tom': 117, 'synth_drum': 118, 'reverse_cymbal': 119,
431
+
432
+ // Sound effects (120-127)
433
+ 'guitar_fret_noise': 120, 'breath_noise': 121, 'seashore': 122, 'bird_tweet': 123,
434
+ 'telephone_ring': 124, 'helicopter': 125, 'applause': 126, 'gunshot': 127
435
+ };
436
+
437
+ const program = instrumentMap[instrument.toLowerCase()];
438
+ return program !== undefined ? program : 0; // Default to Piano
439
+ }
440
+
441
+ /**
442
+ * Get instrument name from MIDI program number (for display purposes)
443
+ * @param {number} programNumber - MIDI program number (0-127)
444
+ * @returns {string} Instrument name or fallback
445
+ */
446
+ static getProgramName(programNumber) {
447
+ const programNames = [
448
+ // Piano family (0-7)
449
+ 'Piano', 'Bright Piano', 'Electric Grand', 'Honky-tonk Piano',
450
+ 'Electric Piano 1', 'Electric Piano 2', 'Harpsichord', 'Clavinet',
451
+
452
+ // Chromatic percussion (8-15)
453
+ 'Celesta', 'Glockenspiel', 'Music Box', 'Vibraphone',
454
+ 'Marimba', 'Xylophone', 'Tubular Bells', 'Dulcimer',
455
+
456
+ // Organ (16-23)
457
+ 'Drawbar Organ', 'Percussive Organ', 'Rock Organ', 'Church Organ',
458
+ 'Reed Organ', 'Accordion', 'Harmonica', 'Tango Accordion',
459
+
460
+ // Guitar (24-31)
461
+ 'Nylon Guitar', 'Steel Guitar', 'Electric Guitar (jazz)', 'Electric Guitar (clean)',
462
+ 'Electric Guitar (muted)', 'Overdriven Guitar', 'Distortion Guitar', 'Guitar Harmonics',
463
+
464
+ // Bass (32-39)
465
+ 'Acoustic Bass', 'Electric Bass (finger)', 'Electric Bass (pick)', 'Fretless Bass',
466
+ 'Slap Bass 1', 'Slap Bass 2', 'Synth Bass 1', 'Synth Bass 2',
467
+
468
+ // Strings (40-47)
469
+ 'Violin', 'Viola', 'Cello', 'Contrabass',
470
+ 'Tremolo Strings', 'Pizzicato Strings', 'Orchestral Harp', 'Timpani',
471
+
472
+ // Ensemble (48-55)
473
+ 'String Ensemble 1', 'String Ensemble 2', 'Synth Strings 1', 'Synth Strings 2',
474
+ 'Choir Aahs', 'Voice Oohs', 'Synth Voice', 'Orchestra Hit',
475
+
476
+ // Brass (56-63)
477
+ 'Trumpet', 'Trombone', 'Tuba', 'Muted Trumpet',
478
+ 'French Horn', 'Brass Section', 'Synth Brass 1', 'Synth Brass 2',
479
+
480
+ // Reed (64-71)
481
+ 'Soprano Sax', 'Alto Sax', 'Tenor Sax', 'Baritone Sax',
482
+ 'Oboe', 'English Horn', 'Bassoon', 'Clarinet',
483
+
484
+ // Pipe (72-79)
485
+ 'Piccolo', 'Flute', 'Recorder', 'Pan Flute',
486
+ 'Blown Bottle', 'Shakuhachi', 'Whistle', 'Ocarina',
487
+
488
+ // Synth lead (80-87)
489
+ 'Lead 1 (square)', 'Lead 2 (sawtooth)', 'Lead 3 (calliope)', 'Lead 4 (chiff)',
490
+ 'Lead 5 (charang)', 'Lead 6 (voice)', 'Lead 7 (fifths)', 'Lead 8 (bass + lead)',
491
+
492
+ // Synth pad (88-95)
493
+ 'Pad 1 (new age)', 'Pad 2 (warm)', 'Pad 3 (polysynth)', 'Pad 4 (choir)',
494
+ 'Pad 5 (bowed)', 'Pad 6 (metallic)', 'Pad 7 (halo)', 'Pad 8 (sweep)',
495
+
496
+ // Synth effects (96-103)
497
+ 'FX 1 (rain)', 'FX 2 (soundtrack)', 'FX 3 (crystal)', 'FX 4 (atmosphere)',
498
+ 'FX 5 (brightness)', 'FX 6 (goblins)', 'FX 7 (echoes)', 'FX 8 (sci-fi)',
499
+
500
+ // Ethnic (104-111)
501
+ 'Sitar', 'Banjo', 'Shamisen', 'Koto',
502
+ 'Kalimba', 'Bag pipe', 'Fiddle', 'Shanai',
503
+
504
+ // Percussive (112-119)
505
+ 'Tinkle Bell', 'Agogo', 'Steel Drums', 'Woodblock',
506
+ 'Taiko Drum', 'Melodic Tom', 'Synth Drum', 'Reverse Cymbal',
507
+
508
+ // Sound effects (120-127)
509
+ 'Guitar Fret Noise', 'Breath Noise', 'Seashore', 'Bird Tweet',
510
+ 'Telephone Ring', 'Helicopter', 'Applause', 'Gunshot'
511
+ ];
512
+
513
+ return programNames[programNumber] || `Program ${programNumber}`;
514
+ }
515
+
516
+ /**
517
+ * Generate a unique note ID
518
+ * @param {number} channel - MIDI channel
519
+ * @param {number} pitch - MIDI pitch
520
+ * @param {number} startTime - Start time
521
+ * @returns {string} Unique note ID
522
+ */
523
+ static generateNoteId(channel, pitch, startTime) {
524
+ return `${channel}_${pitch}_${Math.round(startTime)}`;
525
+ }
526
+ }