mirror of
https://github.com/motajs/template.git
synced 2026-11-07 10:14:30 +08:00
592 lines
17 KiB
TypeScript
592 lines
17 KiB
TypeScript
import { IStreamController, IStreamReader } from '@motajs/loader';
|
|
import { logger } from '@motajs/common';
|
|
import CodecParser, { CodecFrame, MimeType, OggPage } from 'codec-parser';
|
|
import { isNil } from 'lodash-es';
|
|
import {
|
|
AudioType,
|
|
EAudioSourceEvent,
|
|
IAudioBufferSource,
|
|
IAudioDecodeData,
|
|
IAudioDecoder,
|
|
IAudioElementSource,
|
|
IAudioInput,
|
|
IAudioStreamSource,
|
|
IMotaAudioContext
|
|
} from './types';
|
|
import EventEmitter from 'eventemitter3';
|
|
|
|
const mimeTypeMap: Record<AudioType, MimeType | 'unknown'> = {
|
|
[AudioType.Unknown]: 'unknown',
|
|
[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 EventEmitter<EAudioSourceEvent>
|
|
implements IAudioStreamSource, IStreamReader
|
|
{
|
|
readonly ac: AudioContext;
|
|
/** 音频源节点 */
|
|
output: AudioBufferSourceNode;
|
|
|
|
/** 音频数据 */
|
|
buffer: AudioBuffer | null = null;
|
|
|
|
/** 是否已经完全加载完毕 */
|
|
loaded: boolean = false;
|
|
/** 已经缓冲了多长时间,如果缓冲完那么跟歌曲时长一致 */
|
|
buffered: number = 0;
|
|
/** 已经缓冲的采样点数量 */
|
|
bufferedSamples: number = 0;
|
|
/** 当前是否正在播放 */
|
|
playing: boolean = false;
|
|
/** 歌曲时长,加载完毕之前保持为 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 | null = null;
|
|
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: IAudioDecoder | null = null;
|
|
/** 音频解析器 */
|
|
private parser: CodecParser | null = null;
|
|
/** 每多长时间组成一个缓存 Float32Array */
|
|
private bufferChunkSize: number = 10;
|
|
/** 缓存音频数据,每 bufferChunkSize 秒钟组成一个 Float32Array,用于流式解码 */
|
|
private audioData: Float32Array[][] = [];
|
|
|
|
private errored: boolean = false;
|
|
|
|
constructor(readonly motaAC: IMotaAudioContext) {
|
|
super();
|
|
this.ac = motaAC.ac;
|
|
this.output = motaAC.ac.createBufferSource();
|
|
}
|
|
|
|
/**
|
|
* 设置每个缓存数据的大小,默认为10秒钟一个缓存数据
|
|
* @param size 每个缓存数据的时长,单位秒
|
|
*/
|
|
setChunkSize(size: number) {
|
|
if (this.controller?.loading || this.loaded) return;
|
|
this.bufferChunkSize = size;
|
|
}
|
|
|
|
free(): void {
|
|
this.stop();
|
|
this.audioData = [];
|
|
this.decoder?.destroy();
|
|
this.decoder = null;
|
|
this.parser = null;
|
|
this.audioType = '';
|
|
this.headerRecieved = false;
|
|
this.errored = false;
|
|
this.duration = 0;
|
|
this.buffered = 0;
|
|
this.bufferedSamples = 0;
|
|
this.loaded = false;
|
|
this.sampleRate = 0;
|
|
this.buffer = null;
|
|
this.output.buffer = null;
|
|
}
|
|
|
|
piped(controller: IStreamController): void {
|
|
this.controller = controller;
|
|
}
|
|
|
|
unpiped(controller: IStreamController): void {
|
|
if (this.controller === controller) {
|
|
this.controller = null;
|
|
}
|
|
}
|
|
|
|
async pump(data: Uint8Array | undefined, done: boolean): Promise<void> {
|
|
if (!data || this.errored) return;
|
|
if (!this.headerRecieved) {
|
|
// 检查头文件获取音频类型,仅检查前256个字节
|
|
const toCheck = data.slice(0, 256);
|
|
const type = this.motaAC.getAudioTypeFromData(data);
|
|
this.audioType = type;
|
|
if (type === AudioType.Unknown) {
|
|
logger.error(
|
|
25,
|
|
[...toCheck]
|
|
.map(v => v.toString(16).padStart(2, '0'))
|
|
.join(' ')
|
|
.toUpperCase()
|
|
);
|
|
return;
|
|
}
|
|
const decoder = this.motaAC.createDecoder(type);
|
|
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 = decoder;
|
|
// 创建数据解析器
|
|
const mime = mimeTypeMap[this.audioType];
|
|
if (mime !== 'unknown') {
|
|
const parser = new CodecParser(mime);
|
|
this.parser = parser;
|
|
await 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: IAudioDecoder,
|
|
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: IAudioDecoder, 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() {
|
|
this.buffer = null;
|
|
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;
|
|
this.controller = null;
|
|
this.mergeBuffers();
|
|
this.duration = this.buffered;
|
|
this.audioData = [];
|
|
this.decoder?.destroy();
|
|
this.decoder = null;
|
|
this.parser = null;
|
|
this.emit('load');
|
|
} 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.createSourceNode(this.buffer);
|
|
this.output.start(0, when);
|
|
this.playing = true;
|
|
this.emit('play');
|
|
this.output.addEventListener('ended', () => {
|
|
this.playing = false;
|
|
if (this.loop && !this.output.loop) this.play(0);
|
|
this.emit('end');
|
|
});
|
|
}
|
|
|
|
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 EventEmitter<EAudioSourceEvent>
|
|
implements IAudioElementSource
|
|
{
|
|
readonly ac: AudioContext;
|
|
output: MediaElementAudioSourceNode;
|
|
|
|
/** 当前是否正在播放 */
|
|
playing: boolean = false;
|
|
|
|
/** audio 元素 */
|
|
readonly audio: HTMLAudioElement;
|
|
|
|
get duration(): number {
|
|
return this.audio.duration;
|
|
}
|
|
get currentTime(): number {
|
|
return this.audio.currentTime;
|
|
}
|
|
|
|
constructor(readonly motaAC: IMotaAudioContext) {
|
|
super();
|
|
this.ac = motaAC.ac;
|
|
const audio = new Audio();
|
|
audio.preload = 'none';
|
|
this.output = motaAC.ac.createMediaElementSource(audio);
|
|
this.audio = audio;
|
|
audio.addEventListener('play', () => {
|
|
this.playing = true;
|
|
this.emit('play');
|
|
});
|
|
audio.addEventListener('ended', () => {
|
|
this.playing = false;
|
|
this.emit('end');
|
|
});
|
|
audio.addEventListener('load', () => {
|
|
if (audio.src.length > 0) {
|
|
this.emit('load');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 设置音频源的路径
|
|
* @param url 音频路径
|
|
*/
|
|
setSource(url: string) {
|
|
this.audio.src = url;
|
|
}
|
|
|
|
free(): void {
|
|
this.stop();
|
|
this.audio.src = '';
|
|
this.audio.load();
|
|
}
|
|
|
|
play(when: number = 0): void {
|
|
if (this.playing) return;
|
|
this.audio.currentTime = when;
|
|
this.audio.play();
|
|
}
|
|
|
|
stop(): number {
|
|
this.audio.pause();
|
|
this.playing = false;
|
|
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 EventEmitter<EAudioSourceEvent>
|
|
implements IAudioBufferSource
|
|
{
|
|
readonly ac: AudioContext;
|
|
output: AudioBufferSourceNode;
|
|
|
|
/** 音频数据 */
|
|
buffer: AudioBuffer | null = null;
|
|
/** 是否循环 */
|
|
private loop: boolean = false;
|
|
|
|
/** 当前是否正在播放 */
|
|
playing: 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(readonly motaAC: IMotaAudioContext) {
|
|
super();
|
|
this.ac = motaAC.ac;
|
|
this.output = motaAC.ac.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;
|
|
this.emit('load');
|
|
}
|
|
|
|
free(): void {
|
|
this.stop();
|
|
this.output.buffer = null;
|
|
this.buffer = null;
|
|
}
|
|
|
|
play(when?: number): void {
|
|
if (this.playing || !this.buffer) return;
|
|
this.playing = true;
|
|
this.lastStartTime = this.ac.currentTime;
|
|
this.createSourceNode(this.buffer);
|
|
this.output.start(0, when);
|
|
this.emit('play');
|
|
this.output.addEventListener('ended', () => {
|
|
this.playing = false;
|
|
if (this.loop && !this.output.loop) this.play(0);
|
|
this.emit('end');
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|