Last active
March 5, 2024 07:59
-
-
Save GlaireDaggers/b004687476568ce47cc28bdf7a58d9cf to your computer and use it in GitHub Desktop.
Global audio reverb effect plugin for RPG Maker MZ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//============================================================================= | |
// GlaireDaggers Audio Reverb v1.1 | |
// by Hazel Stagner | |
// Date: 3/2/2024 | |
// License: MIT | |
//============================================================================= | |
//============================================================================= | |
/* CHANGELOG | |
* v1.1 - 3/4/2024 | |
* + Added support for applying reverb to BGM, BGS, ME, SE, and static SE (set with flags, default is only applied to SE to preserve compatibility) | |
* + Added support for setting default reverb settings on game start | |
* + Fixed boolean parameters not being parsed correctly (oops) | |
* + BREAKING CHANGE: disableOnMapLoad renamed to revertOnMapLoad, new behavior is to load from default reverb settings instead of silencing | |
*/ | |
//============================================================================= | |
/*:@target MZ | |
* @plugindesc Adds support for applying a global reverb effect to audio | |
* @author Hazel Stagner | |
* | |
* @param revertOnMapLoad | |
* @text Revert on map load | |
* @desc On map load, if the map does not have a reverbParams tag, automatically sets reverb back to default settings | |
* @type boolean | |
* @default true | |
* | |
* @param applyToBgm | |
* @text Apply to BGM | |
* @desc Whether to apply global reverb to BGM | |
* @type boolean | |
* @default false | |
* | |
* @param applyToBgs | |
* @text Apply to BGS | |
* @desc Whether to apply global reverb to BGS | |
* @type boolean | |
* @default false | |
* | |
* @param applyToMe | |
* @text Apply to ME | |
* @desc Whether to apply global reverb to ME | |
* @type boolean | |
* @default false | |
* | |
* @param applyToSe | |
* @text Apply to SE | |
* @desc Whether to apply global reverb to SE | |
* @type boolean | |
* @default true | |
* | |
* @param applyToStaticSe | |
* @text Apply to static SE | |
* @desc Whether to apply global reverb to static SE (UI sounds, etc) | |
* @type boolean | |
* @default false | |
* | |
* @param defaultReverbParams | |
* @text Default Reverb Params | |
* @desc Default reverb parameters to set on game start | |
* @type struct<ReverbParams> | |
* | |
* @command setReverbParams | |
* @text 'Set Reverb Params' | |
* @ desc 'Set global reverb params' | |
* | |
* @arg roomSize | |
* @text 'Room Size' | |
* @ desc 'Affects the duration of the reverb echo' | |
* @min 0 | |
* @max 0.999 | |
* @decimals 3 | |
* @type number | |
* @default 0.9 | |
* | |
* @arg damping | |
* @text 'Damping' | |
* @ desc 'Affects the cutoff (in Hz) of high frequencies' | |
* @min 100 | |
* @max 4000 | |
* @type number | |
* @default 2000 | |
* | |
* @arg preDelay | |
* @text 'Pre-delay' | |
* @ desc 'Adds a delay (in milliseconds) to the reverb echo' | |
* @min 0 | |
* @max 1000 | |
* @type number | |
* @default 20 | |
* | |
* @arg wet | |
* @text 'Wet' | |
* @ desc 'The gain of the wet (reverbed) signal' | |
* @min 0 | |
* @max 1 | |
* @decimals 2 | |
* @type number | |
* @default 0.0 | |
* | |
* @arg dry | |
* @text 'Dry' | |
* @ desc 'The gain of the dry (non-reverbed) signal' | |
* @min 0 | |
* @max 1 | |
* @decimals 2 | |
* @type number | |
* @default 1.0 | |
* | |
* @command revertReverbParams | |
* @text 'Revert Reverb Params' | |
* @ desc 'Revert global reverb params back to map settings' | |
*/ | |
/*~struct~ReverbParams: | |
* @param roomSize | |
* @text 'Room Size' | |
* @ desc 'Affects the duration of the reverb echo' | |
* @min 0 | |
* @max 0.999 | |
* @decimals 3 | |
* @type number | |
* @default 0.9 | |
* | |
* @param damping | |
* @text 'Damping' | |
* @ desc 'Affects the cutoff (in Hz) of high frequencies' | |
* @min 100 | |
* @max 4000 | |
* @type number | |
* @default 2000 | |
* | |
* @param preDelay | |
* @text 'Pre-delay' | |
* @ desc 'Adds a delay (in milliseconds) to the reverb echo' | |
* @min 0 | |
* @max 1000 | |
* @type number | |
* @default 20 | |
* | |
* @param wet | |
* @text 'Wet' | |
* @ desc 'The gain of the wet (reverbed) signal' | |
* @min 0 | |
* @max 1 | |
* @decimals 2 | |
* @type number | |
* @default 0.0 | |
* | |
* @param dry | |
* @text 'Dry' | |
* @ desc 'The gain of the dry (non-reverbed) signal' | |
* @min 0 | |
* @max 1 | |
* @decimals 2 | |
* @type number | |
* @default 1.0 | |
*/ | |
/* | |
* ---- USAGE ---- | |
* In the map data, you can add a <reverbParams: [roomsize] [damping] [predelay] [wet] [dry]> notetag to set reverb when this map is loaded. | |
* From an event script, you can use the 'Set Reverb Params' plugin command to set reverb parameters, or 'Revert Reverb Params' to set back to map settings | |
* | |
* ---- PARAMETERS ---- | |
* Room size: Affects the duration of the reverb echo. The larger the value, the longer the reverb. Must be less than 1.0 (supplying 1.0 will create an infinite reverb) | |
* Damping: Any frequency above this value (in Hz) will be cut off in the reverb echo. The lower this value, the less "bright" the echo will sound. | |
* Pre-delay: An extra delay (in milliseconds) added to the reverb echo. | |
* Wet: A scale applied to the reverb echo. 0.0 is silent, 1.0 is full volume. | |
* Dry: A scale applied to the non-reverbed audio. 0.0 is silent, 1.0 is full volume. | |
* | |
* ---- EXAMPLE PRESETS ---- | |
* Cave/hall: <reverbParams: 0.9 2000 20 0.1 0.9> | |
* Room: <reverbParams: 0.4 3000 10 0.1 0.9> | |
* Outdoors/streets: <reverbParams: 0.4 4000 150 0.05 0.95> | |
* Weird space echo: <reverbParams: 0.95 4000 150 0.05 0.95> | |
*/ | |
(() => { | |
let parameters = PluginManager.parameters('GD_AudioReverb'); | |
let revertOnMapLoad = parameters['revertOnMapLoad'] === 'true'; | |
let applyToBgm = parameters['applyToBgm'] === 'true'; | |
let applyToBgs = parameters['applyToBgs'] === 'true'; | |
let applyToMe = parameters['applyToMe'] === 'true'; | |
let applyToSe = parameters['applyToSe'] === 'true'; | |
let applyToStaticSe = parameters['applyToStaticSe'] === 'true'; | |
let defaultReverbParams = JSON.parse(parameters['defaultReverbParams']); | |
//----------------------------------------------------------------------------- | |
// helper functions | |
// Adapted from https://github.com/mmckegg/freeverb | |
const combFilterTunings = [1557 / 44100, 1617 / 44100, 1491 / 44100, 1422 / 44100, 1277 / 44100, 1356 / 44100, 1188 / 44100, 1116 / 44100] | |
const allpassFilterFrequencies = [225, 556, 441, 341] | |
/** | |
* @param {AudioContext} context | |
*/ | |
function LowpassCombFilter(context) { | |
var node = context.createDelay(1); | |
var output = context.createBiquadFilter(); | |
output.Q.value = -3.0102999566398125; | |
output.type = 'lowpass'; | |
node.dampening = output.frequency; | |
var feedback = context.createGain(); | |
node.resonance = feedback.gain; | |
node.connect(output); | |
output.connect(feedback); | |
feedback.connect(node); | |
node.dampening.value = 3000; | |
node.delayTime.value = 0.1; | |
node.resonance.value = 0.5; | |
return node; | |
} | |
/** | |
* @param {AudioContext} context | |
*/ | |
function Freeverb(context) { | |
var node = context.createGain(); | |
node.channelCountMode = 'explicit'; | |
node.channelCount = 2; | |
var output = context.createGain(); | |
var merger = context.createChannelMerger(2); | |
var splitter = context.createChannelSplitter(2); | |
var highpass = context.createBiquadFilter(); | |
highpass.type = 'highpass'; | |
highpass.frequency.value = 200; | |
var wet = context.createGain(); | |
var dry = context.createGain(); | |
var predelay = context.createDelay(1); | |
predelay.delayTime.value = 0.05; | |
node.connect(dry); | |
node.connect(wet); | |
wet.connect(predelay); | |
predelay.connect(splitter); | |
merger.connect(highpass); | |
highpass.connect(output); | |
dry.connect(output); | |
var combFiltersL = []; | |
var combFiltersR = []; | |
var allpassFiltersL = []; | |
var allpassFiltersR = []; | |
var roomSize = 0.9; | |
var dampening = 2000; | |
// TODO: do we really need to perform reverb separately on left and right channels? | |
// I'm not 100% convinced it's necessary... if it's a performance issue later it can be fixed. | |
// make the allpass filters on the right | |
for (var l = 0; l < allpassFilterFrequencies.length; l++) { | |
var allpassL = context.createBiquadFilter(); | |
allpassL.type = 'allpass'; | |
allpassL.frequency.value = allpassFilterFrequencies[l]; | |
allpassFiltersL.push(allpassL); | |
if (allpassFiltersL[l - 1]) { | |
allpassFiltersL[l - 1].connect(allpassL); | |
} | |
} | |
// make the allpass filters on the left | |
for (var r = 0; r < allpassFilterFrequencies.length; r++) { | |
var allpassR = context.createBiquadFilter(); | |
allpassR.type = 'allpass'; | |
allpassR.frequency.value = allpassFilterFrequencies[r]; | |
allpassFiltersR.push(allpassR); | |
if (allpassFiltersR[r - 1]) { | |
allpassFiltersR[r - 1].connect(allpassR); | |
} | |
} | |
allpassFiltersL[allpassFiltersL.length - 1].connect(merger, 0, 0); | |
allpassFiltersR[allpassFiltersR.length - 1].connect(merger, 0, 1); | |
// make the comb filters on the left | |
for (var c = 0; c < combFilterTunings.length; c++) { | |
var lfpf = LowpassCombFilter(context); | |
lfpf.delayTime.value = combFilterTunings[c]; | |
splitter.connect(lfpf, 0); | |
lfpf.connect(allpassFiltersL[0]); | |
combFiltersL.push(lfpf); | |
} | |
// make the comb filters on the right | |
for (var c = 0; c < combFilterTunings.length; c++) { | |
var lfpf = LowpassCombFilter(context); | |
lfpf.delayTime.value = combFilterTunings[c]; | |
splitter.connect(lfpf, 1); | |
lfpf.connect(allpassFiltersR[0]); | |
combFiltersR.push(lfpf); | |
} | |
Object.defineProperties(node, { | |
roomSize: { | |
get: function () { | |
return roomSize; | |
}, | |
set: function (value) { | |
roomSize = value; | |
refreshFilters(); | |
} | |
}, | |
dampening: { | |
get: function () { | |
return dampening; | |
}, | |
set: function (value) { | |
dampening = value; | |
refreshFilters(); | |
} | |
} | |
}); | |
refreshFilters(); | |
node.connect = output.connect.bind(output); | |
node.disconnect = output.disconnect.bind(output); | |
node.wet = wet.gain; | |
node.dry = dry.gain; | |
node.predelay = predelay.delayTime; | |
// set up defaults | |
node.roomSize = defaultReverbParams.roomSize; | |
node.dampening = defaultReverbParams.damping; | |
node.predelay.value = defaultReverbParams.preDelay / 1000; | |
node.wet.value = defaultReverbParams.wet; | |
node.dry.value = defaultReverbParams.dry; | |
// expose combFilters for direct automation | |
node.combFiltersL = combFiltersL; | |
node.combFiltersR = combFiltersR; | |
return node; | |
// scoped helper function | |
function refreshFilters() { | |
for (var i = 0; i < combFiltersL.length; i++) { | |
combFiltersL[i].resonance.value = roomSize; | |
combFiltersL[i].dampening.value = dampening; | |
} | |
for (var i = 0; i < combFiltersR.length; i++) { | |
combFiltersR[i].resonance.value = roomSize; | |
combFiltersR[i].dampening.value = dampening; | |
} | |
} | |
} | |
function applyMapReverb() { | |
if (WebAudio._masterReverbNode) { | |
// parse notetags for <reverbParams: [roomsize] [damping] [predelay] [wet] [dry]> | |
var regex = /<reverbParams: ([0-9]+(?:.[0-9]+)?) ([0-9]+) ([0-9]+) ([0-9]+(?:.[0-9]+)?) ([0-9]+(?:.[0-9]+)?)>/ig; | |
var str = $dataMap.note, match; | |
if ((match = regex.exec(str)) !== null) { | |
let roomSize = Number(match[1]); | |
let damping = Number(match[2]); | |
let predelay = Number(match[3]); | |
let wet = Number(match[4]); | |
let dry = Number(match[5]); | |
WebAudio._masterReverbNode.roomSize = roomSize; | |
WebAudio._masterReverbNode.dampening = damping; | |
WebAudio._masterReverbNode.predelay.value = predelay / 1000; | |
WebAudio._masterReverbNode.wet.value = wet; | |
WebAudio._masterReverbNode.dry.value = dry; | |
} | |
else if (revertOnMapLoad) { | |
WebAudio._masterReverbNode.roomSize = defaultReverbParams.roomSize; | |
WebAudio._masterReverbNode.dampening = defaultReverbParams.damping; | |
WebAudio._masterReverbNode.predelay.value = defaultReverbParams.preDelay / 1000; | |
WebAudio._masterReverbNode.wet.value = defaultReverbParams.wet; | |
WebAudio._masterReverbNode.dry.value = defaultReverbParams.dry; | |
} | |
} | |
} | |
//----------------------------------------------------------------------------- | |
//----------------------------------------------------------------------------- | |
// function replacements | |
WebAudio._createMasterReverbNode = function () { | |
const context = this._context; | |
if (context) { | |
let rvb = Freeverb(context); | |
this._masterReverbNode = rvb; | |
this._masterReverbNode.connect(this._masterGainNode); | |
} | |
}; | |
let webAudio_initialize = WebAudio.initialize; | |
WebAudio.initialize = function () { | |
let result = webAudio_initialize.call(this); | |
this._createMasterReverbNode(); | |
return result; | |
}; | |
// I unfortunately have to duplicate this one because I need the reverb hookup to happen *before* playback is started to avoid a race condition | |
WebAudio.prototype._startPlaying_ext = function (offset, enableReverb) { | |
if (this._loopLengthTime > 0) { | |
while (offset >= this._loopStartTime + this._loopLengthTime) { | |
offset -= this._loopLengthTime; | |
} | |
} | |
this._startTime = WebAudio._currentTime() - offset / this._pitch; | |
this._removeEndTimer(); | |
this._removeNodes(); | |
this._createPannerNode(); | |
if (enableReverb) { | |
this._pannerNode.disconnect(); | |
this._pannerNode.connect(WebAudio._masterReverbNode); | |
} | |
this._createGainNode(); | |
this._createAllSourceNodes(); | |
this._startAllSourceNodes(); | |
this._createEndTimer(); | |
}; | |
/** | |
* Plays the audio. | |
* | |
* @param {boolean} loop - Whether the audio data play in a loop. | |
* @param {number} offset - The start position to play in seconds. | |
* @param {boolean} enableReverb - Whether to route audio through global reverb | |
*/ | |
WebAudio.prototype.play_ext = function (loop, offset, enableReverb) { | |
this._loop = loop; | |
if (this.isReady()) { | |
offset = offset || 0; | |
this._startPlaying_ext(offset, enableReverb); | |
} else if (WebAudio._context) { | |
this.addLoadListener(() => this.play_ext(loop, offset, enableReverb)); | |
} | |
this._isPlaying = true; | |
}; | |
AudioManager.playBgm = function (bgm, pos) { | |
if (this.isCurrentBgm(bgm)) { | |
this.updateBgmParameters(bgm); | |
} else { | |
this.stopBgm(); | |
if (bgm.name) { | |
this._bgmBuffer = this.createBuffer("bgm/", bgm.name); | |
this.updateBgmParameters(bgm); | |
if (!this._meBuffer) { | |
this._bgmBuffer.play_ext(true, pos || 0, applyToBgm); | |
} | |
} | |
} | |
this.updateCurrentBgm(bgm, pos); | |
}; | |
AudioManager.playBgs = function (bgs, pos) { | |
if (this.isCurrentBgs(bgs)) { | |
this.updateBgsParameters(bgs); | |
} else { | |
this.stopBgs(); | |
if (bgs.name) { | |
this._bgsBuffer = this.createBuffer("bgs/", bgs.name); | |
this.updateBgsParameters(bgs); | |
this._bgsBuffer.play_ext(true, pos || 0, applyToBgs); | |
} | |
} | |
this.updateCurrentBgs(bgs, pos); | |
}; | |
AudioManager.playMe = function (me) { | |
this.stopMe(); | |
if (me.name) { | |
if (this._bgmBuffer && this._currentBgm) { | |
this._currentBgm.pos = this._bgmBuffer.seek(); | |
this._bgmBuffer.stop(); | |
} | |
this._meBuffer = this.createBuffer("me/", me.name); | |
this.updateMeParameters(me); | |
this._meBuffer.play_ext(false, 0, applyToMe); | |
this._meBuffer.addStopListener(this.stopMe.bind(this)); | |
} | |
}; | |
AudioManager.playSe = function (se) { | |
if (se.name) { | |
// [Note] Do not play the same sound in the same frame. | |
const latestBuffers = this._seBuffers.filter( | |
buffer => buffer.frameCount === Graphics.frameCount | |
); | |
if (latestBuffers.find(buffer => buffer.name === se.name)) { | |
return; | |
} | |
const buffer = this.createBuffer("se/", se.name); | |
this.updateSeParameters(buffer, se); | |
buffer.play_ext(false, 0, applyToSe); | |
this._seBuffers.push(buffer); | |
this.cleanupSe(); | |
} | |
}; | |
AudioManager.playStaticSe = function (se) { | |
if (se.name) { | |
this.loadStaticSe(se); | |
for (const buffer of this._staticBuffers) { | |
if (buffer.name === se.name) { | |
buffer.stop(); | |
this.updateSeParameters(buffer, se); | |
buffer.play_ext(false, 0, applyToStaticSe); | |
break; | |
} | |
} | |
} | |
}; | |
let sceneMap_onMapLoaded = Scene_Map.prototype.onMapLoaded; | |
Scene_Map.prototype.onMapLoaded = function () { | |
sceneMap_onMapLoaded.call(this); | |
applyMapReverb(); | |
}; | |
//----------------------------------------------------------------------------- | |
//----------------------------------------------------------------------------- | |
// Plugin Commands | |
PluginManager.registerCommand("GD_AudioReverb", "setReverbParams", args => { | |
const roomSize = Number(args.roomSize); | |
const damping = Number(args.damping); | |
const predelay = Number(args.preDelay); | |
const wet = Number(args.wet); | |
const dry = Number(args.dry); | |
if (WebAudio._masterReverbNode) { | |
WebAudio._masterReverbNode.roomSize = roomSize; | |
WebAudio._masterReverbNode.dampening = damping; | |
WebAudio._masterReverbNode.predelay.value = predelay / 1000; | |
WebAudio._masterReverbNode.wet.value = wet; | |
WebAudio._masterReverbNode.dry.value = dry; | |
} | |
}); | |
PluginManager.registerCommand("GD_AudioReverb", "revertReverbParams", _ => { | |
// todo: should probably cache this off instead of re-parsing tags every time, but this is probably OK for now | |
applyMapReverb(); | |
}); | |
//----------------------------------------------------------------------------- | |
})(); | |
// EOF |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment