Spaces:
Build error
Build error
const Cast = require("../../util/cast"); | |
const Timer = require("./timer"); | |
function MathOver(number, max) { | |
let num = number; | |
while (num > max) { | |
num -= max; | |
} | |
return num; | |
} | |
function Clamp(number, min, max) { | |
return Math.min(Math.max(number, min), max); | |
} | |
class AudioSource { | |
/** | |
* @param {AudioContext} audioContext | |
* @param {object} audioGroup | |
* @param {AudioBuffer} source | |
* @param {object} data | |
* @param {object} parent | |
*/ | |
constructor(audioContext, audioGroup, source, data, parent, runtime) { | |
if (source == null) source = ""; | |
if (data == null) data = {}; | |
this.runtime = runtime; | |
this.src = source; | |
this.duration = source.duration; | |
this.originAudioName = ""; | |
this.volume = data.volume ?? 1; | |
this.speed = data.speed ?? 1; | |
this.pitch = data.pitch ?? 0; | |
this.pan = data.pan ?? 0; | |
this.looping = data.looping ?? false; | |
this.startPosition = data.startPosition ?? 0; | |
this.endPosition = data.endPosition ?? Infinity; | |
this.loopStartPosition = data.loopStartPosition ?? 0; | |
this.loopEndPosition = data.loopEndPosition ?? Infinity; | |
this.resumeSpot = 0; | |
this.paused = false; | |
this.notPlaying = true; | |
this.parent = parent; | |
this._audioNode = null; | |
this._audioContext = audioContext; | |
this._audioGroup = audioGroup; | |
this._audioPanner = this._audioContext.createPanner(); | |
this._audioGainNode = this._audioContext.createGain(); | |
this._audioAnalyzerNode = this._audioContext.createAnalyser(); | |
this._audioPanner.panningModel = 'equalpower'; | |
this._audioGainNode.gain.value = 1; | |
this._audioGainNode.connect(this._audioPanner); | |
this._audioPanner.connect(this._audioAnalyzerNode); | |
this._audioAnalyzerNode.connect(parent.audioGlobalVolumeNode); | |
this._originalConfig = data; | |
this._playingSrc = null; | |
this._timer = new Timer(runtime, audioContext); | |
this._disposed = false; | |
} | |
play(atTime) { | |
if (!this.src) throw "Cannot play an empty audio source"; | |
try { | |
if (this._audioNode) { | |
this._audioNode.onended = null; | |
this._audioNode.stop(); | |
} | |
} catch { | |
// ... idk | |
} finally { | |
this._audioNode = null; | |
} | |
const source = this._audioContext.createBufferSource(); | |
this._audioNode = source; | |
this.update(); | |
source.buffer = this.src; | |
source.connect(this._audioGainNode); | |
this._playingSrc = source.buffer; | |
if (!this.paused) { | |
this._timer.reset(); | |
this._timer.setTime(Clamp(atTime ?? this.startPosition, 0, this.duration) * 1000); | |
this._timer.start(); | |
} else { | |
this.resumeSpot = this.getTimePosition(); | |
this._timer.start(); | |
} | |
// we need to know when the sound starts, so we know how long to play for | |
// we also need to change endTimePos if we are looping | |
let startTimePos = this.resumeSpot; | |
let endTimePos = this.endPosition; | |
if (this.paused) { | |
this.paused = false; | |
} else { | |
startTimePos = atTime ?? this.startPosition; | |
} | |
if (this.looping) { | |
endTimePos = this.loopEndPosition; | |
} | |
// dont play the sound if the playback duration is less than 1 sample frame, otherwise the ended event will not fire | |
this.notPlaying = false; | |
const playbackDuration = Clamp(endTimePos - startTimePos, 0, this.duration); | |
if (playbackDuration < 1 / this.src.sampleRate) { | |
this._onNodeStop(true); | |
} else { | |
source.start(0, Clamp(startTimePos, 0, this.duration), playbackDuration); | |
source.onended = () => { | |
this._onNodeStop(); | |
} | |
} | |
} | |
stop() { | |
this.notPlaying = true; | |
this.paused = false; | |
this._timer.stop(); | |
try { | |
if (this._audioNode) { | |
this._audioNode.stop(); | |
} | |
} catch { | |
// ... idk | |
} finally { | |
this._audioNode = null; | |
} | |
} | |
pause() { | |
if (!this._audioNode) return; | |
this.paused = true; | |
this.notPlaying = true; | |
this._timer.pause(); | |
// onended is already ignored when paused, and stopped nodes cannot restart | |
this._audioNode.onended = null; | |
this._audioNode.stop(); | |
this._audioNode = null; | |
} | |
update() { | |
if (!this._audioNode) return; | |
const audioNode = this._audioNode; | |
const audioGroup = this._audioGroup; | |
const audioGainNode = this._audioGainNode; | |
const audioPanner = this._audioPanner; | |
// we need to manually calculate detune to prevent problems when using playbackRate for other things | |
audioNode.playbackRate.value = this.speed * Math.pow(2, this.pitch / 1200); | |
audioGainNode.gain.value = this.volume; | |
audioNode.playbackRate.value *= audioGroup.globalSpeed * Math.pow(2, audioGroup.globalPitch / 1200); | |
audioGainNode.gain.value *= audioGroup.globalVolume; | |
this._timer.speed = audioNode.playbackRate.value; | |
const pan = Clamp(this.pan + audioGroup.globalPan, -1, 1); | |
audioPanner.positionX.value = pan; | |
audioPanner.positionY.value = 0; | |
audioPanner.positionZ.value = 1 - Math.abs(pan); | |
} | |
dispose() { | |
this._disposed = true; | |
this._timer.dispose(); | |
this.stop(); | |
} | |
clone() { | |
const newSource = new AudioSource(this._audioContext, this._audioGroup, this.src, this._originalConfig, this.parent, this.runtime); | |
return newSource; | |
} | |
reverse() { | |
if (!this.src) throw "Cannot reverse an empty audio source"; | |
const buffer = this.src; | |
const reversedBuffer = this._audioContext.createBuffer( | |
buffer.numberOfChannels, | |
buffer.length, | |
buffer.sampleRate | |
); | |
for (let channel = 0; channel < buffer.numberOfChannels; channel++) { | |
const sourceData = buffer.getChannelData(channel); | |
const destinationData = reversedBuffer.getChannelData(channel); | |
for (let i = 0; i < buffer.length; i++) { | |
destinationData[i] = sourceData[buffer.length - 1 - i]; | |
} | |
} | |
this.src = reversedBuffer; | |
} | |
setTimePosition(newSeconds) { | |
if (!this._audioNode && !this.paused) return; | |
const src = this._getActiveSource(); | |
newSeconds = Clamp(newSeconds, 0, src.duration); | |
if (this.paused) { | |
// only update the time | |
this._timer.setTime(newSeconds * 1000); | |
return; | |
} | |
this._timer.setTime(newSeconds * 1000); | |
this.play(newSeconds); | |
} | |
getVolume() { | |
const analyserNode = this._audioAnalyzerNode; | |
const bufferLength = analyserNode.frequencyBinCount; | |
const dataArray = new Uint8Array(bufferLength); | |
analyserNode.getByteTimeDomainData(dataArray); | |
let sumSquares = 0.0; | |
for (let i = 0; i < bufferLength; i++) { | |
const sample = (dataArray[i] / 128.0) - 1.0; | |
sumSquares += sample * sample; | |
} | |
const volume = Math.sqrt(sumSquares / bufferLength); | |
return volume; | |
} | |
getFrequency() { | |
const analyserNode = this._audioAnalyzerNode; | |
const src = this._getActiveSource(); | |
const bufferLength = analyserNode.frequencyBinCount; | |
const dataArray = new Uint8Array(bufferLength); | |
analyserNode.getByteFrequencyData(dataArray); | |
// find the max frequency | |
let maxIndex = 0; | |
for (let i = 1; i < bufferLength; i++) { | |
if (dataArray[i] > dataArray[maxIndex]) { | |
maxIndex = i; | |
} | |
} | |
// return the dominant freq | |
const nyquist = src.sampleRate / 2; | |
return maxIndex * nyquist / bufferLength; | |
} | |
getTimePosition() { | |
const src = this._getActiveSource(); | |
return Clamp(this._timer.getTime(true), 0, src.duration); | |
} | |
_getActiveSource() { | |
if (this._audioNode) return this._playingSrc; | |
return this.src; | |
} | |
_onNodeStop(didNotPlay) { | |
if (this.paused || !this._audioNode) return; | |
if (!didNotPlay) { | |
if (this.looping && !this.notPlaying) { | |
this.play(this.loopStartPosition || 0); | |
return; | |
} | |
} | |
this._audioNode.onended = null; | |
this.notPlaying = true; | |
this._audioNode = null; | |
this._timer.stop(); | |
} | |
} | |
class AudioExtensionHelper { | |
constructor(runtime) { | |
/** | |
* The runtime that the helper will use for all functions. | |
* @type {runtime} | |
*/ | |
this.runtime = runtime; | |
this.audioGroups = {}; | |
this.audioContext = new AudioContext(); | |
this.audioGlobalVolumeNode = this.audioContext.createGain(); | |
this.audioGlobalVolumeNode.gain.value = 1; | |
this.audioGlobalVolumeNode.connect(this.audioContext.destination); | |
} | |
/** | |
* Creates a new AudioGroup. | |
* @type {string} AudioGroup name | |
* @type {object} AudioGroup settings (optional) | |
* @type {object[]} AudioGroup sources (optional) | |
*/ | |
AddAudioGroup(name, data, sources) { | |
if (data == null) data = {}; | |
this.audioGroups[name] = { | |
id: name, | |
sources: (sources == null ? {} : sources), | |
globalVolume: (data.globalVolume == null ? 1 : data.globalVolume), | |
globalSpeed: (data.globalSpeed == null ? 1 : data.globalSpeed), | |
globalPitch: (data.globalPitch == null ? 0 : data.globalPitch), | |
globalPan: (data.globalPan == null ? 0 : data.globalPan) | |
}; | |
return this.audioGroups[name]; | |
} | |
/** | |
* Deletes an AudioGroup by name. | |
* @type {string} | |
*/ | |
DeleteAudioGroup(name) { | |
const audioGroup = this.audioGroups[name]; | |
if (!audioGroup) return; | |
this.DisposeAudioGroupSources(audioGroup); | |
delete this.audioGroups[name]; | |
} | |
/** | |
* Gets an AudioGroup by name. | |
* @type {string} | |
*/ | |
GetAudioGroup(name) { | |
return this.audioGroups[name]; | |
} | |
/** | |
* Gets all AudioGroups and returns them in an array. | |
*/ | |
GetAllAudioGroups() { | |
return Object.values(this.audioGroups); | |
} | |
/** | |
* Gets all AudioSources in an AudioGroup and updates them. | |
* @type {AudioGroup} | |
*/ | |
UpdateAudioGroupSources(audioGroup) { | |
const audioSources = this.GrabAllGrabAudioSources(audioGroup); | |
for (let i = 0; i < audioSources.length; i++) { | |
const source = audioSources[i]; | |
source.update(); | |
} | |
} | |
/** | |
* Gets all AudioSources in an AudioGroup and disposes them. | |
* @type {AudioGroup} | |
*/ | |
DisposeAudioGroupSources(audioGroup) { | |
const audioSources = this.GrabAllGrabAudioSources(audioGroup); | |
for (let i = 0; i < audioSources.length; i++) { | |
const source = audioSources[i]; | |
source.dispose(); | |
} | |
} | |
/** | |
* Creates a new AudioSource inside of an AudioGroup. | |
* @type {AudioGroup} AudioSource parent | |
* @type {string} AudioSource name | |
* @type {string} AudioSource source (optional) | |
* @type {object} AudioSource settings (optional) | |
*/ | |
AppendAudioSource(parent, name, src, settings) { | |
const group = typeof parent == "string" ? this.GetAudioGroup(parent) : parent; | |
if (!group) return; | |
group.sources[name] = new AudioSource(this.audioContext, group, src, settings, this, this.runtime); | |
return group.sources[name]; | |
} | |
/** | |
* Deletes an AudioSource by name. | |
* @type {AudioGroup} AudioSource parent | |
* @type {string} | |
*/ | |
RemoveAudioSource(parent, name) { | |
const group = typeof parent == "string" ? this.GetAudioGroup(parent) : parent; | |
if (!group) return; | |
const audioSource = group.sources[name]; | |
if (!audioSource) return; | |
audioSource.dispose(); | |
delete group.sources[name]; | |
} | |
/** | |
* Gets an AudioSource by name. | |
* @type {AudioGroup} AudioSource parent | |
* @type {string} | |
*/ | |
GrabAudioSource(audioGroup, name) { | |
const group = typeof audioGroup == "string" ? this.GetAudioGroup(audioGroup) : audioGroup; | |
if (!group) return; | |
return group.sources[name]; | |
} | |
/** | |
* Gets all AudioSources and returns them in an array. | |
* @type {AudioGroup} AudioSource parent | |
*/ | |
GrabAllGrabAudioSources(audioGroup) { | |
const group = typeof audioGroup == "string" ? this.GetAudioGroup(audioGroup) : audioGroup; | |
if (!group) return []; | |
return Object.values(group.sources); | |
} | |
/** | |
* Finds a sound with the specified ID in the sound list. | |
* @type {Array} soundList | |
* @type {string} Sound ID | |
*/ | |
FindSoundBySoundId(soundList, id) { | |
for (let i = 0; i < soundList.length; i++) { | |
const sound = soundList[i]; | |
if (sound.soundId == id) return sound; | |
} | |
return null; | |
} | |
/** | |
* Finds a sound with the specified name in the sound list. | |
* @type {Array} soundList | |
* @type {string} Sound name | |
*/ | |
FindSoundByName(soundList, name) { | |
for (let i = 0; i < soundList.length; i++) { | |
const sound = soundList[i]; | |
if (sound.name == name) return sound; | |
} | |
return null; | |
} | |
/** | |
* Clamps numbers to stay inbetween 2 values. | |
* @type {number} | |
*/ | |
Clamp(number, min, max) { | |
return Math.min(Math.max(number, min), max); | |
} | |
} | |
module.exports.Helper = AudioExtensionHelper | |
module.exports.AudioSource = AudioSource |