@awayjs/core
Version:
AwayJS core classes
427 lines (336 loc) • 11.1 kB
text/typescript
import { IWaveAudioMeta } from '../audio/WaveAudio';
import { BaseAudioChannel } from './BaseAudioChannel';
import { IAudioChannel } from './IAudioChannel';
export class WebAudioChannel extends BaseAudioChannel implements IAudioChannel {
public static maxChannels: number = 128;
public static _channels: Array<WebAudioChannel> = new Array<WebAudioChannel>();
public static _decodeCache: Record<string, AudioBuffer> = {};
public static _errorCache: Record<string, any> = {};
public static _audioCtx: AudioContext;
public static getOfflineContext (channels: number = 1, samples: number = 0, rate: number = 44100) {
// eslint-disable-next-line max-len
const Context = <typeof OfflineAudioContext> (self.OfflineAudioContext || (<any>self).webkitOfflineAudioContext);
return Context ? new Context(channels, samples, rate) : null;
}
public static getAudioContext() {
// eslint-disable-next-line max-len
const Context = <typeof AudioContext> (self.AudioContext || (<any>self).webkitAudioContext);
if (!WebAudioChannel._audioCtx && Context)
WebAudioChannel._audioCtx = new Context();
if (WebAudioChannel._audioCtx && WebAudioChannel._audioCtx.state == 'suspended')
WebAudioChannel._audioCtx.resume();
return WebAudioChannel._audioCtx;
}
private _audioCtx: AudioContext;
private _usingNativePanner: boolean;
private _gainNode: GainNode;
private _pannerNode: PannerNode | StereoPannerNode;
private _source: AudioBufferSourceNode;
private _currentTime: number;
private _volume: number = 1;
private _pan: number = -1;
private _groupID: number = 0;
private _groupVolume: number = 1;
private _groupPan: number = 0;
private _startTime: number = 0;
private _duration: number;
private _onEndedDelegate: (event: any) => void;
public static stopAllSounds(channelGroup: number = -1) {
const len: number = WebAudioChannel._channels.length;
if (channelGroup < 0) {
for (let j: number = 0; j < len; j++) {
WebAudioChannel._channels[j].stop();
}
WebAudioChannel._channels.length = 0;
return;
}
const aliveChannels: WebAudioChannel[] = [];
for (let j: number = 0; j < len; j++) {
if (WebAudioChannel._channels[j].groupID == channelGroup) {
WebAudioChannel._channels[j].stop();
} else {
aliveChannels[aliveChannels.length] = WebAudioChannel._channels[j];
}
}
WebAudioChannel._channels = aliveChannels;
}
public static setChannelGroupVolume(value: number, channelGroup: number = -1) {
const len: number = WebAudioChannel._channels.length;
if (channelGroup < 0) {
for (let j: number = 0; j < len; j++) {
WebAudioChannel._channels[j].groupVolume = value;
}
return;
}
for (let j: number = 0; j < len; j++) {
if (WebAudioChannel._channels[j].groupID == channelGroup) {
WebAudioChannel._channels[j].groupVolume = value;
}
}
}
public get duration(): number {
return this._duration;
}
public get currentTime(): number {
if (this._isDecoding) {
return this._currentTime;
}
return this._audioCtx.currentTime - this._startTime;
}
public get groupID(): number {
return this._groupID;
}
public set groupID(value: number) {
this._groupID = value;
}
public get groupVolume(): number {
return this._groupVolume;
}
public set groupVolume(value: number) {
if (this._groupVolume == value)
return;
this._groupVolume = value;
this._gainNode.gain.value = this._groupVolume * this._volume;
}
public get groupPan(): number {
return this._groupPan;
}
public set groupPan(value: number) {
}
public get volume(): number {
return this._volume;
}
public set volume(value: number) {
if (this._volume == value)
return;
this._volume = value;
this._gainNode.gain.value = this._groupVolume * this._volume;
}
public get pan(): number {
return this._pan;
}
public set pan(value: number) {
if (this._pan == value)
return;
this._pan = value;
if (this._usingNativePanner) {
(<StereoPannerNode> this._pannerNode).pan.value = this._pan;
} else {
const pan = this._pan * (Math.PI / 2);
(<PannerNode> this._pannerNode).setPosition(Math.sin(pan), 0,Math.cos(pan));
}
}
constructor(groupID: number = 0, groupVolume: number = 1, groupPan: number = 1) {
super();
this._groupID = groupID;
this._groupVolume = groupVolume;
this._groupPan = groupPan;
this._audioCtx = WebAudioChannel.getAudioContext();
this._usingNativePanner = typeof this._audioCtx.createStereoPanner === 'function';
this._gainNode = this._audioCtx.createGain();
this._gainNode.gain.value = this._groupVolume * this._volume;
this._pannerNode = this._usingNativePanner ?
this._audioCtx.createStereoPanner() :
this._audioCtx.createPanner();
this.pan = 0;
this._gainNode.connect(this._pannerNode);
this._pannerNode.connect(this._audioCtx.destination);
this._onEndedDelegate = (event) => this._onEnded(event);
}
public restart(): boolean {
this._isPlaying = false;
if (this._stopped) {
throw 'You can\'t restart channel that was fully stopped';
}
const buffer = WebAudioChannel._decodeCache[this._id];
if (!buffer) {
return false;
}
this._isPlaying = true;
this.executeBuffer(buffer);
this.dispatchRestart();
return true;
}
public play(
buffer: ArrayBuffer,
offset: number = 0,
loop: boolean | number = false,
id: number = 0,
meta?: IWaveAudioMeta
): void {
if (buffer.byteLength === 0) {
console.warn('[WabAudioChannel] Input buffer is empty');
return;
}
super.play(buffer, offset, loop, id, meta);
this._isLooping = this._loops > 0;
this._isPlaying = true;
this._currentTime = offset;
this._isDecoding = true;
//fast path for short sounds
if (WebAudioChannel._decodeCache[id]) {
this.executeBuffer(WebAudioChannel._decodeCache[id]);
} else if (!WebAudioChannel._errorCache[id]) {
this._decodeAndExecute(buffer, meta);
} else {
this.stopInternally(false);
this.dispatchStop(true);
}
}
private stopInternally(emitComplete = true) {
// for AVM1 it is a bug to stop a audio thats currently decoding.
// when we call stopAllASounds() during audio-decode, this audio should still play
//if(this._isDecoding){
// return;
//}
if (!this._isPlaying)
return;
this._isPlaying = false;
this._isLooping = false;
this._isDecoding = false;
if (this._source) {
this._disposeSource();
}
// will be try restart
super.completeInternally(emitComplete, emitComplete);
}
public stop(): void {
this.stopInternally(false);
this.dispatchStop(false);
}
private _decodeAndExecute(buffer: ArrayBuffer, meta?: IWaveAudioMeta) {
let ctx: AudioContext | OfflineAudioContext = this._audioCtx;
// try to transcode to RIGHT target
// with RIGHT sample rate, to increase quality
try {
if (meta && meta.sampleRate && meta.sampleRate !== this._audioCtx.sampleRate && meta.samplesCount) {
ctx = WebAudioChannel.getOfflineContext(2, meta.samplesCount + meta.startOffset, meta.sampleRate);
}
} catch (e) {
console.warn('[WebAudioChannel] Error when try create Offline Context:',e.message, meta);
}
const promise = ctx.decodeAudioData(
buffer.slice(0),
(buffer) => this._onDecodeComplete(buffer, meta),
(event) => this._onError(event)
);
// when decodeAudioData is Promise - it not emit error to callback, need catch promise
// @ts-ignore
if (promise instanceof self.Promise) {
promise.catch((e) => this._onError(e));
}
}
public _onDecodeComplete(buffer: AudioBuffer, meta: IWaveAudioMeta): void {
if (!WebAudioChannel._decodeCache[this._id]) {
// transform mp3 buffer, because it has silent regions
// IS VERY IMPORTANT FOR FLASH
if (meta && meta.startOffset > 0) {
buffer = this.removeSilent(
buffer,
meta.startOffset,
meta.samplesCount,
meta.sampleRate
);
}
WebAudioChannel._decodeCache[this._id] = buffer;
}
buffer = WebAudioChannel._decodeCache[this._id];
if (!this._isPlaying)
return;
this.executeBuffer(buffer);
}
private executeBuffer (buffer: AudioBuffer) {
if (this._source)
this._disposeSource();
this._isDecoding = false;
this._source = this._audioCtx.createBufferSource();
//this._source.loop = this._isLooping;
this._source.connect(this._gainNode);
this._source.buffer = buffer;
this._duration = buffer.duration;
this._pan = 0;
this._startTime = this._audioCtx.currentTime - this._currentTime;
this._source.onended = this._onEndedDelegate;
try {
// retrig setter by reset cache
const vol = this._groupVolume;
const pan = this._pan;
this._groupVolume = -1;
this._pan = -1;
this.groupVolume = vol;
this.pan = pan;
// TODO: offset / startTime make problem in dino-run game:
this._source.start(this._audioCtx.currentTime, this._currentTime);
} catch (error) {
console.warn('[WebAudioChannel] Error starting audio: ' + error);
this.dispatchStop(true);
this._disposeSource();
}
}
public _onError(_event: any): void {
console.warn('[WebAudioChannel] Error with decoding audio data:', _event);
WebAudioChannel._errorCache[this._id] = true;
this._isDecoding = false;
this.stopInternally(false);
this.dispatchStop(true);
}
private _onEnded(_event: any): void {
// we dispatch STOP only for manually stopped and if channel still alive
this.stopInternally(true);
}
private _disposeSource(): void {
//clean up
this._source.onended = null;
this._source.stop(this._audioCtx.currentTime);
this._source.disconnect();
this._source = null;
}
private removeSilent (
buffer: AudioBuffer,
begin: number = 0,
length: number = 0,
originalRate: number = 0
): AudioBuffer {
length = length || buffer.length;
originalRate = originalRate || buffer.sampleRate;
// we should transform to buffer rate
begin = Math.ceil ((buffer.sampleRate * begin) / originalRate);
length = ((buffer.sampleRate * length) / originalRate) | 0;
const end = Math.min(buffer.length, length + begin);
const res = this._audioCtx.createBuffer(
buffer.numberOfChannels,
length,
buffer.sampleRate,
);
for (let i = 0; i < buffer.numberOfChannels; i++) {
// interate over channels and copy part of channel
res.copyToChannel(
buffer.getChannelData(i).subarray(begin, end), i, 0
);
}
return res;
}
}
const audioCtx = WebAudioChannel.getAudioContext();
// context state at this time is `undefined` in iOS8 Safari
if (audioCtx && audioCtx.state === 'suspended') {
const resume = () => {
audioCtx.resume();
//create empty buffer
const buffer = audioCtx.createBuffer(1, 1, 22050);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start();
setTimeout(() => {
if (audioCtx.state === 'running') {
document.removeEventListener('touchstart', resume, false);
document.removeEventListener('touchend', resume, false);
}
}, 0);
};
// iOS 6-8
document.addEventListener('touchstart', resume, false);
// iOS 9
document.addEventListener('touchend', resume, false);
}