@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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/device-state.d.ts +28 -0
  4. package/dist/device-state.d.ts.map +1 -0
  5. package/dist/device-state.js +119 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +10 -0
  9. package/dist/ir-mixer.d.ts +30 -0
  10. package/dist/ir-mixer.d.ts.map +1 -0
  11. package/dist/ir-mixer.js +235 -0
  12. package/dist/pcm-message-player.worklet.d.ts +2 -0
  13. package/dist/pcm-message-player.worklet.d.ts.map +1 -0
  14. package/dist/pcm-message-player.worklet.js +119 -0
  15. package/dist/pcm-player.worklet.d.ts +2 -0
  16. package/dist/pcm-player.worklet.d.ts.map +1 -0
  17. package/dist/pcm-player.worklet.js +36 -0
  18. package/dist/pcm-ring.d.ts +39 -0
  19. package/dist/pcm-ring.d.ts.map +1 -0
  20. package/dist/pcm-ring.js +174 -0
  21. package/dist/processing.d.ts +106 -0
  22. package/dist/processing.d.ts.map +1 -0
  23. package/dist/processing.js +386 -0
  24. package/dist/resampler.d.ts +22 -0
  25. package/dist/resampler.d.ts.map +1 -0
  26. package/dist/resampler.js +99 -0
  27. package/dist/time-stretch.d.ts +30 -0
  28. package/dist/time-stretch.d.ts.map +1 -0
  29. package/dist/time-stretch.js +180 -0
  30. package/dist/transferable-pcm-queue.d.ts +30 -0
  31. package/dist/transferable-pcm-queue.d.ts.map +1 -0
  32. package/dist/transferable-pcm-queue.js +73 -0
  33. package/dist/transferable-worklet-clock.d.ts +51 -0
  34. package/dist/transferable-worklet-clock.d.ts.map +1 -0
  35. package/dist/transferable-worklet-clock.js +185 -0
  36. package/dist/video-scheduler.d.ts +39 -0
  37. package/dist/video-scheduler.d.ts.map +1 -0
  38. package/dist/video-scheduler.js +111 -0
  39. package/dist/worklet-clock.d.ts +55 -0
  40. package/dist/worklet-clock.d.ts.map +1 -0
  41. package/dist/worklet-clock.js +193 -0
  42. package/package.json +44 -0
@@ -0,0 +1,111 @@
1
+ import { frameIndexAtTime, frameStartUs } from '@aelionsdk/core';
2
+ export class AudioDrivenVideoScheduler {
3
+ #clock;
4
+ #frameRate;
5
+ #durationUs;
6
+ #onFrame;
7
+ #onEnd;
8
+ #onError;
9
+ #schedule;
10
+ #cancel;
11
+ #handle;
12
+ #lastFrameIndex = -1;
13
+ #generation = 0;
14
+ #disposed = false;
15
+ #rendering = false;
16
+ #ended = false;
17
+ constructor(options) {
18
+ if (!Number.isSafeInteger(options.durationUs) || options.durationUs <= 0) {
19
+ throw new RangeError('Video scheduler duration must be a positive safe integer');
20
+ }
21
+ this.#clock = options.clock;
22
+ this.#frameRate = options.frameRate;
23
+ this.#durationUs = options.durationUs;
24
+ this.#onFrame = options.onFrame;
25
+ this.#onEnd = options.onEnd;
26
+ this.#onError = options.onError;
27
+ this.#schedule = options.schedule ?? (callback => globalThis.requestAnimationFrame(callback));
28
+ this.#cancel = options.cancel ?? (handle => globalThis.cancelAnimationFrame(handle));
29
+ }
30
+ get generation() {
31
+ return this.#generation;
32
+ }
33
+ get disposed() {
34
+ return this.#disposed;
35
+ }
36
+ snapshot() {
37
+ return {
38
+ disposed: this.#disposed,
39
+ scheduled: this.#handle !== undefined,
40
+ rendering: this.#rendering,
41
+ ended: this.#ended,
42
+ generation: this.#generation,
43
+ };
44
+ }
45
+ start() {
46
+ if (this.#disposed)
47
+ throw new ReferenceError('AudioDrivenVideoScheduler is disposed');
48
+ if (this.#handle !== undefined)
49
+ return;
50
+ this.#ended = false;
51
+ this.#handle = this.#schedule(this.#tick);
52
+ }
53
+ pause() {
54
+ if (this.#handle === undefined)
55
+ return;
56
+ this.#cancel(this.#handle);
57
+ this.#handle = undefined;
58
+ }
59
+ seek() {
60
+ this.#generation += 1;
61
+ this.#lastFrameIndex = -1;
62
+ this.#ended = false;
63
+ return this.#generation;
64
+ }
65
+ dispose() {
66
+ if (this.#disposed)
67
+ return;
68
+ this.pause();
69
+ this.#disposed = true;
70
+ this.#generation += 1;
71
+ }
72
+ #tick = () => {
73
+ this.#handle = undefined;
74
+ if (this.#disposed)
75
+ return;
76
+ const clockTimeUs = Math.max(0, this.#clock.nowUs());
77
+ if (clockTimeUs >= this.#durationUs) {
78
+ this.pause();
79
+ if (!this.#ended) {
80
+ this.#ended = true;
81
+ this.#onEnd?.();
82
+ }
83
+ return;
84
+ }
85
+ const timeUs = Math.min(this.#durationUs - 1, clockTimeUs);
86
+ const frameIndex = frameIndexAtTime(timeUs, this.#frameRate);
87
+ if (!this.#rendering && frameIndex !== this.#lastFrameIndex) {
88
+ const generation = this.#generation;
89
+ const droppedFrames = Math.max(0, frameIndex - this.#lastFrameIndex - 1);
90
+ this.#lastFrameIndex = frameIndex;
91
+ this.#rendering = true;
92
+ void Promise.resolve(this.#onFrame({
93
+ generation,
94
+ frameIndex,
95
+ timestampUs: frameStartUs(frameIndex, this.#frameRate),
96
+ droppedFrames,
97
+ })).then(() => {
98
+ this.#rendering = false;
99
+ }, (error) => {
100
+ this.#rendering = false;
101
+ try {
102
+ this.#onError?.(error);
103
+ }
104
+ catch {
105
+ // Error observers cannot create an unhandled scheduler rejection.
106
+ }
107
+ });
108
+ }
109
+ this.#handle = this.#schedule(this.#tick);
110
+ };
111
+ }
@@ -0,0 +1,55 @@
1
+ import type { Disposable } from '@aelionsdk/core';
2
+ import { SharedPcmRingBuffer, type PcmRingSnapshot } from './pcm-ring.js';
3
+ export interface AudioClockReport {
4
+ readonly currentFrame: number;
5
+ readonly currentTime: number;
6
+ readonly snapshot: PcmRingSnapshot;
7
+ }
8
+ export type AudioClockEvent = {
9
+ readonly type: 'started';
10
+ readonly timeUs: number;
11
+ } | {
12
+ readonly type: 'paused';
13
+ readonly timeUs: number;
14
+ } | {
15
+ readonly type: 'interrupted';
16
+ readonly timeUs: number;
17
+ } | {
18
+ readonly type: 'resumed';
19
+ readonly timeUs: number;
20
+ } | {
21
+ readonly type: 'seeked';
22
+ readonly timeUs: number;
23
+ readonly generation: number;
24
+ };
25
+ export interface AudioClockOptions {
26
+ readonly context?: AudioContext;
27
+ /** Host-resolved module URL for non-Vite or CDN deployments. */
28
+ readonly moduleUrl?: string | URL;
29
+ /** Requested hardware context rate when the clock owns its AudioContext. */
30
+ readonly sampleRate?: number;
31
+ readonly capacityFrames?: number;
32
+ readonly channelCount?: number;
33
+ readonly reportEveryFrames?: number;
34
+ }
35
+ export type AudioContextRuntimeState = AudioContextState | 'interrupted';
36
+ export declare function audioContextStateTransition(previous: AudioContextRuntimeState, current: AudioContextRuntimeState): 'interrupted' | 'resumed' | undefined;
37
+ export declare class AudioWorkletClock implements Disposable {
38
+ #private;
39
+ readonly context: AudioContext;
40
+ readonly ring: SharedPcmRingBuffer;
41
+ constructor(options?: AudioClockOptions);
42
+ get disposed(): boolean;
43
+ get ownsContext(): boolean;
44
+ get lastReport(): AudioClockReport | undefined;
45
+ get generation(): number;
46
+ initialize(reportEveryFrames?: number): Promise<void>;
47
+ start(): Promise<void>;
48
+ pause(): Promise<void>;
49
+ resume(): Promise<void>;
50
+ subscribe(listener: (event: AudioClockEvent) => void): () => void;
51
+ resetForSeek(timeUs?: number): number;
52
+ nowUs(): number;
53
+ dispose(): Promise<void>;
54
+ }
55
+ //# sourceMappingURL=worklet-clock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worklet-clock.d.ts","sourceRoot":"","sources":["../src/worklet-clock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD,OAAO,EAAE,mBAAmB,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAE1E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;CACpC;AAED,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrD;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACzD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrD;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,CAAC;IAChC,gEAAgE;IAChE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACrC;AAED,MAAM,MAAM,wBAAwB,GAAG,iBAAiB,GAAG,aAAa,CAAC;AAEzE,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,wBAAwB,EAClC,OAAO,EAAE,wBAAwB,GAChC,aAAa,GAAG,SAAS,GAAG,SAAS,CAIvC;AAoBD,qBAAa,iBAAkB,YAAW,UAAU;;IAClD,SAAgB,OAAO,EAAE,YAAY,CAAC;IACtC,SAAgB,IAAI,EAAE,mBAAmB,CAAC;gBAuBvB,OAAO,GAAE,iBAAsB;IAkBlD,IAAW,QAAQ,IAAI,OAAO,CAE7B;IAED,IAAW,WAAW,IAAI,OAAO,CAEhC;IAED,IAAW,UAAU,IAAI,gBAAgB,GAAG,SAAS,CAEpD;IAED,IAAW,UAAU,IAAI,MAAM,CAE9B;IAEY,UAAU,CAAC,iBAAiB,SAA0B,GAAG,OAAO,CAAC,IAAI,CAAC;IAsDtE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAKtB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAO7B,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,GAAG,MAAM,IAAI;IAKjE,YAAY,CAAC,MAAM,SAAI,GAAG,MAAM;IAYhC,KAAK,IAAI,MAAM;IAQf,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CA+BhC"}
@@ -0,0 +1,193 @@
1
+ import { SharedPcmRingBuffer } from './pcm-ring.js';
2
+ export function audioContextStateTransition(previous, current) {
3
+ if (current === 'interrupted' && previous !== 'interrupted')
4
+ return 'interrupted';
5
+ if (previous === 'interrupted' && current === 'running')
6
+ return 'resumed';
7
+ return undefined;
8
+ }
9
+ async function withTimeout(promise, timeoutMs, operation) {
10
+ let timeoutId;
11
+ const timeout = new Promise((_resolve, reject) => {
12
+ timeoutId = globalThis.setTimeout(() => {
13
+ reject(new Error(`${operation} timed out after ${timeoutMs} ms`));
14
+ }, timeoutMs);
15
+ });
16
+ try {
17
+ return await Promise.race([promise, timeout]);
18
+ }
19
+ finally {
20
+ if (timeoutId !== undefined)
21
+ globalThis.clearTimeout(timeoutId);
22
+ }
23
+ }
24
+ export class AudioWorkletClock {
25
+ context;
26
+ ring;
27
+ #node;
28
+ #initializeTask;
29
+ #disposeTask;
30
+ #ownsContext;
31
+ #disposed = false;
32
+ #lifecycleGeneration = 0;
33
+ #lastReport;
34
+ #contextOriginTime;
35
+ #timelineOriginUs = 0;
36
+ #generation = 0;
37
+ #lastContextState;
38
+ #listeners = new Set();
39
+ #moduleUrl;
40
+ #onStateChange = () => {
41
+ if (this.#disposed)
42
+ return;
43
+ const current = this.context.state;
44
+ const transition = audioContextStateTransition(this.#lastContextState, current);
45
+ this.#lastContextState = current;
46
+ if (transition !== undefined)
47
+ this.#emit(transition);
48
+ };
49
+ constructor(options = {}) {
50
+ this.#moduleUrl = options.moduleUrl ?? new URL('./pcm-player.worklet.js', import.meta.url);
51
+ this.context =
52
+ options.context ??
53
+ new AudioContext({
54
+ latencyHint: 'interactive',
55
+ sampleRate: options.sampleRate ?? 48_000,
56
+ });
57
+ this.#ownsContext = options.context === undefined;
58
+ this.#lastContextState = this.context.state;
59
+ this.ring = SharedPcmRingBuffer.allocate(options.capacityFrames ?? this.context.sampleRate * 2, options.channelCount ?? 2, this.context.sampleRate);
60
+ this.context.addEventListener('statechange', this.#onStateChange);
61
+ }
62
+ get disposed() {
63
+ return this.#disposed;
64
+ }
65
+ get ownsContext() {
66
+ return this.#ownsContext;
67
+ }
68
+ get lastReport() {
69
+ return this.#lastReport;
70
+ }
71
+ get generation() {
72
+ return this.#generation;
73
+ }
74
+ async initialize(reportEveryFrames = this.context.sampleRate) {
75
+ if (this.#disposed)
76
+ throw new ReferenceError('AudioWorkletClock is disposed');
77
+ if (this.#node !== undefined)
78
+ return;
79
+ const existing = this.#initializeTask;
80
+ if (existing !== undefined)
81
+ return existing;
82
+ const generation = this.#lifecycleGeneration;
83
+ const task = this.#initialize(reportEveryFrames, generation).finally(() => {
84
+ if (this.#initializeTask === task)
85
+ this.#initializeTask = undefined;
86
+ });
87
+ this.#initializeTask = task;
88
+ return task;
89
+ }
90
+ async #initialize(reportEveryFrames, generation) {
91
+ await withTimeout(this.context.audioWorklet.addModule(this.#moduleUrl), 5_000, 'AudioWorklet module initialization');
92
+ this.#throwIfInitializationStale(generation);
93
+ const node = new AudioWorkletNode(this.context, 'aelion-pcm-player', {
94
+ numberOfInputs: 0,
95
+ numberOfOutputs: 1,
96
+ outputChannelCount: [this.ring.channelCount],
97
+ processorOptions: {
98
+ ring: this.ring.descriptor(),
99
+ reportEveryFrames,
100
+ },
101
+ });
102
+ let connected = false;
103
+ try {
104
+ node.port.addEventListener('message', event => {
105
+ const value = event.data;
106
+ if (value !== null && typeof value === 'object' && Reflect.get(value, 'type') === 'clock') {
107
+ this.#lastReport = value;
108
+ }
109
+ });
110
+ node.port.start();
111
+ // AudioContext.currentTime starts when the context starts running, which can
112
+ // be well before the worklet module has loaded. The transport clock must
113
+ // instead start when this playback node joins the audio graph; otherwise
114
+ // module-loading latency appears as permanent A/V drift.
115
+ node.connect(this.context.destination);
116
+ connected = true;
117
+ this.#throwIfInitializationStale(generation);
118
+ this.#contextOriginTime = this.context.currentTime;
119
+ this.#node = node;
120
+ }
121
+ catch (error) {
122
+ if (connected)
123
+ node.disconnect();
124
+ node.port.close();
125
+ throw error;
126
+ }
127
+ }
128
+ async start() {
129
+ await this.initialize();
130
+ await withTimeout(this.context.resume(), 5_000, 'AudioContext resume');
131
+ this.#emit('started');
132
+ }
133
+ async pause() {
134
+ if (this.context.state === 'running')
135
+ await this.context.suspend();
136
+ this.#emit('paused');
137
+ }
138
+ async resume() {
139
+ if (this.context.state !== 'running') {
140
+ await withTimeout(this.context.resume(), 5_000, 'AudioContext resume');
141
+ }
142
+ this.#emit('resumed');
143
+ }
144
+ subscribe(listener) {
145
+ this.#listeners.add(listener);
146
+ return () => this.#listeners.delete(listener);
147
+ }
148
+ resetForSeek(timeUs = 0) {
149
+ if (!Number.isSafeInteger(timeUs) || timeUs < 0) {
150
+ throw new RangeError('Seek target must be a non-negative safe integer microsecond value');
151
+ }
152
+ this.ring.flush();
153
+ this.#timelineOriginUs = timeUs;
154
+ this.#contextOriginTime = this.context.currentTime;
155
+ this.#generation += 1;
156
+ this.#emit('seeked');
157
+ return this.#generation;
158
+ }
159
+ nowUs() {
160
+ const origin = this.#contextOriginTime ?? this.context.currentTime;
161
+ return (this.#timelineOriginUs +
162
+ Math.max(0, Math.round((this.context.currentTime - origin) * 1_000_000)));
163
+ }
164
+ dispose() {
165
+ this.#disposeTask ??= this.#dispose();
166
+ return this.#disposeTask;
167
+ }
168
+ async #dispose() {
169
+ this.#disposed = true;
170
+ this.#lifecycleGeneration += 1;
171
+ this.context.removeEventListener('statechange', this.#onStateChange);
172
+ this.ring.close();
173
+ this.#node?.disconnect();
174
+ this.#node?.port.close();
175
+ this.#node = undefined;
176
+ if (this.#ownsContext && this.context.state !== 'closed')
177
+ await this.context.close();
178
+ await this.#initializeTask?.catch(() => undefined);
179
+ this.#listeners.clear();
180
+ }
181
+ #throwIfInitializationStale(generation) {
182
+ if (this.#disposed || generation !== this.#lifecycleGeneration) {
183
+ throw new DOMException('AudioWorkletClock initialization became stale', 'AbortError');
184
+ }
185
+ }
186
+ #emit(type) {
187
+ const event = type === 'seeked'
188
+ ? { type, timeUs: this.nowUs(), generation: this.#generation }
189
+ : { type, timeUs: this.nowUs() };
190
+ for (const listener of this.#listeners)
191
+ listener(event);
192
+ }
193
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@aelionsdk/audio",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "AudioWorklet clock, PCM buffering and audio mixing for AelionSDK",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FoyonaCZY/AelionSDK.git",
9
+ "directory": "packages/audio"
10
+ },
11
+ "keywords": [
12
+ "aelion",
13
+ "audio",
14
+ "audioworklet",
15
+ "video"
16
+ ],
17
+ "sideEffects": false,
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "!dist/.tsbuildinfo"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20.19"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "dependencies": {
37
+ "@aelionsdk/core": "0.1.0-beta.1",
38
+ "@aelionsdk/render-ir": "0.1.0-beta.1"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -b",
42
+ "typecheck": "tsc -b --pretty false"
43
+ }
44
+ }