Compare commits

...

2 Commits

Author SHA1 Message Date
5265b0a90e refactor: 新的音频系统 2025-01-13 22:24:40 +08:00
231a72e78c feat: 流式加载器 & fix: gl2 注释 2025-01-13 16:38:28 +08:00
11 changed files with 1068 additions and 2 deletions

View File

@ -1531,7 +1531,7 @@ export class GL2Program extends EventEmitter<ShaderProgramEvent> {
/** /**
* attribute attribute es 300 in * attribute attribute es 300 in
* @param attrib attribute * @param attrib attribute
* @param type attribute {@link Shader.Attrib1f} {@link Shader.AttribI4uiv} * @param type attribute {@link GL2.ATTRIB_1f} {@link GL2.ATTRIB_I4uiv}
* @returns attribute * @returns attribute
*/ */
defineAttribute<T extends AttribType>( defineAttribute<T extends AttribType>(

View File

@ -69,7 +69,6 @@ Mota.require('var', 'loading').once('coreInit', () => {
</layer-group> </layer-group>
<Textbox id="main-textbox" {...mainTextboxProps}></Textbox> <Textbox id="main-textbox" {...mainTextboxProps}></Textbox>
<FloorChange id="floor-change" zIndex={50}></FloorChange> <FloorChange id="floor-change" zIndex={50}></FloorChange>
<icon icon={13} animate></icon>
</container> </container>
); );
}); });

View File

@ -22,6 +22,7 @@
"20": "Cannot create render element for tag '$1', since there's no registration for it.", "20": "Cannot create render element for tag '$1', since there's no registration for it.",
"21": "Incorrect render prop type is delivered. key: '$1', expected type: '$2', delivered type: '$3'", "21": "Incorrect render prop type is delivered. key: '$1', expected type: '$2', delivered type: '$3'",
"22": "Incorrect props for custom tag. Please ensure you have delivered 'item' prop and other required props.", "22": "Incorrect props for custom tag. Please ensure you have delivered 'item' prop and other required props.",
"23": "Cannot get reader when fetching '$1'.",
"1101": "Shadow extension needs 'floor-hero' extension as dependency.", "1101": "Shadow extension needs 'floor-hero' extension as dependency.",
"1201": "Floor-damage extension needs 'floor-binder' extension as dependency.", "1201": "Floor-damage extension needs 'floor-binder' extension as dependency.",
"1301": "Portal extension need 'floor-binder' extension as dependency.", "1301": "Portal extension need 'floor-binder' extension as dependency.",
@ -71,6 +72,8 @@
"41": "Width of text content components must be positive. receive: $1", "41": "Width of text content components must be positive. receive: $1",
"42": "Repeated Textbox id: '$1'.", "42": "Repeated Textbox id: '$1'.",
"43": "Cannot set icon of '$1', since it does not exists. Please ensure you have delivered correct icon id or number.", "43": "Cannot set icon of '$1', since it does not exists. Please ensure you have delivered correct icon id or number.",
"44": "Unexpected end when loading stream audio, reason: '$1'",
"45": "Audio route with id of '$1' has already existed. New route will override old route.",
"1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency.",
"1101": "Cannot add new effect to point effect instance, for there's no more reserve space for it. Please increase the max count of the instance." "1101": "Cannot add new effect to point effect instance, for there's no more reserve space for it. Please increase the max count of the instance."
} }

266
src/module/audio/effect.ts Normal file
View File

@ -0,0 +1,266 @@
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) {}
/**
* x正方形水平向右y正方形垂直于地面向上z正方向垂直屏幕远离用户
* @param x x坐标
* @param y y坐标
* @param z z坐标
*/
setPosition(x: number, y: number, z: number) {}
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) {}
/**
*
*/
getVolume(): number {}
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
* @param volume
*/
setVolume(channel: number, volume: number) {}
/**
*
* @param channel
*/
getVolume(channel: number): number {}
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) {}
/**
*
*/
getDelay() {}
end(): void {}
start(): void {}
}
export class EchoEffect extends AudioEffect {
output: DelayNode;
input: DelayNode;
/** 延迟节点 */
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();
this.input = delay;
this.output = delay;
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;
}
/**
*
* @param gain 0-110.500
*/
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-Infinity0.010.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;
}
}

View File

@ -0,0 +1,4 @@
export * from './support';
export * from './effect';
export * from './player';
export * from './source';

404
src/module/audio/player.ts Normal file
View File

@ -0,0 +1,404 @@
import EventEmitter from 'eventemitter3';
import {
AudioBufferSource,
AudioElementSource,
AudioSource,
AudioStreamSource
} from './source';
import {
AudioEffect,
ChannelVolumeEffect,
EchoEffect,
IAudioOutput,
StereoEffect,
VolumeEffect
} from './effect';
import { isNil } from 'lodash-es';
import { logger } from '@/core/common/logger';
import { sleep } from 'mutate-animate';
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 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);
}
/**
*
*/
createVolumeEffect() {
return new VolumeEffect(this.ac);
}
/**
*
*/
createStereoEffect() {
return new StereoEffect(this.ac);
}
/**
*
*/
createChannelVolumeEffect() {
return new ChannelVolumeEffect(this.ac);
}
/**
*
*/
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
* @param when
*/
play(id: string, when?: number) {
this.getRoute(id)?.play(when);
}
/**
* 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;
}
}
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;
/** 是否已暂停,注意停止播放是不算暂停的 */
paused: boolean = false;
/** 暂停时刻 */
private pauseTime: number = 0;
private audioStartHook?: AudioStartHook;
private audioEndHook?: AudioEndHook;
constructor(
public readonly source: AudioSource,
public readonly player: AudioPlayer
) {
super();
this.output = source.output;
}
/**
*
* @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
*/
play(when?: number) {
if (this.source.playing) return;
this.link();
if (this.effectRoute.length > 0) {
const first = this.effectRoute[0];
this.source.connect(first);
} else {
this.source.connect({ input: this.player.getDestination() });
}
this.source.play(when);
this.paused = false;
this.pauseTime = 0;
this.audioStartHook?.(this);
this.startAllEffect();
this.emit('play');
}
/**
*
*/
async pause() {
if (this.paused || !this.source.playing) return;
if (this.audioEndHook) {
this.audioEndHook(this.endTime, this);
await sleep(this.endTime);
}
const time = this.source.stop();
this.pauseTime = time;
this.paused = true;
this.endAllEffect();
this.emit('pause');
}
/**
*
*/
resume() {
if (this.source.playing) return;
if (this.paused) {
this.play(this.pauseTime);
} else {
this.play(0);
}
this.paused = false;
this.pauseTime = 0;
this.audioStartHook?.(this);
this.startAllEffect();
this.emit('resume');
}
/**
*
*/
async stop() {
if (!this.source.playing) return;
if (this.audioEndHook) {
this.audioEndHook(this.endTime, this);
await sleep(this.endTime);
}
this.source.stop();
this.paused = false;
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');
}
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());
}
}

234
src/module/audio/source.ts Normal file
View File

@ -0,0 +1,234 @@
import EventEmitter from 'eventemitter3';
import { IStreamController, IStreamReader } from '../loader';
import { IAudioInput, IAudioOutput } from './effect';
import { logger } from '@/core/common/logger';
interface AudioSourceEvent {
play: [];
end: [];
}
export abstract class AudioSource
extends EventEmitter<AudioSourceEvent>
implements IAudioOutput
{
/** 音频源的输出节点 */
abstract readonly output: AudioNode;
/** 是否正在播放 */
playing: boolean = false;
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;
}
export class AudioStreamSource extends AudioSource implements IStreamReader {
output: AudioBufferSourceNode;
/** 音频数据 */
buffer?: AudioBuffer;
/** 是否已经完全加载完毕 */
loaded: boolean = false;
private controller?: IStreamController;
private loop: boolean = false;
/** 开始播放时刻 */
private lastStartTime: number = 0;
constructor(context: AudioContext) {
super(context);
this.output = context.createBufferSource();
}
piped(controller: IStreamController): void {
this.controller = controller;
}
pump(data: Uint8Array | undefined): void {
if (!data) return;
}
start(): void {
delete this.buffer;
}
end(done: boolean, reason?: string): void {
if (done) {
this.loaded = true;
delete this.controller;
} else {
logger.warn(44, reason ?? '');
}
}
play(when?: number): void {
if (this.playing) return;
if (this.loaded && this.buffer) {
this.playing = true;
this.lastStartTime = this.ac.currentTime;
this.emit('play');
this.output.start(when);
this.output.addEventListener('ended', () => {
this.playing = false;
this.emit('end');
if (this.loop) this.play(0);
});
} else {
this.controller?.start();
}
}
stop(): number {
this.output.stop();
return this.ac.currentTime - this.lastStartTime;
}
connect(target: IAudioInput): void {
if (!this.buffer) return;
const node = this.ac.createBufferSource();
node.buffer = this.buffer;
this.output = node;
node.connect(target.input);
node.loop = this.loop;
}
setLoop(loop: boolean): void {
this.loop = loop;
}
}
export class AudioElementSource extends AudioSource {
output: MediaElementAudioSourceNode;
/** audio 元素 */
readonly audio: HTMLAudioElement;
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): 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;
/** 播放开始时刻 */
private lastStartTime: number = 0;
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;
}
}
play(when?: number): void {
if (this.playing) return;
this.playing = true;
this.lastStartTime = this.ac.currentTime;
this.emit('play');
this.output.start(when);
this.output.addEventListener('ended', () => {
this.playing = false;
this.emit('end');
if (this.loop) this.play(0);
});
}
stop(): number {
this.output.stop();
return this.ac.currentTime - this.lastStartTime;
}
connect(target: IAudioInput): void {
if (!this.buffer) return;
const node = this.ac.createBufferSource();
node.buffer = this.buffer;
node.connect(target.input);
}
setLoop(loop: boolean): void {
this.loop = loop;
}
}

View File

@ -0,0 +1,46 @@
const audio = new Audio();
const supportMap = new Map<string, boolean>();
/**
*
* @param type
*/
export function isAudioSupport(type: string): 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, string>([
['ogg', 'audio/ogg; codecs="vorbis"'],
['mp3', 'audio/mpeg'],
['wav', 'audio/wav; codecs="1"'],
['flac', 'audio/flac'],
['opus', 'audio/ogg; codecs="opus"'],
['acc', 'audio/acc']
]);
/**
*
* @param file
*/
export function guessTypeByExt(file: string) {
const ext = /\.[a-zA-Z]$/.exec(file);
if (!ext?.[0]) return '';
const type = ext[0].slice(1);
return typeMap.get(type.toLocaleLowerCase()) ?? '';
}
isAudioSupport('audio/ogg; codecs="vorbis"');
isAudioSupport('audio/mpeg');
isAudioSupport('audio/wav; codecs="1"');
isAudioSupport('audio/flac');
isAudioSupport('audio/ogg; codecs="opus"');
isAudioSupport('audio/acc');
console.log(supportMap);

View File

@ -7,3 +7,7 @@ Mota.register('module', 'Weather', {
WeatherController, WeatherController,
RainWeather RainWeather
}); });
export * from './weather';
export * from './audio';
export * from './loader';

View File

@ -0,0 +1 @@
export * from './stream';

105
src/module/loader/stream.ts Normal file
View File

@ -0,0 +1,105 @@
import { logger } from '@/core/common/logger';
import EventEmitter from 'eventemitter3';
export interface IStreamController<T = void> {
/**
*
*/
start(): Promise<T>;
/**
*
* @param reason
*/
cancel(reason?: string): void;
}
export interface IStreamReader<T = any> {
/**
*
* @param data
* @param done
*/
pump(data: Uint8Array | undefined, done: boolean): void;
/**
*
* @param controller
*/
piped(controller: IStreamController<T>): void;
/**
*
* @param stream
* @param controller
*/
start(stream: ReadableStream, controller: IStreamController<T>): 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;
private loading: boolean = false;
constructor(public readonly url: string) {
super();
}
/**
*
* @param reader
*/
pipe(reader: IStreamReader) {
this.target.add(reader);
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();
this.target.forEach(v => v.start(stream, this));
// 开始流传输
while (true) {
const { value, done } = await reader.read();
this.target.forEach(v => v.pump(value, done));
if (done) break;
}
this.loading = false;
this.target.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));
}
}