mirror of
https://github.com/unanmed/HumanBreak.git
synced 2025-01-19 04:19:30 +08:00
Compare commits
11 Commits
c7b2e6dd30
...
a058dfda4a
Author | SHA1 | Date | |
---|---|---|---|
a058dfda4a | |||
307bf8a10d | |||
5efa60a0de | |||
88c5e39f5c | |||
5265b0a90e | |||
231a72e78c | |||
|
6dde0334e1 | ||
7d8cbac246 | |||
ba30d111a5 | |||
babb478542 | |||
27af3ff5d1 |
@ -22,6 +22,7 @@
|
|||||||
"ant-design-vue": "^3.2.20",
|
"ant-design-vue": "^3.2.20",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"chart.js": "^4.4.3",
|
"chart.js": "^4.4.3",
|
||||||
|
"codec-parser": "^2.5.0",
|
||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
"gl-matrix": "^3.4.3",
|
"gl-matrix": "^3.4.3",
|
||||||
"gsap": "^3.12.5",
|
"gsap": "^3.12.5",
|
||||||
@ -29,6 +30,8 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
"mutate-animate": "^1.4.2",
|
"mutate-animate": "^1.4.2",
|
||||||
|
"ogg-opus-decoder": "^1.6.14",
|
||||||
|
"opus-decoder": "^0.7.7",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -69,6 +72,8 @@
|
|||||||
"typescript-eslint": "^8.18.2",
|
"typescript-eslint": "^8.18.2",
|
||||||
"unplugin-vue-components": "^0.22.12",
|
"unplugin-vue-components": "^0.22.12",
|
||||||
"vite": "^4.5.3",
|
"vite": "^4.5.3",
|
||||||
|
"vite-plugin-dts": "^4.4.0",
|
||||||
|
"vitepress": "^1.5.0",
|
||||||
"vue-tsc": "^2.1.6",
|
"vue-tsc": "^2.1.6",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
|
1783
pnpm-lock.yaml
1783
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -38,7 +38,7 @@ var data_comment_c456ea59_6018_45ef_8bcc_211a24c627dc = {
|
|||||||
"_range": "editor.mode.checkImages(thiseval, './project/images/')",
|
"_range": "editor.mode.checkImages(thiseval, './project/images/')",
|
||||||
"_directory": "./project/images/",
|
"_directory": "./project/images/",
|
||||||
"_transform": (function (one) {
|
"_transform": (function (one) {
|
||||||
if (one.endsWith('.png') || one.endsWith('.jpg') || one.endsWith('.jpeg') || one.endsWith('.gif'))
|
if (one.endsWith('.png') || one.endsWith('.jpg') || one.endsWith('.jpeg') || one.endsWith('.gif') || one.endsWith('.webp'))
|
||||||
return one;
|
return one;
|
||||||
return null;
|
return null;
|
||||||
}).toString(),
|
}).toString(),
|
||||||
@ -96,7 +96,7 @@ var data_comment_c456ea59_6018_45ef_8bcc_211a24c627dc = {
|
|||||||
"_range": "editor.mode.checkUnique(thiseval)",
|
"_range": "editor.mode.checkUnique(thiseval)",
|
||||||
"_directory": "./project/bgms/",
|
"_directory": "./project/bgms/",
|
||||||
"_transform": (function (one) {
|
"_transform": (function (one) {
|
||||||
if (one.endsWith('.mp3') || one.endsWith('.ogg') || one.endsWith('.wav') || one.endsWith('.m4a') || one.endsWith('.flac'))
|
if (one.endsWith('.mp3') || one.endsWith('.ogg') || one.endsWith('.wav') || one.endsWith('.m4a') || one.endsWith('.flac') || one.endsWith('.opus'))
|
||||||
return one;
|
return one;
|
||||||
return null;
|
return null;
|
||||||
}).toString(),
|
}).toString(),
|
||||||
|
@ -193,13 +193,14 @@ var data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d =
|
|||||||
"zone"
|
"zone"
|
||||||
],
|
],
|
||||||
"bgms": [
|
"bgms": [
|
||||||
"beforeBoss.mp3",
|
"beforeBoss.opus",
|
||||||
"cave.mp3",
|
"cave.mp3",
|
||||||
"escape.mp3",
|
"escape.mp3",
|
||||||
"escape2.mp3",
|
"escape2.mp3",
|
||||||
"grass.mp3",
|
"grass.mp3",
|
||||||
"mount.mp3",
|
"mount.opus",
|
||||||
"night.mp3",
|
"night.mp3",
|
||||||
|
"output6.ogg",
|
||||||
"palaceCenter.mp3",
|
"palaceCenter.mp3",
|
||||||
"palaceNorth.mp3",
|
"palaceNorth.mp3",
|
||||||
"palaceSouth.mp3",
|
"palaceSouth.mp3",
|
||||||
|
@ -100,7 +100,7 @@ export class ImageResource extends Resource<HTMLImageElement> {
|
|||||||
super(uri, 'image');
|
super(uri, 'image');
|
||||||
}
|
}
|
||||||
|
|
||||||
load(onProgress?: ProgressFn): Promise<HTMLImageElement> {
|
load(_onProgress?: ProgressFn): Promise<HTMLImageElement> {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = this.resolveURI();
|
img.src = this.resolveURI();
|
||||||
this.resource = img;
|
this.resource = img;
|
||||||
@ -229,9 +229,10 @@ export class AudioResource extends Resource<HTMLAudioElement> {
|
|||||||
super(uri, 'audio');
|
super(uri, 'audio');
|
||||||
}
|
}
|
||||||
|
|
||||||
load(onProgress?: ProgressFn): Promise<HTMLAudioElement> {
|
load(_onProgress?: ProgressFn): Promise<HTMLAudioElement> {
|
||||||
const audio = new Audio();
|
const audio = new Audio();
|
||||||
audio.src = this.resolveURI();
|
audio.src = this.resolveURI();
|
||||||
|
audio.preload = 'none';
|
||||||
this.resource = audio;
|
this.resource = audio;
|
||||||
return new Promise<HTMLAudioElement>(res => {
|
return new Promise<HTMLAudioElement>(res => {
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
@ -383,13 +384,12 @@ export class LoadTask<
|
|||||||
}
|
}
|
||||||
this.loaded = now;
|
this.loaded = now;
|
||||||
})
|
})
|
||||||
.catch(reason => {
|
.catch(() => {
|
||||||
LoadTask.errorTask++;
|
LoadTask.errorTask++;
|
||||||
logger.error(2, this.resource.type, this.resource.uri);
|
logger.error(2, this.resource.type, this.resource.uri);
|
||||||
});
|
});
|
||||||
this.emit('loadStart', this.resource);
|
this.emit('loadStart', this.resource);
|
||||||
const value = await load;
|
const value = await load;
|
||||||
// @ts-ignore
|
|
||||||
LoadTask.loadedTaskList.add(this);
|
LoadTask.loadedTaskList.add(this);
|
||||||
this.loaded = totalByte;
|
this.loaded = totalByte;
|
||||||
LoadTask.loadedTask++;
|
LoadTask.loadedTask++;
|
||||||
@ -407,7 +407,6 @@ export class LoadTask<
|
|||||||
uri: string
|
uri: string
|
||||||
): LoadTask<T> {
|
): LoadTask<T> {
|
||||||
const task = new LoadTask(type, uri);
|
const task = new LoadTask(type, uri);
|
||||||
// @ts-ignore
|
|
||||||
this.taskList.add(task);
|
this.taskList.add(task);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
@ -541,7 +540,7 @@ export function loadDefaultResource() {
|
|||||||
.forEach(v => {
|
.forEach(v => {
|
||||||
const res = LoadTask.add('material', `material/${v}`);
|
const res = LoadTask.add('material', `material/${v}`);
|
||||||
res.once('load', res => {
|
res.once('load', res => {
|
||||||
// @ts-ignore
|
// @ts-expect-error 不能推导
|
||||||
core.material.images[
|
core.material.images[
|
||||||
v.slice(0, -4) as SelectKey<
|
v.slice(0, -4) as SelectKey<
|
||||||
MaterialImages,
|
MaterialImages,
|
||||||
@ -554,7 +553,7 @@ export function loadDefaultResource() {
|
|||||||
weathers.forEach(v => {
|
weathers.forEach(v => {
|
||||||
const res = LoadTask.add('material', `material/${v}.png`);
|
const res = LoadTask.add('material', `material/${v}.png`);
|
||||||
res.once('load', res => {
|
res.once('load', res => {
|
||||||
// @ts-ignore
|
// @ts-expect-error 需要赋值
|
||||||
core.animateFrame.weather[v] = res.resource;
|
core.animateFrame.weather[v] = res.resource;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -674,7 +673,6 @@ export async function loadCompressedResource() {
|
|||||||
|
|
||||||
// material
|
// material
|
||||||
if (materialImages.some(v => name === v + '.png')) {
|
if (materialImages.some(v => name === v + '.png')) {
|
||||||
// @ts-ignore
|
|
||||||
core.material.images[
|
core.material.images[
|
||||||
name.slice(0, -4) as SelectKey<
|
name.slice(0, -4) as SelectKey<
|
||||||
MaterialImages,
|
MaterialImages,
|
||||||
@ -682,7 +680,7 @@ export async function loadCompressedResource() {
|
|||||||
>
|
>
|
||||||
] = image;
|
] = image;
|
||||||
} else if (weathers.some(v => name === v + '.png')) {
|
} else if (weathers.some(v => name === v + '.png')) {
|
||||||
// @ts-ignore
|
// @ts-expect-error 需要赋值
|
||||||
core.animateFrame.weather[v] = image;
|
core.animateFrame.weather[v] = image;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,5 +107,5 @@ export function buildFont(
|
|||||||
weight: number = 500,
|
weight: number = 500,
|
||||||
italic: boolean = false
|
italic: boolean = false
|
||||||
) {
|
) {
|
||||||
return `${italic ? 'italic ' : ''}${weight} ${size}px ${family}`;
|
return `${italic ? 'italic ' : ''}${weight} ${size}px "${family}"`;
|
||||||
}
|
}
|
||||||
|
@ -36,13 +36,9 @@ export class Container<E extends EContainerEvent = EContainerEvent>
|
|||||||
canvas: MotaOffscreenCanvas2D,
|
canvas: MotaOffscreenCanvas2D,
|
||||||
transform: Transform
|
transform: Transform
|
||||||
): void {
|
): void {
|
||||||
const { ctx } = canvas;
|
|
||||||
|
|
||||||
this.sortedChildren.forEach(v => {
|
this.sortedChildren.forEach(v => {
|
||||||
if (v.hidden) return;
|
if (v.hidden) return;
|
||||||
ctx.save();
|
|
||||||
v.renderContent(canvas, transform);
|
v.renderContent(canvas, transform);
|
||||||
ctx.restore();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,6 +248,7 @@ export abstract class GL2<E extends EGL2Event = EGL2Event> extends RenderItem<
|
|||||||
case RenderMode.Arrays: {
|
case RenderMode.Arrays: {
|
||||||
const { mode, first, count } = param as DrawArraysParam;
|
const { mode, first, count } = param as DrawArraysParam;
|
||||||
gl.drawArrays(mode, first, count);
|
gl.drawArrays(mode, first, count);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case RenderMode.Elements: {
|
case RenderMode.Elements: {
|
||||||
if (!indices) return;
|
if (!indices) return;
|
||||||
@ -255,11 +256,13 @@ export abstract class GL2<E extends EGL2Event = EGL2Event> extends RenderItem<
|
|||||||
param as DrawElementsParam;
|
param as DrawElementsParam;
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indices.data);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indices.data);
|
||||||
gl.drawElements(mode, count, type, offset);
|
gl.drawElements(mode, count, type, offset);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case RenderMode.ArraysInstanced: {
|
case RenderMode.ArraysInstanced: {
|
||||||
const { mode, first, count, instanceCount } =
|
const { mode, first, count, instanceCount } =
|
||||||
param as DrawArraysInstancedParam;
|
param as DrawArraysInstancedParam;
|
||||||
gl.drawArraysInstanced(mode, first, count, instanceCount);
|
gl.drawArraysInstanced(mode, first, count, instanceCount);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case RenderMode.ElementsInstanced: {
|
case RenderMode.ElementsInstanced: {
|
||||||
if (!indices) return;
|
if (!indices) return;
|
||||||
@ -272,6 +275,7 @@ export abstract class GL2<E extends EGL2Event = EGL2Event> extends RenderItem<
|
|||||||
} = param as DrawElementsInstancedParam;
|
} = param as DrawElementsInstancedParam;
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indices.data);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indices.data);
|
||||||
gl.drawElementsInstanced(mode, count, type, offset, ins);
|
gl.drawElementsInstanced(mode, count, type, offset, ins);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -714,12 +718,8 @@ class ShaderUniform<T extends UniformType> implements IShaderUniform<T> {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
set(...params: UniformSetFn[T]): void {
|
set(...params: UniformSetFn[T]): void {
|
||||||
this.gl.vertexAttribIPointer;
|
|
||||||
// 因为ts类型推导的限制,类型肯定正确,但是推导不出,所以这里直接 as any 屏蔽掉类型推导
|
// 因为ts类型推导的限制,类型肯定正确,但是推导不出,所以这里直接 as any 屏蔽掉类型推导
|
||||||
const x0 = params[0] as any;
|
const [x0, x1, x2, x3] = params as any[];
|
||||||
const x1 = params[1] as any;
|
|
||||||
const x2 = params[2] as any;
|
|
||||||
const x3 = params[3] as any;
|
|
||||||
switch (this.type) {
|
switch (this.type) {
|
||||||
case UniformType.Uniform1f:
|
case UniformType.Uniform1f:
|
||||||
this.gl.uniform1f(this.location, x0);
|
this.gl.uniform1f(this.location, x0);
|
||||||
@ -807,10 +807,7 @@ class ShaderAttrib<T extends AttribType> implements IShaderAttrib<T> {
|
|||||||
|
|
||||||
set(...params: AttribSetFn[T]) {
|
set(...params: AttribSetFn[T]) {
|
||||||
// 因为ts类型推导的限制,类型肯定正确,但是推导不出,所以这里直接 as any 屏蔽掉类型推导
|
// 因为ts类型推导的限制,类型肯定正确,但是推导不出,所以这里直接 as any 屏蔽掉类型推导
|
||||||
const x0 = params[0] as any;
|
const [x0, x1, x2, x3] = params as any[];
|
||||||
const x1 = params[1] as any;
|
|
||||||
const x2 = params[2] as any;
|
|
||||||
const x3 = params[3] as any;
|
|
||||||
switch (this.type) {
|
switch (this.type) {
|
||||||
case AttribType.Attrib1f:
|
case AttribType.Attrib1f:
|
||||||
this.gl.vertexAttrib1f(this.location, x0);
|
this.gl.vertexAttrib1f(this.location, x0);
|
||||||
@ -1035,10 +1032,10 @@ class ShaderUniformBlock implements IShaderUniformBlock {
|
|||||||
const buffer = this.buffer;
|
const buffer = this.buffer;
|
||||||
gl.bindBuffer(gl.UNIFORM_BUFFER, buffer);
|
gl.bindBuffer(gl.UNIFORM_BUFFER, buffer);
|
||||||
if (srcOffset !== void 0) {
|
if (srcOffset !== void 0) {
|
||||||
// @ts-ignore
|
// @ts-expect-error 无法推断
|
||||||
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, srcData, srcOffset, length);
|
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, srcData, srcOffset, length);
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-expect-error 无法推断
|
||||||
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, srcData);
|
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, srcData);
|
||||||
}
|
}
|
||||||
gl.bindBufferBase(gl.UNIFORM_BUFFER, this.binding, buffer);
|
gl.bindBufferBase(gl.UNIFORM_BUFFER, this.binding, buffer);
|
||||||
@ -1478,7 +1475,7 @@ export class GL2Program extends EventEmitter<ShaderProgramEvent> {
|
|||||||
/**
|
/**
|
||||||
* 定义一个 uniform 变量,并存入本着色器程序的 uniform 变量映射
|
* 定义一个 uniform 变量,并存入本着色器程序的 uniform 变量映射
|
||||||
* @param uniform uniform 变量名
|
* @param uniform uniform 变量名
|
||||||
* @param type uniform 类型,可选 {@link Shader.UNIFORM_1f} 至 {@link Shader.UNIFORM_4uiv}
|
* @param type uniform 类型,可选 {@link GL2.UNIFORM_1f} 至 {@link GL2.UNIFORM_4uiv}
|
||||||
* @returns uniform 变量的操作对象,可用于设置其值
|
* @returns uniform 变量的操作对象,可用于设置其值
|
||||||
*/
|
*/
|
||||||
defineUniform<T extends UniformType>(
|
defineUniform<T extends UniformType>(
|
||||||
@ -1506,7 +1503,7 @@ export class GL2Program extends EventEmitter<ShaderProgramEvent> {
|
|||||||
/**
|
/**
|
||||||
* 定义一个 uniform 矩阵变量,并存入本着色器程序的 uniform 矩阵变量映射
|
* 定义一个 uniform 矩阵变量,并存入本着色器程序的 uniform 矩阵变量映射
|
||||||
* @param uniform uniform 矩阵变量名
|
* @param uniform uniform 矩阵变量名
|
||||||
* @param type uniform 矩阵类型,可选 {@link Shader.U_MATRIX_2x2} 至 {@link Shader.U_MATRIX_4x4}
|
* @param type uniform 矩阵类型,可选 {@link GL2.U_MATRIX_2x2} 至 {@link GL2.U_MATRIX_4x4}
|
||||||
* @returns uniform 矩阵变量的操作对象,可用于设置其值
|
* @returns uniform 矩阵变量的操作对象,可用于设置其值
|
||||||
*/
|
*/
|
||||||
defineUniformMatrix(
|
defineUniformMatrix(
|
||||||
@ -1534,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>(
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -140,8 +140,6 @@ interface IRenderVueSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ERenderItemEvent {
|
export interface ERenderItemEvent {
|
||||||
beforeUpdate: [item?: RenderItem];
|
|
||||||
afterUpdate: [item?: RenderItem];
|
|
||||||
beforeRender: [transform: Transform];
|
beforeRender: [transform: Transform];
|
||||||
afterRender: [transform: Transform];
|
afterRender: [transform: Transform];
|
||||||
destroy: [];
|
destroy: [];
|
||||||
@ -344,7 +342,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
|
|||||||
canvas.ctx.translate(ax, ay);
|
canvas.ctx.translate(ax, ay);
|
||||||
this.render(canvas, tran);
|
this.render(canvas, tran);
|
||||||
}
|
}
|
||||||
canvas.ctx.restore();
|
ctx.restore();
|
||||||
this.emit('afterRender', transform);
|
this.emit('afterRender', transform);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -389,6 +387,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
|
|||||||
* 获取当前元素的绝对位置(不建议使用,因为应当很少会有获取绝对位置的需求)
|
* 获取当前元素的绝对位置(不建议使用,因为应当很少会有获取绝对位置的需求)
|
||||||
*/
|
*/
|
||||||
getAbsolutePosition(): LocArr {
|
getAbsolutePosition(): LocArr {
|
||||||
|
if (this.type === 'absolute') return [0, 0];
|
||||||
const { x, y } = this.transform;
|
const { x, y } = this.transform;
|
||||||
if (!this.parent) return [x, y];
|
if (!this.parent) return [x, y];
|
||||||
else {
|
else {
|
||||||
@ -400,13 +399,15 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
|
|||||||
setAnchor(x: number, y: number): void {
|
setAnchor(x: number, y: number): void {
|
||||||
this.anchorX = x;
|
this.anchorX = x;
|
||||||
this.anchorY = y;
|
this.anchorY = y;
|
||||||
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
update(item: RenderItem<any> = this, force: boolean = false): void {
|
update(item: RenderItem<any> = this): void {
|
||||||
if ((this.needUpdate || this.hidden) && !force) return;
|
if (this.needUpdate) return;
|
||||||
this.needUpdate = true;
|
this.needUpdate = true;
|
||||||
this.cacheDirty = true;
|
this.cacheDirty = true;
|
||||||
this.parent?.update(item, force);
|
if (this.hidden) return;
|
||||||
|
this.parent?.update(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
setHD(hd: boolean): void {
|
setHD(hd: boolean): void {
|
||||||
@ -475,7 +476,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
|
|||||||
hide() {
|
hide() {
|
||||||
if (this.hidden) return;
|
if (this.hidden) return;
|
||||||
this.hidden = true;
|
this.hidden = true;
|
||||||
this.update(this, true);
|
this.update(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -484,13 +485,13 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
|
|||||||
show() {
|
show() {
|
||||||
if (!this.hidden) return;
|
if (!this.hidden) return;
|
||||||
this.hidden = false;
|
this.hidden = false;
|
||||||
this.refreshAllChildren(true);
|
this.refreshAllChildren();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新所有子元素
|
* 刷新所有子元素
|
||||||
*/
|
*/
|
||||||
refreshAllChildren(force: boolean = false) {
|
refreshAllChildren() {
|
||||||
if (this.children.size > 0) {
|
if (this.children.size > 0) {
|
||||||
const stack: RenderItem[] = [this];
|
const stack: RenderItem[] = [this];
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
@ -500,7 +501,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
|
|||||||
item.children.forEach(v => stack.push(v));
|
item.children.forEach(v => stack.push(v));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.update(this, force);
|
this.update(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -523,6 +524,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从渲染树中移除这个节点
|
* 从渲染树中移除这个节点
|
||||||
|
* @returns 是否移除成功
|
||||||
*/
|
*/
|
||||||
remove(): boolean {
|
remove(): boolean {
|
||||||
if (!this.parent) return false;
|
if (!this.parent) return false;
|
||||||
|
@ -2,20 +2,10 @@ import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d';
|
|||||||
import { Container, EContainerEvent } from '../container';
|
import { Container, EContainerEvent } from '../container';
|
||||||
import { Sprite } from '../sprite';
|
import { Sprite } from '../sprite';
|
||||||
import { TimingFn } from 'mutate-animate';
|
import { TimingFn } from 'mutate-animate';
|
||||||
import {
|
import { IAnimateFrame, renderEmits, RenderItem } from '../item';
|
||||||
ERenderItemEvent,
|
|
||||||
IAnimateFrame,
|
|
||||||
renderEmits,
|
|
||||||
RenderItem
|
|
||||||
} from '../item';
|
|
||||||
import { logger } from '@/core/common/logger';
|
import { logger } from '@/core/common/logger';
|
||||||
import { RenderableData, texture } from '../cache';
|
import { RenderableData, texture } from '../cache';
|
||||||
import {
|
import { BlockCacher, CanvasCacheItem, ICanvasCacheItem } from './block';
|
||||||
BlockCacher,
|
|
||||||
CanvasCacheItem,
|
|
||||||
IBlockCacheable,
|
|
||||||
ICanvasCacheItem
|
|
||||||
} from './block';
|
|
||||||
import { Transform } from '../transform';
|
import { Transform } from '../transform';
|
||||||
import { LayerFloorBinder, LayerGroupFloorBinder } from './floor';
|
import { LayerFloorBinder, LayerGroupFloorBinder } from './floor';
|
||||||
import { RenderAdapter } from '../adapter';
|
import { RenderAdapter } from '../adapter';
|
||||||
@ -148,13 +138,9 @@ export class LayerGroup
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(canvas: MotaOffscreenCanvas2D): void {
|
protected render(canvas: MotaOffscreenCanvas2D): void {
|
||||||
const { ctx } = canvas;
|
|
||||||
|
|
||||||
this.sortedChildren.forEach(v => {
|
this.sortedChildren.forEach(v => {
|
||||||
if (v.hidden) return;
|
if (v.hidden) return;
|
||||||
ctx.save();
|
|
||||||
v.renderContent(canvas, this.camera);
|
v.renderContent(canvas, this.camera);
|
||||||
ctx.restore();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,13 +348,14 @@ export class LayerGroup
|
|||||||
if (!this.assertType(nextValue, 'number', key)) return;
|
if (!this.assertType(nextValue, 'number', key)) return;
|
||||||
this.setBlockSize(nextValue);
|
this.setBlockSize(nextValue);
|
||||||
return;
|
return;
|
||||||
case 'floorId':
|
case 'floorId': {
|
||||||
if (!this.assertType(nextValue, 'number', key)) return;
|
if (!this.assertType(nextValue, 'number', key)) return;
|
||||||
const binder = this.getExtends('floor-binder');
|
const binder = this.getExtends('floor-binder');
|
||||||
if (binder instanceof LayerGroupFloorBinder) {
|
if (binder instanceof LayerGroupFloorBinder) {
|
||||||
binder.bindFloor(nextValue);
|
binder.bindFloor(nextValue);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
case 'camera':
|
case 'camera':
|
||||||
if (!this.assertType(nextValue, Camera, key)) return;
|
if (!this.assertType(nextValue, Camera, key)) return;
|
||||||
this.camera = nextValue;
|
this.camera = nextValue;
|
||||||
@ -1376,7 +1363,7 @@ export class Layer extends Container<ELayerEvent> {
|
|||||||
// 删除原始位置的图块
|
// 删除原始位置的图块
|
||||||
this.putRenderData([0], 1, fx, fy);
|
this.putRenderData([0], 1, fx, fy);
|
||||||
|
|
||||||
let nowZ = fy;
|
const nowZ = fy;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
return new Promise<void>(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
this.delegateTicker(
|
this.delegateTicker(
|
||||||
@ -1423,7 +1410,7 @@ export class Layer extends Container<ELayerEvent> {
|
|||||||
time: number,
|
time: number,
|
||||||
relative: boolean = true
|
relative: boolean = true
|
||||||
) {
|
) {
|
||||||
let nowZ = y;
|
const nowZ = y;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
return new Promise<void>(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
this.delegateTicker(
|
this.delegateTicker(
|
||||||
@ -1459,7 +1446,7 @@ export class Layer extends Container<ELayerEvent> {
|
|||||||
parentComponent?: ComponentInternalInstance | null
|
parentComponent?: ComponentInternalInstance | null
|
||||||
): void {
|
): void {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'layer':
|
case 'layer': {
|
||||||
if (!this.assertType(nextValue, 'string', key)) return;
|
if (!this.assertType(nextValue, 'string', key)) return;
|
||||||
const parent = this.parent;
|
const parent = this.parent;
|
||||||
if (parent instanceof LayerGroup) {
|
if (parent instanceof LayerGroup) {
|
||||||
@ -1471,6 +1458,7 @@ export class Layer extends Container<ELayerEvent> {
|
|||||||
}
|
}
|
||||||
this.update();
|
this.update();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
case 'cellSize':
|
case 'cellSize':
|
||||||
if (!this.assertType(nextValue, 'number', key)) return;
|
if (!this.assertType(nextValue, 'number', key)) return;
|
||||||
this.setCellSize(nextValue);
|
this.setCellSize(nextValue);
|
||||||
|
@ -31,21 +31,19 @@ export class MotaRenderer extends Container {
|
|||||||
MotaRenderer.list.set(id, this);
|
MotaRenderer.list.set(id, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(item: RenderItem = this) {
|
update(_item: RenderItem = this) {
|
||||||
if (this.needUpdate || this.hidden) return;
|
if (this.needUpdate || this.hidden) return;
|
||||||
this.needUpdate = true;
|
this.needUpdate = true;
|
||||||
this.requestRenderFrame(() => {
|
this.requestRenderFrame(() => {
|
||||||
this.refresh(item);
|
this.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected refresh(item: RenderItem = this): void {
|
protected refresh(): void {
|
||||||
if (!this.needUpdate) return;
|
if (!this.needUpdate) return;
|
||||||
this.needUpdate = false;
|
this.needUpdate = false;
|
||||||
this.emit('beforeUpdate', item);
|
|
||||||
this.target.clear();
|
this.target.clear();
|
||||||
this.renderContent(this.target, Transform.identity);
|
this.renderContent(this.target, Transform.identity);
|
||||||
this.emit('afterUpdate', item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Ticker, TimingFn } from 'mutate-animate';
|
import { TimingFn } from 'mutate-animate';
|
||||||
import { RenderAdapter } from './adapter';
|
import { RenderAdapter } from './adapter';
|
||||||
import { FloorViewport } from './preset/viewport';
|
import { FloorViewport } from './preset/viewport';
|
||||||
import { JSX } from 'vue/jsx-runtime';
|
import { JSX } from 'vue/jsx-runtime';
|
||||||
import { Component, DefineComponent, DefineSetupFnComponent } from 'vue';
|
import { DefineComponent, DefineSetupFnComponent } from 'vue';
|
||||||
|
|
||||||
export type Props<
|
export type Props<
|
||||||
T extends
|
T extends
|
||||||
@ -12,10 +12,10 @@ export type Props<
|
|||||||
> = T extends keyof JSX.IntrinsicElements
|
> = T extends keyof JSX.IntrinsicElements
|
||||||
? JSX.IntrinsicElements[T]
|
? JSX.IntrinsicElements[T]
|
||||||
: T extends DefineSetupFnComponent<any>
|
: T extends DefineSetupFnComponent<any>
|
||||||
? InstanceType<T>['$props']
|
? InstanceType<T>['$props']
|
||||||
: T extends DefineComponent
|
: T extends DefineComponent
|
||||||
? InstanceType<T>['$props']
|
? InstanceType<T>['$props']
|
||||||
: unknown;
|
: unknown;
|
||||||
|
|
||||||
export function disableViewport() {
|
export function disableViewport() {
|
||||||
const adapter = RenderAdapter.get<FloorViewport>('viewport');
|
const adapter = RenderAdapter.get<FloorViewport>('viewport');
|
||||||
|
@ -22,6 +22,10 @@
|
|||||||
"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'.",
|
||||||
|
"24": "Cannot decode stream source type of '$1', since there is no registered decoder for that type.",
|
||||||
|
"25": "Unknown audio type. Header: '$1'",
|
||||||
|
"26": "Uncaught error when fetching stream data from '$1'. Error info: $2.",
|
||||||
"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 +75,11 @@
|
|||||||
"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.",
|
||||||
|
"46": "Cannot pipe new StreamReader object when stream is loading.",
|
||||||
|
"47": "Audio stream decoder for audio type '$1' has already existed.",
|
||||||
|
"48": "Sample rate in stream audio must be constant.",
|
||||||
"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."
|
||||||
}
|
}
|
||||||
|
28
src/module/audio/bgmLoader.ts
Normal file
28
src/module/audio/bgmLoader.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { StreamLoader } from '../loader';
|
||||||
|
import { audioPlayer, AudioRoute } from './player';
|
||||||
|
import { guessTypeByExt, isAudioSupport } from './support';
|
||||||
|
|
||||||
|
export function loadAllBgm() {
|
||||||
|
const loading = Mota.require('var', 'loading');
|
||||||
|
loading.once('coreInit', () => {
|
||||||
|
const data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d;
|
||||||
|
for (const bgm of data.main.bgms) {
|
||||||
|
const type = guessTypeByExt(bgm);
|
||||||
|
if (!type) continue;
|
||||||
|
if (isAudioSupport(type)) {
|
||||||
|
const source = audioPlayer.createElementSource();
|
||||||
|
source.setSource(`project/bgms/${bgm}`);
|
||||||
|
source.setLoop(true);
|
||||||
|
const route = new AudioRoute(source, audioPlayer);
|
||||||
|
audioPlayer.addRoute(`bgms.${bgm}`, route);
|
||||||
|
} else {
|
||||||
|
const source = audioPlayer.createStreamSource();
|
||||||
|
const stream = new StreamLoader(`project/bgms/${bgm}`);
|
||||||
|
stream.pipe(source);
|
||||||
|
source.setLoop(true);
|
||||||
|
const route = new AudioRoute(source, audioPlayer);
|
||||||
|
audioPlayer.addRoute(`bgms.${bgm}`, route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
45
src/module/audio/decoder.ts
Normal file
45
src/module/audio/decoder.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { OggVorbisDecoder } from '@wasm-audio-decoders/ogg-vorbis';
|
||||||
|
import { IAudioDecodeData, IAudioDecoder } from './source';
|
||||||
|
import { OggOpusDecoder } from 'ogg-opus-decoder';
|
||||||
|
|
||||||
|
export class VorbisDecoder implements IAudioDecoder {
|
||||||
|
decoder?: OggVorbisDecoder;
|
||||||
|
|
||||||
|
async create(): Promise<void> {
|
||||||
|
this.decoder = new OggVorbisDecoder();
|
||||||
|
await this.decoder.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.decoder?.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
async decode(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||||
|
return this.decoder?.decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush(): Promise<IAudioDecodeData | undefined> {
|
||||||
|
return await this.decoder?.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpusDecoder implements IAudioDecoder {
|
||||||
|
decoder?: OggOpusDecoder;
|
||||||
|
|
||||||
|
async create(): Promise<void> {
|
||||||
|
this.decoder = new OggOpusDecoder();
|
||||||
|
await this.decoder.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.decoder?.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
async decode(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||||
|
return this.decoder?.decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush(): Promise<IAudioDecodeData | undefined> {
|
||||||
|
return await this.decoder?.flush();
|
||||||
|
}
|
||||||
|
}
|
288
src/module/audio/effect.ts
Normal file
288
src/module/audio/effect.ts
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
14
src/module/audio/index.ts
Normal file
14
src/module/audio/index.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { loadAllBgm } from './bgmLoader';
|
||||||
|
import { OpusDecoder, VorbisDecoder } from './decoder';
|
||||||
|
import { AudioStreamSource } from './source';
|
||||||
|
import { AudioType } from './support';
|
||||||
|
|
||||||
|
loadAllBgm();
|
||||||
|
AudioStreamSource.registerDecoder(AudioType.Ogg, VorbisDecoder);
|
||||||
|
AudioStreamSource.registerDecoder(AudioType.Opus, OpusDecoder);
|
||||||
|
|
||||||
|
export * from './support';
|
||||||
|
export * from './effect';
|
||||||
|
export * from './player';
|
||||||
|
export * from './source';
|
||||||
|
export * from './bgmLoader';
|
475
src/module/audio/player.ts
Normal file
475
src/module/audio/player.ts
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
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 '@/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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个修改音量的效果器
|
||||||
|
* ```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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个延迟效果器
|
||||||
|
* |-----------|
|
||||||
|
* Input ----> | DelayNode | ----> Output
|
||||||
|
* |-----------|
|
||||||
|
*/
|
||||||
|
createDelay() {
|
||||||
|
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 音频名称
|
||||||
|
* @param when 从音频的哪个位置开始播放,单位秒
|
||||||
|
*/
|
||||||
|
play(id: string, when: number = 0) {
|
||||||
|
this.getRoute(id)?.play(when);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停音频播放
|
||||||
|
* @param id 音频名称
|
||||||
|
* @returns 当音乐真正停止时兑现
|
||||||
|
*/
|
||||||
|
pause(id: string) {
|
||||||
|
const route = this.getRoute(id);
|
||||||
|
if (!route) return Promise.resolve();
|
||||||
|
else return route.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止音频播放
|
||||||
|
* @param id 音频名称
|
||||||
|
* @returns 当音乐真正停止时兑现
|
||||||
|
*/
|
||||||
|
stop(id: string) {
|
||||||
|
const route = this.getRoute(id);
|
||||||
|
if (!route) return Promise.resolve();
|
||||||
|
else return route.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 继续音频播放
|
||||||
|
* @param id 音频名称
|
||||||
|
*/
|
||||||
|
resume(id: string) {
|
||||||
|
this.getRoute(id)?.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 0) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const audioPlayer = new AudioPlayer();
|
623
src/module/audio/source.ts
Normal file
623
src/module/audio/source.ts
Normal file
@ -0,0 +1,623 @@
|
|||||||
|
import EventEmitter from 'eventemitter3';
|
||||||
|
import { IStreamController, IStreamReader } from '../loader';
|
||||||
|
import { IAudioInput, IAudioOutput } from './effect';
|
||||||
|
import { logger } from '@/core/common/logger';
|
||||||
|
import { AudioType } from './support';
|
||||||
|
import CodecParser, { CodecFrame, MimeType, OggPage } from 'codec-parser';
|
||||||
|
import { isNil } from 'lodash-es';
|
||||||
|
|
||||||
|
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 interface IAudioDecodeError {
|
||||||
|
/** 错误信息 */
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAudioDecodeData {
|
||||||
|
/** 每个声道的音频信息 */
|
||||||
|
channelData: Float32Array[];
|
||||||
|
/** 已经被解码的 PCM 采样数 */
|
||||||
|
samplesDecoded: number;
|
||||||
|
/** 音频采样率 */
|
||||||
|
sampleRate: number;
|
||||||
|
/** 解码错误信息 */
|
||||||
|
errors: IAudioDecodeError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAudioDecoder {
|
||||||
|
/**
|
||||||
|
* 创建音频解码器
|
||||||
|
*/
|
||||||
|
create(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 摧毁这个解码器
|
||||||
|
*/
|
||||||
|
destroy(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解码流数据
|
||||||
|
* @param data 流数据
|
||||||
|
*/
|
||||||
|
decode(data: Uint8Array): Promise<IAudioDecodeData | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当音频解码完成后,会调用此函数,需要返回之前还未解析或未返回的音频数据。调用后,该解码器将不会被再次使用
|
||||||
|
*/
|
||||||
|
flush(): Promise<IAudioDecodeData | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSignatures: [AudioType, number[]][] = [
|
||||||
|
[AudioType.Mp3, [0x49, 0x44, 0x33]],
|
||||||
|
[AudioType.Ogg, [0x4f, 0x67, 0x67, 0x53]],
|
||||||
|
[AudioType.Wav, [52, 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]]
|
||||||
|
];
|
||||||
|
|
||||||
|
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 {
|
||||||
|
static readonly decoderMap: Map<AudioType, new () => IAudioDecoder> =
|
||||||
|
new Map();
|
||||||
|
output: AudioBufferSourceNode;
|
||||||
|
|
||||||
|
/** 音频数据 */
|
||||||
|
buffer?: AudioBuffer;
|
||||||
|
|
||||||
|
/** 是否已经完全加载完毕 */
|
||||||
|
loaded: boolean = false;
|
||||||
|
/** 已经缓冲了多长时间,如果缓冲完那么跟歌曲时长一致 */
|
||||||
|
buffered: number = 0;
|
||||||
|
/** 已经缓冲的采样点数量 */
|
||||||
|
bufferedSamples: number = 0;
|
||||||
|
/** 歌曲时长,加载完毕之前保持为 0 */
|
||||||
|
duration: number = 0;
|
||||||
|
/** 在流传输阶段,至少缓冲多长时间的音频之后才开始播放,单位秒 */
|
||||||
|
bufferPlayDuration: number = 1;
|
||||||
|
/** 音频的采样率,未成功解析出之前保持为 0 */
|
||||||
|
sampleRate: number = 0;
|
||||||
|
|
||||||
|
private controller?: IStreamController;
|
||||||
|
private loop: boolean = false;
|
||||||
|
|
||||||
|
private target?: IAudioInput;
|
||||||
|
|
||||||
|
/** 开始播放时刻 */
|
||||||
|
private lastStartTime: number = 0;
|
||||||
|
/** 上一次播放的缓存长度 */
|
||||||
|
private lastBufferSamples: number = 0;
|
||||||
|
|
||||||
|
/** 是否已经获取到头文件 */
|
||||||
|
private headerRecieved: boolean = false;
|
||||||
|
/** 音频类型 */
|
||||||
|
private audioType: AudioType | '' = '';
|
||||||
|
/** 音频解码器 */
|
||||||
|
private decoder?: IAudioDecoder;
|
||||||
|
/** 音频解析器 */
|
||||||
|
private parser?: CodecParser;
|
||||||
|
/** 每多长时间组成一个缓存 Float32Array */
|
||||||
|
private bufferChunkSize = 10;
|
||||||
|
/** 缓存音频数据,每 bufferChunkSize 秒钟组成一个 Float32Array,用于流式解码 */
|
||||||
|
private audioData: Float32Array[][] = [];
|
||||||
|
|
||||||
|
private errored: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册一个解码器
|
||||||
|
* @param type 要注册的解码器允许解码的类型
|
||||||
|
* @param decoder 解码器对象
|
||||||
|
*/
|
||||||
|
static registerDecoder(type: AudioType, decoder: new () => IAudioDecoder) {
|
||||||
|
if (this.decoderMap.has(type)) {
|
||||||
|
logger.warn(47, type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.decoderMap.set(type, decoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
for (const [type, value] of fileSignatures) {
|
||||||
|
if (value.every((v, i) => toCheck[i] === v)) {
|
||||||
|
this.audioType = type;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.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) {
|
||||||
|
this.audioType = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.audioType) {
|
||||||
|
logger.error(
|
||||||
|
25,
|
||||||
|
[...toCheck]
|
||||||
|
.map(v => v.toString().padStart(2, '0'))
|
||||||
|
.join(' ')
|
||||||
|
.toUpperCase()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 创建解码器
|
||||||
|
const Decoder = AudioStreamSource.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: 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;
|
||||||
|
console.log(played, this.lastBufferSamples, this.sampleRate);
|
||||||
|
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();
|
||||||
|
// const played = this.lastBufferSamples / this.sampleRate;
|
||||||
|
// this.playAudio(played);
|
||||||
|
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;
|
||||||
|
console.log(when);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** 播放开始时刻 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
55
src/module/audio/support.ts
Normal file
55
src/module/audio/support.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
console.log(supportMap);
|
@ -7,3 +7,7 @@ Mota.register('module', 'Weather', {
|
|||||||
WeatherController,
|
WeatherController,
|
||||||
RainWeather
|
RainWeather
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export * from './weather';
|
||||||
|
export * from './audio';
|
||||||
|
export * from './loader';
|
||||||
|
1
src/module/loader/index.ts
Normal file
1
src/module/loader/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './stream';
|
125
src/module/loader/stream.ts
Normal file
125
src/module/loader/stream.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { logger } from '@/core/common/logger';
|
||||||
|
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];
|
||||||
|
// try {
|
||||||
|
await Promise.all(targets.map(v => v.start(stream, this, response)));
|
||||||
|
|
||||||
|
// 开始流传输
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
await Promise.all(targets.map(v => v.pump(value, done, response)));
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
targets.forEach(v => v.end(true));
|
||||||
|
// } catch (e) {
|
||||||
|
// logger.error(26, this.url, String(e));
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(reason?: string) {
|
||||||
|
if (!this.stream) return;
|
||||||
|
this.stream.cancel(reason);
|
||||||
|
this.loading = false;
|
||||||
|
this.target.forEach(v => v.end(false, reason));
|
||||||
|
}
|
||||||
|
}
|
5
src/source/data.d.ts
vendored
5
src/source/data.d.ts
vendored
@ -210,13 +210,14 @@ type SoundIds =
|
|||||||
| 'zone.mp3'
|
| 'zone.mp3'
|
||||||
|
|
||||||
type BgmIds =
|
type BgmIds =
|
||||||
| 'beforeBoss.mp3'
|
| 'beforeBoss.opus'
|
||||||
| 'cave.mp3'
|
| 'cave.mp3'
|
||||||
| 'escape.mp3'
|
| 'escape.mp3'
|
||||||
| 'escape2.mp3'
|
| 'escape2.mp3'
|
||||||
| 'grass.mp3'
|
| 'grass.mp3'
|
||||||
| 'mount.mp3'
|
| 'mount.opus'
|
||||||
| 'night.mp3'
|
| 'night.mp3'
|
||||||
|
| 'output6.ogg'
|
||||||
| 'palaceCenter.mp3'
|
| 'palaceCenter.mp3'
|
||||||
| 'palaceNorth.mp3'
|
| 'palaceNorth.mp3'
|
||||||
| 'palaceSouth.mp3'
|
| 'palaceSouth.mp3'
|
||||||
|
Loading…
Reference in New Issue
Block a user