Skip to content

Commit

Permalink
The final worklet update
Browse files Browse the repository at this point in the history
- implemented the worklet system
- improved performance a bit and fixed some bugs
- added some custom modulators
Well, now wasm and reverb and we're done! :D
  • Loading branch information
spessasus committed Oct 1, 2023
1 parent 0771550 commit 350e4db
Show file tree
Hide file tree
Showing 10 changed files with 112 additions and 58 deletions.
24 changes: 5 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
8 changes: 4 additions & 4 deletions src/spessasynth_lib/sequencer/sequencer.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,6 @@ export class Sequencer {

set currentTime(time)
{
if(this.onTimeChange)
{
this.onTimeChange(time);
}
if(time < 0 || time > this.duration)
{
time = 0;
Expand All @@ -126,6 +122,10 @@ export class Sequencer {
this.renderer.noteStartTime = this.absoluteStartTime;
this.resetRendererIndexes();
}
if(this.onTimeChange)
{
this.onTimeChange(time);
}
}

resetRendererIndexes()
Expand Down
2 changes: 1 addition & 1 deletion src/spessasynth_lib/soundfont/chunk/generators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
54 changes: 47 additions & 7 deletions src/spessasynth_lib/soundfont/chunk/modulators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 6 additions & 3 deletions src/spessasynth_lib/synthetizer/synthetizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -164,6 +160,7 @@ export class WorkletChannel {
this.gainController = new GainNode(this.ctx, {
gain: CHANNEL_GAIN
});
this.muted = false;

/**
* @type {Set<number>}
Expand Down Expand Up @@ -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;
}

Expand All @@ -233,7 +232,6 @@ export class WorkletChannel {
{
switch (cc) {
default:
this.midiControllers[cc] = val << 7;
this.post({
messageType: workletMessageType.ccChange,
messageData: [cc, val << 7]
Expand Down Expand Up @@ -444,6 +442,11 @@ export class WorkletChannel {
return;
}

if(this.muted)
{
return;
}

let workletVoices = this.getWorkletVoices(midiNote, velocity);

if(debug)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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)
{
Expand Down

0 comments on commit 350e4db

Please sign in to comment.