1163 lines
30 KiB
JavaScript
Executable File
1163 lines
30 KiB
JavaScript
Executable File
(function (root, factory) {
|
|
if (typeof exports === 'object' && typeof module !== 'undefined') {
|
|
var exports_obj = factory();
|
|
module.exports = exports_obj;
|
|
} else if (typeof define === 'function' && define.amd) {
|
|
define([], factory);
|
|
} else {
|
|
var exports_obj = factory();
|
|
root.Howler = exports_obj.Howler;
|
|
root.Howl = exports_obj.Howl;
|
|
}
|
|
}(typeof self !== 'undefined' ? self : this, function () {
|
|
|
|
/**
|
|
* based on Howler.js - (c) 2013-2020, James Simpson of GoldFire Studios
|
|
* Mostly compatible API
|
|
*/
|
|
|
|
class HowlerGlobal {
|
|
constructor() {
|
|
this._counter = 1000;
|
|
this._howls = [];
|
|
this._volume = 1;
|
|
this._muted = false;
|
|
this._html5Pool = [];
|
|
this._codecs = {};
|
|
this.ctx = null;
|
|
this.masterGain = null;
|
|
this.usingWebAudio = true;
|
|
this.autoUnlock = true;
|
|
this._audioUnlocked = false;
|
|
this._environment = null;
|
|
this._setup();
|
|
}
|
|
|
|
volume(vol) {
|
|
if (vol === undefined) return this._volume;
|
|
this._volume = Math.max(0, Math.min(1, vol));
|
|
|
|
if (!this._muted) {
|
|
if (this.usingWebAudio && this.masterGain) {
|
|
this.masterGain.gain.setValueAtTime(this._volume, this.ctx.currentTime);
|
|
}
|
|
this._howls.forEach(howl => howl._updateVolume());
|
|
}
|
|
return this;
|
|
}
|
|
|
|
mute(muted = true) {
|
|
this._muted = muted;
|
|
let vol = muted ? 0 : this._volume;
|
|
|
|
if (this.usingWebAudio && this.masterGain) {
|
|
this.masterGain.gain.setValueAtTime(vol, this.ctx.currentTime);
|
|
}
|
|
this._howls.forEach(howl => howl._updateVolume());
|
|
return this;
|
|
}
|
|
|
|
stop() {
|
|
this._howls.forEach(howl => howl.stop());
|
|
return this;
|
|
}
|
|
|
|
unload() {
|
|
this._howls.slice().forEach(howl => howl.unload());
|
|
if (this.ctx?.close) {
|
|
this.ctx.close();
|
|
this.ctx = null;
|
|
this._setupAudioContext();
|
|
}
|
|
return this;
|
|
}
|
|
|
|
codecs(ext) {
|
|
return this._codecs[ext?.replace(/^x-/, '')];
|
|
}
|
|
|
|
_setup() {
|
|
this._setupAudioContext();
|
|
this._setupCodecs();
|
|
if (this.autoUnlock) this._setupUnlock();
|
|
}
|
|
|
|
_setupAudioContext() {
|
|
if (!this.usingWebAudio) return;
|
|
|
|
try {
|
|
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
this.masterGain = this.ctx.createGain();
|
|
this.masterGain.connect(this.ctx.destination);
|
|
this.masterGain.gain.setValueAtTime(this._muted ? 0 : this._volume, this.ctx.currentTime);
|
|
|
|
this.listener = {
|
|
position: { x: 0, y: 0, z: 0 },
|
|
orientation: {
|
|
forward: { x: 0, y: 0, z: -1 },
|
|
up: { x: 0, y: 1, z: 0 }
|
|
},
|
|
velocity: { x: 0, y: 0, z: 0 },
|
|
update: () => this._updateListener()
|
|
};
|
|
} catch (e) {
|
|
this.usingWebAudio = false;
|
|
}
|
|
}
|
|
|
|
_setupCodecs() {
|
|
if (typeof Audio === 'undefined') return;
|
|
|
|
let audio = new Audio();
|
|
let test = (mime) => !!audio.canPlayType(mime).replace(/^no$/, '');
|
|
|
|
this._codecs = {
|
|
mp3: test('audio/mpeg;'),
|
|
opus: test('audio/ogg; codecs="opus"'),
|
|
ogg: test('audio/ogg; codecs="vorbis"'),
|
|
wav: test('audio/wav; codecs="1"') || test('audio/wav'),
|
|
aac: test('audio/aac;'),
|
|
m4a: test('audio/x-m4a;') || test('audio/m4a;') || test('audio/aac;'),
|
|
mp4: test('audio/x-mp4;') || test('audio/mp4;') || test('audio/aac;'),
|
|
webm: test('audio/webm; codecs="vorbis"'),
|
|
flac: test('audio/x-flac;') || test('audio/flac;')
|
|
};
|
|
}
|
|
|
|
_setupUnlock() {
|
|
if (this._audioUnlocked || !this.ctx) return;
|
|
|
|
let unlock = () => {
|
|
if (this._audioUnlocked) return;
|
|
|
|
let buffer = this.ctx.createBuffer(1, 1, 22050);
|
|
let source = this.ctx.createBufferSource();
|
|
source.buffer = buffer;
|
|
source.connect(this.ctx.destination);
|
|
source.start(0);
|
|
|
|
if (this.ctx.resume) this.ctx.resume();
|
|
|
|
source.onended = () => {
|
|
this._audioUnlocked = true;
|
|
document.removeEventListener('touchstart', unlock, true);
|
|
document.removeEventListener('click', unlock, true);
|
|
this._howls.forEach(howl => howl._emit('unlock'));
|
|
};
|
|
};
|
|
|
|
document.addEventListener('touchstart', unlock, true);
|
|
document.addEventListener('click', unlock, true);
|
|
}
|
|
|
|
_getHtml5Audio() {
|
|
return this._html5Pool.pop() || new Audio();
|
|
}
|
|
|
|
_releaseHtml5Audio(audio) {
|
|
if (audio._unlocked && this._html5Pool.length < 10) {
|
|
this._html5Pool.push(audio);
|
|
}
|
|
}
|
|
|
|
environment(env) {
|
|
if(!env)
|
|
return this._environment;
|
|
if (typeof env === 'string') {
|
|
let presets = {
|
|
room: { size: 'small', decay: 1.5, dampening: 0.8 },
|
|
hall: { size: 'medium', decay: 2.5, dampening: 0.6 },
|
|
cathedral: { size: 'large', decay: 8.0, dampening: 0.1 },
|
|
cave: { size: 'large', decay: 6.0, dampening: 0.2 },
|
|
underwater: { size: 'medium', decay: 3.0, dampening: 0.9 }
|
|
};
|
|
this._environment = presets[env] || null;
|
|
} else {
|
|
this._environment = env;
|
|
}
|
|
this._howls.forEach(howl => howl._updateEnvironment?.());
|
|
return this;
|
|
}
|
|
|
|
_updateListener() {
|
|
if (!this.ctx?.listener) return;
|
|
|
|
let { position, orientation, velocity } = this.listener;
|
|
let l = this.ctx.listener;
|
|
|
|
if (l.positionX) {
|
|
l.positionX.setValueAtTime(position.x, this.ctx.currentTime);
|
|
l.positionY.setValueAtTime(position.y, this.ctx.currentTime);
|
|
l.positionZ.setValueAtTime(position.z, this.ctx.currentTime);
|
|
l.forwardX.setValueAtTime(orientation.forward.x, this.ctx.currentTime);
|
|
l.forwardY.setValueAtTime(orientation.forward.y, this.ctx.currentTime);
|
|
l.forwardZ.setValueAtTime(orientation.forward.z, this.ctx.currentTime);
|
|
l.upX.setValueAtTime(orientation.up.x, this.ctx.currentTime);
|
|
l.upY.setValueAtTime(orientation.up.y, this.ctx.currentTime);
|
|
l.upZ.setValueAtTime(orientation.up.z, this.ctx.currentTime);
|
|
} else {
|
|
l.setPosition(position.x, position.y, position.z);
|
|
l.setOrientation(
|
|
orientation.forward.x, orientation.forward.y, orientation.forward.z,
|
|
orientation.up.x, orientation.up.y, orientation.up.z
|
|
);
|
|
}
|
|
|
|
if (l.setVelocity) {
|
|
l.setVelocity(velocity.x, velocity.y, velocity.z);
|
|
}
|
|
}
|
|
}
|
|
|
|
class Howl {
|
|
constructor(options = {}) {
|
|
if(options.url) options.src = options.url; // backwards compat
|
|
if(options.urls) options.src = options.urls; // backwards compat
|
|
if (!options.src?.length) throw new Error('src required');
|
|
|
|
Object.assign(this, {
|
|
_src: Array.isArray(options.src) ? options.src : [options.src],
|
|
_volume: options.volume ?? 1,
|
|
_rate: options.rate ?? 1,
|
|
_loop: options.loop ?? false,
|
|
_muted: options.mute ?? false,
|
|
_preload: options.preload ?? true,
|
|
_autoplay: options.autoplay ?? false,
|
|
_sprite: options.sprite ?? {},
|
|
_format: options.format ? (Array.isArray(options.format) ? options.format : [options.format]) : null,
|
|
_html5: options.html5 ?? false,
|
|
_pool: options.pool ?? 5,
|
|
_xhr: { method: 'GET', ...options.xhr },
|
|
_duration: 0,
|
|
_state: 'unloaded',
|
|
_sounds: [],
|
|
_queue: [],
|
|
_listeners: {},
|
|
_webAudio: Howler.usingWebAudio && !options.html5,
|
|
|
|
_analyzer: null,
|
|
_frequencyCallback: null,
|
|
_frequencyData: null,
|
|
_frequencyBands: options.frequencyBands || 16,
|
|
_frequencyBoost: 4.0,
|
|
_frequencyEnabled: false,
|
|
|
|
_effects: []
|
|
});
|
|
|
|
if (options.spatial) {
|
|
this.spatial = {
|
|
enabled: true,
|
|
position: { x: 0, y: 0, z: 0 },
|
|
orientation: { x: 0, y: 0, z: -1 },
|
|
velocity: { x: 0, y: 0, z: 0 },
|
|
distance: {
|
|
ref: 1,
|
|
max: 10000,
|
|
rolloff: 1,
|
|
model: 'inverse'
|
|
},
|
|
cone: {
|
|
inner: 360,
|
|
outer: 360,
|
|
outerGain: 0
|
|
},
|
|
occlusion: 0,
|
|
obstruction: 0,
|
|
panningModel: 'HRTF',
|
|
update: () => this._updateSpatial()
|
|
};
|
|
|
|
if (Array.isArray(options.spatial.position)) {
|
|
let [x, y, z] = options.spatial.position;
|
|
Object.assign(this.spatial.position, { x, y, z });
|
|
}
|
|
if (options.spatial.distance) {
|
|
Object.assign(this.spatial.distance, options.spatial.distance);
|
|
}
|
|
if (options.spatial.cone) {
|
|
Object.assign(this.spatial.cone, options.spatial.cone);
|
|
}
|
|
if (options.spatial.panningModel) {
|
|
this.spatial.panningModel = options.spatial.panningModel;
|
|
}
|
|
}
|
|
|
|
['load', 'loaderror', 'play', 'pause', 'stop', 'end', 'fade', 'volume', 'rate', 'seek', 'mute', 'unlock'].forEach(event => {
|
|
if (options[`on${event}`]) this.on(event, options[`on${event}`]);
|
|
});
|
|
|
|
Howler._howls.push(this);
|
|
|
|
if (this._autoplay) this._queue.push({ event: 'play', action: () => this.play() });
|
|
if (this._preload) this.load();
|
|
}
|
|
|
|
load() {
|
|
if (this._state !== 'unloaded') return Promise.resolve(this);
|
|
|
|
this._state = 'loading';
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let url = this._selectSource();
|
|
if (!url) {
|
|
let error = 'No compatible source found';
|
|
this._emit('loaderror', null, error);
|
|
return reject(new Error(error));
|
|
}
|
|
|
|
this._src = url;
|
|
|
|
if (this._webAudio) {
|
|
this._loadWebAudio(url).then(resolve).catch(reject);
|
|
} else {
|
|
this._loadHtml5(url).then(resolve).catch(reject);
|
|
}
|
|
});
|
|
}
|
|
|
|
play(sprite) {
|
|
if (typeof sprite === 'number') {
|
|
let sound = this._sounds.find(s => s._id === sprite);
|
|
return sound ? this._playSound(sound) : null;
|
|
}
|
|
|
|
if (this._state !== 'loaded') {
|
|
if (this._state === 'loading') {
|
|
return new Promise(resolve => {
|
|
this._queue.push({ event: 'play', action: () => resolve(this.play(sprite)) });
|
|
});
|
|
}
|
|
return this.load().then(() => this.play(sprite));
|
|
}
|
|
|
|
let sound = this._getSound();
|
|
sound._sprite = sprite || '__default';
|
|
return this._playSound(sound);
|
|
}
|
|
|
|
pause(id) {
|
|
this._getSounds(id).forEach(sound => {
|
|
if (!sound._paused) {
|
|
sound._paused = true;
|
|
if (sound._node) {
|
|
if (this._webAudio && sound._source) {
|
|
sound._source.stop();
|
|
this._cleanupSound(sound);
|
|
} else {
|
|
sound._node.pause();
|
|
}
|
|
}
|
|
this._emit('pause', sound._id);
|
|
}
|
|
});
|
|
return this;
|
|
}
|
|
|
|
stop(id) {
|
|
this._getSounds(id).forEach(sound => {
|
|
sound._paused = true;
|
|
sound._ended = true;
|
|
if (sound._node) {
|
|
if (this._webAudio) {
|
|
if (sound._source) {
|
|
sound._source.stop();
|
|
}
|
|
} else {
|
|
sound._node.pause();
|
|
sound._node.currentTime = 0;
|
|
}
|
|
this._cleanupSound(sound);
|
|
}
|
|
this._emit('stop', sound._id);
|
|
});
|
|
return this;
|
|
}
|
|
|
|
mute(muted, id) {
|
|
if (muted === undefined) return this._muted;
|
|
|
|
if (id === undefined) this._muted = muted;
|
|
|
|
this._getSounds(id).forEach(sound => {
|
|
sound._muted = muted;
|
|
this._updateSoundVolume(sound);
|
|
this._emit('mute', sound._id);
|
|
});
|
|
return this;
|
|
}
|
|
|
|
volume(vol, id) {
|
|
if (vol === undefined) {
|
|
let sound = id ? this._sounds.find(s => s._id === id) : this._sounds[0];
|
|
return sound ? sound._volume : this._volume;
|
|
}
|
|
|
|
vol = Math.max(0, Math.min(1, vol));
|
|
if (id === undefined) this._volume = vol;
|
|
|
|
this._getSounds(id).forEach(sound => {
|
|
sound._volume = vol;
|
|
this._updateSoundVolume(sound);
|
|
this._emit('volume', sound._id);
|
|
});
|
|
return this;
|
|
}
|
|
|
|
fade(from, to, duration, id) {
|
|
this._getSounds(id).forEach(sound => {
|
|
sound._volume = from;
|
|
this._updateSoundVolume(sound);
|
|
|
|
let startTime = Date.now();
|
|
let fadeStep = () => {
|
|
let elapsed = Date.now() - startTime;
|
|
let progress = Math.min(elapsed / duration, 1);
|
|
let currentVol = from + (to - from) * progress;
|
|
|
|
sound._volume = currentVol;
|
|
this._updateSoundVolume(sound);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(fadeStep);
|
|
} else {
|
|
this._emit('fade', sound._id);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(fadeStep);
|
|
});
|
|
return this;
|
|
}
|
|
|
|
rate(rate, id) {
|
|
if (rate === undefined) {
|
|
let sound = id ? this._sounds.find(s => s._id === id) : this._sounds[0];
|
|
return sound ? sound._rate : this._rate;
|
|
}
|
|
|
|
if (id === undefined) this._rate = rate;
|
|
|
|
this._getSounds(id).forEach(sound => {
|
|
sound._rate = rate;
|
|
if (sound._node) {
|
|
if (this._webAudio && sound._source) {
|
|
sound._source.playbackRate.setValueAtTime(rate, Howler.ctx.currentTime);
|
|
} else {
|
|
sound._node.playbackRate = rate;
|
|
}
|
|
}
|
|
this._emit('rate', sound._id);
|
|
});
|
|
return this;
|
|
}
|
|
|
|
seek(seek, id) {
|
|
if (seek === undefined) {
|
|
let sound = id ? this._sounds.find(s => s._id === id) : this._sounds[0];
|
|
if (!sound) return 0;
|
|
|
|
if (this._webAudio) {
|
|
let elapsed = sound._playStart ? Howler.ctx.currentTime - sound._playStart : 0;
|
|
return sound._seek + elapsed * sound._rate;
|
|
}
|
|
return sound._node?.currentTime || 0;
|
|
}
|
|
|
|
this._getSounds(id).forEach(sound => {
|
|
let wasPlaying = !sound._paused;
|
|
if (wasPlaying) this.pause(sound._id);
|
|
|
|
sound._seek = seek;
|
|
if (!this._webAudio && sound._node) {
|
|
sound._node.currentTime = seek;
|
|
}
|
|
|
|
if (wasPlaying) this.play(sound._id);
|
|
this._emit('seek', sound._id);
|
|
});
|
|
return this;
|
|
}
|
|
|
|
playing(id) {
|
|
if (id) {
|
|
let sound = this._sounds.find(s => s._id === id);
|
|
return sound ? !sound._paused : false;
|
|
}
|
|
return this._sounds.some(s => !s._paused);
|
|
}
|
|
|
|
duration(id) {
|
|
if (id) {
|
|
let sound = this._sounds.find(s => s._id === id);
|
|
if (sound && this._sprite[sound._sprite]) {
|
|
return this._sprite[sound._sprite][1] / 1000;
|
|
}
|
|
}
|
|
return this._duration;
|
|
}
|
|
|
|
state() {
|
|
return this._state;
|
|
}
|
|
|
|
unload() {
|
|
this.stop();
|
|
this._stopFrequencyAnalysis();
|
|
Howler._howls.splice(Howler._howls.indexOf(this), 1);
|
|
this._sounds = [];
|
|
this._state = 'unloaded';
|
|
return null;
|
|
}
|
|
|
|
enableEQ(bands = 16, callback, boost = 4.0) {
|
|
if (!this._webAudio) {
|
|
console.warn('Frequency analysis requires Web Audio API');
|
|
return this;
|
|
}
|
|
|
|
this._frequencyCallback = callback;
|
|
this._frequencyBands = bands;
|
|
this._frequencyBoost = boost;
|
|
this._frequencyEnabled = true;
|
|
|
|
if (!this._analyzer) {
|
|
this._analyzer = Howler.ctx.createAnalyser();
|
|
this._analyzer.fftSize = 2048;
|
|
this._analyzer.smoothingTimeConstant = 0.3;
|
|
this._frequencyData = new Uint8Array(this._analyzer.frequencyBinCount);
|
|
this._analyzer.connect(Howler.masterGain);
|
|
}
|
|
|
|
this._rebuildEffectChain();
|
|
this._startFrequencyAnalysis();
|
|
return this;
|
|
}
|
|
|
|
disableEQ() {
|
|
this._frequencyEnabled = false;
|
|
this._stopFrequencyAnalysis();
|
|
this._rebuildEffectChain();
|
|
return this;
|
|
}
|
|
|
|
getFrequencyData() {
|
|
if (!this._analyzer || !this._frequencyEnabled) return null;
|
|
|
|
this._analyzer.getByteFrequencyData(this._frequencyData);
|
|
|
|
let bands = new Array(this._frequencyBands);
|
|
let nyquist = Howler.ctx.sampleRate / 2;
|
|
let dataLength = this._frequencyData.length;
|
|
|
|
let minFreq = 20;
|
|
let maxFreq = nyquist;
|
|
let logMin = Math.log(minFreq);
|
|
let logMax = Math.log(maxFreq);
|
|
let logRange = logMax - logMin;
|
|
|
|
for (let i = 0; i < this._frequencyBands; i++) {
|
|
// Logarithmic frequency distribution
|
|
let logStart = logMin + (i / this._frequencyBands) * logRange;
|
|
let logEnd = logMin + ((i + 1) / this._frequencyBands) * logRange;
|
|
|
|
let startFreq = Math.exp(logStart);
|
|
let endFreq = Math.exp(logEnd);
|
|
|
|
let startBin = Math.floor((startFreq / nyquist) * dataLength);
|
|
let endBin = Math.ceil((endFreq / nyquist) * dataLength);
|
|
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (let j = startBin; j < Math.min(endBin, dataLength); j++) {
|
|
sum += this._frequencyData[j];
|
|
count++;
|
|
}
|
|
|
|
bands[i] = count > 0 ? (sum / count / 255) : 0; // Normalize to 0-1
|
|
}
|
|
|
|
for (let i = 0; i < bands.length; i++) {
|
|
bands[i] = Math.min(1.0, bands[i] * this._frequencyBoost);
|
|
}
|
|
|
|
return bands;
|
|
}
|
|
|
|
_startFrequencyAnalysis() {
|
|
if (!this._frequencyEnabled || this._frequencyAnimationId) return;
|
|
|
|
let analyze = () => {
|
|
if (!this._frequencyEnabled) return;
|
|
|
|
let frequencyData = this.getFrequencyData();
|
|
if (frequencyData && this._frequencyCallback) {
|
|
this._frequencyCallback(frequencyData);
|
|
}
|
|
|
|
this._frequencyAnimationId = requestAnimationFrame(analyze);
|
|
};
|
|
|
|
analyze();
|
|
}
|
|
|
|
_stopFrequencyAnalysis() {
|
|
if (this._frequencyAnimationId) {
|
|
cancelAnimationFrame(this._frequencyAnimationId);
|
|
this._frequencyAnimationId = null;
|
|
}
|
|
}
|
|
|
|
addEffect(type, options = {}) {
|
|
if (!this._webAudio) {
|
|
console.warn('Audio effects require Web Audio API');
|
|
return null;
|
|
}
|
|
|
|
let effect;
|
|
|
|
switch (type.toLowerCase()) {
|
|
case 'lowpass':
|
|
effect = Howler.ctx.createBiquadFilter();
|
|
effect.type = 'lowpass';
|
|
effect.frequency.setValueAtTime(options.frequency || 1000, Howler.ctx.currentTime);
|
|
effect.Q.setValueAtTime(options.Q || 1, Howler.ctx.currentTime);
|
|
break;
|
|
|
|
case 'highpass':
|
|
effect = Howler.ctx.createBiquadFilter();
|
|
effect.type = 'highpass';
|
|
effect.frequency.setValueAtTime(options.frequency || 300, Howler.ctx.currentTime);
|
|
effect.Q.setValueAtTime(options.Q || 1, Howler.ctx.currentTime);
|
|
break;
|
|
|
|
case 'bandpass':
|
|
effect = Howler.ctx.createBiquadFilter();
|
|
effect.type = 'bandpass';
|
|
effect.frequency.setValueAtTime(options.frequency || 1000, Howler.ctx.currentTime);
|
|
effect.Q.setValueAtTime(options.Q || 1, Howler.ctx.currentTime);
|
|
break;
|
|
|
|
case 'notch':
|
|
effect = Howler.ctx.createBiquadFilter();
|
|
effect.type = 'notch';
|
|
effect.frequency.setValueAtTime(options.frequency || 1000, Howler.ctx.currentTime);
|
|
effect.Q.setValueAtTime(options.Q || 30, Howler.ctx.currentTime);
|
|
break;
|
|
|
|
case 'delay':
|
|
effect = Howler.ctx.createDelay(options.maxDelay || 1);
|
|
effect.delayTime.setValueAtTime(options.delay || 0.3, Howler.ctx.currentTime);
|
|
break;
|
|
|
|
case 'reverb':
|
|
effect = Howler.ctx.createConvolver();
|
|
let length = Howler.ctx.sampleRate * (options.duration || 2);
|
|
let impulse = Howler.ctx.createBuffer(2, length, Howler.ctx.sampleRate);
|
|
let decay = options.decay || 2;
|
|
|
|
for (let channel = 0; channel < 2; channel++) {
|
|
let channelData = impulse.getChannelData(channel);
|
|
for (let i = 0; i < length; i++) {
|
|
channelData[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
|
|
}
|
|
}
|
|
effect.buffer = impulse;
|
|
break;
|
|
|
|
case 'gain':
|
|
effect = Howler.ctx.createGain();
|
|
effect.gain.setValueAtTime(options.gain || 1, Howler.ctx.currentTime);
|
|
break;
|
|
|
|
case 'distortion':
|
|
effect = Howler.ctx.createWaveShaper();
|
|
let samples = 44100;
|
|
let curve = new Float32Array(samples);
|
|
let amount = options.amount || 50;
|
|
for (let i = 0; i < samples; i++) {
|
|
let x = (i * 2) / samples - 1;
|
|
curve[i] = ((3 + amount) * x * 20 * Math.PI / 180) / (Math.PI + amount * Math.abs(x));
|
|
}
|
|
effect.curve = curve;
|
|
effect.oversample = options.oversample || '4x';
|
|
break;
|
|
|
|
default:
|
|
console.warn(`Unknown effect type: ${type}`);
|
|
return null;
|
|
}
|
|
|
|
if (effect) {
|
|
this._effects.push(effect);
|
|
this._rebuildEffectChain();
|
|
}
|
|
|
|
return effect;
|
|
}
|
|
|
|
get effects() {
|
|
return this._effects;
|
|
}
|
|
|
|
updateEffectsChain() {
|
|
this._rebuildEffectChain();
|
|
return this;
|
|
}
|
|
|
|
_rebuildEffectChain() {
|
|
this._sounds.forEach(sound => {
|
|
if (sound._node && this._webAudio) {
|
|
let startNode = sound._panner || sound._node;
|
|
this._connectSoundToChain(sound, startNode);
|
|
}
|
|
});
|
|
}
|
|
|
|
_connectSoundToChain(sound, startNode = null) {
|
|
if (!sound._node || !this._webAudio) return;
|
|
|
|
let sourceNode = startNode || sound._node;
|
|
sourceNode.disconnect();
|
|
|
|
let currentNode = sourceNode;
|
|
|
|
this._effects.forEach(effect => {
|
|
currentNode.connect(effect);
|
|
currentNode = effect;
|
|
});
|
|
|
|
if (this._environmentReverb && sound._panner) {
|
|
this._ensureEnvironmentSend(sound);
|
|
currentNode.connect(sound._environmentSend);
|
|
sound._environmentSend.connect(this._environmentReverb);
|
|
}
|
|
|
|
if (this._analyzer && this._frequencyEnabled) {
|
|
currentNode.connect(this._analyzer);
|
|
sound._analyzerConnected = true;
|
|
} else {
|
|
currentNode.connect(Howler.masterGain);
|
|
sound._analyzerConnected = false;
|
|
}
|
|
}
|
|
|
|
_ensureEnvironmentSend(sound) {
|
|
if (!sound._environmentSend) {
|
|
sound._environmentSend = Howler.ctx.createGain();
|
|
sound._environmentSend.gain.setValueAtTime(0.7, Howler.ctx.currentTime);
|
|
}
|
|
}
|
|
|
|
on(event, fn, id) {
|
|
(this._listeners[event] = this._listeners[event] || []).push({ fn, id });
|
|
return this;
|
|
}
|
|
|
|
off(event, fn, id) {
|
|
let listeners = this._listeners[event];
|
|
if (!listeners) return this;
|
|
|
|
if (!fn && !id) {
|
|
this._listeners[event] = [];
|
|
} else {
|
|
this._listeners[event] = listeners.filter(l =>
|
|
(fn && l.fn !== fn) || (id && l.id !== id)
|
|
);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
once(event, fn, id) {
|
|
let onceFn = (...args) => {
|
|
fn(...args);
|
|
this.off(event, onceFn, id);
|
|
};
|
|
return this.on(event, onceFn, id);
|
|
}
|
|
|
|
_selectSource() {
|
|
for (let src of this._src) {
|
|
let ext = this._format?.[this._src.indexOf(src)] ||
|
|
src.match(/\.([^.?]+)(\?|$)/)?.[1] ||
|
|
src.match(/^data:audio\/([^;,]+)/)?.[1];
|
|
|
|
if (ext && Howler.codecs(ext)) return src;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async _loadWebAudio(url) {
|
|
try {
|
|
let arrayBuffer;
|
|
|
|
if (url.startsWith('data:')) {
|
|
let data = atob(url.split(',')[1]);
|
|
arrayBuffer = new Uint8Array(data.length);
|
|
for (let i = 0; i < data.length; i++) {
|
|
arrayBuffer[i] = data.charCodeAt(i);
|
|
}
|
|
arrayBuffer = arrayBuffer.buffer;
|
|
} else {
|
|
let response = await fetch(url, this._xhr);
|
|
arrayBuffer = await response.arrayBuffer();
|
|
}
|
|
|
|
let audioBuffer = await Howler.ctx.decodeAudioData(arrayBuffer);
|
|
this._buffer = audioBuffer;
|
|
this._duration = audioBuffer.duration;
|
|
this._finishLoad();
|
|
return this;
|
|
} catch (error) {
|
|
this._emit('loaderror', null, error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async _loadHtml5(url) {
|
|
return new Promise((resolve, reject) => {
|
|
let audio = Howler._getHtml5Audio();
|
|
|
|
let onLoad = () => {
|
|
this._duration = Math.ceil(audio.duration * 10) / 10;
|
|
this._finishLoad();
|
|
audio.removeEventListener('canplaythrough', onLoad);
|
|
audio.removeEventListener('error', onError);
|
|
resolve(this);
|
|
};
|
|
|
|
let onError = () => {
|
|
let error = `Failed to load: ${url}`;
|
|
audio.removeEventListener('canplaythrough', onLoad);
|
|
audio.removeEventListener('error', onError);
|
|
this._emit('loaderror', null, error);
|
|
reject(new Error(error));
|
|
};
|
|
|
|
audio.addEventListener('canplaythrough', onLoad);
|
|
audio.addEventListener('error', onError);
|
|
audio.src = url;
|
|
audio.load();
|
|
});
|
|
}
|
|
|
|
_finishLoad() {
|
|
if (!Object.keys(this._sprite).length) {
|
|
this._sprite = { __default: [0, this._duration * 1000] };
|
|
}
|
|
|
|
this._state = 'loaded';
|
|
this._emit('load');
|
|
this._processQueue();
|
|
}
|
|
|
|
_processQueue() {
|
|
if (this._queue.length) {
|
|
let { action } = this._queue.shift();
|
|
action();
|
|
this._processQueue();
|
|
}
|
|
}
|
|
|
|
_getSound() {
|
|
let sound = this._sounds.find(s => s._ended);
|
|
|
|
if (!sound) {
|
|
if (this._sounds.length >= this._pool) {
|
|
return this._sounds.find(s => s._paused) || this._sounds[0];
|
|
}
|
|
sound = this._createSound();
|
|
}
|
|
|
|
return this._resetSound(sound);
|
|
}
|
|
|
|
_createSound() {
|
|
let sound = {
|
|
_id: ++Howler._counter,
|
|
_volume: this._volume,
|
|
_rate: this._rate,
|
|
_seek: 0,
|
|
_paused: true,
|
|
_ended: true,
|
|
_muted: this._muted,
|
|
_sprite: '__default'
|
|
};
|
|
|
|
this._sounds.push(sound);
|
|
this._createSoundNode(sound);
|
|
return sound;
|
|
}
|
|
|
|
_resetSound(sound) {
|
|
Object.assign(sound, {
|
|
_volume: this._volume,
|
|
_rate: this._rate,
|
|
_seek: 0,
|
|
_paused: true,
|
|
_ended: true,
|
|
_muted: this._muted,
|
|
_sprite: '__default'
|
|
});
|
|
return sound;
|
|
}
|
|
|
|
_createSoundNode(sound) {
|
|
if (this._webAudio) {
|
|
sound._node = Howler.ctx.createGain();
|
|
|
|
if (this.spatial?.enabled) {
|
|
sound._panner = Howler.ctx.createPanner();
|
|
// Set the panning model (this doesn't change dynamically)
|
|
sound._panner.panningModel = this.spatial.panningModel;
|
|
sound._node.connect(sound._panner);
|
|
this._updateSoundSpatial(sound);
|
|
|
|
if (Howler._environment) {
|
|
this._updateEnvironment();
|
|
}
|
|
|
|
this._connectSoundToChain(sound, sound._panner);
|
|
} else {
|
|
this._connectSoundToChain(sound);
|
|
}
|
|
} else {
|
|
sound._node = Howler._getHtml5Audio();
|
|
sound._node.src = this._src;
|
|
}
|
|
}
|
|
|
|
_playSound(sound) {
|
|
if (!sound || this._state !== 'loaded') return null;
|
|
|
|
let sprite = this._sprite[sound._sprite];
|
|
if (!sprite) return null;
|
|
|
|
let [start, duration] = sprite;
|
|
let seek = Math.max(0, sound._seek || start / 1000);
|
|
|
|
sound._paused = false;
|
|
sound._ended = false;
|
|
|
|
if (this._webAudio) {
|
|
this._playWebAudio(sound, seek, duration / 1000);
|
|
} else {
|
|
this._playHtml5(sound, seek);
|
|
}
|
|
|
|
this._emit('play', sound._id);
|
|
return sound._id;
|
|
}
|
|
|
|
_playWebAudio(sound, seek, duration) {
|
|
this._cleanupSound(sound);
|
|
|
|
sound._source = Howler.ctx.createBufferSource();
|
|
sound._source.buffer = this._buffer;
|
|
sound._source.connect(sound._node);
|
|
|
|
sound._source.playbackRate.setValueAtTime(sound._rate, Howler.ctx.currentTime);
|
|
sound._playStart = Howler.ctx.currentTime;
|
|
|
|
if (sound._loop) {
|
|
sound._source.loop = true;
|
|
sound._source.start(0, seek);
|
|
} else {
|
|
// Fix: duration is the sprite duration, not total audio duration
|
|
sound._source.start(0, seek, duration);
|
|
sound._source.onended = () => this._onSoundEnd(sound);
|
|
}
|
|
|
|
this._updateSoundVolume(sound);
|
|
}
|
|
|
|
_playHtml5(sound, seek) {
|
|
let { _node: node } = sound;
|
|
node.currentTime = seek;
|
|
node.volume = this._getEffectiveVolume(sound);
|
|
node.playbackRate = sound._rate;
|
|
node.muted = sound._muted || this._muted || Howler._muted;
|
|
|
|
let promise = node.play();
|
|
if (promise) {
|
|
promise.catch(() => this._emit('playerror', sound._id, 'Playback failed'));
|
|
}
|
|
|
|
if (!sound._loop) {
|
|
node.onended = () => this._onSoundEnd(sound);
|
|
}
|
|
}
|
|
|
|
_onSoundEnd(sound) {
|
|
sound._paused = true;
|
|
sound._ended = true;
|
|
this._emit('end', sound._id);
|
|
|
|
if (sound._loop) {
|
|
sound._ended = false;
|
|
this._playSound(sound);
|
|
}
|
|
}
|
|
|
|
_updateSoundVolume(sound) {
|
|
if (!sound._node) return;
|
|
|
|
let volume = this._getEffectiveVolume(sound);
|
|
|
|
if (this._webAudio) {
|
|
sound._node.gain.setValueAtTime(volume, Howler.ctx.currentTime);
|
|
} else {
|
|
sound._node.volume = volume;
|
|
}
|
|
}
|
|
|
|
_getEffectiveVolume(sound) {
|
|
if (sound._muted || this._muted || Howler._muted) return 0;
|
|
return sound._volume * this._volume * Howler._volume;
|
|
}
|
|
|
|
_updateVolume() {
|
|
this._sounds.forEach(sound => this._updateSoundVolume(sound));
|
|
}
|
|
|
|
_cleanupSound(sound) {
|
|
if (sound._source) {
|
|
sound._source.disconnect();
|
|
sound._source = null;
|
|
}
|
|
if (sound._occlusionFilter) {
|
|
sound._occlusionFilter.disconnect();
|
|
sound._occlusionFilter = null;
|
|
}
|
|
}
|
|
|
|
_getSounds(id) {
|
|
return id ? this._sounds.filter(s => s._id === id) : this._sounds;
|
|
}
|
|
|
|
_emit(event, id, data) {
|
|
let listeners = this._listeners[event];
|
|
if (!listeners) return;
|
|
|
|
listeners.forEach(({ fn, id: listenerId }) => {
|
|
if (!listenerId || listenerId === id) {
|
|
setTimeout(() => fn(id, data), 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
_updateSpatial() {
|
|
if (!this.spatial?.enabled) return;
|
|
|
|
this._sounds.forEach(sound => {
|
|
if (sound._panner) {
|
|
this._updateSoundSpatial(sound);
|
|
}
|
|
});
|
|
}
|
|
|
|
_updateSoundSpatial(sound) {
|
|
if (!sound._panner || !this.spatial?.enabled) return;
|
|
|
|
let { position, orientation, velocity, distance, cone, occlusion, obstruction } = this.spatial;
|
|
let p = sound._panner;
|
|
|
|
if (p.positionX) {
|
|
p.positionX.setValueAtTime(position.x, Howler.ctx.currentTime);
|
|
p.positionY.setValueAtTime(position.y, Howler.ctx.currentTime);
|
|
p.positionZ.setValueAtTime(position.z, Howler.ctx.currentTime);
|
|
p.orientationX.setValueAtTime(orientation.x, Howler.ctx.currentTime);
|
|
p.orientationY.setValueAtTime(orientation.y, Howler.ctx.currentTime);
|
|
p.orientationZ.setValueAtTime(orientation.z, Howler.ctx.currentTime);
|
|
} else {
|
|
p.setPosition(position.x, position.y, position.z);
|
|
p.setOrientation(orientation.x, orientation.y, orientation.z);
|
|
}
|
|
|
|
if (p.setVelocity && velocity) {
|
|
p.setVelocity(velocity.x, velocity.y, velocity.z);
|
|
}
|
|
|
|
p.refDistance = distance.ref;
|
|
p.maxDistance = distance.max;
|
|
p.rolloffFactor = distance.rolloff;
|
|
p.distanceModel = distance.model;
|
|
|
|
p.coneInnerAngle = cone.inner;
|
|
p.coneOuterAngle = cone.outer;
|
|
p.coneOuterGain = cone.outerGain;
|
|
|
|
this._applyOcclusion(sound, occlusion || 0, obstruction || 0);
|
|
}
|
|
|
|
_applyOcclusion(sound, occlusion, obstruction) {
|
|
if (!sound._occlusionFilter && (occlusion > 0 || obstruction > 0)) {
|
|
sound._occlusionFilter = Howler.ctx.createBiquadFilter();
|
|
sound._occlusionFilter.type = 'lowpass';
|
|
sound._node.disconnect();
|
|
sound._node.connect(sound._occlusionFilter);
|
|
sound._occlusionFilter.connect(sound._panner || Howler.masterGain);
|
|
}
|
|
|
|
if (sound._occlusionFilter) {
|
|
let freq = 20000 * (1 - Math.max(occlusion, obstruction) * 0.9);
|
|
let gain = 1 - occlusion * 0.8;
|
|
sound._occlusionFilter.frequency.setValueAtTime(freq, Howler.ctx.currentTime);
|
|
sound._node.gain.setValueAtTime(gain * this._getEffectiveVolume(sound), Howler.ctx.currentTime);
|
|
}
|
|
}
|
|
|
|
_updateEnvironment() {
|
|
if (!Howler._environment || !this._webAudio) return;
|
|
|
|
if (!this._environmentReverb) {
|
|
this._environmentReverb = Howler.ctx.createConvolver();
|
|
let { size, decay, dampening } = Howler._environment;
|
|
let length = Howler.ctx.sampleRate * (size === 'large' ? 6 : size === 'medium' ? 3 : 1.5);
|
|
let impulse = Howler.ctx.createBuffer(2, length, Howler.ctx.sampleRate);
|
|
|
|
for (let channel = 0; channel < 2; channel++) {
|
|
let channelData = impulse.getChannelData(channel);
|
|
for (let i = 0; i < length; i++) {
|
|
let progress = i / length;
|
|
let sample = 0;
|
|
|
|
if (i < Howler.ctx.sampleRate * 0.2) {
|
|
for (let j = 0; j < 8; j++) {
|
|
let delay = (j + 1) * 0.02 * Howler.ctx.sampleRate;
|
|
if (Math.abs(i - delay) < 100) {
|
|
sample += (Math.random() * 2 - 1) * 0.3 * Math.exp(-j * 0.5);
|
|
}
|
|
}
|
|
}
|
|
|
|
let envelope = Math.pow(1 - progress, decay);
|
|
let reverb = (Math.random() * 2 - 1) * envelope;
|
|
sample += reverb * (1 - dampening + Math.random() * dampening * 0.3);
|
|
|
|
if (size === 'large') {
|
|
let lowFreq = Math.sin(i * 0.01) * envelope * 0.2;
|
|
sample += lowFreq;
|
|
}
|
|
|
|
channelData[i] = Math.tanh(sample * 0.8);
|
|
}
|
|
}
|
|
this._environmentReverb.buffer = impulse;
|
|
|
|
this._environmentGain = Howler.ctx.createGain();
|
|
this._environmentGain.gain.setValueAtTime(0.8, Howler.ctx.currentTime);
|
|
this._environmentReverb.connect(this._environmentGain);
|
|
this._environmentGain.connect(Howler.masterGain);
|
|
}
|
|
|
|
this._rebuildEffectChain();
|
|
}
|
|
}
|
|
|
|
let Howler = new HowlerGlobal();
|
|
|
|
return { Howler, Howl };
|
|
|
|
}));
|