UNPKG

spessasynth_lib

Version:

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

1,688 lines (1,675 loc) 74.9 kB
import { BasicMIDI, BasicSoundBank, DEFAULT_CHANNEL_MIDI_PARAMETERS, DEFAULT_CHANNEL_SYSTEM_PARAMETERS, DEFAULT_GLOBAL_MIDI_PARAMETERS, DEFAULT_GLOBAL_SYSTEM_PARAMETERS, KeyModifier, MIDIControllers, MIDIMessageTypes, MIDITrack, MIDIUtils, SoundBankLoader, SpessaLog, SpessaSynthCoreUtils, SpessaSynthProcessor, SpessaSynthSequencer, audioToWav } from "spessasynth_core"; //#region src/synthesizer/basic/synth_config.ts const DEFAULT_SYNTH_CONFIG = { eventsEnabled: 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) { SpessaLog.warn("1 sound bank left. Aborting!"); return; } if (!this.soundBankList.some((s) => s.id === id)) { SpessaLog.warn(`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(), controllerChange: /* @__PURE__ */ new Map(), programChange: /* @__PURE__ */ new Map(), polyPressure: /* @__PURE__ */ new Map(), stopAll: /* @__PURE__ */ new Map(), channelAdded: /* @__PURE__ */ new Map(), presetListChange: /* @__PURE__ */ new Map(), reset: /* @__PURE__ */ new Map(), soundBankError: /* @__PURE__ */ new Map(), displayMessage: /* @__PURE__ */ new Map(), globalParamChange: /* @__PURE__ */ new Map(), channelParamChange: /* @__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/lib_midi_channel.ts var LibMIDIChannel = class { /** * This channel number. * @private */ channel; synth; _systemParameters = { ...DEFAULT_CHANNEL_SYSTEM_PARAMETERS }; /** * @internal * @param channel * @param synth */ constructor(channel, synth) { this.channel = channel; this.synth = synth; } _patch = { bankMSB: 0, bankLSB: 0, program: 0, isDrum: false, isGMGSDrum: false, name: "" }; /** * The currently selected MIDI patch of the channel. * Note that the exact matching preset may not be available, but this represents exactly what MIDI asks for. */ get patch() { return this._patch; } /** * @internal * @param patch */ set patch(patch) { this._patch = patch; } _midiParameters = { ...DEFAULT_CHANNEL_MIDI_PARAMETERS }; /** * The channel MIDI parameters of this channel. * These are only editable via MIDI messages. */ get midiParameters() { return this._midiParameters; } /** * The channel system parameters of this channel. * These are only editable via the API. */ get systemParameters() { return this._systemParameters; } _voiceCount = 0; /** * The channel's current voice count. */ get voiceCount() { return this._voiceCount; } /** * @internal * @param value */ set voiceCount(value) { this._voiceCount = value; } /** * Toggles drums on a given channel. * @param isDrum If the channel should be drums. */ setDrums(isDrum) { this.synth.post({ channelNumber: this.channel, type: "setDrums", data: isDrum }); } /** * Causes the given midi channel to ignore controller messages for the given controller number. * @param controller 0-127 MIDI CC number. * @param isLocked True if locked, false if unlocked. */ lockController(controller, isLocked) { this.synth.post({ channelNumber: this.channel, type: "lockController", data: { controller, isLocked } }); } /** * Sets a system parameter of the channel. * @param parameter The type of the parameter to set. * @param value The value to set for the parameter. */ setSystemParameter(parameter, value) { this._systemParameters[parameter] = value; this.synth.post({ type: "setChannelSystemParameter", channelNumber: this.channel, data: { type: parameter, data: value } }); } /** * @internal * @param parameter * @param value */ setMIDIParameter(parameter, value) { this._midiParameters[parameter] = value; } /** * @internal */ reset() { this._midiParameters = { ...DEFAULT_CHANNEL_MIDI_PARAMETERS }; } }; //#endregion //#region src/synthesizer/basic/basic_synthesizer.ts const DEFAULT_SYNTH_METHOD_OPTIONS = { time: 0 }; const SPESSASYNTH_LIB_HANDLER = (event) => `SPESSASYNTH_LIB_HANDLE_${event}_${Math.random()}`; 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. */ midiChannels = []; /** * The current preset list. */ presetList = []; /** * INTERNAL USE ONLY! * @internal * All sequencer callbacks */ sequencers = new Array(); /** * Resolves when the synthesizer is ready. */ isReady; /** * 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 */ _outputCount = 16; _systemParameters = { ...DEFAULT_GLOBAL_SYSTEM_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) { SpessaLog.info("%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 < 16; i++) this.addNewChannelInternal(false); this.registerInternalEvent("channelAdded", () => { this.addNewChannelInternal(false); }); this.registerInternalEvent("presetListChange", (e) => this.presetList = [...e]); this.registerInternalEvent("globalParamChange", (e) => this._midiParameters[e.parameter] = e.value); this.registerInternalEvent("channelParamChange", (e) => this.midiChannels[e.channel].setMIDIParameter(e.parameter, e.value)); this.registerInternalEvent("programChange", (e) => this.midiChannels[e.channel].patch = { ...e }); this.registerInternalEvent("reset", () => { for (const c of this.midiChannels) c.reset(); this._midiParameters = { ...DEFAULT_GLOBAL_MIDI_PARAMETERS }; }); } _midiParameters = { ...DEFAULT_GLOBAL_MIDI_PARAMETERS }; /** * The global MIDI parameters of the synthesizer. * These are only editable via MIDI messages. */ get midiParameters() { return this._midiParameters; } /** * The current channel count of the synthesizer. */ get channelCount() { return this.midiChannels.length; } /** * Current voice amount */ _voiceCount = 0; /** * The current number of voices playing. */ get voiceCount() { return this._voiceCount; } /** * The audioContext's current time. */ get currentTime() { return this.context.currentTime; } /** * The global system parameters of the synthesizer. * These are only editable via the API. */ get systemParameters() { return this._systemParameters; } /** * 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: -1, type: "setLogLevel", data: { enableInfo, enableWarning, enableGroup } }); } /** * Sets a system parameter to a given value. * @param type The parameter to set. * @param value The value to set. */ setSystemParameter(type, value) { this._systemParameters[type] = value; this.post({ type: "setGlobalSystemParameter", channelNumber: -1, data: { type, data: value } }); } /** * Gets a complete snapshot of the synthesizer, effects. */ async getSnapshot() { return new Promise((resolve) => { this.awaitWorkerResponse("synthesizerSnapshot", (s) => { resolve(s); }); this.post({ type: "requestSynthesizerSnapshot", data: null, channelNumber: -1 }); }); } /** * Adds a new channel to the synthesizer. */ addNewChannel() { this.addNewChannelInternal(true); } /** * 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._outputCount) throw new Error(`input nodes amount differs from the system's outputs amount! Expected ${this._outputCount} got ${audioNodes.length}`); for (let channel = 0; channel < this._outputCount; 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._outputCount) throw new Error(`input nodes amount differs from the system's outputs amount! Expected ${this._outputCount} got ${audioNodes.length}`); for (let channel = 0; channel < this._outputCount; channel++) this.disconnectChannel(audioNodes[channel], channel); } /** * 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: -1, type: "stopAll", data: force ? 1 : 0 }); } /** * Changes the given controller * @param channel Usually 0-15: the channel to change the controller. * @param controller 0-127 the MIDI CC number. * @param value 0-127 the controller value. * @param eventOptions Additional options for this command. */ controllerChange(channel, controller, value, eventOptions = DEFAULT_SYNTH_METHOD_OPTIONS) { if (controller > 127 || controller < 0) throw new Error(`Invalid controller number: ${controller}`); value = Math.floor(value) % 128; controller = Math.floor(controller) % 128; const ch = channel % 16; const offset = channel - ch; this._sendInternal([ MIDIMessageTypes.controllerChange | ch, controller, value ], offset, eventOptions); } /** * Fully resets the synthesizer. */ reset() { this.post({ channelNumber: -1, type: "ccReset", data: null }); } /** * 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); } /** * 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); } /** * Yes please! */ reverbateEverythingBecauseWhyNot() { for (let i = 0; i < this.midiChannels.length; i++) { this.controllerChange(i, MIDIControllers.reverbDepth, 127); this.midiChannels[i].lockController(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: -1, 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 "voiceCountChange": for (let i = 0; i < m.data.length; i++) { this.midiChannels[i].voiceCount = m.data[i]; this._voiceCount = m.data.reduce((s, v) => s + v, 0); } break; case "isFullyInitialized": this.workletResponds(m.data.type, m.data.data); break; case "soundBankError": SpessaLog.warn(m.data); this.eventHandler.callEventInternal("soundBankError", m.data); break; case "renderingProgress": this.renderingProgressTracker.get(m.data.type)?.(m.data.data); } } addNewChannelInternal(post) { this.midiChannels.push(new LibMIDIChannel(this.midiChannels.length, this)); if (!post) return; this.post({ channelNumber: 0, type: "addNewChannel", data: null }); } workletResponds(type, data) { this.resolveMap.get(type)?.(data); this.resolveMap.delete(type); } registerInternalEvent(event, callback) { this.eventHandler.addEvent(event, SPESSASYNTH_LIB_HANDLER(event), callback); } }; //#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, eventsEnabled: synthConfig.eventsEnabled } }); } 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, { eventsEnabled: 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.applySnapshot(snapshot); } rendererSynth.setSystemParameter("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, compressionAction: "keep", compressionQuality: 1, software: "SpessaSynth" }; const DEFAULT_RMIDI_WRITE_OPTIONS = { ...DEFAULT_BANK_WRITE_OPTIONS, applySnapshot: false, bankOffset: 0, correctBankOffset: true, metadata: {}, format: "sf2", ...DEFAULT_SF2_WRITE_OPTIONS }; const DEFAULT_DLS_WRITE_OPTIONS = { ...DEFAULT_BANK_WRITE_OPTIONS, software: "SpessaSynth" }; /** * 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, eventsEnabled: synthConfig.eventsEnabled } }); } 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) => { this.revokeProgressTracker("workerSynthWriteFile"); 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) => { this.revokeProgressTracker("workerSynthWriteFile"); 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) => { this.revokeProgressTracker("workerSynthWriteFile"); 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, timeline 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! */ timeline = []; /** * 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; /** * For syncing voice counts, implemented separately in the `process()` method. * @protected */ voiceCounts = new Array(16).fill(0); /** * Indicates if the processor is alive. * @protected */ alive = false; eventsEnabled; constructor(sampleRate, options, postMessage) { this.synthesizer = new SpessaSynthProcessor(sampleRate, options); this.eventsEnabled = options.eventsEnabled ?? false; this.post = postMessage; this.synthesizer.onEventCall = (event) => { if (event.type === "channelAdded") { const l = this.synthesizer.midiChannels.length; for (let i = this.voiceCounts.length; i < l; i++) this.voiceCounts.push(0); } this.post({ type: "eventCall", data: event, currentTime: this.synthesizer.currentTime }); }; } createNewSequencer() { const sequencer = new SpessaSynthSequencer(this.synthesizer); const sequencerID = this.sequencers.length; this.sequencers.push(sequencer); sequencer.onEventCall = (e) => { if (!this.eventsEnabled) 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.currentTime }); return; } this.post({ type: "sequencerReturn", data: { ...e, id: sequencerID }, currentTime: this.synthesizer.currentTime }); }; } postReady(type, data, transferable = []) { this.post({ type: "isFullyInitialized", data: { type, data }, currentTime: this.synthesizer.currentTime }, transferable); } postProgress(type, data) { this.post({ type: "renderingProgress", data: { type, data }, currentTime: this.synthesizer.currentTime }); } destroy() { this.synthesizer.destroySynthProcessor(); delete this.synthesizer; delete this.sequencers; } handleMessage(m) { const channel = m.channelNumber; let channelObject; if (channel >= 0) { channelObject = this.synthesizer.midiChannels[channel]; if (channelObject === void 0) { SpessaLog.warn(`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 "ccReset": this.synthesizer.reset(); break; case "stopAll": if (channel === -1) this.synthesizer.stopAllChannels(m.data === 1); else channelObject?.stopAllNotes(m.data === 1); break; case "addNewChannel": this.synthesizer.createMIDIChannel(); break; case "setGlobalSystemParameter": this.synthesizer.setSystemParameter(m.data.type, m.data.data); break; case "setChannelSystemParameter": channelObject?.setSystemParameter(m.data.type, m.data.data); break; case "setDrums": channelObject?.setDrums(m.data); break; case "lockController": channelObject?.lockController(m.data.controller, 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.currentTime }); } 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.currentTime }); 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.currentTime }); } 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 = this.synthesizer.getSnapshot(); this.postReady("synthesizerSnapshot", snapshot); break; } case "requestNewSequencer": this.createNewSequencer(); break; case "setLogLevel": SpessaLog.setLogLevel(m.data.enableInfo, m.data.enableWarning, m.data.enableGroup); break; case "destroyWorklet": this.alive = false; this.synthesizer.destroySynthProcessor(); this.destroy(); break; default: SpessaLog.warn("Unrecognized event!", m); break; } } }; //#endregion //#region src/synthesizer/worker/write_sf_worker.ts async function writeSF2Wo