Skip to content

Instantly share code, notes, and snippets.

@GlaireDaggers
Last active March 5, 2024 07:59
Show Gist options
  • Save GlaireDaggers/b004687476568ce47cc28bdf7a58d9cf to your computer and use it in GitHub Desktop.
Save GlaireDaggers/b004687476568ce47cc28bdf7a58d9cf to your computer and use it in GitHub Desktop.
Global audio reverb effect plugin for RPG Maker MZ
//=============================================================================
// 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