UNPKG

spessasynth_core

Version:

MIDI and SoundFont2/DLS library with no compromises

817 lines (710 loc) 24.2 kB
import { SpessaSynthInfo } from "../../utils/loggin.js"; import { consoleColors } from "../../utils/other.js"; import { voiceKilling } from "./engine_methods/stopping_notes/voice_killing.js"; import { ALL_CHANNELS_OR_DIFFERENT_ACTION, DEFAULT_SYNTH_MODE, VOICE_CAP } from "../synth_constants.js"; import { stbvorbis } from "../../externals/stbvorbis_sync/stbvorbis_sync.min.js"; import { VOLUME_ENVELOPE_SMOOTHING_FACTOR } from "./engine_components/volume_envelope.js"; import { systemExclusive } from "./engine_methods/system_exclusive.js"; import { masterParameterType, setMasterParameter } from "./engine_methods/controller_control/master_parameters.js"; import { resetAllControllers } from "./engine_methods/controller_control/reset_controllers.js"; import { SoundFontManager } from "./engine_components/soundfont_manager.js"; import { KeyModifierManager } from "./engine_components/key_modifier_manager.js"; import { getVoices, getVoicesForPreset } from "./engine_components/voice.js"; import { PAN_SMOOTHING_FACTOR } from "./engine_components/stereo_panner.js"; import { stopAllChannels } from "./engine_methods/stopping_notes/stop_all_channels.js"; import { clearEmbeddedBank, setEmbeddedSoundFont } from "./engine_methods/soundfont_management/embedded_sound_bank.js"; import { updatePresetList } from "./engine_methods/soundfont_management/update_preset_list.js"; import { transposeAllChannels } from "./engine_methods/tuning_control/transpose_all_channels.js"; import { setMasterTuning } from "./engine_methods/tuning_control/set_master_tuning.js"; import { applySynthesizerSnapshot } from "./snapshot/apply_synthesizer_snapshot.js"; import { createMidiChannel } from "./engine_methods/create_midi_channel.js"; import { FILTER_SMOOTHING_FACTOR } from "./engine_components/lowpass_filter.js"; import { getEvent, messageTypes } from "../../midi/midi_message.js"; import { IndexedByteArray } from "../../utils/indexed_array.js"; import { interpolationTypes } from "./engine_components/enums.js"; import { DEFAULT_SYNTH_OPTIONS } from "./synth_processor_options.js"; import { fillWithDefaults } from "../../utils/fill_with_defaults.js"; import { isSystemXG } from "../../utils/xg_hacks.js"; /** * @typedef {"gm"|"gm2"|"gs"|"xg"} SynthSystem */ /** * main_processor.js * purpose: the core synthesis engine */ /** * @typedef {Object} NoteOnCallback * @property {number} midiNote - The MIDI note number. * @property {number} channel - The MIDI channel number. * @property {number} velocity - The velocity of the note. */ /** * @typedef {Object} NoteOffCallback * @property {number} midiNote - The MIDI note number. * @property {number} channel - The MIDI channel number. */ /** * @typedef {Object} DrumChangeCallback * @property {number} channel - The MIDI channel number. * @property {boolean} isDrumChannel - Indicates if the channel is a drum channel. */ /** * @typedef {Object} ProgramChangeCallback * @property {number} channel - The MIDI channel number. * @property {number} program - The program number. * @property {number} bank - The bank number. */ /** * @typedef {Object} ControllerChangeCallback * @property {number} channel - The MIDI channel number. * @property {number} controllerNumber - The controller number. * @property {number} controllerValue - The value of the controller. */ /** * @typedef {Object} MuteChannelCallback * @property {number} channel - The MIDI channel number. * @property {boolean} isMuted - Indicates if the channel is muted. */ /** * @typedef {Object} PresetListChangeCallbackSingle * @property {string} presetName - The name of the preset. * @property {number} bank - The bank number. * @property {number} program - The program number. */ /** * @typedef {PresetListChangeCallbackSingle[]} PresetListChangeCallback - A list of preset objects. */ /** * @typedef {Object} SynthDisplayCallback * @property {Uint8Array} displayData - The data to display. * @property {synthDisplayTypes} displayType - The type of display. */ /** * @typedef {Object} PitchWheelCallback * @property {number} channel - The MIDI channel number. * @property {number} MSB - The most significant byte of the pitch-wheel value. * @property {number} LSB - The least significant byte of the pitch-wheel value. */ /** * @typedef {Object} ChannelPressureCallback * @property {number} channel - The MIDI channel number. * @property {number} pressure - The pressure value. */ /** * @typedef {Error} SoundfontErrorCallback - The error message for soundfont errors. */ /** * @typedef { * NoteOnCallback | * NoteOffCallback | * DrumChangeCallback | * ProgramChangeCallback | * ControllerChangeCallback | * MuteChannelCallback | * PresetListChangeCallback | * PitchWheelCallback | * SoundfontErrorCallback | * ChannelPressureCallback | * SynthDisplayCallback | * undefined * } EventCallbackData */ /** * @typedef { * "noteon"| * "noteoff"| * "pitchwheel"| * "controllerchange"| * "programchange"| * "channelpressure"| * "polypressure" | * "drumchange"| * "stopall"| * "newchannel"| * "mutechannel"| * "presetlistchange"| * "allcontrollerreset"| * "soundfonterror"| * "synthdisplay"} EventTypes */ /** * @typedef {Object} SynthMethodOptions * @property {number} time - the audio context time when the event should execute, in seconds. */ /** * @type {SynthMethodOptions} */ const DEFAULT_SYNTH_METHOD_OPTIONS = { time: 0 }; // if the note is released faster than that, it forced to last that long // this is used mostly for drum channels, where a lot of midis like to send instant note off after a note on export const MIN_NOTE_LENGTH = 0.03; // this sounds way nicer for an instant hi-hat cutoff export const MIN_EXCLUSIVE_LENGTH = 0.07; export const SYNTHESIZER_GAIN = 1.0; // the core synthesis engine of spessasynth. class SpessaSynthProcessor { /** * Manages sound banks. * @type {SoundFontManager} */ soundfontManager = new SoundFontManager(this.updatePresetList.bind(this)); /** * Cached voices for all presets for this synthesizer. * Nesting goes like this: * this.cachedVoices[bankNumber][programNumber][midiNote][velocity] = a list of voices for that. * @type {Voice[][][][][]} */ cachedVoices = []; /** * Synth's device id: -1 means all * @type {number} */ deviceID = ALL_CHANNELS_OR_DIFFERENT_ACTION; /** * Synth's event queue from the main thread * @type {{callback: function(), time: number}[]} */ eventQueue = []; /** * Interpolation type used * @type {interpolationTypes} */ interpolationType = interpolationTypes.fourthOrder; /** * Global transposition in semitones * @type {number} */ transposition = 0; /** * this.tunings[program][key] = tuning * @type {MTSProgramTuning[]} */ tunings = []; /** * The volume gain, set by user * @type {number} */ masterGain = SYNTHESIZER_GAIN; /** * The volume gain, set by MIDI sysEx * @type {number} */ midiVolume = 1; /** * Reverb linear gain * @type {number} */ reverbGain = 1; /** * Chorus linear gain * @type {number} */ chorusGain = 1; /** * Set via system exclusive * @type {number} */ reverbSend = 1; /** * Set via system exclusive * @type {number} */ chorusSend = 1; /** * Maximum number of voices allowed at once * @type {number} */ voiceCap = VOICE_CAP; /** * (-1 to 1) * @type {number} */ pan = 0.0; /** * the pan of the left channel * @type {number} */ panLeft = 0.5; /** * the pan of the right channel * @type {number} */ panRight = 0.5; /** * forces note killing instead of releasing * @type {boolean} */ highPerformanceMode = false; /** * Handlese custom key overrides: velocity and preset * @type {KeyModifierManager} */ keyModifierManager = new KeyModifierManager(); /** * contains all the channels with their voices on the processor size * @type {MidiAudioChannel[]} */ midiAudioChannels = []; /** * Controls the bank selection & SysEx * @type {SynthSystem} */ system = DEFAULT_SYNTH_MODE; /** * Current total voices amount * @type {number} */ totalVoicesAmount = 0; /** * Synth's default (reset) preset * @type {BasicPreset} */ defaultPreset; /** * Synth's default (reset) drum preset * @type {BasicPreset} */ drumPreset; /** * Controls if the processor is fully initialized * @type {Promise<boolean>} */ processorInitialized = stbvorbis.isInitialized; /** * Current audio time * @type {number} */ currentSynthTime = 0; /** * in hertz * @type {number} */ sampleRate; /** * Sample time in seconds * @type {number} */ sampleTime; /** * are the chorus and reverb effects enabled? * @type {boolean} */ effectsEnabled = true; /** * one voice per note and track (issue #7) */ _monophonicRetriggerMode = false; /** * for applying the snapshot after an override sound bank too * @type {SynthesizerSnapshot} * @private */ _snapshot; /** * Calls when an event occurs. * @type {function} * @param {EventTypes} eventType - the event type. * @param {EventCallbackData} eventData - the event data. */ onEventCall; /** * Calls when a channel property is changed. * @type {function} * @param {ChannelProperty} property - the updated property. * @param {number} channelNumber - the channel number of the said property. */ onChannelPropertyChange; /** * Calls when a master parameter is changed. * @type {function} * @param {masterParameterType} parameter - the parameter type * @param {number|string} value - the new value. */ onMasterParameterChange; /** * Creates a new synthesizer engine. * @param sampleRate {number} - sample rate, in Hertz. * @param options {SynthProcessorOptions} - the processor's options. */ constructor(sampleRate, options = DEFAULT_SYNTH_OPTIONS) { options = fillWithDefaults(options, DEFAULT_SYNTH_OPTIONS); /** * Midi output count * @type {number} */ this.midiOutputsCount = options.midiChannels; this.effectsEnabled = options.effectsEnabled; this.enableEventSystem = options.enableEventSystem; this.currentSynthTime = options.initialTime; this.sampleTime = 1 / sampleRate; this.sampleRate = sampleRate; // these smoothing factors were tested on 44,100 Hz, adjust them to target sample rate here this.volumeEnvelopeSmoothingFactor = VOLUME_ENVELOPE_SMOOTHING_FACTOR * (44100 / sampleRate); this.panSmoothingFactor = PAN_SMOOTHING_FACTOR * (44100 / sampleRate); this.filterSmoothingFactor = FILTER_SMOOTHING_FACTOR * (44100 / sampleRate); for (let i = 0; i < 128; i++) { this.tunings.push([]); } for (let i = 0; i < this.midiOutputsCount; i++) { this.createMidiChannel(false); } this.processorInitialized.then(() => { SpessaSynthInfo("%cSpessaSynth is ready!", consoleColors.recognized); }); } /** * @returns {number} */ get currentGain() { return this.masterGain * this.midiVolume; } getDefaultPresets() { // override this to XG, to set the default preset to NOT be XG drums! const sys = this.system; this.system = "xg"; this.defaultPreset = this.getPreset(0, 0); this.system = sys; this.drumPreset = this.getPreset(128, 0); } /** * @param value {SynthSystem} */ setSystem(value) { this.system = value; this?.onMasterParameterChange?.(masterParameterType.midiSystem, this.system); } /** * @param bank {number} * @param program {number} * @param midiNote {number} * @param velocity {number} * @returns {Voice[]|undefined} */ getCachedVoice(bank, program, midiNote, velocity) { return this.cachedVoices?.[bank]?.[program]?.[midiNote]?.[velocity]; } /** * @param bank {number} * @param program {number} * @param midiNote {number} * @param velocity {number} * @param voices {Voice[]} */ setCachedVoice(bank, program, midiNote, velocity, voices) { // make sure that it exists if (!this.cachedVoices[bank]) { this.cachedVoices[bank] = []; } if (!this.cachedVoices[bank][program]) { this.cachedVoices[bank][program] = []; } if (!this.cachedVoices[bank][program][midiNote]) { this.cachedVoices[bank][program][midiNote] = []; } // cache this.cachedVoices[bank][program][midiNote][velocity] = voices; } // noinspection JSUnusedGlobalSymbols /** * Renders float32 audio data to stereo outputs; buffer size of 128 is recommended * All float arrays must have the same length * @param outputs {Float32Array[]} output stereo channels (L, R) * @param reverb {Float32Array[]} reverb stereo channels (L, R) * @param chorus {Float32Array[]} chorus stereo channels (L, R) * @param startIndex {number} start offset of the passed arrays, rendering starts at this index, defaults to 0 * @param sampleCount {number} the length of the rendered buffer, defaults to float32array length - startOffset */ renderAudio(outputs, reverb, chorus, startIndex = 0, sampleCount = 0 ) { this.renderAudioSplit(reverb, chorus, Array(16).fill(outputs), startIndex, sampleCount); } /** * Renders the float32 audio data of each channel; buffer size of 128 is recommended * All float arrays must have the same length * @param reverbChannels {Float32Array[]} reverb stereo channels (L, R) * @param chorusChannels {Float32Array[]} chorus stereo channels (L, R) * @param separateChannels {Float32Array[][]} a total of 16 stereo pairs (L, R) for each MIDI channel * @param startIndex {number} start offset of the passed arrays, rendering starts at this index, defaults to 0 * @param sampleCount {number} the length of the rendered buffer, defaults to float32array length - startOffset */ renderAudioSplit(reverbChannels, chorusChannels, separateChannels, startIndex = 0, sampleCount = 0 ) { // process event queue const time = this.currentSynthTime; while (this.eventQueue[0]?.time <= time) { this.eventQueue.shift().callback(); } const revL = reverbChannels[0]; const revR = reverbChannels[1]; const chrL = chorusChannels[0]; const chrR = chorusChannels[1]; // validate startIndex = Math.max(startIndex, 0); const quantumSize = sampleCount || separateChannels[0][0].length - startIndex; // for every channel this.totalVoicesAmount = 0; this.midiAudioChannels.forEach((channel, index) => { if (channel.voices.length < 1 || channel.isMuted) { // there's nothing to do! return; } let voiceCount = channel.voices.length; const ch = index % 16; // render to the appropriate output channel.renderAudio( separateChannels[ch][0], separateChannels[ch][1], revL, revR, chrL, chrR, startIndex, quantumSize ); this.totalVoicesAmount += channel.voices.length; // if voice count changed, update voice amount if (channel.voices.length !== voiceCount) { channel.sendChannelProperty(); } }); // advance the time appropriately this.currentSynthTime += quantumSize * this.sampleTime; } // noinspection JSUnusedGlobalSymbols destroySynthProcessor() { this.midiAudioChannels.forEach(c => { delete c.midiControllers; delete c.voices; delete c.sustainedVoices; delete c.lockedControllers; delete c.preset; delete c.customControllers; }); delete this.cachedVoices; delete this.midiAudioChannels; this.soundfontManager.destroyManager(); delete this.soundfontManager; } /** * @param channel {number} * @param controllerNumber {number} * @param controllerValue {number} * @param force {boolean} */ controllerChange(channel, controllerNumber, controllerValue, force = false) { this.midiAudioChannels[channel].controllerChange(controllerNumber, controllerValue, force); } /** * @param channel {number} * @param midiNote {number} * @param velocity {number} */ noteOn(channel, midiNote, velocity) { this.midiAudioChannels[channel].noteOn(midiNote, velocity); } /** * @param channel {number} * @param midiNote {number} */ noteOff(channel, midiNote) { this.midiAudioChannels[channel].noteOff(midiNote); } /** * @param channel {number} * @param midiNote {number} * @param pressure {number} */ polyPressure(channel, midiNote, pressure) { this.midiAudioChannels[channel].polyPressure(midiNote, pressure); } /** * @param channel {number} * @param pressure {number} */ channelPressure(channel, pressure) { this.midiAudioChannels[channel].channelPressure(pressure); } /** * @param channel {number} * @param MSB {number} * @param LSB {number} */ pitchWheel(channel, MSB, LSB) { this.midiAudioChannels[channel].pitchWheel(MSB, LSB); } /** * @param channel {number} * @param programNumber {number} */ programChange(channel, programNumber) { this.midiAudioChannels[channel].programChange(programNumber); } // noinspection JSUnusedGlobalSymbols /** * Processes a MIDI message * @param message {Uint8Array} - the message to process * @param channelOffset {number} - channel offset for the message * @param force {boolean} cool stuff * @param options {SynthMethodOptions} - additional options for scheduling the message */ processMessage(message, channelOffset = 0, force = false, options = DEFAULT_SYNTH_METHOD_OPTIONS) { const call = () => { const statusByteData = getEvent(message[0]); const channel = statusByteData.channel + channelOffset; // process the event switch (statusByteData.status) { case messageTypes.noteOn: const velocity = message[2]; if (velocity > 0) { this.noteOn(channel, message[1], velocity); } else { this.noteOff(channel, message[1]); } break; case messageTypes.noteOff: if (force) { this.midiAudioChannels[channel].killNote(message[1]); } else { this.noteOff(channel, message[1]); } break; case messageTypes.pitchBend: this.pitchWheel(channel, message[2], message[1]); break; case messageTypes.controllerChange: this.controllerChange(channel, message[1], message[2], force); break; case messageTypes.programChange: this.programChange(channel, message[1]); break; case messageTypes.polyPressure: this.polyPressure(channel, message[0], message[1]); break; case messageTypes.channelPressure: this.channelPressure(channel, message[1]); break; case messageTypes.systemExclusive: this.systemExclusive(new IndexedByteArray(message.slice(1)), channelOffset); break; case messageTypes.reset: this.stopAllChannels(true); this.resetAllControllers(); break; default: break; } }; const time = options.time; if (time > this.currentSynthTime) { this.eventQueue.push({ callback: call.bind(this), time: time }); this.eventQueue.sort((e1, e2) => e1.time - e2.time); } else { call(); } } /** * @param volume {number} 0 to 1 */ setMIDIVolume(volume) { // GM2 specification, section 4.1: volume is squared. // though, according to my own testing, Math.E seems like a better choice this.midiVolume = Math.pow(volume, Math.E); } /** * Calls synth event * @param eventName {EventTypes} the event name * @param eventData {EventCallbackData} * @this {SpessaSynthProcessor} */ callEvent(eventName, eventData) { this?.onEventCall?.(eventName, eventData); } clearCache() { this.cachedVoices = []; } /** * @param program {number} * @param bank {number} * @returns {BasicPreset} */ getPreset(bank, program) { return this.soundfontManager.getPreset(bank, program, isSystemXG(this.system)).preset; } } // include other methods // voice related SpessaSynthProcessor.prototype.voiceKilling = voiceKilling; SpessaSynthProcessor.prototype.getVoicesForPreset = getVoicesForPreset; SpessaSynthProcessor.prototype.getVoices = getVoices; // system-exclusive related SpessaSynthProcessor.prototype.systemExclusive = systemExclusive; // channel related SpessaSynthProcessor.prototype.stopAllChannels = stopAllChannels; SpessaSynthProcessor.prototype.createMidiChannel = createMidiChannel; SpessaSynthProcessor.prototype.resetAllControllers = resetAllControllers; // master parameter related SpessaSynthProcessor.prototype.setMasterParameter = setMasterParameter; // tuning related SpessaSynthProcessor.prototype.transposeAllChannels = transposeAllChannels; SpessaSynthProcessor.prototype.setMasterTuning = setMasterTuning; // program related SpessaSynthProcessor.prototype.clearEmbeddedBank = clearEmbeddedBank; SpessaSynthProcessor.prototype.setEmbeddedSoundFont = setEmbeddedSoundFont; SpessaSynthProcessor.prototype.updatePresetList = updatePresetList; // snapshot related SpessaSynthProcessor.prototype.applySynthesizerSnapshot = applySynthesizerSnapshot; export { SpessaSynthProcessor };