diff --git a/README.md b/README.md index 681939e7..c0e75b5d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,10 @@ SoundFont2 based realtime synthetizer and MIDI player written in JavaScript usin # [Live demo](https://spessasus.github.io/SpessaSynth/) ## Features -- SoundFont2 Generator Support (Specifcally [here](#currently-supported-generators)) +- SoundFont2 Generator Support +- SoundFont2 Modulator Support +- A few custom modulators to support some additional controllers (see `modulators.js`) +- Written using AudioWorklets - MIDI Controller Support (Currently supported controllers can be found [here](../../wiki/Synthetizer-Class#supported-controllers)) - Supports some Roland and Yamaha XG sysex messages - High performance mode for playing black MIDIs (Don't go too crazy with the amount of notes though) @@ -26,7 +29,7 @@ SoundFont2 based realtime synthetizer and MIDI player written in JavaScript usin - Comes bundled with a small [GeneralUser GS](https://schristiancollins.com/generaluser.php) soundFont to get you started ### Limitations -- The program currently supports no modulators (Work in progress) and no reverb. +- The program currently supports no reverb. - It might not sound as good as other synthetizers (e.g. FluidSynth or BASSMIDI) ## Installation @@ -52,24 +55,7 @@ The program is divided into parts: - [Synthetizer](../../wiki/Synthetizer-Class) - generates the sound using the given preset - UI classes - used for user interface, connect to their respective parts (eg. synth, sequencer, keyboard etc) - -## Currently supported generators -- Full volume envelope -- All address offsets -- Chorus (on channel level) -- Looping modes -- FilterFc and FilterQ -- Modulation envelope for the low-pass filter (attack is linear instead of convex) -- KeyNumTo ModEnv hold and decay, same for volEnv -- Overriding root key, keynum and velocity -- Vibrato LFO (freq, depth and delay) **Including the Mod wheel support!** -- Scale tuning, fine tune and coarse tune -- exclusive class (although sometimes broken) -- pan - #### todo -- make the worklet system work - make the worklet system perform good -- implement the worklet system - port the worklet system to emscripten (maybe) - reverb that actually runs well \ No newline at end of file diff --git a/src/spessasynth_lib/sequencer/sequencer.js b/src/spessasynth_lib/sequencer/sequencer.js index 36042494..1e8034a6 100644 --- a/src/spessasynth_lib/sequencer/sequencer.js +++ b/src/spessasynth_lib/sequencer/sequencer.js @@ -107,10 +107,6 @@ export class Sequencer { set currentTime(time) { - if(this.onTimeChange) - { - this.onTimeChange(time); - } if(time < 0 || time > this.duration) { time = 0; @@ -126,6 +122,10 @@ export class Sequencer { this.renderer.noteStartTime = this.absoluteStartTime; this.resetRendererIndexes(); } + if(this.onTimeChange) + { + this.onTimeChange(time); + } } resetRendererIndexes() diff --git a/src/spessasynth_lib/soundfont/chunk/generators.js b/src/spessasynth_lib/soundfont/chunk/generators.js index 4acafd4e..dfb34be6 100644 --- a/src/spessasynth_lib/soundfont/chunk/generators.js +++ b/src/spessasynth_lib/soundfont/chunk/generators.js @@ -109,7 +109,7 @@ generatorLimits[generatorTypes.attackModEnv] = {min: -12000, max: 8000, def: -12 generatorLimits[generatorTypes.holdModEnv] = {min: -12000, max: 5000, def: -12000}; generatorLimits[generatorTypes.decayModEnv] = {min: -12000, max: 8000, def: -12000}; generatorLimits[generatorTypes.sustainModEnv] = {min: 0, max: 1000, def: 0}; -generatorLimits[generatorTypes.releaseModEnv] = {min: -12000, max: 8000, def: -7200}; +generatorLimits[generatorTypes.releaseModEnv] = {min: -12000, max: 8000, def: -12000}; // keynum to mod env generatorLimits[generatorTypes.keyNumToModEnvHold] = {min: -1200, max: 1200, def: 0}; generatorLimits[generatorTypes.keyNumToModEnvDecay] = {min: -1200, max: 1200, def: 0}; diff --git a/src/spessasynth_lib/soundfont/chunk/modulators.js b/src/spessasynth_lib/soundfont/chunk/modulators.js index 341c04ff..4d661680 100644 --- a/src/spessasynth_lib/soundfont/chunk/modulators.js +++ b/src/spessasynth_lib/soundfont/chunk/modulators.js @@ -2,6 +2,7 @@ import {signedInt16, readByte, readBytesAsUintLittleEndian} from "../../utils/by import { ShiftableByteArray } from '../../utils/shiftable_array.js'; import { generatorTypes } from './generators.js' import { consoleColors } from '../../utils/other.js' +import { midiControllers } from '../../midi_parser/midi_message.js' export const modulatorSources = { noController: 0, @@ -78,14 +79,53 @@ export class Modulator{ } } +function getModSourceEnum(curveType, polarity, direction, isCC, index) +{ + return (curveType << 10) | (polarity << 9) | (direction << 8) | (isCC << 7) | index; +} + export const defaultModulators = [ - new Modulator({srcEnum: 0x0502, dest: generatorTypes.initialAttenuation, amt: 960, secSrcEnum: 0x0, transform: 0}), // vel to attenuation - new Modulator({srcEnum: 0x0081, dest: generatorTypes.vibLfoToPitch, amt: 50, secSrcEnum: 0x0, transform: 0}), // mod to vibrato - new Modulator({srcEnum: 0x0587, dest: generatorTypes.initialAttenuation, amt: 960, secSrcEnum: 0x0, transform: 0}), // vol to attenuation - new Modulator({srcEnum: 0x020E, dest: generatorTypes.fineTune, amt: 12700, secSrcEnum: 0x0010, transform: 0}), // pitch to tuning - new Modulator({srcEnum: 0x028A, dest: generatorTypes.pan, amt: 1000, secSrcEnum: 0x0, transform: 0}), // pan to uhh, pan - new Modulator({srcEnum: 0x058B, dest: generatorTypes.initialAttenuation, amt: 960, secSrcEnum: 0x0, transform: 0}) // expression to attenuation -] + // vel to attenuation + new Modulator({srcEnum: 0x0502, dest: generatorTypes.initialAttenuation, amt: 960, secSrcEnum: 0x0, transform: 0}), + // mod wheel to vibrato + new Modulator({srcEnum: 0x0081, dest: generatorTypes.vibLfoToPitch, amt: 50, secSrcEnum: 0x0, transform: 0}), + // vol to attenuation + new Modulator({srcEnum: 0x0587, dest: generatorTypes.initialAttenuation, amt: 1440, secSrcEnum: 0x0, transform: 0}), + // pitch wheel to tuning + new Modulator({srcEnum: 0x020E, dest: generatorTypes.fineTune, amt: 12700, secSrcEnum: 0x0010, transform: 0}), + // pan to uhh, pan + new Modulator({srcEnum: 0x028A, dest: generatorTypes.pan, amt: 1000, secSrcEnum: 0x0, transform: 0}), + // expression to attenuation + new Modulator({srcEnum: 0x058B, dest: generatorTypes.initialAttenuation, amt: 1440, secSrcEnum: 0x0, transform: 0}), + + // custom modulators heck yeah + // cc 92 (tremolo) to modLFO volume + new Modulator({ + srcEnum: getModSourceEnum(modulatorCurveTypes.linear, 0, 0, 1, midiControllers.effects2Depth), /*linear forward unipolar cc 92 */ + dest: generatorTypes.modLfoToVolume, + amt: 24, + secSrcEnum: 0x0, // no controller + transform: 0 + }), + + // cc 72 (release time) to volEnv release + new Modulator({ + srcEnum: getModSourceEnum(modulatorCurveTypes.linear, 1, 0, 1, midiControllers.releaseTime), // linear forward bipolar cc 72 + dest: generatorTypes.releaseVolEnv, + amt: 1200, + secSrcEnum: 0x0, // no controller + transform: 0 + }), + + // cc 74 (brightness) to filterFc + new Modulator({ + srcEnum: getModSourceEnum(modulatorCurveTypes.linear, 1, 0, 1, midiControllers.brightness), // linear forwards bipolar cc 74 + dest: generatorTypes.initialFilterFc, + amt: 5000, + secSrcEnum: 0x0, // no controller + transform: 0 + }) +]; console.log("%cDefault Modulators:", consoleColors.recognized, defaultModulators) diff --git a/src/spessasynth_lib/synthetizer/synthetizer.js b/src/spessasynth_lib/synthetizer/synthetizer.js index 8d023137..5be74679 100644 --- a/src/spessasynth_lib/synthetizer/synthetizer.js +++ b/src/spessasynth_lib/synthetizer/synthetizer.js @@ -7,7 +7,7 @@ import { WorkletChannel } from './worklet_system/worklet_channel.js' import { EventHandler } from '../utils/event_handler.js' // i mean come on -const VOICES_CAP = 2000; +const VOICES_CAP = 1300; export const DEFAULT_GAIN = 0.5; export const DEFAULT_PERCUSSION = 9; @@ -53,8 +53,11 @@ export class Synthetizer { this.defaultPreset = this.soundFont.getPreset(0, 0); this.percussionPreset = this.soundFont.getPreset(128, 0); - // create 16 channels - this.midiChannels = [...Array(16).keys()].map(j => new MidiChannel(this.volumeController, this.defaultPreset, j + 1, false)); + /** + * create 16 channels + * @type {WorkletChannel[]|MidiChannel[]} + */ + this.midiChannels = [...Array(16).keys()].map(j => new WorkletChannel(this.volumeController, this.defaultPreset, j + 1, false)); // change percussion channel to the percussion preset this.midiChannels[DEFAULT_PERCUSSION].percussionChannel = true; diff --git a/src/spessasynth_lib/synthetizer/worklet_system/channel_processor.js b/src/spessasynth_lib/synthetizer/worklet_system/channel_processor.js index e1989658..3eb855e9 100644 --- a/src/spessasynth_lib/synthetizer/worklet_system/channel_processor.js +++ b/src/spessasynth_lib/synthetizer/worklet_system/channel_processor.js @@ -15,7 +15,7 @@ import { applyVolumeEnvelope } from './worklet_utilities/volume_envelope.js' import { applyLowpassFilter } from './worklet_utilities/lowpass_filter.js' import { getModEnvValue } from './worklet_utilities/modulation_envelope.js' -export const MIN_AUDIBLE_GAIN = 0.0001; +const CHANNEL_CAP = 400; const CONTROLLER_TABLE_SIZE = 147; @@ -26,6 +26,8 @@ const resetArray = new Int16Array(146); resetArray[midiControllers.mainVolume] = 100 << 7; resetArray[midiControllers.expressionController] = 127 << 7; resetArray[midiControllers.pan] = 64 << 7; +resetArray[midiControllers.releaseTime] = 64 << 7; +resetArray[midiControllers.brightness] = 64 << 7; resetArray[NON_CC_INDEX_OFFSET + modulatorSources.pitchWheel] = 8192; resetArray[NON_CC_INDEX_OFFSET + modulatorSources.pitchWheelRange] = 2 << 7; @@ -88,8 +90,17 @@ class ChannelProcessor extends AudioWorkletProcessor { break; case workletMessageType.killNote: - this.voices = this.voices.filter(v => v.midiNote !== data); - this.port.postMessage(this.voices.length); + this.voices.forEach(v => { + if(v.midiNote !== data) + { + return; + } + v.generators[generatorTypes.releaseVolEnv] = -7200; + computeModulators(v, this.midiControllers); + this.releaseVoice(v); + }); + // this.voices = this.voices.filter(v => v.midiNote !== data); + // this.port.postMessage(this.voices.length); break; case workletMessageType.noteOn: @@ -120,6 +131,10 @@ class ChannelProcessor extends AudioWorkletProcessor { } }) this.voices.push(...data); + if(this.voices.length > CHANNEL_CAP) + { + this.voices.splice(0, this.voices.length - CHANNEL_CAP); + } this.port.postMessage(this.voices.length); break; @@ -215,6 +230,13 @@ class ChannelProcessor extends AudioWorkletProcessor { return; } + + // if the initial attenuation is more than 100dB, skip the voice (it's silent anyways) + if(voice.modulatedGenerators[generatorTypes.initialAttenuation] > 2500) + { + return; + } + // TUNING // calculate tuning diff --git a/src/spessasynth_lib/synthetizer/worklet_system/worklet_channel.js b/src/spessasynth_lib/synthetizer/worklet_system/worklet_channel.js index b125bc60..7519fbcc 100644 --- a/src/spessasynth_lib/synthetizer/worklet_system/worklet_channel.js +++ b/src/spessasynth_lib/synthetizer/worklet_system/worklet_channel.js @@ -136,10 +136,6 @@ export class WorkletChannel { this.cachedWorkletVoices.push([]); } - - // contains all the midi controllers and their values (and the source enum controller palettes - this.midiControllers = new Int16Array(146); // 127 controllers + sf2 spec 8.2.1 + other things - this.preset = defaultPreset; this.bank = this.preset.bank; this.channelVolume = 1; @@ -164,6 +160,7 @@ export class WorkletChannel { this.gainController = new GainNode(this.ctx, { gain: CHANNEL_GAIN }); + this.muted = false; /** * @type {Set} @@ -218,10 +215,12 @@ export class WorkletChannel { muteChannel() { this.gainController.gain.value = 0; + this.muted = true; } unmuteChannel() { + this.muted = false; this.gainController.gain.value = CHANNEL_GAIN; } @@ -233,7 +232,6 @@ export class WorkletChannel { { switch (cc) { default: - this.midiControllers[cc] = val << 7; this.post({ messageType: workletMessageType.ccChange, messageData: [cc, val << 7] @@ -444,6 +442,11 @@ export class WorkletChannel { return; } + if(this.muted) + { + return; + } + let workletVoices = this.getWorkletVoices(midiNote, velocity); if(debug) @@ -488,7 +491,6 @@ export class WorkletChannel { setPitchBend(bendMSB, bendLSB) { // bend all the notes this.pitchBend = (bendLSB | (bendMSB << 7)) ; - this.midiControllers[NON_CC_INDEX_OFFSET + modulatorSources.pitchWheel] = this.pitchBend; this.post({ messageType: workletMessageType.ccChange, messageData: [NON_CC_INDEX_OFFSET + modulatorSources.pitchWheel, this.pitchBend] @@ -640,7 +642,6 @@ export class WorkletChannel { case 0x0000: this.channelPitchBendRange = dataValue; console.log(`Channel ${this.channelNumber} bend range. Semitones:`, dataValue); - this.midiControllers[NON_CC_INDEX_OFFSET + modulatorSources.pitchWheelRange] = this.channelPitchBendRange << 7; this.post({ messageType: workletMessageType.ccChange, messageData: [NON_CC_INDEX_OFFSET + modulatorSources.pitchWheelRange, this.channelPitchBendRange << 7] @@ -652,7 +653,6 @@ export class WorkletChannel { // semitones this.channelTuningSemitones = dataValue - 64; console.log("tuning", this.channelTuningSemitones, "for", this.channelNumber); - this.midiControllers[NON_CC_INDEX_OFFSET + modulatorSources.channelTuning] = (this.channelTuningSemitones) * 100; this.post({ messageType: workletMessageType.ccChange, messageData: [NON_CC_INDEX_OFFSET + modulatorSources.channelTuning, (this.channelTuningSemitones) * 100] @@ -683,7 +683,6 @@ export class WorkletChannel { return; } this.channelTranspose = semitones; - this.midiControllers[NON_CC_INDEX_OFFSET + modulatorSources.channelTranspose] = this.channelTranspose * 100; this.post({ messageType: workletMessageType.ccChange, messageData: [NON_CC_INDEX_OFFSET + modulatorSources.channelTranspose, this.channelTranspose * 100] diff --git a/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/modulation_envelope.js b/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/modulation_envelope.js index c79a58fc..67a6d512 100644 --- a/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/modulation_envelope.js +++ b/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/modulation_envelope.js @@ -1,9 +1,17 @@ import { timecentsToSeconds } from './unit_converter.js' import { generatorTypes } from '../../../soundfont/chunk/generators.js' -import { CONVEX_ATTACK } from './volume_envelope.js' +import { getModulatorCurveValue } from './modulator_curves.js' +import { modulatorCurveTypes } from '../../../soundfont/chunk/modulators.js' const PEAK = 1; +// 1000 should be precise enough +const CONVEX_ATTACK = new Float32Array(1000); +for (let i = 0; i < CONVEX_ATTACK.length; i++) { + // this makes the db linear ( i think + CONVEX_ATTACK[i] = getModulatorCurveValue(0, modulatorCurveTypes.convex, i / 1000, 0); +} + /** * @param voice {WorkletVoice} * @param currentTime {number} diff --git a/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/unit_converter.js b/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/unit_converter.js index 9e8d6a71..17a3d90a 100644 --- a/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/unit_converter.js +++ b/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/unit_converter.js @@ -3,7 +3,7 @@ const MIN_TIMECENT = -15000; const MAX_TIMECENT = 15000; const timecentLookupTable = new Float32Array(MAX_TIMECENT - MIN_TIMECENT + 1); -for (let i = 1; i < timecentLookupTable.length; i++) { +for (let i = 0; i < timecentLookupTable.length; i++) { const timecents = MIN_TIMECENT + i; timecentLookupTable[i] = Math.pow(2, timecents / 1200); } diff --git a/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/volume_envelope.js b/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/volume_envelope.js index 6051c07f..fb452618 100644 --- a/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/volume_envelope.js +++ b/src/spessasynth_lib/synthetizer/worklet_system/worklet_utilities/volume_envelope.js @@ -1,16 +1,7 @@ import { decibelAttenuationToGain, timecentsToSeconds } from './unit_converter.js' import { generatorTypes } from '../../../soundfont/chunk/generators.js' -import { getModulatorCurveValue } from './modulator_curves.js' -import { modulatorCurveTypes } from '../../../soundfont/chunk/modulators.js' const DB_SILENCE = 100; - -// 1000 should be precise enough -export const CONVEX_ATTACK = new Float32Array(1000); -for (let i = 0; i < CONVEX_ATTACK.length; i++) { - CONVEX_ATTACK[i] = getModulatorCurveValue(0, modulatorCurveTypes.convex, i / 1000, 0); -} - /** * @param voice {WorkletVoice} * @param audioBuffer {Float32Array} @@ -64,7 +55,12 @@ export function applyVolumeEnvelope(voice, audioBuffer, currentTime, centibelOff else if(currentFrameTime < attackEnd) { // we're in the attack phase - dbAttenuation = CONVEX_ATTACK[~~(((attackEnd - currentFrameTime) / attack) * 1000)] * (DB_SILENCE - attenuation) + attenuation; + // Special case: linear instead of exponential + const elapsed = (attackEnd - currentFrameTime) / attack; + audioBuffer[i] = audioBuffer[i] * (1 - elapsed) * decibelAttenuationToGain(attenuation); + currentFrameTime += sampleTime; + dbAttenuation = elapsed * attenuation; + continue; } else if(currentFrameTime < holdEnd) {