mirror of
https://github.com/motajs/template.git
synced 2026-11-03 15:34:31 +08:00
refactor: 音频系统
This commit is contained in:
1 parent
191ba8d1db
commit
ee54148558
29 files changed
+2170
-1273
No files matched your search
@@ -1,268 +0,0 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { audioPlayer, AudioPlayer, AudioRoute, AudioStatus } from './player';
|
||||
import { guessTypeByExt, isAudioSupport } from './support';
|
||||
import { logger } from '@motajs/common';
|
||||
import { StreamLoader } from '../loader';
|
||||
import { linear, sleep, Transition } from 'mutate-animate';
|
||||
import { VolumeEffect } from './effect';
|
||||
|
||||
interface BgmVolume {
|
||||
effect: VolumeEffect;
|
||||
transition: Transition;
|
||||
}
|
||||
|
||||
interface BgmControllerEvent {
|
||||
play: [];
|
||||
pause: [];
|
||||
resume: [];
|
||||
stop: [];
|
||||
}
|
||||
|
||||
export class BgmController<
|
||||
T extends string = BgmIds
|
||||
> extends EventEmitter<BgmControllerEvent> {
|
||||
/** bgm音频名称的前缀 */
|
||||
prefix: string = 'bgms.';
|
||||
/** 每个 bgm 的音量控制器 */
|
||||
readonly gain: Map<T, BgmVolume> = new Map();
|
||||
|
||||
/** 正在播放的 bgm */
|
||||
playingBgm?: T;
|
||||
/** 是否正在播放 */
|
||||
playing: boolean = false;
|
||||
|
||||
/** 是否已经启用 */
|
||||
enabled: boolean = true;
|
||||
/** 主音量控制器 */
|
||||
private readonly mainGain: VolumeEffect;
|
||||
/** 是否屏蔽所有的音乐切换 */
|
||||
private blocking: boolean = false;
|
||||
/** 渐变时长 */
|
||||
private transitionTime: number = 2000;
|
||||
|
||||
constructor(public readonly player: AudioPlayer) {
|
||||
super();
|
||||
this.mainGain = player.createVolumeEffect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频渐变时长
|
||||
* @param time 渐变时长
|
||||
*/
|
||||
setTransitionTime(time: number) {
|
||||
this.transitionTime = time;
|
||||
for (const [, value] of this.gain) {
|
||||
value.transition.time(time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 屏蔽音乐切换
|
||||
*/
|
||||
blockChange() {
|
||||
this.blocking = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消屏蔽音乐切换
|
||||
*/
|
||||
unblockChange() {
|
||||
this.blocking = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置总音量大小
|
||||
* @param volume 音量大小
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.mainGain.setVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取总音量大小
|
||||
*/
|
||||
getVolume() {
|
||||
return this.mainGain.getVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否启用
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
setEnabled(enabled: boolean) {
|
||||
if (enabled) this.resume();
|
||||
else this.stop();
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 bgm 音频名称的前缀
|
||||
*/
|
||||
setPrefix(prefix: string) {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
private getId(name: T) {
|
||||
return `${this.prefix}${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 bgm 名称获取其 AudioRoute 实例
|
||||
* @param id 音频名称
|
||||
*/
|
||||
get(id: T) {
|
||||
return this.player.getRoute(this.getId(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个 bgm
|
||||
* @param id 要添加的 bgm 的名称
|
||||
* @param url 指定 bgm 的加载地址
|
||||
*/
|
||||
addBgm(id: T, url: string = `project/bgms/${id}`) {
|
||||
const type = guessTypeByExt(id);
|
||||
if (!type) {
|
||||
logger.warn(50, id.split('.').slice(0, -1).join('.'));
|
||||
return;
|
||||
}
|
||||
const gain = this.player.createVolumeEffect();
|
||||
if (isAudioSupport(type)) {
|
||||
const source = audioPlayer.createElementSource();
|
||||
source.setSource(url);
|
||||
source.setLoop(true);
|
||||
const route = new AudioRoute(source, audioPlayer);
|
||||
route.addEffect([gain, this.mainGain]);
|
||||
audioPlayer.addRoute(this.getId(id), route);
|
||||
this.setTransition(id, route, gain);
|
||||
} else {
|
||||
const source = audioPlayer.createStreamSource();
|
||||
const stream = new StreamLoader(url);
|
||||
stream.pipe(source);
|
||||
source.setLoop(true);
|
||||
const route = new AudioRoute(source, audioPlayer);
|
||||
route.addEffect([gain, this.mainGain]);
|
||||
audioPlayer.addRoute(this.getId(id), route);
|
||||
this.setTransition(id, route, gain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个 bgm
|
||||
* @param id 要移除的 bgm 的名称
|
||||
*/
|
||||
removeBgm(id: T) {
|
||||
this.player.removeRoute(this.getId(id));
|
||||
const gain = this.gain.get(id);
|
||||
gain?.transition.ticker.destroy();
|
||||
this.gain.delete(id);
|
||||
}
|
||||
|
||||
private setTransition(id: T, route: AudioRoute, gain: VolumeEffect) {
|
||||
const transition = new Transition();
|
||||
transition
|
||||
.time(this.transitionTime)
|
||||
.mode(linear())
|
||||
.transition('volume', 0);
|
||||
|
||||
const tick = () => {
|
||||
gain.setVolume(transition.value.volume);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param expect 在结束时应该是正在播放还是停止
|
||||
*/
|
||||
const setTick = async (expect: AudioStatus) => {
|
||||
transition.ticker.remove(tick);
|
||||
transition.ticker.add(tick);
|
||||
const identifier = route.stopIdentifier;
|
||||
await sleep(this.transitionTime + 500);
|
||||
if (
|
||||
route.status === expect &&
|
||||
identifier === route.stopIdentifier
|
||||
) {
|
||||
transition.ticker.remove(tick);
|
||||
if (route.status === AudioStatus.Playing) {
|
||||
gain.setVolume(1);
|
||||
} else {
|
||||
gain.setVolume(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
route.onStart(async () => {
|
||||
transition.transition('volume', 1);
|
||||
setTick(AudioStatus.Playing);
|
||||
});
|
||||
route.onEnd(() => {
|
||||
transition.transition('volume', 0);
|
||||
setTick(AudioStatus.Paused);
|
||||
});
|
||||
route.setEndTime(this.transitionTime);
|
||||
|
||||
this.gain.set(id, { effect: gain, transition });
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放一个 bgm
|
||||
* @param id 要播放的 bgm 名称
|
||||
*/
|
||||
play(id: T, when?: number) {
|
||||
if (this.blocking) return;
|
||||
if (id !== this.playingBgm && this.playingBgm) {
|
||||
this.player.pause(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playingBgm = id;
|
||||
if (!this.enabled) return;
|
||||
this.player.play(this.getId(id), when);
|
||||
this.playing = true;
|
||||
this.emit('play');
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续当前的 bgm
|
||||
*/
|
||||
resume() {
|
||||
if (this.blocking || !this.enabled || this.playing) return;
|
||||
if (this.playingBgm) {
|
||||
this.player.resume(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playing = true;
|
||||
this.emit('resume');
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停当前的 bgm
|
||||
*/
|
||||
pause() {
|
||||
if (this.blocking || !this.enabled) return;
|
||||
if (this.playingBgm) {
|
||||
this.player.pause(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playing = false;
|
||||
this.emit('pause');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前的 bgm
|
||||
*/
|
||||
stop() {
|
||||
if (this.blocking || !this.enabled) return;
|
||||
if (this.playingBgm) {
|
||||
this.player.stop(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playing = false;
|
||||
this.emit('stop');
|
||||
}
|
||||
}
|
||||
|
||||
export const bgmController = new BgmController<BgmIds>(audioPlayer);
|
||||
|
||||
export function loadAllBgm() {
|
||||
const { loading } = Mota.require('@user/data-base');
|
||||
loading.once('coreInit', () => {
|
||||
const data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d;
|
||||
for (const bgm of data.main.bgms) {
|
||||
bgmController.addBgm(bgm);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import { OggVorbisDecoderWebWorker } from '@wasm-audio-decoders/ogg-vorbis';
|
||||
import { OggOpusDecoderWebWorker } from 'ogg-opus-decoder';
|
||||
import { AudioType, isAudioSupport } from './support';
|
||||
import type { AudioPlayer } from './player';
|
||||
|
||||
const fileSignatures: [AudioType, number[]][] = [
|
||||
[AudioType.Mp3, [0x49, 0x44, 0x33]],
|
||||
[AudioType.Ogg, [0x4f, 0x67, 0x67, 0x53]],
|
||||
[AudioType.Wav, [0x52, 0x49, 0x46, 0x46]],
|
||||
[AudioType.Flac, [0x66, 0x4c, 0x61, 0x43]],
|
||||
[AudioType.Aac, [0xff, 0xf1]],
|
||||
[AudioType.Aac, [0xff, 0xf9]]
|
||||
];
|
||||
const oggHeaders: [AudioType, number[]][] = [
|
||||
[AudioType.Opus, [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]]
|
||||
];
|
||||
|
||||
export function checkAudioType(data: Uint8Array) {
|
||||
let audioType: AudioType | '' = '';
|
||||
// 检查头文件获取音频类型,仅检查前256个字节
|
||||
const toCheck = data.slice(0, 256);
|
||||
for (const [type, value] of fileSignatures) {
|
||||
if (value.every((v, i) => toCheck[i] === v)) {
|
||||
audioType = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (audioType === AudioType.Ogg) {
|
||||
// 如果是ogg的话,进一步判断是不是opus
|
||||
for (const [key, value] of oggHeaders) {
|
||||
const has = toCheck.some((_, i) => {
|
||||
return value.every((v, ii) => toCheck[i + ii] === v);
|
||||
});
|
||||
if (has) {
|
||||
audioType = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return audioType;
|
||||
}
|
||||
|
||||
export interface IAudioDecodeError {
|
||||
/** 错误信息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface IAudioDecodeData {
|
||||
/** 每个声道的音频信息 */
|
||||
channelData: Float32Array<ArrayBuffer>[];
|
||||
/** 已经被解码的 PCM 采样数 */
|
||||
samplesDecoded: number;
|
||||
/** 音频采样率 */
|
||||
sampleRate: number;
|
||||
/** 解码错误信息 */
|
||||
errors: IAudioDecodeError[];
|
||||
}
|
||||
|
||||
export abstract class AudioDecoder {
|
||||
static readonly decoderMap: Map<AudioType, new () => AudioDecoder> =
|
||||
new Map();
|
||||
|
||||
/**
|
||||
* 注册一个解码器
|
||||
* @param type 要注册的解码器允许解码的类型
|
||||
* @param decoder 解码器对象
|
||||
*/
|
||||
static registerDecoder(type: AudioType, decoder: new () => AudioDecoder) {
|
||||
if (this.decoderMap.has(type)) {
|
||||
logger.warn(47, type);
|
||||
return;
|
||||
}
|
||||
this.decoderMap.set(type, decoder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码音频数据
|
||||
* @param data 音频文件数据
|
||||
* @param player AudioPlayer实例
|
||||
*/
|
||||
static async decodeAudioData(data: Uint8Array, player: AudioPlayer) {
|
||||
// 检查头文件获取音频类型,仅检查前256个字节
|
||||
const toCheck = data.slice(0, 256);
|
||||
const type = checkAudioType(data);
|
||||
if (type === '') {
|
||||
logger.error(
|
||||
25,
|
||||
[...toCheck]
|
||||
.map(v => v.toString(16).padStart(2, '0'))
|
||||
.join(' ')
|
||||
.toUpperCase()
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (isAudioSupport(type)) {
|
||||
if (data.buffer instanceof ArrayBuffer) {
|
||||
return player.ac.decodeAudioData(data.buffer);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const Decoder = this.decoderMap.get(type);
|
||||
if (!Decoder) {
|
||||
return null;
|
||||
} else {
|
||||
const decoder = new Decoder();
|
||||
await decoder.create();
|
||||
const decodedData = await decoder.decodeAll(data);
|
||||
if (!decodedData) return null;
|
||||
const buffer = player.ac.createBuffer(
|
||||
decodedData.channelData.length,
|
||||
decodedData.channelData[0].length,
|
||||
decodedData.sampleRate
|
||||
);
|
||||
decodedData.channelData.forEach((v, i) => {
|
||||
buffer.copyToChannel(v, i);
|
||||
});
|
||||
decoder.destroy();
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建音频解码器
|
||||
*/
|
||||
abstract create(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 摧毁这个解码器
|
||||
*/
|
||||
abstract destroy(): void;
|
||||
|
||||
/**
|
||||
* 解码流数据
|
||||
* @param data 流数据
|
||||
*/
|
||||
abstract decode(data: Uint8Array): Promise<IAudioDecodeData | undefined>;
|
||||
|
||||
/**
|
||||
* 解码整个文件
|
||||
* @param data 文件数据
|
||||
*/
|
||||
abstract decodeAll(data: Uint8Array): Promise<IAudioDecodeData | undefined>;
|
||||
|
||||
/**
|
||||
* 当音频解码完成后,会调用此函数,需要返回之前还未解析或未返回的音频数据。调用后,该解码器将不会被再次使用
|
||||
*/
|
||||
abstract flush(): Promise<IAudioDecodeData | undefined>;
|
||||
}
|
||||
|
||||
export class VorbisDecoder extends AudioDecoder {
|
||||
decoder?: OggVorbisDecoderWebWorker;
|
||||
|
||||
async create(): Promise<void> {
|
||||
this.decoder = new OggVorbisDecoderWebWorker();
|
||||
await this.decoder.ready;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.decoder?.free();
|
||||
}
|
||||
|
||||
async decode(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decode(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async decodeAll(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decodeFile(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async flush(): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.flush() as Promise<IAudioDecodeData>;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpusDecoder extends AudioDecoder {
|
||||
decoder?: OggOpusDecoderWebWorker;
|
||||
|
||||
async create(): Promise<void> {
|
||||
this.decoder = new OggOpusDecoderWebWorker({
|
||||
speechQualityEnhancement: 'none'
|
||||
});
|
||||
await this.decoder.ready;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.decoder?.free();
|
||||
}
|
||||
|
||||
async decode(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decode(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async decodeAll(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decodeFile(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async flush(): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.flush() as Promise<IAudioDecodeData>;
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { isNil } from 'lodash-es';
|
||||
import { sleep } from 'mutate-animate';
|
||||
|
||||
export interface IAudioInput {
|
||||
/** 输入节点 */
|
||||
input: AudioNode;
|
||||
}
|
||||
|
||||
export interface IAudioOutput {
|
||||
/** 输出节点 */
|
||||
output: AudioNode;
|
||||
}
|
||||
|
||||
export abstract class AudioEffect implements IAudioInput, IAudioOutput {
|
||||
/** 输出节点 */
|
||||
abstract output: AudioNode;
|
||||
/** 输入节点 */
|
||||
abstract input: AudioNode;
|
||||
|
||||
constructor(public readonly ac: AudioContext) {}
|
||||
|
||||
/**
|
||||
* 当音频播放结束时触发,可以用于节点结束后处理
|
||||
*/
|
||||
abstract end(): void;
|
||||
|
||||
/**
|
||||
* 当音频开始播放时触发,可以用于节点初始化
|
||||
*/
|
||||
abstract start(): void;
|
||||
|
||||
/**
|
||||
* 连接至其他效果器
|
||||
* @param target 目标输入
|
||||
* @param output 当前效果器输出通道
|
||||
* @param input 目标效果器的输入通道
|
||||
*/
|
||||
connect(target: IAudioInput, output?: number, input?: number) {
|
||||
this.output.connect(target.input, output, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与其他效果器取消连接
|
||||
* @param target 目标输入
|
||||
* @param output 当前效果器输出通道
|
||||
* @param input 目标效果器的输入通道
|
||||
*/
|
||||
disconnect(target?: IAudioInput, output?: number, input?: number) {
|
||||
if (!target) {
|
||||
if (!isNil(output)) {
|
||||
this.output.disconnect(output);
|
||||
} else {
|
||||
this.output.disconnect();
|
||||
}
|
||||
} else {
|
||||
if (!isNil(output)) {
|
||||
if (!isNil(input)) {
|
||||
this.output.disconnect(target.input, output, input);
|
||||
} else {
|
||||
this.output.disconnect(target.input, output);
|
||||
}
|
||||
} else {
|
||||
this.output.disconnect(target.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class StereoEffect extends AudioEffect {
|
||||
output: PannerNode;
|
||||
input: PannerNode;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const panner = ac.createPanner();
|
||||
this.input = panner;
|
||||
this.output = panner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频朝向,x正方形水平向右,y正方形垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 朝向x坐标
|
||||
* @param y 朝向y坐标
|
||||
* @param z 朝向z坐标
|
||||
*/
|
||||
setOrientation(x: number, y: number, z: number) {
|
||||
this.output.orientationX.value = x;
|
||||
this.output.orientationY.value = y;
|
||||
this.output.orientationZ.value = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频位置,x正方形水平向右,y正方形垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 位置x坐标
|
||||
* @param y 位置y坐标
|
||||
* @param z 位置z坐标
|
||||
*/
|
||||
setPosition(x: number, y: number, z: number) {
|
||||
this.output.positionX.value = x;
|
||||
this.output.positionY.value = y;
|
||||
this.output.positionZ.value = z;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class VolumeEffect extends AudioEffect {
|
||||
output: GainNode;
|
||||
input: GainNode;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const gain = ac.createGain();
|
||||
this.input = gain;
|
||||
this.output = gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音量大小
|
||||
* @param volume 音量大小
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.output.gain.value = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音量大小
|
||||
*/
|
||||
getVolume(): number {
|
||||
return this.output.gain.value;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class ChannelVolumeEffect extends AudioEffect {
|
||||
output: ChannelMergerNode;
|
||||
input: ChannelSplitterNode;
|
||||
|
||||
/** 所有的音量控制节点 */
|
||||
private readonly gain: GainNode[] = [];
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const splitter = ac.createChannelSplitter();
|
||||
const merger = ac.createChannelMerger();
|
||||
this.output = merger;
|
||||
this.input = splitter;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const gain = ac.createGain();
|
||||
splitter.connect(gain, i);
|
||||
gain.connect(merger, 0, i);
|
||||
this.gain.push(gain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置某个声道的音量大小
|
||||
* @param channel 要设置的声道,可填0-5
|
||||
* @param volume 这个声道的音量大小
|
||||
*/
|
||||
setVolume(channel: number, volume: number) {
|
||||
if (!this.gain[channel]) return;
|
||||
this.gain[channel].gain.value = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某个声道的音量大小,可填0-5
|
||||
* @param channel 要获取的声道
|
||||
*/
|
||||
getVolume(channel: number): number {
|
||||
if (!this.gain[channel]) return 0;
|
||||
return this.gain[channel].gain.value;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class DelayEffect extends AudioEffect {
|
||||
output: DelayNode;
|
||||
input: DelayNode;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const delay = ac.createDelay();
|
||||
this.input = delay;
|
||||
this.output = delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置延迟时长
|
||||
* @param delay 延迟时长,单位秒
|
||||
*/
|
||||
setDelay(delay: number) {
|
||||
this.output.delayTime.value = delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取延迟时长
|
||||
*/
|
||||
getDelay() {
|
||||
return this.output.delayTime.value;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class EchoEffect extends AudioEffect {
|
||||
output: GainNode;
|
||||
input: GainNode;
|
||||
|
||||
/** 延迟节点 */
|
||||
private readonly delay: DelayNode;
|
||||
/** 反馈增益节点 */
|
||||
private readonly gainNode: GainNode;
|
||||
/** 当前增益 */
|
||||
private gain: number = 0.5;
|
||||
/** 是否正在播放 */
|
||||
private playing: boolean = false;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const delay = ac.createDelay();
|
||||
const gain = ac.createGain();
|
||||
gain.gain.value = 0.5;
|
||||
delay.delayTime.value = 0.05;
|
||||
delay.connect(gain);
|
||||
gain.connect(delay);
|
||||
this.delay = delay;
|
||||
this.gainNode = gain;
|
||||
this.input = gain;
|
||||
this.output = gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置回声反馈增益大小
|
||||
* @param gain 增益大小,范围 0-1,大于等于1的视为0.5,小于0的视为0
|
||||
*/
|
||||
setFeedbackGain(gain: number) {
|
||||
const resolved = gain >= 1 ? 0.5 : gain < 0 ? 0 : gain;
|
||||
this.gain = resolved;
|
||||
if (this.playing) this.gainNode.gain.value = resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置回声间隔时长
|
||||
* @param delay 回声时长,范围 0.01-Infinity,小于0.01的视为0.01
|
||||
*/
|
||||
setEchoDelay(delay: number) {
|
||||
const resolved = delay < 0.01 ? 0.01 : delay;
|
||||
this.delay.delayTime.value = resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈节点增益
|
||||
*/
|
||||
getFeedbackGain() {
|
||||
return this.gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取回声间隔时长
|
||||
*/
|
||||
getEchoDelay() {
|
||||
return this.delay.delayTime.value;
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.playing = false;
|
||||
const echoTime = Math.ceil(Math.log(0.001) / Math.log(this.gain)) + 10;
|
||||
sleep(this.delay.delayTime.value * echoTime).then(() => {
|
||||
if (!this.playing) this.gainNode.gain.value = 0;
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.playing = true;
|
||||
this.gainNode.gain.value = this.gain;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { loadAllBgm } from './bgm';
|
||||
import { OpusDecoder, VorbisDecoder } from './decoder';
|
||||
import { AudioType } from './support';
|
||||
import { AudioDecoder } from './decoder';
|
||||
|
||||
export function createAudio() {
|
||||
loadAllBgm();
|
||||
AudioDecoder.registerDecoder(AudioType.Ogg, VorbisDecoder);
|
||||
AudioDecoder.registerDecoder(AudioType.Opus, OpusDecoder);
|
||||
}
|
||||
|
||||
export * from './support';
|
||||
export * from './effect';
|
||||
export * from './player';
|
||||
export * from './source';
|
||||
export * from './bgm';
|
||||
export * from './decoder';
|
||||
export * from './sound';
|
||||
@@ -1,605 +0,0 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import {
|
||||
AudioBufferSource,
|
||||
AudioElementSource,
|
||||
AudioSource,
|
||||
AudioStreamSource
|
||||
} from './source';
|
||||
import {
|
||||
AudioEffect,
|
||||
ChannelVolumeEffect,
|
||||
DelayEffect,
|
||||
EchoEffect,
|
||||
IAudioOutput,
|
||||
StereoEffect,
|
||||
VolumeEffect
|
||||
} from './effect';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { logger } from '@motajs/common';
|
||||
import { sleep } from 'mutate-animate';
|
||||
import { AudioDecoder } from './decoder';
|
||||
|
||||
interface AudioPlayerEvent {}
|
||||
|
||||
export class AudioPlayer extends EventEmitter<AudioPlayerEvent> {
|
||||
/** 音频播放上下文 */
|
||||
readonly ac: AudioContext;
|
||||
|
||||
/** 所有的音频播放路由 */
|
||||
readonly audioRoutes: Map<string, AudioRoute> = new Map();
|
||||
/** 音量节点 */
|
||||
readonly gain: GainNode;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.ac = new AudioContext();
|
||||
this.gain = this.ac.createGain();
|
||||
this.gain.connect(this.ac.destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码音频数据
|
||||
* @param data 音频数据
|
||||
*/
|
||||
decodeAudioData(data: Uint8Array) {
|
||||
return AudioDecoder.decodeAudioData(data, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音量
|
||||
* @param volume 音量
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.gain.gain.value = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音量
|
||||
*/
|
||||
getVolume() {
|
||||
return this.gain.gain.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个音频源
|
||||
* @param Source 音频源类
|
||||
*/
|
||||
createSource<T extends AudioSource>(
|
||||
Source: new (ac: AudioContext) => T
|
||||
): T {
|
||||
return new Source(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个兼容流式音频源,可以与流式加载相结合,主要用于处理 opus ogg 不兼容的情况
|
||||
*/
|
||||
createStreamSource() {
|
||||
return new AudioStreamSource(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个通过 audio 元素播放的音频源
|
||||
*/
|
||||
createElementSource() {
|
||||
return new AudioElementSource(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个通过 AudioBuffer 播放的音频源
|
||||
*/
|
||||
createBufferSource() {
|
||||
return new AudioBufferSource(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频目的地
|
||||
*/
|
||||
getDestination() {
|
||||
return this.gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个音频效果器
|
||||
* @param Effect 效果器类
|
||||
*/
|
||||
createEffect<T extends AudioEffect>(
|
||||
Effect: new (ac: AudioContext) => T
|
||||
): T {
|
||||
return new Effect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个修改音量的效果器
|
||||
* ```txt
|
||||
* |----------|
|
||||
* Input ----> | GainNode | ----> Output
|
||||
* |----------|
|
||||
* ```
|
||||
*/
|
||||
createVolumeEffect() {
|
||||
return new VolumeEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个立体声效果器
|
||||
* ```txt
|
||||
* |------------|
|
||||
* Input ----> | PannerNode | ----> Output
|
||||
* |------------|
|
||||
* ```
|
||||
*/
|
||||
createStereoEffect() {
|
||||
return new StereoEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个修改单个声道音量的效果器
|
||||
* ```txt
|
||||
* |----------|
|
||||
* -> | GainNode | \
|
||||
* |--------------| / |----------| -> |------------|
|
||||
* Input ----> | SplitterNode | ...... | MergerNode | ----> Output
|
||||
* |--------------| \ |----------| -> |------------|
|
||||
* -> | GainNode | /
|
||||
* |----------|
|
||||
* ```
|
||||
*/
|
||||
createChannelVolumeEffect() {
|
||||
return new ChannelVolumeEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个延迟效果器
|
||||
* ```txt
|
||||
* |-----------|
|
||||
* Input ----> | DelayNode | ----> Output
|
||||
* |-----------|
|
||||
* ```
|
||||
*/
|
||||
createDelayEffect() {
|
||||
return new DelayEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个回声效果器
|
||||
* ```txt
|
||||
* |----------|
|
||||
* Input ----> | GainNode | ----> Output
|
||||
* ^ |----------| |
|
||||
* | |
|
||||
* | |------------| ↓
|
||||
* |-- | Delay Node | <--
|
||||
* |------------|
|
||||
* ```
|
||||
*/
|
||||
createEchoEffect() {
|
||||
return new EchoEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个音频播放路由
|
||||
* @param source 音频源
|
||||
*/
|
||||
createRoute(source: AudioSource) {
|
||||
return new AudioRoute(source, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个音频播放路由,可以直接被播放
|
||||
* @param id 这个音频播放路由的名称
|
||||
* @param route 音频播放路由对象
|
||||
*/
|
||||
addRoute(id: string, route: AudioRoute) {
|
||||
if (this.audioRoutes.has(id)) {
|
||||
logger.warn(45, id);
|
||||
}
|
||||
this.audioRoutes.set(id, route);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据名称获取音频播放路由对象
|
||||
* @param id 音频播放路由的名称
|
||||
*/
|
||||
getRoute(id: string) {
|
||||
return this.audioRoutes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个音频播放路由
|
||||
* @param id 要移除的播放路由的名称
|
||||
*/
|
||||
removeRoute(id: string) {
|
||||
const route = this.audioRoutes.get(id);
|
||||
if (route) {
|
||||
route.destroy();
|
||||
}
|
||||
this.audioRoutes.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放音频
|
||||
* @param id 音频名称
|
||||
* @param when 从音频的哪个位置开始播放,单位秒
|
||||
*/
|
||||
play(id: string, when: number = 0) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'play', id);
|
||||
return;
|
||||
}
|
||||
route.play(when);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停音频播放
|
||||
* @param id 音频名称
|
||||
* @returns 当音乐真正停止时兑现
|
||||
*/
|
||||
pause(id: string) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'pause', id);
|
||||
return;
|
||||
}
|
||||
return route.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止音频播放
|
||||
* @param id 音频名称
|
||||
* @returns 当音乐真正停止时兑现
|
||||
*/
|
||||
stop(id: string) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'stop', id);
|
||||
return;
|
||||
}
|
||||
return route.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续音频播放
|
||||
* @param id 音频名称
|
||||
*/
|
||||
resume(id: string) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'resume', id);
|
||||
return;
|
||||
}
|
||||
route.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置听者位置,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 位置x坐标
|
||||
* @param y 位置y坐标
|
||||
* @param z 位置z坐标
|
||||
*/
|
||||
setListenerPosition(x: number, y: number, z: number) {
|
||||
const listener = this.ac.listener;
|
||||
listener.positionX.value = x;
|
||||
listener.positionY.value = y;
|
||||
listener.positionZ.value = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置听者朝向,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 朝向x坐标
|
||||
* @param y 朝向y坐标
|
||||
* @param z 朝向z坐标
|
||||
*/
|
||||
setListenerOrientation(x: number, y: number, z: number) {
|
||||
const listener = this.ac.listener;
|
||||
listener.forwardX.value = x;
|
||||
listener.forwardY.value = y;
|
||||
listener.forwardZ.value = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置听者头顶朝向,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 头顶朝向x坐标
|
||||
* @param y 头顶朝向y坐标
|
||||
* @param z 头顶朝向z坐标
|
||||
*/
|
||||
setListenerUp(x: number, y: number, z: number) {
|
||||
const listener = this.ac.listener;
|
||||
listener.upX.value = x;
|
||||
listener.upY.value = y;
|
||||
listener.upZ.value = z;
|
||||
}
|
||||
}
|
||||
|
||||
export const enum AudioStatus {
|
||||
Playing,
|
||||
Pausing,
|
||||
Paused,
|
||||
Stoping,
|
||||
Stoped
|
||||
}
|
||||
|
||||
type AudioStartHook = (route: AudioRoute) => void;
|
||||
type AudioEndHook = (time: number, route: AudioRoute) => void;
|
||||
|
||||
interface AudioRouteEvent {
|
||||
updateEffect: [];
|
||||
play: [];
|
||||
stop: [];
|
||||
pause: [];
|
||||
resume: [];
|
||||
}
|
||||
|
||||
export class AudioRoute
|
||||
extends EventEmitter<AudioRouteEvent>
|
||||
implements IAudioOutput
|
||||
{
|
||||
output: AudioNode;
|
||||
|
||||
/** 效果器路由图 */
|
||||
readonly effectRoute: AudioEffect[] = [];
|
||||
|
||||
/** 结束时长,当音频暂停或停止时,会经过这么长时间之后才真正终止播放,期间可以做音频淡入淡出等效果 */
|
||||
endTime: number = 0;
|
||||
|
||||
/** 当前播放状态 */
|
||||
status: AudioStatus = AudioStatus.Stoped;
|
||||
/** 暂停时刻 */
|
||||
private pauseTime: number = 0;
|
||||
/** 暂停时播放了多长时间 */
|
||||
private pauseCurrentTime: number = 0;
|
||||
|
||||
/** 音频时长,单位秒 */
|
||||
get duration() {
|
||||
return this.source.duration;
|
||||
}
|
||||
/** 当前播放了多长时间,单位秒 */
|
||||
get currentTime() {
|
||||
if (this.status === AudioStatus.Paused) {
|
||||
return this.pauseCurrentTime;
|
||||
} else {
|
||||
return this.source.currentTime;
|
||||
}
|
||||
}
|
||||
set currentTime(time: number) {
|
||||
this.source.stop();
|
||||
this.source.play(time);
|
||||
}
|
||||
|
||||
private shouldStop: boolean = false;
|
||||
/**
|
||||
* 每次暂停或停止时自增,用于判断当前正在处理的情况。
|
||||
* 假如暂停后很快播放,然后很快暂停,那么需要根据这个来判断实际是否应该执行暂停后操作
|
||||
*/
|
||||
stopIdentifier: number = 0;
|
||||
|
||||
private audioStartHook?: AudioStartHook;
|
||||
private audioEndHook?: AudioEndHook;
|
||||
|
||||
constructor(
|
||||
public readonly source: AudioSource,
|
||||
public readonly player: AudioPlayer
|
||||
) {
|
||||
super();
|
||||
this.output = source.output;
|
||||
source.on('end', () => {
|
||||
if (this.status === AudioStatus.Playing) {
|
||||
this.status = AudioStatus.Stoped;
|
||||
}
|
||||
});
|
||||
source.on('play', () => {
|
||||
if (this.status !== AudioStatus.Playing) {
|
||||
this.status = AudioStatus.Playing;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置结束时间,暂停或停止时,会经过这么长时间才终止音频的播放,这期间可以做一下音频淡出的效果。
|
||||
* @param time 暂停或停止时,经过多长时间之后才会结束音频的播放
|
||||
*/
|
||||
setEndTime(time: number) {
|
||||
this.endTime = time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当音频播放时执行的函数,可以用于音频淡入效果
|
||||
* @param fn 音频开始播放时执行的函数
|
||||
*/
|
||||
onStart(fn?: AudioStartHook) {
|
||||
this.audioStartHook = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当音频暂停或停止时执行的函数,可以用于音频淡出效果
|
||||
* @param fn 音频在暂停或停止时执行的函数,不填时表示取消这个钩子。
|
||||
* 包含两个参数,第一个参数是结束时长,第二个参数是当前音频播放路由对象
|
||||
*/
|
||||
onEnd(fn?: AudioEndHook) {
|
||||
this.audioEndHook = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始播放这个音频
|
||||
* @param when 从音频的什么时候开始播放,单位秒
|
||||
*/
|
||||
async play(when: number = 0) {
|
||||
if (this.status === AudioStatus.Playing) return;
|
||||
this.link();
|
||||
await this.player.ac.resume();
|
||||
if (this.effectRoute.length > 0) {
|
||||
const first = this.effectRoute[0];
|
||||
this.source.connect(first);
|
||||
const last = this.effectRoute.at(-1)!;
|
||||
last.connect({ input: this.player.getDestination() });
|
||||
} else {
|
||||
this.source.connect({ input: this.player.getDestination() });
|
||||
}
|
||||
this.source.play(when);
|
||||
this.status = AudioStatus.Playing;
|
||||
this.pauseTime = 0;
|
||||
this.audioStartHook?.(this);
|
||||
this.startAllEffect();
|
||||
this.emit('play');
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停音频播放
|
||||
*/
|
||||
async pause() {
|
||||
if (this.status !== AudioStatus.Playing) return;
|
||||
this.status = AudioStatus.Pausing;
|
||||
this.stopIdentifier++;
|
||||
const identifier = this.stopIdentifier;
|
||||
if (this.audioEndHook) {
|
||||
this.audioEndHook(this.endTime, this);
|
||||
await sleep(this.endTime);
|
||||
}
|
||||
if (
|
||||
this.status !== AudioStatus.Pausing ||
|
||||
this.stopIdentifier !== identifier
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pauseCurrentTime = this.source.currentTime;
|
||||
const time = this.source.stop();
|
||||
this.pauseTime = time;
|
||||
if (this.shouldStop) {
|
||||
this.status = AudioStatus.Stoped;
|
||||
this.endAllEffect();
|
||||
this.emit('stop');
|
||||
this.shouldStop = false;
|
||||
} else {
|
||||
this.status = AudioStatus.Paused;
|
||||
this.endAllEffect();
|
||||
this.emit('pause');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续音频播放
|
||||
*/
|
||||
resume() {
|
||||
if (this.status === AudioStatus.Playing) return;
|
||||
if (
|
||||
this.status === AudioStatus.Pausing ||
|
||||
this.status === AudioStatus.Stoping
|
||||
) {
|
||||
this.audioStartHook?.(this);
|
||||
this.emit('resume');
|
||||
return;
|
||||
}
|
||||
if (this.status === AudioStatus.Paused) {
|
||||
this.play(this.pauseTime);
|
||||
} else {
|
||||
this.play(0);
|
||||
}
|
||||
this.status = AudioStatus.Playing;
|
||||
this.pauseTime = 0;
|
||||
this.audioStartHook?.(this);
|
||||
this.startAllEffect();
|
||||
this.emit('resume');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止音频播放
|
||||
*/
|
||||
async stop() {
|
||||
if (this.status !== AudioStatus.Playing) {
|
||||
if (this.status === AudioStatus.Pausing) {
|
||||
this.shouldStop = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.status = AudioStatus.Stoping;
|
||||
this.stopIdentifier++;
|
||||
const identifier = this.stopIdentifier;
|
||||
if (this.audioEndHook) {
|
||||
this.audioEndHook(this.endTime, this);
|
||||
await sleep(this.endTime);
|
||||
}
|
||||
if (
|
||||
this.status !== AudioStatus.Stoping ||
|
||||
this.stopIdentifier !== identifier
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.source.stop();
|
||||
this.status = AudioStatus.Stoped;
|
||||
this.pauseTime = 0;
|
||||
this.endAllEffect();
|
||||
this.emit('stop');
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加效果器
|
||||
* @param effect 要添加的效果,可以是数组,表示一次添加多个
|
||||
* @param index 从哪个位置开始添加,如果大于数组长度,那么加到末尾,如果小于0,那么将会从后面往前数。默认添加到末尾
|
||||
*/
|
||||
addEffect(effect: AudioEffect | AudioEffect[], index?: number) {
|
||||
if (isNil(index)) {
|
||||
if (effect instanceof Array) {
|
||||
this.effectRoute.push(...effect);
|
||||
} else {
|
||||
this.effectRoute.push(effect);
|
||||
}
|
||||
} else {
|
||||
if (effect instanceof Array) {
|
||||
this.effectRoute.splice(index, 0, ...effect);
|
||||
} else {
|
||||
this.effectRoute.splice(index, 0, effect);
|
||||
}
|
||||
}
|
||||
this.setOutput();
|
||||
if (this.source.playing) this.link();
|
||||
this.emit('updateEffect');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个效果器
|
||||
* @param effect 要移除的效果
|
||||
*/
|
||||
removeEffect(effect: AudioEffect) {
|
||||
const index = this.effectRoute.indexOf(effect);
|
||||
if (index === -1) return;
|
||||
this.effectRoute.splice(index, 1);
|
||||
effect.disconnect();
|
||||
this.setOutput();
|
||||
if (this.source.playing) this.link();
|
||||
this.emit('updateEffect');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.effectRoute.forEach(v => v.disconnect());
|
||||
}
|
||||
|
||||
private setOutput() {
|
||||
const effect = this.effectRoute.at(-1);
|
||||
if (!effect) this.output = this.source.output;
|
||||
else this.output = effect.output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接音频路由图
|
||||
*/
|
||||
private link() {
|
||||
this.effectRoute.forEach(v => v.disconnect());
|
||||
this.effectRoute.forEach((v, i) => {
|
||||
const next = this.effectRoute[i + 1];
|
||||
if (next) {
|
||||
v.connect(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startAllEffect() {
|
||||
this.effectRoute.forEach(v => v.start());
|
||||
}
|
||||
|
||||
private endAllEffect() {
|
||||
this.effectRoute.forEach(v => v.end());
|
||||
}
|
||||
}
|
||||
|
||||
export const audioPlayer = new AudioPlayer();
|
||||
// window.audioPlayer = audioPlayer;
|
||||
@@ -1,135 +0,0 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { audioPlayer, AudioPlayer } from './player';
|
||||
import { logger } from '@motajs/common';
|
||||
import { VolumeEffect } from './effect';
|
||||
|
||||
type LocationArray = [number, number, number];
|
||||
|
||||
interface SoundPlayerEvent {}
|
||||
|
||||
export class SoundPlayer<
|
||||
T extends string = SoundIds
|
||||
> extends EventEmitter<SoundPlayerEvent> {
|
||||
/** 每个音效的唯一标识符 */
|
||||
private num: number = 0;
|
||||
|
||||
/** 每个音效的数据 */
|
||||
readonly buffer: Map<T, AudioBuffer> = new Map();
|
||||
/** 所有正在播放的音乐 */
|
||||
readonly playing: Set<number> = new Set();
|
||||
/** 音量节点 */
|
||||
readonly gain: VolumeEffect;
|
||||
|
||||
/** 是否已经启用 */
|
||||
enabled: boolean = true;
|
||||
|
||||
constructor(public readonly player: AudioPlayer) {
|
||||
super();
|
||||
this.gain = player.createVolumeEffect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否启用音效
|
||||
* @param enabled 是否启用音效
|
||||
*/
|
||||
setEnabled(enabled: boolean) {
|
||||
if (!enabled) this.stopAllSounds();
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音量大小
|
||||
* @param volume 音量大小
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.gain.setVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音量大小
|
||||
*/
|
||||
getVolume() {
|
||||
return this.gain.getVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个音效
|
||||
* @param id 音效名称
|
||||
* @param data 音效的Uint8Array数据
|
||||
*/
|
||||
async add(id: T, data: Uint8Array) {
|
||||
const buffer = await this.player.decodeAudioData(data);
|
||||
if (!buffer) {
|
||||
logger.warn(51, id);
|
||||
return;
|
||||
}
|
||||
this.buffer.set(id, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放一个音效
|
||||
* @param id 音效名称
|
||||
* @param position 音频位置,[0, 0, 0]表示正中心,x轴指向水平向右,y轴指向水平向上,z轴指向竖直向上
|
||||
* @param orientation 音频朝向,[0, 1, 0]表示朝向前方
|
||||
*/
|
||||
play(
|
||||
id: T,
|
||||
position: LocationArray = [0, 0, 0],
|
||||
orientation: LocationArray = [1, 0, 0]
|
||||
) {
|
||||
if (!this.enabled) return -1;
|
||||
const buffer = this.buffer.get(id);
|
||||
if (!buffer) {
|
||||
logger.warn(52, id);
|
||||
return -1;
|
||||
}
|
||||
const soundNum = this.num++;
|
||||
const source = this.player.createBufferSource();
|
||||
source.setBuffer(buffer);
|
||||
const route = this.player.createRoute(source);
|
||||
const stereo = this.player.createStereoEffect();
|
||||
stereo.setPosition(position[0], position[1], position[2]);
|
||||
stereo.setOrientation(orientation[0], orientation[1], orientation[2]);
|
||||
route.addEffect([stereo, this.gain]);
|
||||
this.player.addRoute(`sounds.${soundNum}`, route);
|
||||
route.play();
|
||||
// 清理垃圾
|
||||
source.output.addEventListener('ended', () => {
|
||||
this.playing.delete(soundNum);
|
||||
this.player.removeRoute(`sounds.${soundNum}`);
|
||||
});
|
||||
this.playing.add(soundNum);
|
||||
return soundNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止一个音效
|
||||
* @param num 音效的唯一 id
|
||||
*/
|
||||
stop(num: number) {
|
||||
const id = `sounds.${num}`;
|
||||
const route = this.player.getRoute(id);
|
||||
if (route) {
|
||||
route.stop();
|
||||
this.player.removeRoute(id);
|
||||
this.playing.delete(num);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止播放所有音效
|
||||
*/
|
||||
stopAllSounds() {
|
||||
this.playing.forEach(v => {
|
||||
const id = `sounds.${v}`;
|
||||
const route = this.player.getRoute(id);
|
||||
if (route) {
|
||||
route.stop();
|
||||
this.player.removeRoute(id);
|
||||
}
|
||||
});
|
||||
this.playing.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const soundPlayer = new SoundPlayer<SoundIds>(audioPlayer);
|
||||
@@ -1,562 +0,0 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { IStreamController, IStreamReader } from '../loader';
|
||||
import { IAudioInput, IAudioOutput } from './effect';
|
||||
import { logger } from '@motajs/common';
|
||||
import { AudioType } from './support';
|
||||
import CodecParser, { CodecFrame, MimeType, OggPage } from 'codec-parser';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { IAudioDecodeData, AudioDecoder, checkAudioType } from './decoder';
|
||||
|
||||
interface AudioSourceEvent {
|
||||
play: [];
|
||||
end: [];
|
||||
}
|
||||
|
||||
export abstract class AudioSource
|
||||
extends EventEmitter<AudioSourceEvent>
|
||||
implements IAudioOutput
|
||||
{
|
||||
/** 音频源的输出节点 */
|
||||
abstract readonly output: AudioNode;
|
||||
|
||||
/** 是否正在播放 */
|
||||
playing: boolean = false;
|
||||
|
||||
/** 获取音频时长 */
|
||||
abstract get duration(): number;
|
||||
/** 获取当前音频播放了多长时间 */
|
||||
abstract get currentTime(): number;
|
||||
|
||||
constructor(public readonly ac: AudioContext) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始播放这个音频源
|
||||
*/
|
||||
abstract play(when?: number): void;
|
||||
|
||||
/**
|
||||
* 停止播放这个音频源
|
||||
* @returns 音频暂停的时刻
|
||||
*/
|
||||
abstract stop(): number;
|
||||
|
||||
/**
|
||||
* 连接到音频路由图上,每次调用播放的时候都会执行一次
|
||||
* @param target 连接至的目标
|
||||
*/
|
||||
abstract connect(target: IAudioInput): void;
|
||||
|
||||
/**
|
||||
* 设置是否循环播放
|
||||
* @param loop 是否循环
|
||||
*/
|
||||
abstract setLoop(loop: boolean): void;
|
||||
}
|
||||
|
||||
const mimeTypeMap: Record<AudioType, MimeType> = {
|
||||
[AudioType.Aac]: 'audio/aac',
|
||||
[AudioType.Flac]: 'audio/flac',
|
||||
[AudioType.Mp3]: 'audio/mpeg',
|
||||
[AudioType.Ogg]: 'application/ogg',
|
||||
[AudioType.Opus]: 'application/ogg',
|
||||
[AudioType.Wav]: 'application/ogg'
|
||||
};
|
||||
|
||||
function isOggPage(data: any): data is OggPage {
|
||||
return !isNil(data.isFirstPage);
|
||||
}
|
||||
|
||||
export class AudioStreamSource extends AudioSource implements IStreamReader {
|
||||
output: AudioBufferSourceNode;
|
||||
|
||||
/** 音频数据 */
|
||||
buffer?: AudioBuffer;
|
||||
|
||||
/** 是否已经完全加载完毕 */
|
||||
loaded: boolean = false;
|
||||
/** 已经缓冲了多长时间,如果缓冲完那么跟歌曲时长一致 */
|
||||
buffered: number = 0;
|
||||
/** 已经缓冲的采样点数量 */
|
||||
bufferedSamples: number = 0;
|
||||
/** 歌曲时长,加载完毕之前保持为 0 */
|
||||
duration: number = 0;
|
||||
/** 当前已经播放了多长时间 */
|
||||
get currentTime(): number {
|
||||
return this.ac.currentTime - this.lastStartTime + this.lastStartWhen;
|
||||
}
|
||||
/** 在流传输阶段,至少缓冲多长时间的音频之后才开始播放,单位秒 */
|
||||
bufferPlayDuration: number = 1;
|
||||
/** 音频的采样率,未成功解析出之前保持为 0 */
|
||||
sampleRate: number = 0;
|
||||
|
||||
private controller?: IStreamController;
|
||||
private loop: boolean = false;
|
||||
|
||||
private target?: IAudioInput;
|
||||
|
||||
/** 上一次播放是从何时开始的 */
|
||||
private lastStartWhen: number = 0;
|
||||
/** 开始播放时刻 */
|
||||
private lastStartTime: number = 0;
|
||||
/** 上一次播放的缓存长度 */
|
||||
private lastBufferSamples: number = 0;
|
||||
|
||||
/** 是否已经获取到头文件 */
|
||||
private headerRecieved: boolean = false;
|
||||
/** 音频类型 */
|
||||
private audioType: AudioType | '' = '';
|
||||
/** 音频解码器 */
|
||||
private decoder?: AudioDecoder;
|
||||
/** 音频解析器 */
|
||||
private parser?: CodecParser;
|
||||
/** 每多长时间组成一个缓存 Float32Array */
|
||||
private bufferChunkSize: number = 10;
|
||||
/** 缓存音频数据,每 bufferChunkSize 秒钟组成一个 Float32Array,用于流式解码 */
|
||||
private audioData: Float32Array[][] = [];
|
||||
|
||||
private errored: boolean = false;
|
||||
|
||||
constructor(context: AudioContext) {
|
||||
super(context);
|
||||
this.output = context.createBufferSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置每个缓存数据的大小,默认为10秒钟一个缓存数据
|
||||
* @param size 每个缓存数据的时长,单位秒
|
||||
*/
|
||||
setChunkSize(size: number) {
|
||||
if (this.controller?.loading || this.loaded) return;
|
||||
this.bufferChunkSize = size;
|
||||
}
|
||||
|
||||
piped(controller: IStreamController): void {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
async pump(data: Uint8Array | undefined, done: boolean): Promise<void> {
|
||||
if (!data || this.errored) return;
|
||||
if (!this.headerRecieved) {
|
||||
// 检查头文件获取音频类型,仅检查前256个字节
|
||||
const toCheck = data.slice(0, 256);
|
||||
this.audioType = checkAudioType(data);
|
||||
if (!this.audioType) {
|
||||
logger.error(
|
||||
25,
|
||||
[...toCheck]
|
||||
.map(v => v.toString(16).padStart(2, '0'))
|
||||
.join(' ')
|
||||
.toUpperCase()
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 创建解码器
|
||||
const Decoder = AudioDecoder.decoderMap.get(this.audioType);
|
||||
if (!Decoder) {
|
||||
this.errored = true;
|
||||
logger.error(24, this.audioType);
|
||||
return Promise.reject(
|
||||
`Cannot decode stream source type of '${this.audioType}', since there is no registered decoder for that type.`
|
||||
);
|
||||
}
|
||||
this.decoder = new Decoder();
|
||||
// 创建数据解析器
|
||||
const mime = mimeTypeMap[this.audioType];
|
||||
const parser = new CodecParser(mime);
|
||||
this.parser = parser;
|
||||
await this.decoder.create();
|
||||
this.headerRecieved = true;
|
||||
}
|
||||
|
||||
const decoder = this.decoder;
|
||||
const parser = this.parser;
|
||||
if (!decoder || !parser) {
|
||||
this.errored = true;
|
||||
return Promise.reject(
|
||||
'No parser or decoder attached in this AudioStreamSource'
|
||||
);
|
||||
}
|
||||
|
||||
await this.decodeData(data, decoder, parser);
|
||||
if (done) await this.decodeFlushData(decoder, parser);
|
||||
this.checkBufferedPlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查采样率,如果还未解析出采样率,那么将设置采样率,如果当前采样率与之前不同,那么发出警告
|
||||
*/
|
||||
private checkSampleRate(info: (OggPage | CodecFrame)[]) {
|
||||
for (const one of info) {
|
||||
const frame = isOggPage(one) ? one.codecFrames[0] : one;
|
||||
if (frame) {
|
||||
const rate = frame.header.sampleRate;
|
||||
if (this.sampleRate === 0) {
|
||||
this.sampleRate = rate;
|
||||
break;
|
||||
} else {
|
||||
if (rate !== this.sampleRate) {
|
||||
logger.warn(48);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析音频数据
|
||||
*/
|
||||
private async decodeData(
|
||||
data: Uint8Array,
|
||||
decoder: AudioDecoder,
|
||||
parser: CodecParser
|
||||
) {
|
||||
// 解析音频数据
|
||||
const audioData = await decoder.decode(data);
|
||||
if (!audioData) return;
|
||||
// @ts-expect-error 库类型声明错误
|
||||
const audioInfo = [...parser.parseChunk(data)] as (
|
||||
| OggPage
|
||||
| CodecFrame
|
||||
)[];
|
||||
|
||||
// 检查采样率
|
||||
this.checkSampleRate(audioInfo);
|
||||
// 追加音频数据
|
||||
this.appendDecodedData(audioData, audioInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码剩余数据
|
||||
*/
|
||||
private async decodeFlushData(decoder: AudioDecoder, parser: CodecParser) {
|
||||
const audioData = await decoder.flush();
|
||||
if (!audioData) return;
|
||||
// @ts-expect-error 库类型声明错误
|
||||
const audioInfo = [...parser.flush()] as (OggPage | CodecFrame)[];
|
||||
|
||||
this.checkSampleRate(audioInfo);
|
||||
this.appendDecodedData(audioData, audioInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加音频数据
|
||||
*/
|
||||
private appendDecodedData(
|
||||
data: IAudioDecodeData,
|
||||
info: (CodecFrame | OggPage)[]
|
||||
) {
|
||||
const channels = data.channelData.length;
|
||||
if (channels === 0) return;
|
||||
if (this.audioData.length !== channels) {
|
||||
this.audioData = [];
|
||||
for (let i = 0; i < channels; i++) {
|
||||
this.audioData.push([]);
|
||||
}
|
||||
}
|
||||
// 计算出应该放在哪
|
||||
const chunk = this.sampleRate * this.bufferChunkSize;
|
||||
const sampled = this.bufferedSamples;
|
||||
const pushIndex = Math.floor(sampled / chunk);
|
||||
const bufferIndex = sampled % chunk;
|
||||
const dataLength = data.channelData[0].length;
|
||||
let buffered = 0;
|
||||
let nowIndex = pushIndex;
|
||||
let toBuffer = bufferIndex;
|
||||
while (buffered < dataLength) {
|
||||
const rest = toBuffer !== 0 ? chunk - bufferIndex : chunk;
|
||||
|
||||
for (let i = 0; i < channels; i++) {
|
||||
const audioData = this.audioData[i];
|
||||
if (!audioData[nowIndex]) {
|
||||
audioData.push(new Float32Array(chunk));
|
||||
}
|
||||
const toPush = data.channelData[i].slice(
|
||||
buffered,
|
||||
buffered + rest
|
||||
);
|
||||
|
||||
audioData[nowIndex].set(toPush, toBuffer);
|
||||
}
|
||||
buffered += rest;
|
||||
nowIndex++;
|
||||
toBuffer = 0;
|
||||
}
|
||||
|
||||
this.buffered +=
|
||||
info.reduce((prev, curr) => prev + curr.duration, 0) / 1000;
|
||||
this.bufferedSamples += info.reduce(
|
||||
(prev, curr) => prev + curr.samples,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查已缓冲内容,并在未开始播放时播放
|
||||
*/
|
||||
private checkBufferedPlay() {
|
||||
if (this.playing || this.sampleRate === 0) return;
|
||||
const played = this.lastBufferSamples / this.sampleRate;
|
||||
const dt = this.buffered - played;
|
||||
if (this.loaded) {
|
||||
this.playAudio(played);
|
||||
return;
|
||||
}
|
||||
if (dt < this.bufferPlayDuration) return;
|
||||
this.lastBufferSamples = this.bufferedSamples;
|
||||
// 需要播放
|
||||
this.mergeBuffers();
|
||||
if (!this.buffer) return;
|
||||
if (this.playing) this.output.stop();
|
||||
this.createSourceNode(this.buffer);
|
||||
this.output.loop = false;
|
||||
this.output.start(0, played);
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
this.playing = true;
|
||||
this.output.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.checkBufferedPlay();
|
||||
});
|
||||
}
|
||||
|
||||
private mergeBuffers() {
|
||||
const buffer = this.ac.createBuffer(
|
||||
this.audioData.length,
|
||||
this.bufferedSamples,
|
||||
this.sampleRate
|
||||
);
|
||||
const chunk = this.sampleRate * this.bufferChunkSize;
|
||||
const bufferedChunks = Math.floor(this.bufferedSamples / chunk);
|
||||
const restLength = this.bufferedSamples % chunk;
|
||||
for (let i = 0; i < this.audioData.length; i++) {
|
||||
const audio = this.audioData[i];
|
||||
const data = new Float32Array(this.bufferedSamples);
|
||||
for (let j = 0; j < bufferedChunks; j++) {
|
||||
data.set(audio[j], chunk * j);
|
||||
}
|
||||
if (restLength !== 0) {
|
||||
data.set(
|
||||
audio[bufferedChunks].slice(0, restLength),
|
||||
chunk * bufferedChunks
|
||||
);
|
||||
}
|
||||
|
||||
buffer.copyToChannel(data, i, 0);
|
||||
}
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
async start() {
|
||||
delete this.buffer;
|
||||
this.headerRecieved = false;
|
||||
this.audioType = '';
|
||||
this.errored = false;
|
||||
this.buffered = 0;
|
||||
this.sampleRate = 0;
|
||||
this.bufferedSamples = 0;
|
||||
this.duration = 0;
|
||||
this.loaded = false;
|
||||
if (this.playing) this.output.stop();
|
||||
this.playing = false;
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
}
|
||||
|
||||
end(done: boolean, reason?: string): void {
|
||||
if (done && this.buffer) {
|
||||
this.loaded = true;
|
||||
delete this.controller;
|
||||
this.mergeBuffers();
|
||||
this.duration = this.buffered;
|
||||
this.audioData = [];
|
||||
this.decoder?.destroy();
|
||||
delete this.decoder;
|
||||
delete this.parser;
|
||||
} else {
|
||||
logger.warn(44, reason ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
private playAudio(when?: number) {
|
||||
if (!this.buffer) return;
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
if (this.playing) this.output.stop();
|
||||
this.emit('play');
|
||||
this.createSourceNode(this.buffer);
|
||||
this.output.start(0, when);
|
||||
this.playing = true;
|
||||
this.output.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
if (this.loop && !this.output.loop) this.play(0);
|
||||
});
|
||||
}
|
||||
|
||||
play(when?: number): void {
|
||||
if (this.playing || this.errored) return;
|
||||
if (this.loaded && this.buffer) {
|
||||
this.playing = true;
|
||||
this.playAudio(when);
|
||||
} else {
|
||||
this.controller?.start();
|
||||
}
|
||||
}
|
||||
|
||||
private createSourceNode(buffer: AudioBuffer) {
|
||||
if (!this.target) return;
|
||||
const node = this.ac.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
if (this.playing) this.output.stop();
|
||||
this.playing = false;
|
||||
this.output = node;
|
||||
node.connect(this.target.input);
|
||||
node.loop = this.loop;
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
if (this.playing) this.output.stop();
|
||||
this.playing = false;
|
||||
return this.ac.currentTime - this.lastStartTime;
|
||||
}
|
||||
|
||||
connect(target: IAudioInput): void {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
setLoop(loop: boolean): void {
|
||||
this.loop = loop;
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioElementSource extends AudioSource {
|
||||
output: MediaElementAudioSourceNode;
|
||||
|
||||
/** audio 元素 */
|
||||
readonly audio: HTMLAudioElement;
|
||||
|
||||
get duration(): number {
|
||||
return this.audio.duration;
|
||||
}
|
||||
get currentTime(): number {
|
||||
return this.audio.currentTime;
|
||||
}
|
||||
|
||||
constructor(context: AudioContext) {
|
||||
super(context);
|
||||
const audio = new Audio();
|
||||
audio.preload = 'none';
|
||||
this.output = context.createMediaElementSource(audio);
|
||||
this.audio = audio;
|
||||
audio.addEventListener('play', () => {
|
||||
this.playing = true;
|
||||
this.emit('play');
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频源的路径
|
||||
* @param url 音频路径
|
||||
*/
|
||||
setSource(url: string) {
|
||||
this.audio.src = url;
|
||||
}
|
||||
|
||||
play(when: number = 0): void {
|
||||
if (this.playing) return;
|
||||
this.audio.currentTime = when;
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
this.audio.pause();
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
return this.audio.currentTime;
|
||||
}
|
||||
|
||||
connect(target: IAudioInput): void {
|
||||
this.output.connect(target.input);
|
||||
}
|
||||
|
||||
setLoop(loop: boolean): void {
|
||||
this.audio.loop = loop;
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioBufferSource extends AudioSource {
|
||||
output: AudioBufferSourceNode;
|
||||
|
||||
/** 音频数据 */
|
||||
buffer?: AudioBuffer;
|
||||
/** 是否循环 */
|
||||
private loop: boolean = false;
|
||||
|
||||
duration: number = 0;
|
||||
get currentTime(): number {
|
||||
return this.ac.currentTime - this.lastStartTime + this.lastStartWhen;
|
||||
}
|
||||
|
||||
/** 上一次播放是从何时开始的 */
|
||||
private lastStartWhen: number = 0;
|
||||
/** 播放开始时刻 */
|
||||
private lastStartTime: number = 0;
|
||||
private target?: IAudioInput;
|
||||
|
||||
constructor(context: AudioContext) {
|
||||
super(context);
|
||||
this.output = context.createBufferSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频源数据
|
||||
* @param buffer 音频源,可以是未解析的 ArrayBuffer,也可以是已解析的 AudioBuffer
|
||||
*/
|
||||
async setBuffer(buffer: ArrayBuffer | AudioBuffer) {
|
||||
if (buffer instanceof ArrayBuffer) {
|
||||
this.buffer = await this.ac.decodeAudioData(buffer);
|
||||
} else {
|
||||
this.buffer = buffer;
|
||||
}
|
||||
this.duration = this.buffer.duration;
|
||||
}
|
||||
|
||||
play(when?: number): void {
|
||||
if (this.playing || !this.buffer) return;
|
||||
this.playing = true;
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
this.emit('play');
|
||||
this.createSourceNode(this.buffer);
|
||||
this.output.start(0, when);
|
||||
this.output.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
if (this.loop && !this.output.loop) this.play(0);
|
||||
});
|
||||
}
|
||||
|
||||
private createSourceNode(buffer: AudioBuffer) {
|
||||
if (!this.target) return;
|
||||
const node = this.ac.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
this.output = node;
|
||||
node.connect(this.target.input);
|
||||
node.loop = this.loop;
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
this.output.stop();
|
||||
return this.ac.currentTime - this.lastStartTime;
|
||||
}
|
||||
|
||||
connect(target: IAudioInput): void {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
setLoop(loop: boolean): void {
|
||||
this.loop = loop;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
const audio = new Audio();
|
||||
|
||||
const supportMap = new Map<string, boolean>();
|
||||
|
||||
export const enum AudioType {
|
||||
Mp3 = 'audio/mpeg',
|
||||
Wav = 'audio/wav; codecs="1"',
|
||||
Flac = 'audio/flac',
|
||||
Opus = 'audio/ogg; codecs="opus"',
|
||||
Ogg = 'audio/ogg; codecs="vorbis"',
|
||||
Aac = 'audio/aac'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查一种音频类型是否能被播放
|
||||
* @param type 音频类型
|
||||
*/
|
||||
export function isAudioSupport(type: AudioType): boolean {
|
||||
if (supportMap.has(type)) return supportMap.get(type)!;
|
||||
else {
|
||||
const support = audio.canPlayType(type);
|
||||
const canPlay = support === 'maybe' || support === 'probably';
|
||||
supportMap.set(type, canPlay);
|
||||
return canPlay;
|
||||
}
|
||||
}
|
||||
|
||||
const typeMap = new Map<string, AudioType>([
|
||||
['ogg', AudioType.Ogg],
|
||||
['mp3', AudioType.Mp3],
|
||||
['wav', AudioType.Wav],
|
||||
['flac', AudioType.Flac],
|
||||
['opus', AudioType.Opus],
|
||||
['aac', AudioType.Aac]
|
||||
]);
|
||||
|
||||
/**
|
||||
* 根据文件名拓展猜测其类型
|
||||
* @param file 文件名
|
||||
*/
|
||||
export function guessTypeByExt(file: string): AudioType | '' {
|
||||
const ext = /\.[a-zA-Z\d]+$/.exec(file);
|
||||
if (!ext?.[0]) return '';
|
||||
const type = ext[0].slice(1);
|
||||
return typeMap.get(type.toLocaleLowerCase()) ?? '';
|
||||
}
|
||||
|
||||
isAudioSupport(AudioType.Ogg);
|
||||
isAudioSupport(AudioType.Mp3);
|
||||
isAudioSupport(AudioType.Wav);
|
||||
isAudioSupport(AudioType.Flac);
|
||||
isAudioSupport(AudioType.Opus);
|
||||
isAudioSupport(AudioType.Aac);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Patch, PatchClass } from '@motajs/legacy-common';
|
||||
import { audioPlayer, bgmController, soundPlayer } from '../audio';
|
||||
import { audioContext, bgmPlayer, soundPlayer } from '@user/client-base';
|
||||
import { mainSetting } from '@motajs/legacy-ui';
|
||||
import { sleep } from 'mutate-animate';
|
||||
import { sleep } from '@motajs/common';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
// todo: 添加弃用警告 logger.warn(56)
|
||||
@@ -10,10 +10,10 @@ export function patchAudio() {
|
||||
const patch = new Patch(PatchClass.Control);
|
||||
|
||||
const play = (bgm: BgmIds, when?: number) => {
|
||||
bgmController.play(bgm, when);
|
||||
bgmPlayer.play(bgm, when);
|
||||
};
|
||||
const pause = () => {
|
||||
bgmController.pause();
|
||||
bgmPlayer.pause();
|
||||
};
|
||||
|
||||
patch.add('playBgm', function (bgm, startTime) {
|
||||
@@ -23,13 +23,13 @@ export function patchAudio() {
|
||||
pause();
|
||||
});
|
||||
patch.add('resumeBgm', function () {
|
||||
bgmController.resume();
|
||||
bgmPlayer.resume();
|
||||
});
|
||||
patch.add('checkBgm', function () {
|
||||
if (bgmController.playing) return;
|
||||
if (bgmPlayer.playing) return;
|
||||
if (mainSetting.getValue('audio.bgmEnabled')) {
|
||||
if (bgmController.playingBgm) {
|
||||
bgmController.play(bgmController.playingBgm);
|
||||
if (bgmPlayer.playingBgm) {
|
||||
bgmPlayer.play(bgmPlayer.playingBgm);
|
||||
} else {
|
||||
play(main.startBgm, 0);
|
||||
}
|
||||
@@ -38,8 +38,8 @@ export function patchAudio() {
|
||||
}
|
||||
});
|
||||
patch.add('triggerBgm', function () {
|
||||
if (bgmController.playing) bgmController.pause();
|
||||
else bgmController.resume();
|
||||
if (bgmPlayer.playing) bgmPlayer.pause();
|
||||
else bgmPlayer.resume();
|
||||
});
|
||||
|
||||
patch.add(
|
||||
@@ -47,7 +47,7 @@ export function patchAudio() {
|
||||
function (sound, _pitch, callback, position, orientation) {
|
||||
const name = core.getMappedName(sound) as SoundIds;
|
||||
const num = soundPlayer.play(name, position, orientation);
|
||||
const route = audioPlayer.getRoute(`sounds.${num}`);
|
||||
const route = audioContext.getRoute(`sounds.${num}`);
|
||||
if (!route) {
|
||||
callback?.();
|
||||
return -1;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { loading } from '@user/data-base';
|
||||
import { createAudio } from './audio';
|
||||
import { patchAll } from './fallback';
|
||||
import { createGameRenderer, createRender } from './render';
|
||||
|
||||
export function create() {
|
||||
patchAll();
|
||||
createAudio();
|
||||
createRender();
|
||||
loading.once('coreInit', () => {
|
||||
createGameRenderer();
|
||||
@@ -13,7 +11,5 @@ export function create() {
|
||||
}
|
||||
|
||||
export * from './action';
|
||||
export * from './audio';
|
||||
export * from './fallback';
|
||||
export * from './loader';
|
||||
export * from './render';
|
||||
@@ -1 +0,0 @@
|
||||
export * from './stream';
|
||||
@@ -1,128 +0,0 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
export interface IStreamController<T = void> {
|
||||
readonly loading: boolean;
|
||||
|
||||
/**
|
||||
* 开始流传输
|
||||
*/
|
||||
start(): Promise<T>;
|
||||
|
||||
/**
|
||||
* 主动终止流传输
|
||||
* @param reason 终止原因
|
||||
*/
|
||||
cancel(reason?: string): void;
|
||||
}
|
||||
|
||||
export interface IStreamReader<T = any> {
|
||||
/**
|
||||
* 接受字节流流传输的数据
|
||||
* @param data 传入的字节流数据,只包含本分块的内容
|
||||
* @param done 是否传输完成
|
||||
*/
|
||||
pump(
|
||||
data: Uint8Array | undefined,
|
||||
done: boolean,
|
||||
response: Response
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 当前对象被传递给加载流时执行的函数
|
||||
* @param controller 传输流控制对象
|
||||
*/
|
||||
piped(controller: IStreamController<T>): void;
|
||||
|
||||
/**
|
||||
* 开始流传输
|
||||
* @param stream 传输流对象
|
||||
* @param controller 传输流控制对象
|
||||
*/
|
||||
start(
|
||||
stream: ReadableStream,
|
||||
controller: IStreamController<T>,
|
||||
response: Response
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 结束流传输
|
||||
* @param done 是否传输完成,如果为 false 的话,说明可能是由于出现错误导致的终止
|
||||
* @param reason 如果没有传输完成,那么表示失败的原因
|
||||
*/
|
||||
end(done: boolean, reason?: string): void;
|
||||
}
|
||||
|
||||
interface StreamLoaderEvent {
|
||||
data: [data: Uint8Array | undefined, done: boolean];
|
||||
}
|
||||
|
||||
export class StreamLoader
|
||||
extends EventEmitter<StreamLoaderEvent>
|
||||
implements IStreamController<void>
|
||||
{
|
||||
/** 传输目标 */
|
||||
private target: Set<IStreamReader> = new Set();
|
||||
/** 读取流对象 */
|
||||
private stream?: ReadableStream;
|
||||
|
||||
loading: boolean = false;
|
||||
|
||||
constructor(public readonly url: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将加载流传递给字节流读取对象
|
||||
* @param reader 字节流读取对象
|
||||
*/
|
||||
pipe(reader: IStreamReader) {
|
||||
if (this.loading) {
|
||||
logger.warn(46);
|
||||
return;
|
||||
}
|
||||
this.target.add(reader);
|
||||
reader.piped(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
const response = await window.fetch(this.url);
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
logger.error(23);
|
||||
return;
|
||||
}
|
||||
// 获取读取器
|
||||
this.stream = stream;
|
||||
const reader = response.body?.getReader();
|
||||
const targets = [...this.target];
|
||||
await Promise.all(targets.map(v => v.start(stream, this, response)));
|
||||
if (reader && reader.read) {
|
||||
// 开始流传输
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
await Promise.all(
|
||||
targets.map(v => v.pump(value, done, response))
|
||||
);
|
||||
if (done) break;
|
||||
}
|
||||
} else {
|
||||
// 如果不支持流传输
|
||||
const buffer = await response.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
await Promise.all(targets.map(v => v.pump(data, true, response)));
|
||||
}
|
||||
this.loading = false;
|
||||
targets.forEach(v => v.end(true));
|
||||
}
|
||||
|
||||
cancel(reason?: string) {
|
||||
if (!this.stream) return;
|
||||
this.stream.cancel(reason);
|
||||
this.loading = false;
|
||||
this.target.forEach(v => v.end(false, reason));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user