@aelionsdk/audio 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/device-state.d.ts +28 -0
- package/dist/device-state.d.ts.map +1 -0
- package/dist/device-state.js +119 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/ir-mixer.d.ts +30 -0
- package/dist/ir-mixer.d.ts.map +1 -0
- package/dist/ir-mixer.js +235 -0
- package/dist/pcm-message-player.worklet.d.ts +2 -0
- package/dist/pcm-message-player.worklet.d.ts.map +1 -0
- package/dist/pcm-message-player.worklet.js +119 -0
- package/dist/pcm-player.worklet.d.ts +2 -0
- package/dist/pcm-player.worklet.d.ts.map +1 -0
- package/dist/pcm-player.worklet.js +36 -0
- package/dist/pcm-ring.d.ts +39 -0
- package/dist/pcm-ring.d.ts.map +1 -0
- package/dist/pcm-ring.js +174 -0
- package/dist/processing.d.ts +106 -0
- package/dist/processing.d.ts.map +1 -0
- package/dist/processing.js +386 -0
- package/dist/resampler.d.ts +22 -0
- package/dist/resampler.d.ts.map +1 -0
- package/dist/resampler.js +99 -0
- package/dist/time-stretch.d.ts +30 -0
- package/dist/time-stretch.d.ts.map +1 -0
- package/dist/time-stretch.js +180 -0
- package/dist/transferable-pcm-queue.d.ts +30 -0
- package/dist/transferable-pcm-queue.d.ts.map +1 -0
- package/dist/transferable-pcm-queue.js +73 -0
- package/dist/transferable-worklet-clock.d.ts +51 -0
- package/dist/transferable-worklet-clock.d.ts.map +1 -0
- package/dist/transferable-worklet-clock.js +185 -0
- package/dist/video-scheduler.d.ts +39 -0
- package/dist/video-scheduler.d.ts.map +1 -0
- package/dist/video-scheduler.js +111 -0
- package/dist/worklet-clock.d.ts +55 -0
- package/dist/worklet-clock.d.ts.map +1 -0
- package/dist/worklet-clock.js +193 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FoyonaCZY and AelionSDK contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @aelionsdk/audio
|
|
2
|
+
|
|
3
|
+
AudioWorklet clock, PCM buffering and audio mixing for AelionSDK
|
|
4
|
+
|
|
5
|
+
Install with `npm install @aelionsdk/audio@next`.
|
|
6
|
+
|
|
7
|
+
Version 0.1.0-beta.1 is a prerelease and its API may change before the first stable release. This package is part of [AelionSDK](https://github.com/FoyonaCZY/AelionSDK); see the repository README for supported browsers, examples and deployment requirements.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Disposable } from '@aelionsdk/core';
|
|
2
|
+
export type AudioRuntimeState = 'idle' | 'running' | 'interrupted' | 'switching-device' | 'failed' | 'disposed';
|
|
3
|
+
export interface AudioOutputBackend {
|
|
4
|
+
readonly state: string;
|
|
5
|
+
resume(): Promise<void>;
|
|
6
|
+
suspend(): Promise<void>;
|
|
7
|
+
setSinkId?(sinkId: string): Promise<void>;
|
|
8
|
+
addEventListener(type: 'statechange', listener: () => void): void;
|
|
9
|
+
removeEventListener(type: 'statechange', listener: () => void): void;
|
|
10
|
+
}
|
|
11
|
+
export interface AudioRuntimeSnapshot {
|
|
12
|
+
readonly state: AudioRuntimeState;
|
|
13
|
+
readonly outputDeviceId: string;
|
|
14
|
+
readonly generation: number;
|
|
15
|
+
readonly errorCode?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Serializes device changes and browser interruption recovery without losing mixer state. */
|
|
18
|
+
export declare class AudioRuntimeStateMachine implements Disposable {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(backend: AudioOutputBackend);
|
|
21
|
+
get disposed(): boolean;
|
|
22
|
+
snapshot(): AudioRuntimeSnapshot;
|
|
23
|
+
start(signal?: AbortSignal): Promise<void>;
|
|
24
|
+
switchOutputDevice(deviceId: string, signal?: AbortSignal): Promise<void>;
|
|
25
|
+
recover(signal?: AbortSignal): Promise<void>;
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=device-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-state.d.ts","sourceRoot":"","sources":["../src/device-state.ts"],"names":[],"mappings":"AAAA,OAAO,EAA+B,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE/E,MAAM,MAAM,iBAAiB,GACzB,MAAM,GACN,SAAS,GACT,aAAa,GACb,kBAAkB,GAClB,QAAQ,GACR,UAAU,CAAC;AAEf,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,SAAS,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IAClE,mBAAmB,CAAC,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CACtE;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAMD,8FAA8F;AAC9F,qBAAa,wBAAyB,YAAW,UAAU;;gBAQtC,OAAO,EAAE,kBAAkB;IAK9C,IAAW,QAAQ,IAAI,OAAO,CAE7B;IAEM,QAAQ,IAAI,oBAAoB;IAS1B,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBhD,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAyCnE,OAAO,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAMlD,OAAO,IAAI,IAAI;CA6BvB"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { AelionError, throwIfAborted } from '@aelionsdk/core';
|
|
2
|
+
function diagnostic(code, message, recoverable) {
|
|
3
|
+
return new AelionError([{ code, severity: 'error', message, recoverable }]);
|
|
4
|
+
}
|
|
5
|
+
/** Serializes device changes and browser interruption recovery without losing mixer state. */
|
|
6
|
+
export class AudioRuntimeStateMachine {
|
|
7
|
+
#backend;
|
|
8
|
+
#state = 'idle';
|
|
9
|
+
#deviceId = 'default';
|
|
10
|
+
#generation = 0;
|
|
11
|
+
#errorCode;
|
|
12
|
+
#switchTask = Promise.resolve();
|
|
13
|
+
constructor(backend) {
|
|
14
|
+
this.#backend = backend;
|
|
15
|
+
backend.addEventListener('statechange', this.#handleStateChange);
|
|
16
|
+
}
|
|
17
|
+
get disposed() {
|
|
18
|
+
return this.#state === 'disposed';
|
|
19
|
+
}
|
|
20
|
+
snapshot() {
|
|
21
|
+
return {
|
|
22
|
+
state: this.#state,
|
|
23
|
+
outputDeviceId: this.#deviceId,
|
|
24
|
+
generation: this.#generation,
|
|
25
|
+
...(this.#errorCode === undefined ? {} : { errorCode: this.#errorCode }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async start(signal) {
|
|
29
|
+
this.#requireActive();
|
|
30
|
+
throwIfAborted(signal, 'Audio runtime start');
|
|
31
|
+
try {
|
|
32
|
+
await this.#backend.resume();
|
|
33
|
+
throwIfAborted(signal, 'Audio runtime start');
|
|
34
|
+
this.#state = this.#backend.state === 'running' ? 'running' : 'interrupted';
|
|
35
|
+
this.#errorCode = undefined;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
this.#fail('AUDIO_CONTEXT_RESUME_FAILED');
|
|
39
|
+
throw diagnostic('AUDIO_CONTEXT_RESUME_FAILED', error instanceof Error ? error.message : 'AudioContext resume failed', true);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
switchOutputDevice(deviceId, signal) {
|
|
43
|
+
this.#requireActive();
|
|
44
|
+
if (deviceId.length === 0)
|
|
45
|
+
return Promise.reject(new TypeError('AUDIO_DEVICE_ID_INVALID'));
|
|
46
|
+
const generation = ++this.#generation;
|
|
47
|
+
const task = this.#switchTask.then(async () => {
|
|
48
|
+
this.#requireActive();
|
|
49
|
+
throwIfAborted(signal, 'Audio output device switch');
|
|
50
|
+
if (generation !== this.#generation)
|
|
51
|
+
return;
|
|
52
|
+
if (this.#backend.setSinkId === undefined) {
|
|
53
|
+
throw diagnostic('AUDIO_OUTPUT_DEVICE_UNSUPPORTED', 'The active browser does not support AudioContext output device selection', true);
|
|
54
|
+
}
|
|
55
|
+
const wasRunning = this.#state === 'running';
|
|
56
|
+
this.#state = 'switching-device';
|
|
57
|
+
try {
|
|
58
|
+
if (wasRunning)
|
|
59
|
+
await this.#backend.suspend();
|
|
60
|
+
throwIfAborted(signal, 'Audio output device switch');
|
|
61
|
+
await this.#backend.setSinkId(deviceId);
|
|
62
|
+
throwIfAborted(signal, 'Audio output device switch');
|
|
63
|
+
if (wasRunning)
|
|
64
|
+
await this.#backend.resume();
|
|
65
|
+
if (generation !== this.#generation)
|
|
66
|
+
return;
|
|
67
|
+
this.#deviceId = deviceId;
|
|
68
|
+
this.#state = this.#backend.state === 'running' ? 'running' : 'interrupted';
|
|
69
|
+
this.#errorCode = undefined;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (generation === this.#generation)
|
|
73
|
+
this.#fail('AUDIO_OUTPUT_DEVICE_SWITCH_FAILED');
|
|
74
|
+
if (error instanceof AelionError)
|
|
75
|
+
throw error;
|
|
76
|
+
throw diagnostic('AUDIO_OUTPUT_DEVICE_SWITCH_FAILED', error instanceof Error ? error.message : 'Audio output device switch failed', true);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
this.#switchTask = task.catch(() => undefined);
|
|
80
|
+
return task;
|
|
81
|
+
}
|
|
82
|
+
async recover(signal) {
|
|
83
|
+
this.#requireActive();
|
|
84
|
+
if (this.#state !== 'interrupted' && this.#state !== 'failed')
|
|
85
|
+
return;
|
|
86
|
+
await this.start(signal);
|
|
87
|
+
}
|
|
88
|
+
dispose() {
|
|
89
|
+
if (this.disposed)
|
|
90
|
+
return;
|
|
91
|
+
this.#generation++;
|
|
92
|
+
this.#backend.removeEventListener('statechange', this.#handleStateChange);
|
|
93
|
+
this.#state = 'disposed';
|
|
94
|
+
this.#errorCode = undefined;
|
|
95
|
+
}
|
|
96
|
+
#handleStateChange = () => {
|
|
97
|
+
if (this.disposed || this.#state === 'switching-device')
|
|
98
|
+
return;
|
|
99
|
+
if (this.#backend.state === 'running') {
|
|
100
|
+
this.#state = 'running';
|
|
101
|
+
this.#errorCode = undefined;
|
|
102
|
+
}
|
|
103
|
+
else if (this.#backend.state === 'suspended' || this.#backend.state === 'interrupted') {
|
|
104
|
+
this.#state = 'interrupted';
|
|
105
|
+
this.#errorCode = 'AUDIO_CONTEXT_INTERRUPTED';
|
|
106
|
+
}
|
|
107
|
+
else if (this.#backend.state === 'closed') {
|
|
108
|
+
this.#fail('AUDIO_CONTEXT_CLOSED');
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
#fail(code) {
|
|
112
|
+
this.#state = 'failed';
|
|
113
|
+
this.#errorCode = code;
|
|
114
|
+
}
|
|
115
|
+
#requireActive() {
|
|
116
|
+
if (this.disposed)
|
|
117
|
+
throw new ReferenceError('Audio runtime is disposed');
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './ir-mixer.js';
|
|
2
|
+
export * from './processing.js';
|
|
3
|
+
export * from './pcm-ring.js';
|
|
4
|
+
export * from './worklet-clock.js';
|
|
5
|
+
export * from './video-scheduler.js';
|
|
6
|
+
export * from './transferable-pcm-queue.js';
|
|
7
|
+
export * from './transferable-worklet-clock.js';
|
|
8
|
+
export * from './device-state.js';
|
|
9
|
+
export * from './time-stretch.js';
|
|
10
|
+
export * from './resampler.js';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './ir-mixer.js';
|
|
2
|
+
export * from './processing.js';
|
|
3
|
+
export * from './pcm-ring.js';
|
|
4
|
+
export * from './worklet-clock.js';
|
|
5
|
+
export * from './video-scheduler.js';
|
|
6
|
+
export * from './transferable-pcm-queue.js';
|
|
7
|
+
export * from './transferable-worklet-clock.js';
|
|
8
|
+
export * from './device-state.js';
|
|
9
|
+
export * from './time-stretch.js';
|
|
10
|
+
export * from './resampler.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type RenderIr } from '@aelionsdk/render-ir';
|
|
2
|
+
export interface PcmSourceBlock {
|
|
3
|
+
readonly sampleRate: number;
|
|
4
|
+
readonly channelCount: number;
|
|
5
|
+
readonly frameCount: number;
|
|
6
|
+
readonly interleaved: Float32Array;
|
|
7
|
+
}
|
|
8
|
+
export interface IrPcmSource {
|
|
9
|
+
pcmRange(assetId: string, streamIndex: number, startUs: number, durationUs: number, signal?: AbortSignal): Promise<PcmSourceBlock>;
|
|
10
|
+
}
|
|
11
|
+
export interface RenderIrAudioOptions {
|
|
12
|
+
readonly ir: RenderIr;
|
|
13
|
+
readonly startFrame: number;
|
|
14
|
+
readonly frameCount: number;
|
|
15
|
+
readonly channelCount: number;
|
|
16
|
+
readonly source: IrPcmSource;
|
|
17
|
+
/** Optional stem selection used by waveform, silence removal and sidechain export. */
|
|
18
|
+
readonly trackIds?: readonly string[];
|
|
19
|
+
readonly itemIds?: readonly string[];
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
export declare function renderIrAudio(options: RenderIrAudioOptions): Promise<Float32Array>;
|
|
23
|
+
export interface AvSyncSample {
|
|
24
|
+
readonly videoTimestampUs: number;
|
|
25
|
+
readonly audioFrame: number;
|
|
26
|
+
readonly audioTimestampUs: number;
|
|
27
|
+
readonly driftUs: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function measureAvSync(videoTimestampUs: number, audioFrame: number, sampleRate: number): AvSyncSample;
|
|
30
|
+
//# sourceMappingURL=ir-mixer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ir-mixer.d.ts","sourceRoot":"","sources":["../src/ir-mixer.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,QAAQ,EACd,MAAM,sBAAsB,CAAC;AAI9B,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC;CACpC;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CACN,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAAC;CAC5B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAqID,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,YAAY,CAAC,CAyLxF;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,aAAa,CAC3B,gBAAgB,EAAE,MAAM,EACxB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,YAAY,CAQd"}
|
package/dist/ir-mixer.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { sampleBoundaryUs, throwIfAborted } from '@aelionsdk/core';
|
|
2
|
+
import { evaluateAnimatableNumber, evaluateAudioState, mapIrSourceTime, } from '@aelionsdk/render-ir';
|
|
3
|
+
import { pitchPreservingTimeStretch } from './time-stretch.js';
|
|
4
|
+
function gains(gain, pan) {
|
|
5
|
+
const angle = ((pan + 1) * Math.PI) / 4;
|
|
6
|
+
return [gain * Math.cos(angle), gain * Math.sin(angle)];
|
|
7
|
+
}
|
|
8
|
+
function objectProperty(value) {
|
|
9
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
+
? value
|
|
11
|
+
: {};
|
|
12
|
+
}
|
|
13
|
+
function fadeEnvelope(audio, localTimeUs, durationUs) {
|
|
14
|
+
const fadeInUs = typeof audio.fadeInUs === 'number' ? audio.fadeInUs : 0;
|
|
15
|
+
const fadeOutUs = typeof audio.fadeOutUs === 'number' ? audio.fadeOutUs : 0;
|
|
16
|
+
const fadeIn = fadeInUs > 0 ? Math.max(0, Math.min(1, localTimeUs / fadeInUs)) : 1;
|
|
17
|
+
const remainingUs = durationUs - localTimeUs;
|
|
18
|
+
const fadeOut = fadeOutUs > 0 ? Math.max(0, Math.min(1, remainingUs / fadeOutUs)) : 1;
|
|
19
|
+
return Math.min(fadeIn, fadeOut);
|
|
20
|
+
}
|
|
21
|
+
function interpolatedChannel(block, sourceFrame, nextSourceFrame, fraction, channel) {
|
|
22
|
+
if (channel >= block.channelCount)
|
|
23
|
+
return 0;
|
|
24
|
+
const first = block.interleaved[sourceFrame * block.channelCount + channel] ?? 0;
|
|
25
|
+
const next = block.interleaved[nextSourceFrame * block.channelCount + channel] ?? first;
|
|
26
|
+
return first + (next - first) * fraction;
|
|
27
|
+
}
|
|
28
|
+
function sampleBoundaryCeilUs(sampleIndex, sampleRate) {
|
|
29
|
+
const floorUs = sampleBoundaryUs(sampleIndex, sampleRate);
|
|
30
|
+
const remainder = (BigInt(sampleIndex) * 1000000n) % BigInt(sampleRate);
|
|
31
|
+
const result = floorUs + (remainder === 0n ? 0 : 1);
|
|
32
|
+
if (!Number.isSafeInteger(result)) {
|
|
33
|
+
throw new RangeError('PCM request boundary exceeds the safe integer range');
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function pcmRequestSegments(active, startFrame, frameCount, sampleRate) {
|
|
38
|
+
const source = active.clip.source;
|
|
39
|
+
const activeEndUs = active.sequenceStartUs + active.durationUs;
|
|
40
|
+
const mappedFrames = [];
|
|
41
|
+
for (let outputOffset = 0; outputOffset < frameCount; outputOffset += 1) {
|
|
42
|
+
const sequenceTimeUs = sampleBoundaryUs(startFrame + outputOffset, sampleRate);
|
|
43
|
+
if (sequenceTimeUs < active.sequenceStartUs || sequenceTimeUs >= activeEndUs)
|
|
44
|
+
continue;
|
|
45
|
+
const sourceTimeUs = mapIrSourceTime(source, active.clip.range.durationUs, sequenceTimeUs - active.clip.range.startUs);
|
|
46
|
+
if (sourceTimeUs !== null)
|
|
47
|
+
mappedFrames.push({ outputOffset, sourceTimeUs });
|
|
48
|
+
}
|
|
49
|
+
if (mappedFrames.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const groups = [];
|
|
52
|
+
let current = [];
|
|
53
|
+
let direction = 0;
|
|
54
|
+
let minimum = Number.POSITIVE_INFINITY;
|
|
55
|
+
let maximum = Number.NEGATIVE_INFINITY;
|
|
56
|
+
for (const frame of mappedFrames) {
|
|
57
|
+
const previous = current.at(-1);
|
|
58
|
+
const difference = previous === undefined ? 0 : frame.sourceTimeUs - previous.sourceTimeUs;
|
|
59
|
+
const nextDirection = Math.sign(difference);
|
|
60
|
+
const nextMinimum = Math.min(minimum, frame.sourceTimeUs);
|
|
61
|
+
const nextMaximum = Math.max(maximum, frame.sourceTimeUs);
|
|
62
|
+
const discontinuity = previous !== undefined &&
|
|
63
|
+
((direction !== 0 && nextDirection !== 0 && direction !== nextDirection) ||
|
|
64
|
+
Math.abs(difference) > 250_000 ||
|
|
65
|
+
nextMaximum - nextMinimum > 1_000_000);
|
|
66
|
+
if (discontinuity) {
|
|
67
|
+
groups.push(current);
|
|
68
|
+
current = [];
|
|
69
|
+
direction = 0;
|
|
70
|
+
minimum = Number.POSITIVE_INFINITY;
|
|
71
|
+
maximum = Number.NEGATIVE_INFINITY;
|
|
72
|
+
}
|
|
73
|
+
const groupPrevious = current.at(-1);
|
|
74
|
+
current.push(frame);
|
|
75
|
+
minimum = Math.min(minimum, frame.sourceTimeUs);
|
|
76
|
+
maximum = Math.max(maximum, frame.sourceTimeUs);
|
|
77
|
+
if (groupPrevious !== undefined) {
|
|
78
|
+
const groupDirection = Math.sign(frame.sourceTimeUs - groupPrevious.sourceTimeUs);
|
|
79
|
+
if (groupDirection !== 0)
|
|
80
|
+
direction = groupDirection;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (current.length > 0)
|
|
84
|
+
groups.push(current);
|
|
85
|
+
const sourceRangeEndUs = source.sourceRange.startUs + source.sourceRange.durationUs;
|
|
86
|
+
const sampleDurationUs = sampleBoundaryCeilUs(1, sampleRate);
|
|
87
|
+
const segments = [];
|
|
88
|
+
for (const frames of groups) {
|
|
89
|
+
const times = frames.map(frame => frame.sourceTimeUs);
|
|
90
|
+
const sourceStartUs = Math.min(...times);
|
|
91
|
+
const sourceEndUs = Math.max(...times);
|
|
92
|
+
const durationUs = Math.max(1, Math.min(sourceRangeEndUs - sourceStartUs, sourceEndUs - sourceStartUs + sampleDurationUs));
|
|
93
|
+
segments.push({ sourceStartUs, durationUs, frames });
|
|
94
|
+
}
|
|
95
|
+
return segments;
|
|
96
|
+
}
|
|
97
|
+
export async function renderIrAudio(options) {
|
|
98
|
+
throwIfAborted(options.signal, 'Render IR audio');
|
|
99
|
+
if (!Number.isSafeInteger(options.startFrame) || options.startFrame < 0) {
|
|
100
|
+
throw new RangeError('startFrame must be a non-negative safe integer');
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isSafeInteger(options.frameCount) || options.frameCount <= 0) {
|
|
103
|
+
throw new RangeError('frameCount must be a positive safe integer');
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isSafeInteger(options.channelCount) ||
|
|
106
|
+
options.channelCount < 1 ||
|
|
107
|
+
options.channelCount > 8) {
|
|
108
|
+
throw new RangeError('Audio mixer supports between 1 and 8 output channels');
|
|
109
|
+
}
|
|
110
|
+
const startUs = sampleBoundaryUs(options.startFrame, options.ir.sampleRate);
|
|
111
|
+
// A microsecond range whose end is floored can be one source sample short
|
|
112
|
+
// whenever the target frame boundary is fractional in microseconds (for
|
|
113
|
+
// example most 48 kHz block boundaries). Cover the complete final target
|
|
114
|
+
// sample; per-segment copy bounds below discard any provider over-read.
|
|
115
|
+
const endUs = sampleBoundaryCeilUs(options.startFrame + options.frameCount, options.ir.sampleRate);
|
|
116
|
+
const selectedTracks = options.trackIds === undefined ? undefined : new Set(options.trackIds);
|
|
117
|
+
const selectedItems = options.itemIds === undefined ? undefined : new Set(options.itemIds);
|
|
118
|
+
const evaluatedState = evaluateAudioState(options.ir, startUs, endUs - startUs);
|
|
119
|
+
const state = {
|
|
120
|
+
...evaluatedState,
|
|
121
|
+
clips: evaluatedState.clips.filter(active => (selectedTracks === undefined || selectedTracks.has(active.clip.trackId)) &&
|
|
122
|
+
(selectedItems === undefined || selectedItems.has(active.clip.id))),
|
|
123
|
+
};
|
|
124
|
+
const output = new Float32Array(options.frameCount * options.channelCount);
|
|
125
|
+
await Promise.all(state.clips.map(async (active) => {
|
|
126
|
+
const trackAudio = objectProperty(active.trackAudio);
|
|
127
|
+
const clipAudio = objectProperty(active.clip.audio);
|
|
128
|
+
for (const segment of pcmRequestSegments(active, options.startFrame, options.frameCount, options.ir.sampleRate)) {
|
|
129
|
+
const block = await options.source.pcmRange(active.clip.source.assetId, active.clip.source.streamIndex, segment.sourceStartUs, segment.durationUs, options.signal);
|
|
130
|
+
throwIfAborted(options.signal, 'Render IR audio');
|
|
131
|
+
if (!Number.isSafeInteger(block.sampleRate) || block.sampleRate <= 0) {
|
|
132
|
+
throw new RangeError('PCM source returned an invalid sample rate');
|
|
133
|
+
}
|
|
134
|
+
if (!Number.isSafeInteger(block.channelCount) ||
|
|
135
|
+
block.channelCount < 1 ||
|
|
136
|
+
block.channelCount > 8) {
|
|
137
|
+
throw new RangeError('PCM source returned an invalid channel count');
|
|
138
|
+
}
|
|
139
|
+
if (block.interleaved.length !== block.frameCount * block.channelCount) {
|
|
140
|
+
throw new RangeError('PCM source returned an inconsistent interleaved block');
|
|
141
|
+
}
|
|
142
|
+
if (block.frameCount < 1)
|
|
143
|
+
continue;
|
|
144
|
+
const linearMapping = active.clip.source.timeMapping?.type === 'linear'
|
|
145
|
+
? active.clip.source.timeMapping
|
|
146
|
+
: undefined;
|
|
147
|
+
const preservePitch = clipAudio.pitchPolicy === 'preserve' &&
|
|
148
|
+
linearMapping !== undefined &&
|
|
149
|
+
linearMapping.rate.numerator !== linearMapping.rate.denominator;
|
|
150
|
+
const stretched = preservePitch
|
|
151
|
+
? pitchPreservingTimeStretch({
|
|
152
|
+
input: block.interleaved,
|
|
153
|
+
inputFrames: block.frameCount,
|
|
154
|
+
outputFrames: segment.frames.length,
|
|
155
|
+
channelCount: block.channelCount,
|
|
156
|
+
reverse: linearMapping.reverse,
|
|
157
|
+
})
|
|
158
|
+
: undefined;
|
|
159
|
+
for (const [segmentFrame, mapped] of segment.frames.entries()) {
|
|
160
|
+
const targetFrame = options.startFrame + mapped.outputOffset;
|
|
161
|
+
const sequenceTimeUs = sampleBoundaryUs(targetFrame, options.ir.sampleRate);
|
|
162
|
+
const gainDb = evaluateAnimatableNumber(trackAudio.gainDb, sequenceTimeUs, 0, 0) +
|
|
163
|
+
evaluateAnimatableNumber(clipAudio.gainDb, sequenceTimeUs, active.clip.range.startUs, 0);
|
|
164
|
+
const pan = Math.max(-1, Math.min(1, evaluateAnimatableNumber(trackAudio.pan, sequenceTimeUs, 0, 0) +
|
|
165
|
+
evaluateAnimatableNumber(clipAudio.pan, sequenceTimeUs, active.clip.range.startUs, 0)));
|
|
166
|
+
const envelope = fadeEnvelope(clipAudio, sequenceTimeUs - active.clip.range.startUs, active.clip.range.durationUs);
|
|
167
|
+
const [leftGain, rightGain] = gains(10 ** (gainDb / 20) * envelope, pan);
|
|
168
|
+
const masterGain = 10 ** (gainDb / 20) * envelope;
|
|
169
|
+
const sourcePosition = ((mapped.sourceTimeUs - segment.sourceStartUs) * block.sampleRate) / 1_000_000;
|
|
170
|
+
const sourceFrame = Math.max(0, Math.min(block.frameCount - 1, Math.floor(sourcePosition)));
|
|
171
|
+
const nextSourceFrame = Math.min(block.frameCount - 1, sourceFrame + 1);
|
|
172
|
+
const fraction = Math.max(0, Math.min(1, sourcePosition - sourceFrame));
|
|
173
|
+
const channelSample = (channel) => stretched === undefined
|
|
174
|
+
? interpolatedChannel(block, sourceFrame, nextSourceFrame, fraction, channel)
|
|
175
|
+
: (stretched[segmentFrame * block.channelCount + channel] ?? 0);
|
|
176
|
+
const sourceLeft = channelSample(0);
|
|
177
|
+
const sourceRight = block.channelCount === 1 ? sourceLeft : channelSample(1);
|
|
178
|
+
const target = mapped.outputOffset * options.channelCount;
|
|
179
|
+
const channelMap = Array.isArray(clipAudio.channelMap) ? clipAudio.channelMap : undefined;
|
|
180
|
+
if (channelMap !== undefined) {
|
|
181
|
+
if (channelMap.length !== options.channelCount ||
|
|
182
|
+
channelMap.some(row => !Array.isArray(row) ||
|
|
183
|
+
row.length !== block.channelCount ||
|
|
184
|
+
row.some(value => typeof value !== 'number' || !Number.isFinite(value)))) {
|
|
185
|
+
throw new RangeError(`Audio channelMap for ${active.clip.id} must be outputChannels × sourceChannels`);
|
|
186
|
+
}
|
|
187
|
+
for (let outputChannel = 0; outputChannel < options.channelCount; outputChannel += 1) {
|
|
188
|
+
const row = channelMap[outputChannel];
|
|
189
|
+
let sample = 0;
|
|
190
|
+
for (let inputChannel = 0; inputChannel < block.channelCount; inputChannel += 1) {
|
|
191
|
+
sample += channelSample(inputChannel) * (row[inputChannel] ?? 0);
|
|
192
|
+
}
|
|
193
|
+
const channelGain = outputChannel === 0 ? leftGain : outputChannel === 1 ? rightGain : masterGain;
|
|
194
|
+
output[target + outputChannel] =
|
|
195
|
+
(output[target + outputChannel] ?? 0) + sample * channelGain;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (options.channelCount === 1) {
|
|
199
|
+
output[target] =
|
|
200
|
+
(output[target] ?? 0) +
|
|
201
|
+
(sourceLeft * leftGain + sourceRight * rightGain) / Math.SQRT2;
|
|
202
|
+
}
|
|
203
|
+
else if (options.channelCount === 2) {
|
|
204
|
+
output[target] = (output[target] ?? 0) + sourceLeft * leftGain;
|
|
205
|
+
output[target + 1] = (output[target + 1] ?? 0) + sourceRight * rightGain;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
for (let channel = 0; channel < options.channelCount; channel += 1) {
|
|
209
|
+
const sourceSample = channel < block.channelCount
|
|
210
|
+
? channelSample(channel)
|
|
211
|
+
: channel < 2
|
|
212
|
+
? sourceLeft
|
|
213
|
+
: 0;
|
|
214
|
+
const channelGain = channel === 0 ? leftGain : channel === 1 ? rightGain : masterGain;
|
|
215
|
+
output[target + channel] =
|
|
216
|
+
(output[target + channel] ?? 0) + sourceSample * channelGain;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}));
|
|
222
|
+
for (let index = 0; index < output.length; index += 1) {
|
|
223
|
+
output[index] = Math.max(-1, Math.min(1, output[index] ?? 0));
|
|
224
|
+
}
|
|
225
|
+
return output;
|
|
226
|
+
}
|
|
227
|
+
export function measureAvSync(videoTimestampUs, audioFrame, sampleRate) {
|
|
228
|
+
const audioTimestampUs = sampleBoundaryUs(audioFrame, sampleRate);
|
|
229
|
+
return {
|
|
230
|
+
videoTimestampUs,
|
|
231
|
+
audioFrame,
|
|
232
|
+
audioTimestampUs,
|
|
233
|
+
driftUs: videoTimestampUs - audioTimestampUs,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pcm-message-player.worklet.d.ts","sourceRoot":"","sources":["../src/pcm-message-player.worklet.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
class AelionMessagePcmPlayerProcessor extends AudioWorkletProcessor {
|
|
3
|
+
#channelCount;
|
|
4
|
+
#reportEveryFrames;
|
|
5
|
+
#queue = [];
|
|
6
|
+
#generation;
|
|
7
|
+
#lastReportFrame = 0;
|
|
8
|
+
#playedFrames = 0;
|
|
9
|
+
#underrunFrames = 0;
|
|
10
|
+
#closed = false;
|
|
11
|
+
#playing = false;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
super(options);
|
|
14
|
+
const processorOptions = options?.processorOptions;
|
|
15
|
+
if (processorOptions === undefined) {
|
|
16
|
+
throw new TypeError('Aelion message PCM processor requires options');
|
|
17
|
+
}
|
|
18
|
+
this.#channelCount = processorOptions.channelCount;
|
|
19
|
+
this.#generation = processorOptions.generation;
|
|
20
|
+
this.#reportEveryFrames = processorOptions.reportEveryFrames;
|
|
21
|
+
this.port.addEventListener('message', this.#onMessage);
|
|
22
|
+
this.port.start();
|
|
23
|
+
this.port.postMessage({ type: 'ready', sampleRate });
|
|
24
|
+
}
|
|
25
|
+
process(inputs, outputs, parameters) {
|
|
26
|
+
void inputs;
|
|
27
|
+
void parameters;
|
|
28
|
+
const output = outputs[0];
|
|
29
|
+
if (output === undefined)
|
|
30
|
+
return !this.#closed;
|
|
31
|
+
for (const plane of output)
|
|
32
|
+
plane.fill(0);
|
|
33
|
+
if (!this.#playing)
|
|
34
|
+
return !this.#closed;
|
|
35
|
+
const requestedFrames = output[0]?.length ?? 0;
|
|
36
|
+
let writtenFrames = 0;
|
|
37
|
+
while (writtenFrames < requestedFrames) {
|
|
38
|
+
const block = this.#queue[0];
|
|
39
|
+
if (block === undefined)
|
|
40
|
+
break;
|
|
41
|
+
if (block.generation !== this.#generation) {
|
|
42
|
+
this.#queue.shift();
|
|
43
|
+
this.port.postMessage({ type: 'ack', id: block.id, generation: block.generation });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const available = block.frameCount - block.offsetFrames;
|
|
47
|
+
const frames = Math.min(available, requestedFrames - writtenFrames);
|
|
48
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
49
|
+
for (let channel = 0; channel < this.#channelCount; channel += 1) {
|
|
50
|
+
const plane = output[channel];
|
|
51
|
+
if (plane !== undefined) {
|
|
52
|
+
plane[writtenFrames + frame] =
|
|
53
|
+
block.samples[(block.offsetFrames + frame) * block.channelCount + channel] ?? 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
block.offsetFrames += frames;
|
|
58
|
+
writtenFrames += frames;
|
|
59
|
+
if (block.offsetFrames >= block.frameCount) {
|
|
60
|
+
this.#queue.shift();
|
|
61
|
+
this.port.postMessage({ type: 'ack', id: block.id, generation: block.generation });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
this.#playedFrames += requestedFrames;
|
|
65
|
+
this.#underrunFrames += requestedFrames - writtenFrames;
|
|
66
|
+
if (currentFrame - this.#lastReportFrame >= this.#reportEveryFrames) {
|
|
67
|
+
this.#lastReportFrame = currentFrame;
|
|
68
|
+
this.port.postMessage({
|
|
69
|
+
type: 'clock',
|
|
70
|
+
currentFrame,
|
|
71
|
+
currentTime,
|
|
72
|
+
generation: this.#generation,
|
|
73
|
+
playedFrames: this.#playedFrames,
|
|
74
|
+
underrunFrames: this.#underrunFrames,
|
|
75
|
+
queuedBlocks: this.#queue.length,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return !this.#closed;
|
|
79
|
+
}
|
|
80
|
+
#onMessage = (event) => {
|
|
81
|
+
const value = event.data;
|
|
82
|
+
if (value === null || typeof value !== 'object')
|
|
83
|
+
return;
|
|
84
|
+
const type = Reflect.get(value, 'type');
|
|
85
|
+
if (type === 'block') {
|
|
86
|
+
const block = value;
|
|
87
|
+
if (block.generation !== this.#generation) {
|
|
88
|
+
this.port.postMessage({ type: 'ack', id: block.id, generation: block.generation });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.#queue.push({ ...block, offsetFrames: 0 });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (type === 'start') {
|
|
95
|
+
this.#playing = true;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (type === 'seek') {
|
|
99
|
+
for (const block of this.#queue) {
|
|
100
|
+
this.port.postMessage({ type: 'ack', id: block.id, generation: block.generation });
|
|
101
|
+
}
|
|
102
|
+
this.#queue.length = 0;
|
|
103
|
+
const generation = Reflect.get(value, 'generation');
|
|
104
|
+
if (typeof generation !== 'number' || !Number.isSafeInteger(generation))
|
|
105
|
+
return;
|
|
106
|
+
this.#generation = generation;
|
|
107
|
+
this.#playedFrames = 0;
|
|
108
|
+
this.#underrunFrames = 0;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (type === 'close') {
|
|
112
|
+
this.#playing = false;
|
|
113
|
+
this.#closed = true;
|
|
114
|
+
this.#queue.length = 0;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
registerProcessor('aelion-message-pcm-player', AelionMessagePcmPlayerProcessor);
|
|
119
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pcm-player.worklet.d.ts","sourceRoot":"","sources":["../src/pcm-player.worklet.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { SharedPcmRingBuffer } from './pcm-ring.js';
|
|
3
|
+
class AelionPcmPlayerProcessor extends AudioWorkletProcessor {
|
|
4
|
+
#ring;
|
|
5
|
+
#reportEveryFrames;
|
|
6
|
+
#lastReportFrame = 0;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
super(options);
|
|
9
|
+
const processorOptions = options?.processorOptions;
|
|
10
|
+
if (processorOptions === undefined) {
|
|
11
|
+
throw new TypeError('Aelion PCM processor requires a ring descriptor');
|
|
12
|
+
}
|
|
13
|
+
this.#ring = new SharedPcmRingBuffer(processorOptions.ring);
|
|
14
|
+
this.#reportEveryFrames = processorOptions.reportEveryFrames ?? sampleRate;
|
|
15
|
+
this.port.postMessage({ type: 'ready', sampleRate });
|
|
16
|
+
}
|
|
17
|
+
process(inputs, outputs, parameters) {
|
|
18
|
+
void inputs;
|
|
19
|
+
void parameters;
|
|
20
|
+
const output = outputs[0];
|
|
21
|
+
if (output === undefined)
|
|
22
|
+
return true;
|
|
23
|
+
this.#ring.readPlanar(output);
|
|
24
|
+
if (currentFrame - this.#lastReportFrame >= this.#reportEveryFrames) {
|
|
25
|
+
this.#lastReportFrame = currentFrame;
|
|
26
|
+
this.port.postMessage({
|
|
27
|
+
type: 'clock',
|
|
28
|
+
currentFrame,
|
|
29
|
+
currentTime,
|
|
30
|
+
snapshot: this.#ring.snapshot(),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return this.#ring.snapshot().state !== 'closed';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
registerProcessor('aelion-pcm-player', AelionPcmPlayerProcessor);
|