Original Source
- Original title: Bug Audio does not pause when game loses focus
- Original author: caethyril
- Original date: October 29, 2021
- Source thread: https://forums.rpgmakerweb.com/threads/audio-does-not-pause-when-game-loses-focus.141642/
- Source forum path: Game Development Engines > RPG Maker MZ Support > RPG Maker MZ Feedback
Summary
Seen in RMMZ core scripts up to the current version (v1.8.0). Edit 2021-12-09: as of v1.4.0, Effekseer pauses when the game is out of focus, but audio does not. The thread title and content have been updated to reflect this. (This thread was previously titled "Audio + Effekseer do not pause when game loses focus".) DescriptionThe current scene updates only if the game is in focus. Audio does not pause/resume in the same way. This discrepancy can cause in-game desynchronisation, e.g.
Archived First Post
v1.8.0).Edit 2021-12-09: as of
v1.4.0, Effekseer pauses when the game is out of focus, but audio does not. The thread title and content have been updated to reflect this.(This thread was previously titled "Audio + Effekseer do not pause when game loses focus".)
Description
The current scene updates only if the game is in focus. Audio does not pause/resume in the same way. This discrepancy can cause in-game desynchronisation, e.g.- A cutscene timed to its BGM.
v1.4.0- no change.
- Audio cues for Effekseer animations.
v1.4.0- partial fix: new sound cues will no longer trigger when paused.
Audio already playing will continue to play as it would if the game were not paused.
Steps to reproduce
- Start the game.
- Play any audible track in-game (BGS, BGM, ME, SE), e.g. via:
- The Autoplay BGM and/or Autoplay BGS map settings;
- A Play BGM, Play BGS, Play ME, or Play SE event command;
- A corresponding script command via event or console; or
- An animation with at least one audio cue.
- Make the game lose focus while the audio is playing, e.g. by clicking on the editor or console.
- The audio will continue to play while the scene is "paused".
Technical explanation
The game's underlying audio updates independently of theSceneManager. This loop continues regardless of whether or not SceneManager.isGameActive() is true. More information on the Web Audio context object can be found here:AudioContext - Web APIs | MDN
Potential fix
Apply the relevant pause/resume methods when the game focus changes, e.g./*:
* @target MZ
* @help Free to use and/or modify for any project.
*/
(function(alias) {
'use strict';
let isActive = true;
const getAudioContext = function() {
return WebAudio._context;
};
const pauseAudio = function(context, active) {
if (active) context.resume();
else context.suspend();
};
const updateActive = function() {
pauseAudio(getAudioContext(), isActive);
};
SceneManager.updateScene = function() {
const active = this.isGameActive();
if (isActive !== active) {
isActive = active;
updateActive();
}
alias.apply(this, arguments);
};
})(SceneManager.updateScene);
/*:
* @target MZ
* @help Free to use and/or modify for any project.
*/
(function(alias) {
'use strict';
let isActive = true;
const getAudioContext = function() {
return WebAudio._context;
};
const getEffekseerHandles = function() {
return (SceneManager._scene._spriteset?._animationSprites || [])
.map(s => s._handle)
.filter(h => h);
};
const pauseAudio = function(context, active) {
if (active) context.resume();
else context.suspend();
};
const pauseEffekseer = function(handles, active) {
handles.forEach(h => h.setPaused(!active));
};
const updateActive = function() {
pauseAudio(getAudioContext(), isActive);
pauseEffekseer(getEffekseerHandles(), isActive);
};
SceneManager.updateScene = function() {
const active = this.isGameActive();
if (isActive !== active) {
isActive = active;
updateActive();
}
alias.apply(this, arguments);
};
})(SceneManager.updateScene);
Edit 2022-03-21: sorry for the wait, here's a version that seems to work OK for all audio. It swaps the
setTimeout for an onended handler, fixing the issue seen in my previous update. It will apply the Effekseer pause effect as well for core script versions older than 1.4.0.- view/download CaeF_PauseFix.js (Google Drive)
/*:
* @target MZ
* @plugindesc Pause/resume audio (and Effekseer) with the game loop.
* @author Caethyril
* @url https://forums.rpgmakerweb.com/index.php?threads/141642/
* @help Free to use and/or modify for any project. No credit required.
*/
(() => {
'use strict';
let isActive = true;
const SHOULD_LOOP = Symbol();
const getEffekseerHandles = function() {
return (SceneManager._scene._spriteset?._animationSprites || [])
.map(s => s._handle)
.filter(h => h);
};
const pauseEffekseer = function(handles, active) {
handles.forEach(h => h.setPaused(!active));
};
const getAudioContext = function() {
return WebAudio._context;
};
const pauseAudio = function(context, active) {
if (active) context.resume();
else context.suspend();
};
const updateActive = function() {
if (true)
// 1.4.4 core script does not pause audio
pauseAudio(getAudioContext(), isActive);
if (!Utils.checkRMVersion("1.4.0"))
// apply for pre-1.4.0 core script only
pauseEffekseer(getEffekseerHandles(), isActive);
};
const onAudioEnd = function(index) {
if (this[SHOULD_LOOP]) {
// This is the original onended event handler, for encrypted audio loops.
this._createSourceNode(index);
this._startSourceNode(index);
} else {
// This replaces the original setTimeout method
// so that end timers trigger when they are supposed to.
const endTime = this._startTime + this._totalTime / this._pitch;
if (WebAudio._currentTime() >= endTime)
this.stop();
}
};
// Also update the audio's "active" status.
const alias = SceneManager.updateScene;
SceneManager.updateScene = function() {
const active = this.isGameActive();
if (isActive !== active) {
isActive = active;
updateActive();
}
alias.apply(this, arguments);
};
// Assign "onended" event handler to onAudioEnd (see above).
// A new flag (SHOULD_LOOP symbol) is set for looped encrypted audio.
// This flag determines the behaviour of onAudioEnd.
WebAudio.prototype._startSourceNode = function(index) {
delete this[SHOULD_LOOP]; // <- edit 1 of 3
const sourceNode = this._sourceNodes[index];
const seekPos = this.seek();
const currentTime = WebAudio._currentTime();
const loop = this._loop;
const loopStart = this._loopStartTime;
const loopLength = this._loopLengthTime;
const loopEnd = loopStart + loopLength;
const pitch = this._pitch;
let chunkStart = 0;
for (let i = 0; i < index; i++) {
chunkStart += this._buffers[i].duration;
}
const chunkEnd = chunkStart + sourceNode.buffer.duration;
let when = 0;
let offset = 0;
let duration = sourceNode.buffer.duration;
if (seekPos >= chunkStart && seekPos < chunkEnd - 0.01) {
when = currentTime;
offset = seekPos - chunkStart;
} else {
when = currentTime + (chunkStart - seekPos) / pitch;
offset = 0;
if (loop) {
if (when < currentTime - 0.01) {
when += loopLength / pitch;
}
if (seekPos >= loopStart && chunkStart < loopStart) {
when += (loopStart - chunkStart) / pitch;
offset = loopStart - chunkStart;
}
}
}
if (loop && loopEnd < chunkEnd) {
duration = loopEnd - chunkStart - offset;
}
if (this._shouldUseDecoder()) {
if (when >= currentTime && offset < duration) {
sourceNode.loop = false;
sourceNode.start(when, offset, duration);
if (loop && chunkEnd > loopStart) {
this[SHOULD_LOOP] = true; // <- edit 2 of 3
sourceNode.onended = onAudioEnd.bind(this); // <- edit 3 of 3
}
}
} else {
if (when >= currentTime && offset < sourceNode.buffer.duration) {
sourceNode.start(when, offset);
}
}
chunkStart += sourceNode.buffer.duration;
};
// Rewritten: end timer now fires via "onended" event.
// This allows it to respect AudioContext timing changes, e.g. pause/resume.
WebAudio.prototype._createEndTimer = function() {
if (!this._loop)
// (Note to self: multiple source nodes are used for encrypted audio.)
this._sourceNodes.forEach((source, n) => {
source.onended = onAudioEnd.bind(this, n);
}, this);
// original:
// if (this._sourceNodes.length > 0 && !this._loop) {
// const endTime = this._startTime + this._totalTime / this._pitch;
// const delay = endTime - WebAudio._currentTime();
// this._endTimer = setTimeout(this.stop.bind(this), delay * 1000);
// }
};
})();
Edit 2024-05-20: aaand another update to account for minimise/maximise, as well as video:
/*:
* @target MZ
* @plugindesc v1.1 - Pause/resume audio (and video/Effekseer) with the game loop.
* @author Caethyril
* @url https://forums.rpgmakerweb.com/threads/141642/
* @help Free to use and/or modify for any RMMZ project. No credit required.
*
* Update log:
* 2024-05-20: now accounts for minimise/maximise.
* now also affects video.
* tidied up the code a little.
*/
// While testing, if I left it some time the audio (BGM, BGS)
// resumed correctly but was extremely quiet/inaudible.
// Problem stopped on disabling my system's audio driver "enhancements".
void (() => {
'use strict';
/** Tracks game active status, for applying pause. */
let isActive = true;
/**
* New non-conflicting `WebAudio` property identifier.
*
* - The original `setTimeout` end timer system has been replaced by the
* native `AudioContext` timing, providing improved precision and central
* control.
* <br>
* - The core scripts already attach an `onended` context event handler
* for software-decoded sources (`libs/vorbisdecoder.js`), because they
* are decoded in chunks.
*
* This property is used as a flag for a new `onended` handler
* that incorporates both mechanics.
*/
const SHOULD_LOOP = Symbol();
/** `true` iff should apply audio pause. */
const PAUSE_AUDIO = true;
/**
* `true` iff should apply Effekseer pause.\
* This is handled by the core scripts since v1.4.0.
*/
const PAUSE_FX = !Utils.checkRMVersion("1.4.0");
/** `true` iff should apply video pause. */
const PAUSE_VIDEO = true;
/** @returns {AudioContext} game audio context */
const getAudioContext = function() {
return WebAudio._context;
};
/** @returns {EffekseerHandle} Effekseer animation handle */
const getEffekseerHandles = function() {
return Array.from(
SceneManager._scene._spriteset?._animationSprites ?? [],
s => s._handle
).filter(h => h);
};
/**
* Pauses/resumes all audio under `context` based on `active` value.
* @param {boolean} active `false` iff game is currently paused
* @param {AudioContext} context reference audio context
*/
const pauseAudio = function(active, context = getAudioContext()) {
const running = context.state === 'running';
if (active && !running)
context.resume().then(() => isActive = true);
else if (running)
context.suspend().then(() => isActive = false);
};
/**
* Pauses/resumes all active Effekseer `handles` based on `active` value.
* @param {boolean} active `false` iff game is currently paused
* @param {EffekseerHandle[]} handles Effekseer handles to affect
*/
const pauseEffekseer = function(active, handles = getEffekseerHandles()) {
for (const h of handles)
h.setPaused(!active);
};
/**
* Pauses/resumes core video element based on `active` value.
* @param {boolean} active `false` iff game is currently paused
*/
const pauseVideo = function(active) {
// rely on core resume functionality (valid user gesture, e.g. click)
if (!active && Video.isPlaying())
Video._element.pause();
};
/** Updates audio/fx/video pause status. */
const update = function() {
const active = SceneManager.isGameActive();
if (active !== isActive) {
if (PAUSE_AUDIO)
pauseAudio(active);
if (PAUSE_FX)
pauseEffekseer(active);
}
if (PAUSE_VIDEO)
pauseVideo(active);
};
/**
* Runs when an applicable source node ends.
* @this {WebAudio}
* @param {number} index source node index
*/
const onAudioEnd = function(index) {
if (SHOULD_LOOP in this) {
// This is the original onended event handler, for script-decoded loops.
this._createSourceNode(index);
this._startSourceNode(index);
} else {
// This replaces the original setTimeout method
// so that end timers trigger when they are supposed to.
const endTime = this._startTime + this._totalTime / this._pitch;
if (WebAudio._currentTime() >= endTime)
this.stop();
}
};
// Patch - visibility change pause/resume hooks, e.g. minimise window.
void (alias => {
WebAudio._onHide = function() {
alias.apply(this, arguments);
update();
};
})(WebAudio._onHide);
void (alias => {
WebAudio._onShow = function() {
alias.apply(this, arguments);
update();
};
})(WebAudio._onShow);
// Patch - focus change pause/resume hook, e.g. focus different window.
void (alias => {
SceneManager.update = function(deltaTime) {
alias.apply(this, arguments);
update();
};
})(SceneManager.update);
// Assign "onended" event handler to onAudioEnd (see above).
// A new flag (SHOULD_LOOP symbol) is set for software decoding (libs/vorbisdecoder.js).
// This flag determines the behaviour of onAudioEnd.
WebAudio.prototype._startSourceNode = function(index) {
delete this[SHOULD_LOOP]; // <- edit 1 of 3
const sourceNode = this._sourceNodes[index];
const seekPos = this.seek();
const currentTime = WebAudio._currentTime();
const loop = this._loop;
const loopStart = this._loopStartTime;
const loopLength = this._loopLengthTime;
const loopEnd = loopStart + loopLength;
const pitch = this._pitch;
let chunkStart = 0;
for (let i = 0; i < index; i++) {
chunkStart += this._buffers[i].duration;
}
const chunkEnd = chunkStart + sourceNode.buffer.duration;
let when = 0;
let offset = 0;
let duration = sourceNode.buffer.duration;
if (seekPos >= chunkStart && seekPos < chunkEnd - 0.01) {
when = currentTime;
offset = seekPos - chunkStart;
} else {
when = currentTime + (chunkStart - seekPos) / pitch;
offset = 0;
if (loop) {
if (when < currentTime - 0.01) {
when += loopLength / pitch;
}
if (seekPos >= loopStart && chunkStart < loopStart) {
when += (loopStart - chunkStart) / pitch;
offset = loopStart - chunkStart;
}
}
}
if (loop && loopEnd < chunkEnd) {
duration = loopEnd - chunkStart - offset;
}
if (this._shouldUseDecoder()) {
if (when >= currentTime && offset < duration) {
sourceNode.loop = false;
sourceNode.start(when, offset, duration);
if (loop && chunkEnd > loopStart) {
this[SHOULD_LOOP] = true; // <- edit 2 of 3
sourceNode.onended = onAudioEnd.bind(this, index); // <- edit 3 of 3
}
}
} else {
if (when >= currentTime && offset < sourceNode.buffer.duration) {
sourceNode.start(when, offset);
}
}
chunkStart += sourceNode.buffer.duration;
};
// Rewritten: end timer now fires via "onended" event.
// This allows it to respect AudioContext timing changes, e.g. pause/resume.
WebAudio.prototype._createEndTimer = function() {
if (!this._loop) {
const S = this._sourceNodes;
for (let n = S.length; n--;)
S[n].onended = onAudioEnd.bind(this, n);
}
};
})();
Downloads / Referenced Files
Log in, then follow the RPG Maker Developers Group to see these download links.
Log in to downloadLicense / Terms Note
* @help Free to use and/or modify for any project. No credit required. */ (() => { 'use strict';
Creator Claims / Removal
If you are the original creator and want this listing reassigned, edited, or removed, join BMMPlay and contact the moderators with proof that matches the original RPG Maker Web profile, linked GitHub, itch.io page, or another public creator identity.
Replies (0)
No replies yet.
Topic Summary
Loading summary...