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.
- package/dist/audio-mixer-engine.cjs.js +1 -1
- package/dist/audio-mixer-engine.es.js +209 -132
- package/package.json +1 -1
- package/src/lib/audio-engine.js +578 -578
- package/src/lib/midi-player.js +3 -1
- package/src/lib/spessasynth-audio-engine.js +90 -78
- package/src/lib/spessasynth-channel-handle.js +83 -1
package/src/lib/midi-player.js
CHANGED
|
@@ -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 - (
|
|
194
|
+
this.playbackStartTime = this.audioEngine.audioContext.currentTime - (midiTime / this.playbackSpeed);
|
|
193
195
|
}
|
|
194
196
|
}
|
|
195
197
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import AudioEngine
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
388
|
-
|
|
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
|
-
}
|
|
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
|
}
|