UNPKG

spessasynth_core

Version:

MIDI and SoundFont2/DLS library with no compromises

402 lines (360 loc) 15.4 kB
import { decibelAttenuationToGain, timecentsToSeconds } from "./unit_converter.js"; import { generatorTypes } from "../../../soundfont/basic_soundfont/generator_types.js"; /** * volume_envelope.js * purpose: applies a volume envelope for a given voice */ export const VOLUME_ENVELOPE_SMOOTHING_FACTOR = 0.01; const DB_SILENCE = 100; const PERCEIVED_DB_SILENCE = 90; // around 96 dB of attenuation const PERCEIVED_GAIN_SILENCE = 0.000015; // can't go lower than that (see #50) /** * VOL ENV STATES: * 0 - delay * 1 - attack * 2 - hold/peak * 3 - decay * 4 - sustain * release indicates by isInRelease property */ export class VolumeEnvelope { /** * The envelope's current time in samples * @type {number} */ currentSampleTime = 0; /** * The sample rate in Hz * @type {number} */ sampleRate; /** * The current attenuation of the envelope in dB * @type {number} */ currentAttenuationDb = DB_SILENCE; /** * The current stage of the volume envelope * @type {0|1|2|3|4} */ state = 0; /** * The dB attenuation of the envelope when it entered the release stage * @type {number} */ releaseStartDb = DB_SILENCE; /** * The time in samples relative to the start of the envelope * @type {number} */ releaseStartTimeSamples = 0; /** * The current gain applied to the voice in the release stage * @type {number} */ currentReleaseGain = 1; /** * The attack duration in samples * @type {number} */ attackDuration = 0; /** * The decay duration in samples * @type {number} */ decayDuration = 0; /** * The release duration in samples * @type {number} */ releaseDuration = 0; /** * The voice's absolute attenuation as linear gain * @type {number} */ attenuation = 0; /** * The attenuation target, which the "attenuation" property is linearly interpolated towards (gain) * @type {number} */ attenuationTargetGain = 0; /** * The attenuation target, which the "attenuation" property is linearly interpolated towards (dB) * @type {number} */ attenuationTarget = 0; /** * The voice's sustain amount in dB, relative to attenuation * @type {number} */ sustainDbRelative = 0; /** * The time in samples to the end of delay stage, relative to the start of the envelope * @type {number} */ delayEnd = 0; /** * The time in samples to the end of attack stage, relative to the start of the envelope * @type {number} */ attackEnd = 0; /** * The time in samples to the end of hold stage, relative to the start of the envelope * @type {number} */ holdEnd = 0; /** * The time in samples to the end of decay stage, relative to the start of the envelope * @type {number} */ decayEnd = 0; /** * @param sampleRate {number} Hz * @param initialDecay {number} cb */ constructor(sampleRate, initialDecay) { this.sampleRate = sampleRate; /** * if sustain stge is silent, * then we can turn off the voice when it is silent. * We can't do that with modulated as it can silence the volume and then raise it again, and the voice must keep playing * @type {boolean} */ this.canEndOnSilentSustain = initialDecay / 10 >= PERCEIVED_DB_SILENCE; } /** * Starts the release phase in the envelope * @param voice {Voice} the voice this envelope belongs to */ static startRelease(voice) { voice.volumeEnvelope.releaseStartTimeSamples = voice.volumeEnvelope.currentSampleTime; voice.volumeEnvelope.currentReleaseGain = decibelAttenuationToGain(voice.volumeEnvelope.currentAttenuationDb); VolumeEnvelope.recalculate(voice); } /** * Recalculates the envelope * @param voice {Voice} the voice this envelope belongs to */ static recalculate(voice) { const env = voice.volumeEnvelope; const timecentsToSamples = tc => { return Math.max(0, Math.floor(timecentsToSeconds(tc) * env.sampleRate)); }; // calculate absolute times (they can change so we have to recalculate every time env.attenuationTarget = Math.max( 0, Math.min(voice.modulatedGenerators[generatorTypes.initialAttenuation], 1440) ) / 10; // divide by ten to get decibels env.attenuationTargetGain = decibelAttenuationToGain(env.attenuationTarget); env.sustainDbRelative = Math.min(DB_SILENCE, voice.modulatedGenerators[generatorTypes.sustainVolEnv] / 10); const sustainDb = Math.min(DB_SILENCE, env.sustainDbRelative); // calculate durations env.attackDuration = timecentsToSamples(voice.modulatedGenerators[generatorTypes.attackVolEnv]); // decay: sfspec page 35: the time is for change from attenuation to -100dB, // therefore, we need to calculate the real time // (changing from attenuation to sustain instead of -100dB) const fullChange = voice.modulatedGenerators[generatorTypes.decayVolEnv]; const keyNumAddition = (60 - voice.targetKey) * voice.modulatedGenerators[generatorTypes.keyNumToVolEnvDecay]; const fraction = sustainDb / DB_SILENCE; env.decayDuration = timecentsToSamples(fullChange + keyNumAddition) * fraction; env.releaseDuration = timecentsToSamples(voice.modulatedGenerators[generatorTypes.releaseVolEnv]); // calculate absolute end times for the values env.delayEnd = timecentsToSamples(voice.modulatedGenerators[generatorTypes.delayVolEnv]); env.attackEnd = env.attackDuration + env.delayEnd; // make sure to take keyNumToVolEnvHold into account! const holdExcursion = (60 - voice.targetKey) * voice.modulatedGenerators[generatorTypes.keyNumToVolEnvHold]; env.holdEnd = timecentsToSamples(voice.modulatedGenerators[generatorTypes.holdVolEnv] + holdExcursion) + env.attackEnd; env.decayEnd = env.decayDuration + env.holdEnd; // if this is the first recalculation and the voice has no attack or delay time, set current db to peak if (env.state === 0 && env.attackEnd === 0) { // env.currentAttenuationDb = env.attenuationTarget; env.state = 2; } // check if voice is in release if (voice.isInRelease) { // no interpolation this time: force update to actual attenuation and calculate release start from there //env.attenuation = Math.min(DB_SILENCE, env.attenuationTarget); const sustainDb = Math.max(0, Math.min(DB_SILENCE, env.sustainDbRelative)); const fraction = sustainDb / DB_SILENCE; env.decayDuration = timecentsToSamples(fullChange + keyNumAddition) * fraction; switch (env.state) { case 0: env.releaseStartDb = DB_SILENCE; break; case 1: // attack phase: get linear gain of the attack phase when release started // and turn it into db as we're ramping the db up linearly // (to make volume go down exponentially) // attack is linear (in gain) so we need to do get db from that let elapsed = 1 - ((env.attackEnd - env.releaseStartTimeSamples) / env.attackDuration); // calculate the gain that the attack would have, so // turn that into db env.releaseStartDb = 20 * Math.log10(elapsed) * -1; break; case 2: env.releaseStartDb = 0; break; case 3: env.releaseStartDb = (1 - (env.decayEnd - env.releaseStartTimeSamples) / env.decayDuration) * sustainDb; break; case 4: env.releaseStartDb = sustainDb; break; } env.releaseStartDb = Math.max(0, Math.min(env.releaseStartDb, DB_SILENCE)); if (env.releaseStartDb >= PERCEIVED_DB_SILENCE) { voice.finished = true; } env.currentReleaseGain = decibelAttenuationToGain(env.releaseStartDb); // release: sfspec page 35: the time is for change from attenuation to -100dB, // therefore, we need to calculate the real time // (changing from release start to -100dB instead of from peak to -100dB) const releaseFraction = (DB_SILENCE - env.releaseStartDb) / DB_SILENCE; env.releaseDuration *= releaseFraction; } } /** * Applies volume envelope gain to the given output buffer * @param voice {Voice} the voice we're working on * @param audioBuffer {Float32Array} the audio buffer to modify * @param centibelOffset {number} the centibel offset of volume, for modLFOtoVolume * @param smoothingFactor {number} the adjusted smoothing factor for the envelope * @description essentially we use approach of 100dB is silence, 0dB is peak, and always add attenuation to that (which is interpolated) */ static apply(voice, audioBuffer, centibelOffset, smoothingFactor) { const env = voice.volumeEnvelope; let decibelOffset = centibelOffset / 10; const attenuationSmoothing = smoothingFactor; // RELEASE PHASE if (voice.isInRelease) { let elapsedRelease = env.currentSampleTime - env.releaseStartTimeSamples; if (elapsedRelease >= env.releaseDuration) { for (let i = 0; i < audioBuffer.length; i++) { audioBuffer[i] = 0; } voice.finished = true; return; } let dbDifference = DB_SILENCE - env.releaseStartDb; for (let i = 0; i < audioBuffer.length; i++) { // attenuation interpolation env.attenuation += (env.attenuationTargetGain - env.attenuation) * attenuationSmoothing; let db = (elapsedRelease / env.releaseDuration) * dbDifference + env.releaseStartDb; env.currentReleaseGain = env.attenuation * decibelAttenuationToGain(db + decibelOffset); audioBuffer[i] *= env.currentReleaseGain; env.currentSampleTime++; elapsedRelease++; } if (env.currentReleaseGain <= PERCEIVED_GAIN_SILENCE) { voice.finished = true; } return; } let filledBuffer = 0; switch (env.state) { case 0: // delay phase, no sound is produced while (env.currentSampleTime < env.delayEnd) { env.currentAttenuationDb = DB_SILENCE; audioBuffer[filledBuffer] = 0; env.currentSampleTime++; if (++filledBuffer >= audioBuffer.length) { return; } } env.state++; // fallthrough case 1: // attack phase: ramp from 0 to attenuation while (env.currentSampleTime < env.attackEnd) { // attenuation interpolation env.attenuation += (env.attenuationTargetGain - env.attenuation) * attenuationSmoothing; // Special case: linear gain ramp instead of linear db ramp let linearAttenuation = 1 - (env.attackEnd - env.currentSampleTime) / env.attackDuration; // 0 to 1 audioBuffer[filledBuffer] *= linearAttenuation * env.attenuation * decibelAttenuationToGain( decibelOffset); // set current attenuation to peak as its invalid during this phase env.currentAttenuationDb = 0; env.currentSampleTime++; if (++filledBuffer >= audioBuffer.length) { return; } } env.state++; // fallthrough case 2: // hold/peak phase: stay at attenuation while (env.currentSampleTime < env.holdEnd) { // attenuation interpolation env.attenuation += (env.attenuationTargetGain - env.attenuation) * attenuationSmoothing; audioBuffer[filledBuffer] *= env.attenuation * decibelAttenuationToGain(decibelOffset); env.currentAttenuationDb = 0; env.currentSampleTime++; if (++filledBuffer >= audioBuffer.length) { return; } } env.state++; // fallthrough case 3: // decay phase: linear ramp from attenuation to sustain while (env.currentSampleTime < env.decayEnd) { // attenuation interpolation env.attenuation += (env.attenuationTargetGain - env.attenuation) * attenuationSmoothing; env.currentAttenuationDb = (1 - (env.decayEnd - env.currentSampleTime) / env.decayDuration) * env.sustainDbRelative; audioBuffer[filledBuffer] *= env.attenuation * decibelAttenuationToGain(env.currentAttenuationDb + decibelOffset); env.currentSampleTime++; if (++filledBuffer >= audioBuffer.length) { return; } } env.state++; // fallthrough case 4: if (env.canEndOnSilentSustain && env.sustainDbRelative >= PERCEIVED_DB_SILENCE) { voice.finished = true; } // sustain phase: stay at sustain while (true) { // attenuation interpolation env.attenuation += (env.attenuationTargetGain - env.attenuation) * attenuationSmoothing; audioBuffer[filledBuffer] *= env.attenuation * decibelAttenuationToGain(env.sustainDbRelative + decibelOffset); env.currentAttenuationDb = env.sustainDbRelative; env.currentSampleTime++; if (++filledBuffer >= audioBuffer.length) { return; } } } } }