UNPKG

spessasynth_lib

Version:

MIDI and SoundFont2/DLS library for the browsers with no compromises

1,636 lines (1,623 loc) 73.7 kB
import { ALL_CHANNELS_OR_DIFFERENT_ACTION, BasicMIDI, BasicSoundBank, DEFAULT_MASTER_PARAMETERS, DEFAULT_PERCUSSION, KeyModifier, MIDITrack, SoundBankLoader, SpessaSynthCoreUtils, SpessaSynthLogging, SpessaSynthProcessor, SpessaSynthSequencer, SynthesizerSnapshot, SynthesizerSnapshot as LibSynthesizerSnapshot, audioToWav, midiControllers, midiMessageTypes } from "spessasynth_core"; //#region src/synthesizer/basic/synth_config.ts const DEFAULT_SYNTH_CONFIG = { enableEventSystem: true, oneOutput: false, audioNodeCreators: void 0 }; //#endregion //#region src/synthesizer/worklet/worklet_processor_name.ts const WORKLET_PROCESSOR_NAME = "spessasynth-worklet-processor"; //#endregion //#region src/utils/fill_with_defaults.ts /** * Fills the object with default values. * @param obj object to fill. * @param defObj object to fill with. */ function fillWithDefaults(obj, defObj) { return { ...defObj, ...obj }; } //#endregion //#region src/synthesizer/basic/key_modifier_manager.ts var WorkletKeyModifierManagerWrapper = class { keyModifiers = []; synth; constructor(synth) { this.synth = synth; } /** * Modifies a single key. * @param channel The channel affected. Usually 0-15. * @param midiNote The MIDI note to change. 0-127. * @param options The key's modifiers. */ addModifier(channel, midiNote, options) { const mod = new KeyModifier(); mod.gain = options?.gain ?? 1; mod.velocity = options?.velocity ?? -1; mod.patch = fillWithDefaults(options.patch ?? {}, { isGMGSDrum: false, bankLSB: -1, bankMSB: -1, program: -1 }); this.keyModifiers[channel] ??= []; this.keyModifiers[channel][midiNote] = mod; this.sendToWorklet("addMapping", { channel, midiNote, mapping: mod }); } /** * Gets a key modifier. * @param channel The channel affected. Usually 0-15. * @param midiNote The MIDI note to change. 0-127. * @returns The key modifier if it exists. */ getModifier(channel, midiNote) { return this.keyModifiers?.[channel]?.[midiNote]; } /** * Deletes a key modifier. * @param channel The channel affected. Usually 0-15. * @param midiNote The MIDI note to change. 0-127. */ deleteModifier(channel, midiNote) { this.sendToWorklet("deleteMapping", { channel, midiNote }); if (this.keyModifiers[channel]?.[midiNote] === void 0) return; this.keyModifiers[channel][midiNote] = void 0; } /** * Clears ALL Modifiers */ clearModifiers() { this.sendToWorklet("clearMappings", null); this.keyModifiers = []; } sendToWorklet(type, data) { const msg = { type, data }; this.synth.post({ type: "keyModifierManager", channelNumber: -1, data: msg }); } }; //#endregion //#region src/synthesizer/basic/sound_bank_manager.ts var SoundBankManager = class { /** * All the sound banks, ordered from the most important to the least. */ soundBankList; synth; /** * Creates a new instance of the sound bank manager. */ constructor(synth) { this.soundBankList = []; this.synth = synth; } /** * The current sound bank priority order. * @returns The IDs of the sound banks in the current order. */ get priorityOrder() { return this.soundBankList.map((s) => s.id); } /** * Rearranges the sound banks in a given order. * @param newList The order of sound banks, a list of identifiers, first overwrites second. */ set priorityOrder(newList) { this.sendToWorklet("rearrangeSoundBanks", newList); this.soundBankList.sort((a, b) => newList.indexOf(a.id) - newList.indexOf(b.id)); } /** * Adds a new sound bank buffer with a given ID. * @param soundBankBuffer The sound bank's buffer * @param id The sound bank's unique identifier. * @param bankOffset The sound bank's bank offset. Default is 0. */ async addSoundBank(soundBankBuffer, id, bankOffset = 0) { this.sendToWorklet("addSoundBank", { soundBankBuffer, bankOffset, id }, [soundBankBuffer]); await this.awaitResponse(); const found = this.soundBankList.find((s) => s.id === id); if (found === void 0) this.soundBankList.push({ id, bankOffset }); else found.bankOffset = bankOffset; } /** * Deletes a sound bank with the given ID. * @param id The sound bank to delete. */ async deleteSoundBank(id) { if (this.soundBankList.length < 2) { SpessaSynthCoreUtils.SpessaSynthWarn("1 sound bank left. Aborting!"); return; } if (!this.soundBankList.some((s) => s.id === id)) { SpessaSynthCoreUtils.SpessaSynthWarn(`No sound banks with id of "${id}" found. Aborting!`); return; } this.sendToWorklet("deleteSoundBank", id); this.soundBankList = this.soundBankList.filter((s) => s.id !== id); await this.awaitResponse(); } async awaitResponse() { return new Promise((r) => this.synth.awaitWorkerResponse("soundBankManager", r)); } sendToWorklet(type, data, transferable = []) { const msg = { type: "soundBankManager", channelNumber: -1, data: { type, data } }; this.synth.post(msg, transferable); } }; //#endregion //#region src/synthesizer/basic/synth_event_handler.ts var SynthEventHandler = class { /** * The time delay before an event is called. * Set to 0 to disable it. */ timeDelay = 0; /** * The main list of events. * @private */ events = { noteOff: /* @__PURE__ */ new Map(), noteOn: /* @__PURE__ */ new Map(), pitchWheel: /* @__PURE__ */ new Map(), controllerChange: /* @__PURE__ */ new Map(), programChange: /* @__PURE__ */ new Map(), channelPressure: /* @__PURE__ */ new Map(), polyPressure: /* @__PURE__ */ new Map(), drumChange: /* @__PURE__ */ new Map(), stopAll: /* @__PURE__ */ new Map(), newChannel: /* @__PURE__ */ new Map(), muteChannel: /* @__PURE__ */ new Map(), presetListChange: /* @__PURE__ */ new Map(), allControllerReset: /* @__PURE__ */ new Map(), soundBankError: /* @__PURE__ */ new Map(), synthDisplay: /* @__PURE__ */ new Map(), masterParameterChange: /* @__PURE__ */ new Map(), channelPropertyChange: /* @__PURE__ */ new Map(), effectChange: /* @__PURE__ */ new Map() }; /** * Adds a new event listener. * @param event The event to listen to. * @param id The unique identifier for the event. It can be used to overwrite existing callback with the same ID. * @param callback The callback for the event. */ addEvent(event, id, callback) { this.events[event].set(id, callback); } /** * Removes an event listener * @param name The event to remove a listener from. * @param id The unique identifier for the event to remove. */ removeEvent(name, id) { this.events[name].delete(id); } /** * Calls the given event. * INTERNAL USE ONLY! * @internal */ callEventInternal(name, eventData) { const eventList = this.events[name]; const callback = () => { for (const callback of eventList.values()) try { callback(eventData); } catch (error) { console.error(`Error while executing an event callback for ${name}:`, error); } }; if (this.timeDelay > 0) setTimeout(callback.bind(this), this.timeDelay * 1e3); else callback(); } }; //#endregion //#region src/utils/other.ts /** * Other.js * purpose: contains some useful functions that don't belong in any specific category */ const consoleColors = SpessaSynthCoreUtils.consoleColors; //#endregion //#region src/synthesizer/basic/basic_synthesizer.ts const DEFAULT_SYNTH_METHOD_OPTIONS = { time: 0 }; var BasicSynthesizer = class { /** * Allows managing the sound bank list. */ soundBankManager = new SoundBankManager(this); /** * Allows managing key modifications. */ keyModifierManager = new WorkletKeyModifierManagerWrapper(this); /** * Allows setting up custom event listeners for the synthesizer. */ eventHandler = new SynthEventHandler(); /** * Synthesizer's parent AudioContext instance. */ context; /** * Synth's current channel properties. */ channelProperties = []; /** * The current preset list. */ presetList = []; /** * INTERNAL USE ONLY! * @internal * All sequencer callbacks */ sequencers = new Array(); /** * Resolves when the synthesizer is ready. */ isReady; /** * Legacy parameter. * @deprecated */ reverbProcessor = void 0; /** * Legacy parameter. * @deprecated */ chorusProcessor = void 0; /** * INTERNAL USE ONLY! * @internal */ post; worklet; /** * The new channels will have their audio sent to the modulated output by this constant. * what does that mean? * e.g., if outputsAmount is 16, then channel's 16 audio data will be sent to channel 0 */ _outputsAmount = 16; /** * The current amount of MIDI channels the synthesizer has. */ channelsAmount = this._outputsAmount; masterParameters = { ...DEFAULT_MASTER_PARAMETERS }; resolveMap = /* @__PURE__ */ new Map(); renderingProgressTracker = /* @__PURE__ */ new Map(); /** * Creates a new instance of a synthesizer. * @param worklet The AudioWorkletNode to use. * @param postFunction The internal post function. * @param config Optional configuration for the synthesizer. */ constructor(worklet, postFunction, config) { SpessaSynthCoreUtils.SpessaSynthInfo("%cInitializing SpessaSynth synthesizer...", consoleColors.info); this.context = worklet.context; this.worklet = worklet; this.post = postFunction; this.isReady = new Promise((resolve) => this.awaitWorkerResponse("sf3Decoder", resolve)); this.worklet.port.onmessage = (e) => this.handleMessage(e.data); for (let i = 0; i < this.channelsAmount; i++) this.addNewChannelInternal(false); this.channelProperties[DEFAULT_PERCUSSION].isDrum = true; this.eventHandler.addEvent("newChannel", `synth-new-channel-${Math.random()}`, () => { this.channelsAmount++; }); this.eventHandler.addEvent("presetListChange", `synth-preset-list-change-${Math.random()}`, (e) => { this.presetList = [...e]; }); this.eventHandler.addEvent("masterParameterChange", `synth-master-parameter-change-${Math.random()}`, (e) => { this.masterParameters[e.parameter] = e.value; }); this.eventHandler.addEvent("channelPropertyChange", `synth-channel-property-change-${Math.random()}`, (e) => { this.channelProperties[e.channel] = e.property; this._voicesAmount = this.channelProperties.reduce((sum, voices) => sum + voices.voicesAmount, 0); }); } /** * Current voice amount */ _voicesAmount = 0; /** * The current number of voices playing. */ get voicesAmount() { return this._voicesAmount; } /** * The audioContext's current time. */ get currentTime() { return this.context.currentTime; } /** * Connects from a given node. * @param destinationNode The node to connect to. */ connect(destinationNode) { for (let i = 0; i < 17; i++) this.worklet.connect(destinationNode, i); return destinationNode; } /** * Disconnects from a given node. * @param destinationNode The node to disconnect from. */ disconnect(destinationNode) { if (!destinationNode) { this.worklet.disconnect(); return; } for (let i = 0; i < 17; i++) this.worklet.disconnect(destinationNode, i); return destinationNode; } /** * Sets the SpessaSynth's log level in the processor. * @param enableInfo Enable info (verbose) * @param enableWarning Enable warnings (unrecognized messages) * @param enableGroup Enable groups (to group a lot of logs) */ setLogLevel(enableInfo, enableWarning, enableGroup) { this.post({ channelNumber: ALL_CHANNELS_OR_DIFFERENT_ACTION, type: "setLogLevel", data: { enableInfo, enableWarning, enableGroup } }); } /** * Gets a master parameter from the synthesizer. * @param type The parameter to get. * @returns The parameter value. */ getMasterParameter(type) { return this.masterParameters[type]; } /** * Sets a master parameter to a given value. * @param type The parameter to set. * @param value The value to set. */ setMasterParameter(type, value) { this.masterParameters[type] = value; this.post({ type: "setMasterParameter", channelNumber: ALL_CHANNELS_OR_DIFFERENT_ACTION, data: { type, data: value } }); } /** * Gets a complete snapshot of the synthesizer, effects. */ async getSnapshot() { return new Promise((resolve) => { this.awaitWorkerResponse("synthesizerSnapshot", (s) => { resolve(LibSynthesizerSnapshot.copyFrom(s)); }); this.post({ type: "requestSynthesizerSnapshot", data: null, channelNumber: -1 }); }); } /** * Adds a new channel to the synthesizer. */ addNewChannel() { this.addNewChannelInternal(true); } /** * DEPRECATED, please don't use it! * @deprecated */ setVibrato(channel, value) {} /** * Connects a given channel output to the given audio node. * Note that this output is only meant for visualization and may be silent when Insertion Effect for this channel is enabled. * @param targetNode The node to connect to. * @param channelNumber The channel number to connect to, will be rolled over if value is greater than 15. * @returns The target node. */ connectChannel(targetNode, channelNumber) { this.worklet.connect(targetNode, channelNumber % 16 + 1); return targetNode; } /** * Disconnects a given channel output to the given audio node. * @param targetNode The node to disconnect from. * @param channelNumber The channel number to connect to, will be rolled over if value is greater than 15. */ disconnectChannel(targetNode, channelNumber) { this.worklet.disconnect(targetNode, channelNumber % 16 + 1); } /** * Connects the individual audio outputs to the given audio nodes. * Note that these outputs is only meant for visualization and may be silent when Insertion Effect for this channel is enabled. * @param audioNodes Exactly 16 outputs. */ connectIndividualOutputs(audioNodes) { if (audioNodes.length !== this._outputsAmount) throw new Error(`input nodes amount differs from the system's outputs amount! Expected ${this._outputsAmount} got ${audioNodes.length}`); for (let channel = 0; channel < this._outputsAmount; channel++) this.connectChannel(audioNodes[channel], channel); } /** * Disconnects the individual audio outputs from the given audio nodes. * @param audioNodes Exactly 16 outputs. */ disconnectIndividualOutputs(audioNodes) { if (audioNodes.length !== this._outputsAmount) throw new Error(`input nodes amount differs from the system's outputs amount! Expected ${this._outputsAmount} got ${audioNodes.length}`); for (let channel = 0; channel < this._outputsAmount; channel++) this.disconnectChannel(audioNodes[channel], channel); } /** * Disables the GS NRPN parameters like vibrato or drum key tuning. * @deprecated Deprecated! Please use master parameters */ disableGSNPRNParams() { this.setMasterParameter("nprnParamLock", true); } /** * Sends a raw MIDI message to the synthesizer. * @param message the midi message, each number is a byte. * @param channelOffset the channel offset of the message. * @param eventOptions additional options for this command. */ sendMessage(message, channelOffset = 0, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { this._sendInternal(message, channelOffset, eventOptions); } /** * Starts playing a note * @param channel Usually 0-15: the channel to play the note. * @param midiNote 0-127 the key number of the note. * @param velocity 0-127 the velocity of the note (generally controls loudness). * @param eventOptions Additional options for this command. */ noteOn(channel, midiNote, velocity, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { const ch = channel % 16; const offset = channel - ch; midiNote %= 128; velocity %= 128; this.sendMessage([ midiMessageTypes.noteOn | ch, midiNote, velocity ], offset, eventOptions); } /** * Stops playing a note. * @param channel Usually 0-15: the channel of the note. * @param midiNote {number} 0-127 the key number of the note. * @param eventOptions Additional options for this command. */ noteOff(channel, midiNote, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { midiNote %= 128; const ch = channel % 16; const offset = channel - ch; this._sendInternal([midiMessageTypes.noteOff | ch, midiNote], offset, eventOptions); } /** * Stops all notes. * @param force If the notes should immediately be stopped, defaults to false. */ stopAll(force = false) { this.post({ channelNumber: ALL_CHANNELS_OR_DIFFERENT_ACTION, type: "stopAll", data: force ? 1 : 0 }); } /** * Changes the given controller * @param channel Usually 0-15: the channel to change the controller. * @param controllerNumber 0-127 the MIDI CC number. * @param controllerValue 0-127 the controller value. * @param eventOptions Additional options for this command. */ controllerChange(channel, controllerNumber, controllerValue, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { if (controllerNumber > 127 || controllerNumber < 0) throw new Error(`Invalid controller number: ${controllerNumber}`); controllerValue = Math.floor(controllerValue) % 128; controllerNumber = Math.floor(controllerNumber) % 128; const ch = channel % 16; const offset = channel - ch; this._sendInternal([ midiMessageTypes.controllerChange | ch, controllerNumber, controllerValue ], offset, eventOptions); } /** * Resets all controllers (for every channel) */ resetControllers() { this.post({ channelNumber: ALL_CHANNELS_OR_DIFFERENT_ACTION, type: "ccReset", data: null }); } /** * Causes the given midi channel to ignore controller messages for the given controller number. * @param channel Usually 0-15: the channel to lock. * @param controllerNumber 0-127 MIDI CC number. * @param isLocked True if locked, false if unlocked. * @remarks * Controller number -1 locks the preset. */ lockController(channel, controllerNumber, isLocked) { this.post({ channelNumber: channel, type: "lockController", data: { controllerNumber, isLocked } }); } /** * Applies pressure to a given channel. * @param channel Usually 0-15: the channel to change the controller. * @param pressure 0-127: the pressure to apply. * @param eventOptions Additional options for this command. */ channelPressure(channel, pressure, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { const ch = channel % 16; const offset = channel - ch; pressure %= 128; this.sendMessage([midiMessageTypes.channelPressure | ch, pressure], offset, eventOptions); } /** * Applies pressure to a given note. * @param channel Usually 0-15: the channel to change the controller. * @param midiNote 0-127: the MIDI note. * @param pressure 0-127: the pressure to apply. * @param eventOptions Additional options for this command. */ polyPressure(channel, midiNote, pressure, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { const ch = channel % 16; const offset = channel - ch; midiNote %= 128; pressure %= 128; this.sendMessage([ midiMessageTypes.polyPressure | ch, midiNote, pressure ], offset, eventOptions); } /** * Sets the pitch of the given channel. * @param channel Usually 0-15: the channel to change pitch. * @param value The bend of the MIDI pitch wheel message. 0 - 16384 * @param eventOptions Additional options for this command. */ pitchWheel(channel, value, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { const ch = channel % 16; const offset = channel - ch; this.sendMessage([ midiMessageTypes.pitchWheel | ch, value & 127, value >> 7 ], offset, eventOptions); } /** * Sets the channel's pitch wheel range, in semitones. * @param channel Usually 0-15: the channel to change. * @param range The bend range in semitones. * @param eventOptions Additional options for this command. */ pitchWheelRange(channel, range, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { this.controllerChange(channel, midiControllers.registeredParameterMSB, 0, eventOptions); this.controllerChange(channel, midiControllers.registeredParameterLSB, 0, eventOptions); this.controllerChange(channel, midiControllers.dataEntryMSB, range); this.controllerChange(channel, midiControllers.registeredParameterMSB, 127, eventOptions); this.controllerChange(channel, midiControllers.registeredParameterLSB, 127, eventOptions); this.controllerChange(channel, midiControllers.dataEntryMSB, 0, eventOptions); } /** * Changes the program for a given channel * @param channel Usually 0-15: the channel to change. * @param programNumber 0-127 the MIDI patch number. * @param eventOptions Additional options for this command. */ programChange(channel, programNumber, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { const ch = channel % 16; const offset = channel - ch; programNumber %= 128; this.sendMessage([midiMessageTypes.programChange | ch, programNumber], offset, eventOptions); } /** * Transposes the channel by given number of semitones. * @param channel The channel number. * @param semitones The transposition of the channel, it can be a float. * @param force Defaults to false, if true transposes the channel even if it's a drum channel. */ transposeChannel(channel, semitones, force = false) { this.post({ channelNumber: channel, type: "transposeChannel", data: { semitones, force } }); } /** * Mutes or unmutes the given channel. * @param channel Usually 0-15: the channel to mute. * @param isMuted Indicates if the channel is muted. */ muteChannel(channel, isMuted) { this.post({ channelNumber: channel, type: "muteChannel", data: isMuted }); } /** * Sends a MIDI Sysex message to the synthesizer. * @param messageData The message's data, excluding the F0 byte, but including the F7 at the end. * @param channelOffset Channel offset for the system exclusive message, defaults to zero. * @param eventOptions Additional options for this command. */ systemExclusive(messageData, channelOffset = 0, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { this._sendInternal([midiMessageTypes.systemExclusive, ...Array.from(messageData)], channelOffset, eventOptions); } /** * Tune MIDI keys of a given program using the MIDI Tuning Standard. * @param program 0 - 127 the MIDI program number to use. * @param tunings The keys and their tunings. * TargetPitch of -1 sets the tuning for this key to be tuned regularly. */ tuneKeys(program, tunings) { if (tunings.length > 127) throw new Error("Too many tunings. Maximum allowed is 127."); const systemExclusive = [ 127, 16, 8, 2, program, tunings.length ]; for (const tuning of tunings) { systemExclusive.push(tuning.sourceKey); if (tuning.targetPitch === -1) systemExclusive.push(127, 127, 127); else { const midiNote = Math.floor(tuning.targetPitch); const fraction = Math.floor((tuning.targetPitch - midiNote) / 61e-6); systemExclusive.push(midiNote, fraction >> 7 & 127, fraction & 127); } } systemExclusive.push(247); this.systemExclusive(systemExclusive); } /** * Toggles drums on a given channel. * @param channel The channel number. * @param isDrum If the channel should be drums. */ setDrums(channel, isDrum) { this.post({ channelNumber: channel, type: "setDrums", data: isDrum }); } /** * Yes please! */ reverbateEverythingBecauseWhyNot() { for (let i = 0; i < this.channelsAmount; i++) { this.controllerChange(i, midiControllers.reverbDepth, 127); this.lockController(i, midiControllers.reverbDepth, true); } return "That's the spirit!"; } /** * INTERNAL USE ONLY! * @param type INTERNAL USE ONLY! * @param resolve INTERNAL USE ONLY! * @internal */ awaitWorkerResponse(type, resolve) { this.resolveMap.set(type, resolve); } /** * INTERNAL USE ONLY! * @param callback the sequencer callback * @internal */ assignNewSequencer(callback) { this.post({ channelNumber: -1, type: "requestNewSequencer", data: null }); this.sequencers.push(callback); return this.sequencers.length - 1; } assignProgressTracker(type, progressFunction) { if (this.renderingProgressTracker.get(type)) throw new Error("Something is already being rendered!"); this.renderingProgressTracker.set(type, progressFunction); } revokeProgressTracker(type) { this.renderingProgressTracker.delete(type); } _sendInternal(message, channelOffset, eventOptions) { const options = fillWithDefaults(eventOptions, DEFAULT_SYNTH_METHOD_OPTIONS); this.post({ type: "midiMessage", channelNumber: ALL_CHANNELS_OR_DIFFERENT_ACTION, data: { messageData: new Uint8Array(message), channelOffset, options } }); } /** * Handles the messages received from the worklet. */ handleMessage(m) { switch (m.type) { case "eventCall": this.eventHandler.callEventInternal(m.data.type, m.data.data); break; case "sequencerReturn": this.sequencers[m.data.id]?.(m.data); break; case "isFullyInitialized": this.workletResponds(m.data.type, m.data.data); break; case "soundBankError": SpessaSynthCoreUtils.SpessaSynthWarn(m.data); this.eventHandler.callEventInternal("soundBankError", m.data); break; case "renderingProgress": this.renderingProgressTracker.get(m.data.type)?.(m.data.data); } } addNewChannelInternal(post) { this.channelProperties.push({ voicesAmount: 0, pitchWheel: 0, pitchWheelRange: 0, isMuted: false, isDrum: this.channelsAmount % 16 === DEFAULT_PERCUSSION, isEFX: false, transposition: 0 }); if (!post) return; this.post({ channelNumber: 0, type: "addNewChannel", data: null }); } workletResponds(type, data) { this.resolveMap.get(type)?.(data); this.resolveMap.delete(type); } }; //#endregion //#region src/synthesizer/worklet/worklet_synthesizer.ts /** * This synthesizer uses an audio worklet node containing the processor. */ var WorkletSynthesizer = class extends BasicSynthesizer { /** * Creates a new instance of an AudioWorklet-based synthesizer. * @param context The audio context. * @param config Optional configuration for the synthesizer. */ constructor(context, config = DEFAULT_SYNTH_CONFIG) { const synthConfig = fillWithDefaults(config, DEFAULT_SYNTH_CONFIG); let outputChannelCount = new Array(17).fill(2); let numberOfOutputs = 17; if (synthConfig.oneOutput) { outputChannelCount = [34]; numberOfOutputs = 1; } let worklet; try { worklet = (synthConfig?.audioNodeCreators?.worklet ?? ((context, name, options) => { return new AudioWorkletNode(context, name, options); }))(context, WORKLET_PROCESSOR_NAME, { outputChannelCount, numberOfOutputs, processorOptions: { oneOutput: synthConfig.oneOutput, enableEventSystem: synthConfig.enableEventSystem } }); } catch (error) { console.error(error); throw new Error("Could not create the AudioWorkletNode. Did you forget to addModule()?", { cause: error }); } super(worklet, (data, transfer = []) => { worklet.port.postMessage(data, transfer); }, synthConfig); } /** * Starts an offline audio render. * @param config The configuration to use. * @remarks * Call this method immediately after you've set up the synthesizer. * Do NOT call any other methods after initializing before this one. * Chromium seems to ignore worklet messages for OfflineAudioContext. */ async startOfflineRender(config) { this.post({ type: "startOfflineRender", data: config, channelNumber: -1 }, config.soundBankList.map((b) => b.soundBankBuffer)); await new Promise((r) => this.awaitWorkerResponse("startOfflineRender", r)); } /** * Destroys the synthesizer instance. */ destroy() { this.post({ channelNumber: 0, type: "destroyWorklet", data: null }); this.worklet.disconnect(); delete this.worklet; } }; //#endregion //#region src/synthesizer/worker/playback_worklet.ts const PLAYBACK_WORKLET_PROCESSOR_NAME = `spessasynth-playback-worklet-processor`; function getPlaybackWorkletURL(maxQueuedChunks) { const PLAYBACK_WORKLET_CODE = ` const BLOCK_SIZE = 128; const MAX_QUEUED = ${maxQueuedChunks}; /** * An AudioWorkletProcessor that plays back 18 separate streams of stereo audio: reverb, and chorus and 16 dry channels. */ class PlaybackProcessor extends AudioWorkletProcessor { /** @type {Float32Array[]} */ data = []; updateRequested = false; alive = true; /** * * @type {MessagePort} */ sentPort; constructor() { super(); /** * @param e {MessageEvent} */ this.port.onmessage = (e) => { if (e.ports.length) { const sentPort = e.ports[0]; this.sentPort = sentPort; sentPort.onmessage = (e) => { if(e.data === null) { // the worklet is dead this.alive = false; } this.data.push(e.data); this.updateRequested = false; // if we need more, request immediately if (this.data.length < MAX_QUEUED) { this.sentPort.postMessage(null); } }; } }; } // noinspection JSUnusedGlobalSymbols /** * @param _inputs {[Float32Array, Float32Array][]} * @param outputs {[Float32Array, Float32Array][]} * @returns {boolean} */ process(_inputs, outputs) { const data = this.data.shift(); if (!data) { return this.alive; } let offset = 0; // decode the data nicely for (let i = 0; i < 17; i++) { outputs[i][0].set(data.subarray(offset, offset + BLOCK_SIZE)); offset += BLOCK_SIZE; outputs[i][1].set(data.subarray(offset, offset + BLOCK_SIZE)); offset += BLOCK_SIZE; } // if it has already been requested, we need to wait if (!this.updateRequested) { this.sentPort.postMessage(null); this.updateRequested = true; } // keep it online return this.alive; } } registerProcessor("${PLAYBACK_WORKLET_PROCESSOR_NAME}", PlaybackProcessor); `; const blob = new Blob([PLAYBACK_WORKLET_CODE], { type: "application/javascript" }); return URL.createObjectURL(blob); } //#endregion //#region src/synthesizer/worker/render_audio_worker.ts const DEFAULT_WORKER_RENDER_AUDIO_OPTIONS = { extraTime: 2, separateChannels: false, loopCount: 0, progressCallback: void 0, preserveSynthParams: true, enableEffects: true, sequencerID: 0 }; const RENDER_BLOCKS_PER_PROGRESS = 64; const BLOCK_SIZE$1 = 128; function renderAudioWorker(sampleRate, options) { const rendererSynth = new SpessaSynthProcessor(sampleRate, { enableEventSystem: false }); for (const entry of this.synthesizer.soundBankManager.soundBankList) rendererSynth.soundBankManager.addSoundBank(entry.soundBank, entry.id, entry.bankOffset); rendererSynth.soundBankManager.priorityOrder = this.synthesizer.soundBankManager.priorityOrder; this.stopAudioLoop(); const seq = this.sequencers[options.sequencerID]; const parsedMid = seq.midiData; if (!parsedMid) throw new Error("No MIDI is loaded!"); const playbackRate = seq.playbackRate; const loopStartAbsolute = parsedMid.midiTicksToSeconds(parsedMid.loop.start) / playbackRate; const loopDuration = parsedMid.midiTicksToSeconds(parsedMid.loop.end) / playbackRate - loopStartAbsolute; const sampleDuration = sampleRate * (parsedMid.duration / playbackRate + options.extraTime + loopDuration * options.loopCount); const rendererSeq = new SpessaSynthSequencer(rendererSynth); rendererSeq.loopCount = options.loopCount; if (options.preserveSynthParams) { rendererSeq.playbackRate = seq.playbackRate; const snapshot = this.synthesizer.getSnapshot(); rendererSynth.applySynthesizerSnapshot(snapshot); } rendererSynth.setMasterParameter("autoAllocateVoices", true); rendererSeq.loadNewSongList([parsedMid]); rendererSeq.play(); const wetL = new Float32Array(sampleDuration); const wetR = new Float32Array(sampleDuration); const returnedChunks = { effects: [wetL, wetR], dry: [] }; const sampleDurationNoLastQuantum = sampleDuration - BLOCK_SIZE$1; if (options.separateChannels) { const dry = []; for (let i = 0; i < 16; i++) { const d = [new Float32Array(sampleDuration), new Float32Array(sampleDuration)]; dry.push(d); returnedChunks.dry.push(d); } let index = 0; while (true) { for (let i = 0; i < RENDER_BLOCKS_PER_PROGRESS; i++) { if (index >= sampleDurationNoLastQuantum) { rendererSeq.processTick(); rendererSynth.processSplit(dry, wetL, wetR, index, sampleDuration - index); this.startAudioLoop(); return returnedChunks; } rendererSeq.processTick(); rendererSynth.processSplit(dry, wetL, wetR, index, BLOCK_SIZE$1); index += BLOCK_SIZE$1; } this.postProgress("renderAudio", index / sampleDuration); } } else { const dryL = new Float32Array(sampleDuration); const dryR = new Float32Array(sampleDuration); const dry = [dryL, dryR]; returnedChunks.dry.push(dry); let index = 0; while (true) { for (let i = 0; i < RENDER_BLOCKS_PER_PROGRESS; i++) { if (index >= sampleDurationNoLastQuantum) { rendererSeq.processTick(); rendererSynth.process(dryL, dryR, index, sampleDuration - index); this.startAudioLoop(); return returnedChunks; } rendererSeq.processTick(); rendererSynth.process(dryL, dryR, index, BLOCK_SIZE$1); index += BLOCK_SIZE$1; } this.postProgress("renderAudio", index / sampleDuration); } } } //#endregion //#region src/synthesizer/worker/worker_synthesizer.ts const DEFAULT_BANK_WRITE_OPTIONS = { trim: true, bankID: "", writeEmbeddedSoundBank: true, sequencerID: 0 }; const DEFAULT_SF2_WRITE_OPTIONS = { ...DEFAULT_BANK_WRITE_OPTIONS, writeDefaultModulators: true, writeExtendedLimits: true, compress: false, compressionQuality: 1, decompress: false }; const DEFAULT_RMIDI_WRITE_OPTIONS = { ...DEFAULT_BANK_WRITE_OPTIONS, bankOffset: 0, correctBankOffset: true, metadata: {}, format: "sf2", ...DEFAULT_SF2_WRITE_OPTIONS }; const DEFAULT_DLS_WRITE_OPTIONS = { ...DEFAULT_BANK_WRITE_OPTIONS }; /** * This synthesizer uses a Worker containing the processor and an audio worklet node for playback. */ var WorkerSynthesizer = class extends BasicSynthesizer { /** * Time offset for syncing with the synth * @private */ timeOffset = 0; /** * Creates a new instance of a Worker-based synthesizer. * @param context The audio context. * @param workerPostMessage The postMessage for the worker containing the synthesizer core. * @param config Optional configuration for the synthesizer. */ constructor(context, workerPostMessage, config = DEFAULT_SYNTH_CONFIG) { const synthConfig = fillWithDefaults(config, DEFAULT_SYNTH_CONFIG); if (synthConfig.oneOutput) throw new Error("One output mode is not supported in the WorkerSynthesizer."); let worklet; try { worklet = (synthConfig?.audioNodeCreators?.worklet ?? ((context, name, options) => { return new AudioWorkletNode(context, name, options); }))(context, PLAYBACK_WORKLET_PROCESSOR_NAME, { outputChannelCount: new Array(18).fill(2), numberOfOutputs: 18, processorOptions: { oneOutput: synthConfig.oneOutput, enableEventSystem: synthConfig.enableEventSystem } }); } catch (error) { console.error(error); throw new Error("Could not create the AudioWorkletNode. Did you forget to registerPlaybackWorklet()?", { cause: error }); } super(worklet, workerPostMessage, synthConfig); const messageChannel = new MessageChannel(); const workerPort = messageChannel.port1; const workletPort = messageChannel.port2; this.worklet.port.postMessage(null, [workletPort]); workerPostMessage({ initialTime: this.context.currentTime, sampleRate: this.context.sampleRate }, [workerPort]); } get currentTime() { return this.context.currentTime + this.timeOffset; } /** * Registers an audio worklet for the WorkerSynthesizer. * @param context The context to register the worklet for. * @param maxQueueSize The maximum amount of 128-sample chunks to store in the worklet. Higher values result in less breakups but higher latency. */ static async registerPlaybackWorklet(context, maxQueueSize = 20) { if (!context?.audioWorklet.addModule) throw new Error("Audio worklet is not supported."); return context.audioWorklet.addModule(getPlaybackWorkletURL(maxQueueSize)); } /** * Handles a return message from the Worker. * @param e The event received from the Worker. */ handleWorkerMessage(e) { this.timeOffset = e.currentTime - this.context.currentTime; this.handleMessage(e); } /** * Writes a DLS file directly in the worker. * @param options Options for writing the file. * @returns The file array buffer and its corresponding name. */ async writeDLS(options = DEFAULT_DLS_WRITE_OPTIONS) { const writeOptions = fillWithDefaults(options, DEFAULT_DLS_WRITE_OPTIONS); return new Promise((resolve) => { this.assignProgressTracker("workerSynthWriteFile", (p) => { options.progressFunction?.(p); }); const postOptions = { ...writeOptions, progressFunction: null }; this.awaitWorkerResponse("workerSynthWriteFile", (data) => resolve(data)); this.post({ type: "writeDLS", data: postOptions, channelNumber: -1 }); }); } /** * Writes an SF2/SF3 file directly in the worker. * @param options Options for writing the file. * @returns The file array buffer and its corresponding name. */ async writeSF2(options = DEFAULT_SF2_WRITE_OPTIONS) { const writeOptions = fillWithDefaults(options, DEFAULT_SF2_WRITE_OPTIONS); return new Promise((resolve) => { this.assignProgressTracker("workerSynthWriteFile", (p) => { options.progressFunction?.(p); }); const postOptions = { ...writeOptions, progressFunction: null }; this.awaitWorkerResponse("workerSynthWriteFile", (data) => resolve(data)); this.post({ type: "writeSF2", data: postOptions, channelNumber: -1 }); }); } /** * Writes an embedded MIDI (RMIDI) file directly in the worker. * @param options Options for writing the file. * @returns The file array buffer. */ async writeRMIDI(options = DEFAULT_RMIDI_WRITE_OPTIONS) { const writeOptions = fillWithDefaults(options, DEFAULT_RMIDI_WRITE_OPTIONS); return new Promise((resolve) => { this.assignProgressTracker("workerSynthWriteFile", (p) => { options.progressFunction?.(p); }); const postOptions = { ...writeOptions, progressFunction: null }; this.awaitWorkerResponse("workerSynthWriteFile", (data) => resolve(data.binary)); this.post({ type: "writeRMIDI", data: postOptions, channelNumber: -1 }); }); } /** * Renders the current song in the connected sequencer to Float32 buffers. * @param sampleRate The sample rate to use, in Hertz. * @param renderOptions Extra options for the render. * @returns A single audioBuffer if separate channels were not enabled, otherwise 16. * @remarks * This stops the synthesizer. */ async renderAudio(sampleRate, renderOptions = DEFAULT_WORKER_RENDER_AUDIO_OPTIONS) { const options = fillWithDefaults(renderOptions, DEFAULT_WORKER_RENDER_AUDIO_OPTIONS); if (options.enableEffects && options.separateChannels) throw new Error("Effects cannot be applied to separate channels."); return new Promise((resolve) => { this.awaitWorkerResponse("renderAudio", (data) => { this.revokeProgressTracker("renderAudio"); const bufferLength = data.dry[0][0].length; const dryChannels = data.dry.map((dryPair) => { const buffer = new AudioBuffer({ sampleRate, numberOfChannels: 2, length: bufferLength }); buffer.copyToChannel(dryPair[0], 0); buffer.copyToChannel(dryPair[1], 1); return buffer; }); if (options.enableEffects) { const buffer = new AudioBuffer({ sampleRate, numberOfChannels: 2, length: bufferLength }); buffer.copyToChannel(data.effects[0], 0); buffer.copyToChannel(data.effects[1], 1); dryChannels.push(buffer); } resolve(dryChannels); }); this.assignProgressTracker("renderAudio", (p) => { options.progressCallback?.(p, 0); }); const strippedOptions = { ...options, progressCallback: void 0 }; this.post({ type: "renderAudio", data: { sampleRate, options: strippedOptions }, channelNumber: -1 }); }); } }; //#endregion //#region src/sequencer/midi_data.ts var MIDIDataTrack = class extends MIDITrack { /** * THIS DATA WILL BE EMPTY! USE sequencer.getMIDI() TO GET THE ACTUAL DATA! */ events = []; constructor(track) { super(); super.copyFrom(track); this.events = []; } }; /** * A simplified version of the MIDI, accessible at all times from the Sequencer. * Use getMIDI() to get the actual sequence. * This class contains all properties that MIDI does, except for tracks and the embedded sound bank. */ var MIDIData = class MIDIData extends BasicMIDI { tracks; /** * THIS DATA WILL BE EMPTY! USE sequencer.getMIDI() TO GET THE ACTUAL DATA! */ embeddedSoundBank = void 0; /** * The byte length of the sound bank if it exists. */ embeddedSoundBankSize; constructor(mid) { super(); super.copyMetadataFrom(mid); this.tracks = mid.tracks.map((t) => new MIDIDataTrack(t)); this.embeddedSoundBankSize = mid instanceof MIDIData ? mid.embeddedSoundBankSize : mid?.embeddedSoundBank?.byteLength; } }; //#endregion //#region src/sequencer/enums.ts const songChangeType = { shuffleOn: 1, shuffleOff: 2, index: 3 }; //#endregion //#region src/synthesizer/basic/basic_synthesizer_core.ts /** * The interface for the audio processing code that uses spessasynth_core and runs on a separate thread. */ var BasicSynthesizerCore = class { synthesizer; sequencers = new Array(); post; lastSequencerSync = 0; /** * Indicates if the processor is alive. * @protected */ alive = false; enableEventSystem; constructor(sampleRate, options, postMessage) { this.synthesizer = new SpessaSynthProcessor(sampleRate, options); this.enableEventSystem = options.enableEventSystem ?? false; this.post = postMessage; this.synthesizer.onEventCall = (event) => { this.post({ type: "eventCall", data: event, currentTime: this.synthesizer.currentSynthTime }); }; } createNewSequencer() { const sequencer = new SpessaSynthSequencer(this.synthesizer); const sequencerID = this.sequencers.length; this.sequencers.push(sequencer); sequencer.onEventCall = (e) => { if (!this.enableEventSystem) return; if (e.type === "songListChange") { const midiDatas = e.data.newSongList.map((s) => { return new MIDIData(s); }); this.post({ type: "sequencerReturn", data: { type: e.type, data: { newSongList: midiDatas, shuffledSongIndexes: sequencer.shuffledSongIndexes }, id: sequencerID }, currentTime: this.synthesizer.currentSynthTime }); return; } this.post({ type: "sequencerReturn", data: { ...e, id: sequencerID }, currentTime: this.synthesizer.currentSynthTime }); }; } postReady(type, data, transferable = []) { this.post({ type: "isFullyInitialized", data: { type, data }, currentTime: this.synthesizer.currentSynthTime }, transferable); } postProgress(type, data) { this.post({ type: "renderingProgress", data: { type, data }, currentTime: this.synthesizer.currentSynthTime }); } destroy() { this.synthesizer.destroySynthProcessor(); delete this.synthesizer; delete this.sequencers; } handleMessage(m) { const channel = m.channelNumber; let channelObject = void 0; if (channel >= 0) { channelObject = this.synthesizer.midiChannels[channel]; if (channelObject === void 0) { SpessaSynthCoreUtils.SpessaSynthWarn(`Trying to access channel ${channel} which does not exist... ignoring!`); return; } } switch (m.type) { case "midiMessage": this.synthesizer.processMessage(m.data.messageData, m.data.channelOffset, m.data.options); break; case "customCcChange": channelObject?.setCustomController(m.data.ccNumber, m.data.ccValue); break; case "ccReset": if (channel === ALL_CHANNELS_OR_DIFFERENT_ACTION) this.synthesizer.resetAllControllers(); else channelObject?.resetControllers(); break; case "stopAll": if (channel === ALL_CHANNELS_OR_DIFFERENT_ACTION) this.synthesizer.stopAllChannels(m.data === 1); else channelObject?.stopAllNotes(m.data === 1); break; case "muteChannel": channelObject?.muteChannel(m.data); break; case "addNewChannel": this.synthesizer.createMIDIChannel(); break; case "setMasterParameter": this.synthesizer.setMasterParameter(m.data.type, m.data.data); break; case "setDrums": channelObject?.setDrums(m.data); break; case "transposeChannel": channelObject?.transposeChannel(m.data.semitones, m.data.force); break; case "lockController": if (m.data.controllerNumber === ALL_CHANNELS_OR_DIFFERENT_ACTION) channelObject?.setPresetLock(m.data.isLocked); else { if (!channelObject) return; channelObject.lockedControllers[m.data.controllerNumber] = m.data.isLocked; } break; case "sequencerSpecific": { const seq = this.sequencers[m.data.id]; if (!seq) return; const seqMsg = m.data; switch (seqMsg.type) { default: break; case "loadNewSongList": try { const songMap = seqMsg.data.map((s) => { if ("duration" in s) return BasicMIDI.copyFrom(s); return BasicMIDI.fromArrayBuffer(s.binary, s.fileName); }); seq.loadNewSongList(songMap); } catch (error) { console.error(error); this.post({ type: "sequencerReturn", data: { type: "midiError", data: error, id: m.data.id }, currentTime: this.synthesizer.currentSynthTime }); } break; case "pause": seq.pause(); break; case "play": seq.play(); break; case "setTime": seq.currentTime = seqMsg.data; break; case "changeMIDIMessageSending": seq.externalMIDIPlayback = seqMsg.data; break; case "setPlaybackRate": seq.playbackRate = seqMsg.data; break; case "setLoopCount": seq.loopCount = seqMsg.data; break; case "changeSong": switch (seqMsg.data.changeType) { case songChangeType.shuffleOff: seq.shuffleMode = false; break; case songChangeType.shuffleOn: seq.shuffleMode = true; break; case songChangeType.index: if (seqMsg.data.data !== void 0) seq.songIndex = seqMsg.data.data; break; } break; case "getMIDI": if (!seq.midiData) throw new Error("No MIDI is loaded!"); this.post({ type: "sequencerReturn", data: { type: "getMIDI", data: seq.midiData, id: m.data.id }, currentTime: this.synthesizer.currentSynthTime }); break; case "setSkipToFirstNote": seq.skipToFirstNoteOn = seqMsg.data; break; } break; } case "soundBankManager": try { const sfManager = this.synthesizer.soundBankManager; const sfManMsg = m.data; let font; switch (sfManMsg.type) { case "addSoundBank": font = SoundBankLoader.fromArrayBuffer(sfManMsg.data.soundBankBuffer); sfManager.addSoundBank(font, sfManMsg.data.id, sfManMsg.data.bankOffset); this.postReady("soundBankManager", null); break; case "deleteSoundBank": sfManager.deleteSoundBank(sfManMsg.data); this.postReady("soundBankManager", null); break; case "rearrangeSoundBanks": sfManager.priorityOrder = sfManMsg.data; this.postReady("soundBankManager", null); } } catch (error) { this.post({ type: "soundBankError", data: error, currentTime: this.synthesizer.currentSynthTime }); } break; case "keyModifierManager": { const kmMsg = m.data; const man = this.synthesizer.keyModifierManager; switch (kmMsg.type) { default: return; case "addMapping": man.addMapping(kmMsg.data.channel, kmMsg.data.midiNote, kmMsg.data.mapping); break; case "clearMappings": man.clearMappings(); break; case "deleteMapping": man.deleteMapping(kmMsg.data.channel, kmMsg.data.midiNote); } break; } case "requestSynthesizerSnapshot": { const snapshot = SynthesizerSnapshot.create(this.synthesizer); this.postReady("synthesizerSnapshot", snapshot); break; } case "requestNewSequencer": this.createNewSequencer(); break; case "setLogLevel": SpessaSynthLogging(m.data.enableInfo, m.data.enableWarning, m.data.enableGroup); break; case "destroyWorklet": this.alive = false; this.synthesizer.destroySynthProcessor(); this.destroy(); break; default: SpessaSynthCoreUtils.SpessaSynthWarn("Unrecognized event!", m); break; } } }; //#endregion //#region src/synthesizer/worker/write_sf_worker.ts async function writeSF2Worker(opts) { let sf = this.getBank(opts); if (opts.compress && !this.compressionFunction) { const e = /* @__PURE__ */ new Error(`Compression enabled but no compression has been provided to WorkerSynthesizerCore.`); this.post({ type: "soundBankError", data: e, currentTime: this.synthesizer.currentSynthTime }); throw e; } const sq = this.sequencers[opts.sequencerID]; if (opts.trim) { if (!sq.midiData) throw new Error("Sound bank MIDI trimming is enabled but no MIDI is loaded!"); const