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.
@@ -182,6 +182,8 @@ export default class MidiPlayer {
182
182
  if (doRestart) {
183
183
  this.pause();
184
184
  }
185
+
186
+ const midiTime = (this.audioEngine.audioContext.currentTime - this.playbackStartTime) * this.playbackSpeed;
185
187
 
186
188
  this.playbackSpeed = speed;
187
189
 
@@ -189,7 +191,7 @@ export default class MidiPlayer {
189
191
  this.play();
190
192
  } else {
191
193
  // if not restarting, then reset the reference time
192
- this.playbackStartTime = this.audioEngine.audioContext.currentTime - (this._currentTime / this.playbackSpeed);
194
+ this.playbackStartTime = this.audioEngine.audioContext.currentTime - (midiTime / this.playbackSpeed);
193
195
  }
194
196
  }
195
197
 
@@ -1,4 +1,4 @@
1
- import AudioEngine, { ChannelHandle, AudioEngineUtils } from './audio-engine.js';
1
+ import AudioEngine from './audio-engine.js';
2
2
  import SpessaSynthChannelHandle from './spessasynth-channel-handle.js';
3
3
 
4
4
  /**
@@ -18,54 +18,48 @@ export default class SpessaSynthAudioEngine extends AudioEngine {
18
18
  }
19
19
 
20
20
  async initialize(soundfontData) {
21
- try {
22
- // Import SpessaSynth WorkletSynthesizer
23
- const { WorkletSynthesizer } = await import('spessasynth_lib');
24
-
25
- // Handle different soundfont data types
26
- let soundfontBuffer;
27
- if (typeof soundfontData === 'string') {
28
- soundfontBuffer = await this._loadSoundfontFromPath(soundfontData);
29
- } else if (soundfontData instanceof ArrayBuffer) {
30
- soundfontBuffer = soundfontData;
31
- } else {
32
- throw new Error('Invalid soundfont data type. Expected string path or ArrayBuffer.');
33
- }
34
-
35
- // Add the AudioWorklet module (required by SpessaSynth)
36
- // Use aggressive Firefox-specific loading strategy
37
- await this._loadAudioWorkletSafely();
38
-
39
- // Create individual output nodes for each MIDI channel (16 channels)
40
- this._setupIndividualOutputs();
41
-
42
- // Create a dummy target node for the synthesizer (won't be used for output)
43
- // SpessaSynth requires a valid AudioNode but we'll use individual outputs instead
44
- this.dummyTarget = this.audioContext.createGain();
45
- // Don't connect dummyTarget to anything - it's just to satisfy the constructor
46
-
47
- // Add delay before synthesizer creation to prevent Firefox timing issues
48
- await new Promise(resolve => setTimeout(resolve, 50));
49
-
50
- // Create synthesizer with v4 API
51
- this.synthesizer = new WorkletSynthesizer(this.audioContext);
52
-
53
- // Load soundfont using the v4 API
54
- await this.synthesizer.soundBankManager.addSoundBank(soundfontBuffer, 'main');
55
- await this.synthesizer.isReady;
56
-
57
- // Connect individual outputs for per-channel routing
58
- this._connectIndividualOutputs();
59
-
60
- // Initialize metronome channel with woodblock instrument
61
- this._initializeMetronomeChannel();
62
-
63
- this.isInitialized = true;
64
-
65
- } catch (error) {
66
- console.error('Failed to initialize SpessaSynthAudioEngine:', error);
67
- throw error;
21
+ // Import SpessaSynth WorkletSynthesizer
22
+ const { WorkletSynthesizer } = await import('spessasynth_lib');
23
+
24
+ // Handle different soundfont data types
25
+ let soundfontBuffer;
26
+ if (typeof soundfontData === 'string') {
27
+ soundfontBuffer = await this._loadSoundfontFromPath(soundfontData);
28
+ } else if (soundfontData instanceof ArrayBuffer) {
29
+ soundfontBuffer = soundfontData;
30
+ } else {
31
+ throw new Error('Invalid soundfont data type. Expected string path or ArrayBuffer.');
68
32
  }
33
+
34
+ // Add the AudioWorklet module (required by SpessaSynth)
35
+ // Use aggressive Firefox-specific loading strategy
36
+ await this._loadAudioWorkletSafely();
37
+
38
+ // Create individual output nodes for each MIDI channel (16 channels)
39
+ this._setupIndividualOutputs();
40
+
41
+ // Create a dummy target node for the synthesizer (won't be used for output)
42
+ // SpessaSynth requires a valid AudioNode but we'll use individual outputs instead
43
+ this.dummyTarget = this.audioContext.createGain();
44
+ // Don't connect dummyTarget to anything - it's just to satisfy the constructor
45
+
46
+ // Add delay before synthesizer creation to prevent Firefox timing issues
47
+ await new Promise(resolve => setTimeout(resolve, 50));
48
+
49
+ // Create synthesizer with v4 API
50
+ this.synthesizer = new WorkletSynthesizer(this.audioContext);
51
+
52
+ // Load soundfont using the v4 API
53
+ await this.synthesizer.soundBankManager.addSoundBank(soundfontBuffer, 'main');
54
+ await this.synthesizer.isReady;
55
+
56
+ // Connect individual outputs for per-channel routing
57
+ this._connectIndividualOutputs();
58
+
59
+ // Initialize metronome channel with woodblock instrument
60
+ this._initializeMetronomeChannel();
61
+
62
+ this.isInitialized = true;
69
63
  }
70
64
 
71
65
  createChannel(partId, options = {}) {
@@ -261,21 +255,11 @@ export default class SpessaSynthAudioEngine extends AudioEngine {
261
255
  * @private
262
256
  */
263
257
  async _loadSoundfontFromPath(path) {
264
- if (typeof window !== 'undefined') {
265
- // Browser environment
266
- const response = await fetch(path);
267
- if (!response.ok) {
268
- throw new Error(`Failed to load soundfont: ${response.status} ${response.statusText}`);
269
- }
270
- return await response.arrayBuffer();
271
- } else {
272
- // Node.js environment
273
- const fs = await import('fs');
274
- const pathModule = await import('path');
275
-
276
- const resolvedPath = pathModule.isAbsolute(path) ? path : pathModule.resolve(process.cwd(), path);
277
- return fs.readFileSync(resolvedPath).buffer;
258
+ const response = await fetch(path);
259
+ if (!response.ok) {
260
+ throw new Error(`Failed to load soundfont: ${response.status} ${response.statusText}`);
278
261
  }
262
+ return await response.arrayBuffer();
279
263
  }
280
264
 
281
265
  /**
@@ -371,32 +355,60 @@ export default class SpessaSynthAudioEngine extends AudioEngine {
371
355
  const delay = startTime - currentTime;
372
356
 
373
357
  // Note: instrument (woodblock) is already set during channel creation
374
- if (delay <= 0.010) { // 10ms tolerance for immediate playback (includes test scenarios)
375
- // Play immediately if time is now or very close
376
- if (synthesizer.noteOn) {
377
- synthesizer.noteOn(midiChannel, note, velocity);
378
- }
379
-
380
- // Schedule note off after brief duration
381
- setTimeout(() => {
382
- if (synthesizer.noteOff) {
383
- synthesizer.noteOff(midiChannel, note);
358
+ // Use SpessaSynth's precise scheduling with options.time (NO setTimeout!)
359
+ const tickDuration = 0.1; // 100ms tick duration
360
+
361
+ if (synthesizer.post) {
362
+ // Send note-on message with precise timing
363
+ synthesizer.post({
364
+ channelNumber: midiChannel,
365
+ type: "midiMessage",
366
+ data: {
367
+ messageData: [0x90 | midiChannel, note, velocity],
368
+ channelOffset: 0,
369
+ force: false,
370
+ options: {
371
+ time: startTime // Sample-accurate metronome timing!
372
+ }
384
373
  }
385
- }, 100); // 100ms tick duration
374
+ });
375
+
376
+ // Send note-off message with precise timing
377
+ synthesizer.post({
378
+ channelNumber: midiChannel,
379
+ type: "midiMessage",
380
+ data: {
381
+ messageData: [0x80 | midiChannel, note, 0],
382
+ channelOffset: 0,
383
+ force: false,
384
+ options: {
385
+ time: startTime + tickDuration // Precise tick duration!
386
+ }
387
+ }
388
+ });
386
389
  } else {
387
- // Schedule for future playback
388
- setTimeout(() => {
390
+ // Fallback to direct method calls if post() not available
391
+ if (delay <= 0.010) {
389
392
  if (synthesizer.noteOn) {
390
393
  synthesizer.noteOn(midiChannel, note, velocity);
391
394
  }
392
-
393
- // Schedule note off
394
395
  setTimeout(() => {
395
396
  if (synthesizer.noteOff) {
396
397
  synthesizer.noteOff(midiChannel, note);
397
398
  }
398
399
  }, 100);
399
- }, delay * 1000); // Convert seconds to milliseconds
400
+ } else {
401
+ setTimeout(() => {
402
+ if (synthesizer.noteOn) {
403
+ synthesizer.noteOn(midiChannel, note, velocity);
404
+ }
405
+ setTimeout(() => {
406
+ if (synthesizer.noteOff) {
407
+ synthesizer.noteOff(midiChannel, note);
408
+ }
409
+ }, 100);
410
+ }, delay * 1000);
411
+ }
400
412
  }
401
413
 
402
414
  } catch (error) {
@@ -111,6 +111,88 @@ export default class SpessaSynthChannelHandle extends ChannelHandle {
111
111
  return this.activeNotes.size;
112
112
  }
113
113
 
114
+ /**
115
+ * Override playNote to use SpessaSynth's precise sample-accurate scheduling
116
+ * @param {number} startTime - Absolute audio context time when note should start
117
+ * @param {number} pitch - MIDI pitch (0-127)
118
+ * @param {number} velocity - Note velocity (0-127)
119
+ * @param {number} duration - Note duration in seconds
120
+ * @returns {string} Event ID for compatibility
121
+ */
122
+ playNote(startTime, pitch, velocity, duration) {
123
+ this._validateActive();
124
+
125
+ const eventId = `${this.partId}_${startTime}_${pitch}_${Date.now()}`;
126
+
127
+ // Get the synthesizer for message posting
128
+ const synthesizer = this.engine._getSynthesizer();
129
+ if (synthesizer && synthesizer.post) {
130
+ // Apply channel volume
131
+ const adjustedVelocity = Math.round(velocity * this.currentVolume);
132
+
133
+ // Send note-on message with precise timing (NO setTimeout!)
134
+ synthesizer.post({
135
+ channelNumber: this.midiChannel,
136
+ type: "midiMessage",
137
+ data: {
138
+ messageData: [0x90 | this.midiChannel, pitch, adjustedVelocity],
139
+ channelOffset: 0,
140
+ force: false,
141
+ options: {
142
+ time: startTime // SpessaSynth will schedule this precisely!
143
+ }
144
+ }
145
+ });
146
+
147
+ // Send note-off message with precise timing (NO setTimeout!)
148
+ synthesizer.post({
149
+ channelNumber: this.midiChannel,
150
+ type: "midiMessage",
151
+ data: {
152
+ messageData: [0x80 | this.midiChannel, pitch, 0],
153
+ channelOffset: 0,
154
+ force: false,
155
+ options: {
156
+ time: startTime + duration // Precise note-off timing!
157
+ }
158
+ }
159
+ });
160
+
161
+ } else {
162
+ // Fallback to parent implementation if synthesizer.post not available
163
+ return super.playNote(startTime, pitch, velocity, duration);
164
+ }
165
+
166
+ return eventId;
167
+ }
168
+
169
+ /**
170
+ * Override allNotesOff to use SpessaSynth messaging system
171
+ */
172
+ allNotesOff() {
173
+ this._validateActive();
174
+
175
+ const synthesizer = this.engine._getSynthesizer();
176
+ if (synthesizer && synthesizer.post) {
177
+ // Send MIDI All Notes Off controller message (CC 123) immediately
178
+ synthesizer.post({
179
+ channelNumber: this.midiChannel,
180
+ type: "midiMessage",
181
+ data: {
182
+ messageData: [0xB0 | this.midiChannel, 123, 0], // Control Change: All Notes Off
183
+ channelOffset: 0,
184
+ force: false,
185
+ options: {
186
+ time: this.engine.audioContext.currentTime // Execute immediately
187
+ }
188
+ }
189
+ });
190
+ } else {
191
+ // Fallback to parent implementation
192
+ super.allNotesOff();
193
+ }
194
+ }
195
+
114
196
  destroy() {
115
197
  if (!this.isDestroyed) {
116
198
  // Note: Don't disconnect outputGain if it's an individual output from the synthesizer
@@ -122,7 +204,7 @@ export default class SpessaSynthChannelHandle extends ChannelHandle {
122
204
  }
123
205
  this.outputGain = null;
124
206
  }
125
-
207
+
126
208
  // Call parent destroy (handles allNotesOff and cleanup)
127
209
  super.destroy();
128
210
  }