@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,180 @@
1
+ function positiveInteger(value, name) {
2
+ if (!Number.isSafeInteger(value) || value <= 0) {
3
+ throw new RangeError(`${name} must be a positive safe integer`);
4
+ }
5
+ }
6
+ function concat(left, right) {
7
+ if (left.length === 0)
8
+ return right.slice();
9
+ if (right.length === 0)
10
+ return left;
11
+ const result = new Float32Array(left.length + right.length);
12
+ result.set(left);
13
+ result.set(right, left.length);
14
+ return result;
15
+ }
16
+ /**
17
+ * Stateful deterministic synchronous overlap-add time stretch.
18
+ *
19
+ * Input can arrive in arbitrary chunks. Grains and correlation searches stay
20
+ * anchored to the complete stream, and only samples that can no longer be
21
+ * affected by a later grain are emitted. This makes adjacent offline mixer
22
+ * blocks continuous without retaining the whole source or output.
23
+ */
24
+ export class StreamingPitchPreservingTimeStretch {
25
+ #inputFrames;
26
+ #outputFrames;
27
+ #channelCount;
28
+ #grainFrames;
29
+ #synthesisHop;
30
+ #searchRadius;
31
+ #maximumInputStart;
32
+ #accumulator;
33
+ #normalization;
34
+ #input = new Float32Array();
35
+ #inputStartFrame = 0;
36
+ #receivedFrames = 0;
37
+ #nextOutputStart = 0;
38
+ #previousGrain;
39
+ #sealed = false;
40
+ constructor(options) {
41
+ positiveInteger(options.inputFrames, 'inputFrames');
42
+ positiveInteger(options.outputFrames, 'outputFrames');
43
+ positiveInteger(options.channelCount, 'channelCount');
44
+ if (options.channelCount > 8)
45
+ throw new RangeError('channelCount must not exceed 8');
46
+ const requestedGrain = options.grainFrames ?? 1_024;
47
+ positiveInteger(requestedGrain, 'grainFrames');
48
+ this.#inputFrames = options.inputFrames;
49
+ this.#outputFrames = options.outputFrames;
50
+ this.#channelCount = options.channelCount;
51
+ this.#grainFrames = Math.min(options.inputFrames, requestedGrain);
52
+ this.#synthesisHop = Math.max(1, Math.floor(this.#grainFrames / 4));
53
+ this.#searchRadius = Math.min(256, this.#synthesisHop);
54
+ this.#maximumInputStart = Math.max(0, options.inputFrames - this.#grainFrames);
55
+ this.#accumulator = new Float32Array(this.#grainFrames * options.channelCount);
56
+ this.#normalization = new Float32Array(this.#grainFrames);
57
+ }
58
+ push(interleaved, final = false) {
59
+ if (this.#sealed)
60
+ throw new ReferenceError('Time stretch is sealed');
61
+ if (interleaved.length % this.#channelCount !== 0) {
62
+ throw new RangeError('PCM length must be divisible by channelCount');
63
+ }
64
+ const addedFrames = interleaved.length / this.#channelCount;
65
+ if (this.#receivedFrames + addedFrames > this.#inputFrames) {
66
+ throw new RangeError('Time stretch received more frames than declared');
67
+ }
68
+ this.#input = concat(this.#input, interleaved);
69
+ this.#receivedFrames += addedFrames;
70
+ if (final && this.#receivedFrames !== this.#inputFrames) {
71
+ throw new RangeError('Final time-stretch chunk does not complete inputFrames');
72
+ }
73
+ const emitted = [];
74
+ while (this.#nextOutputStart < this.#outputFrames) {
75
+ const expected = this.#expectedInputStart(this.#nextOutputStart);
76
+ const lastCandidate = Math.min(this.#maximumInputStart, expected + this.#searchRadius);
77
+ if (!final && lastCandidate + this.#grainFrames > this.#receivedFrames)
78
+ break;
79
+ const firstCandidate = Math.max(0, expected - this.#searchRadius);
80
+ let selected = expected;
81
+ if (this.#previousGrain !== undefined) {
82
+ let bestScore = Number.NEGATIVE_INFINITY;
83
+ for (let candidate = firstCandidate; candidate <= lastCandidate; candidate += 1) {
84
+ let cross = 0;
85
+ let previousEnergy = 0;
86
+ let candidateEnergy = 0;
87
+ const overlapFrames = this.#grainFrames - this.#synthesisHop;
88
+ for (let frame = 0; frame < overlapFrames; frame += 1) {
89
+ const previous = this.#previousGrain[(frame + this.#synthesisHop) * this.#channelCount] ?? 0;
90
+ const next = this.#source(candidate + frame, 0);
91
+ cross += previous * next;
92
+ previousEnergy += previous * previous;
93
+ candidateEnergy += next * next;
94
+ }
95
+ const score = cross / Math.sqrt(Math.max(Number.EPSILON, previousEnergy * candidateEnergy));
96
+ if (score > bestScore) {
97
+ bestScore = score;
98
+ selected = candidate;
99
+ }
100
+ }
101
+ }
102
+ const grain = new Float32Array(this.#grainFrames * this.#channelCount);
103
+ for (let frame = 0; frame < this.#grainFrames; frame += 1) {
104
+ const phase = (frame + 0.5) / this.#grainFrames;
105
+ const window = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase);
106
+ this.#normalization[frame] = (this.#normalization[frame] ?? 0) + window;
107
+ for (let channel = 0; channel < this.#channelCount; channel += 1) {
108
+ const sample = this.#source(selected + frame, channel);
109
+ grain[frame * this.#channelCount + channel] = sample;
110
+ const index = frame * this.#channelCount + channel;
111
+ this.#accumulator[index] = (this.#accumulator[index] ?? 0) + sample * window;
112
+ }
113
+ }
114
+ this.#previousGrain = grain;
115
+ const emitFrames = Math.min(this.#synthesisHop, this.#outputFrames - this.#nextOutputStart);
116
+ for (let frame = 0; frame < emitFrames; frame += 1) {
117
+ const scale = this.#normalization[frame] ?? 0;
118
+ for (let channel = 0; channel < this.#channelCount; channel += 1) {
119
+ const value = this.#accumulator[frame * this.#channelCount + channel] ?? 0;
120
+ emitted.push(scale > 1e-6 ? value / scale : this.#source(selected + frame, channel));
121
+ }
122
+ }
123
+ this.#shiftAccumulator(this.#synthesisHop);
124
+ this.#nextOutputStart += this.#synthesisHop;
125
+ const nextMinimum = Math.max(0, this.#expectedInputStart(this.#nextOutputStart) - this.#searchRadius);
126
+ const discardFrames = Math.max(0, Math.min(this.#input.length / this.#channelCount, nextMinimum - this.#inputStartFrame));
127
+ if (discardFrames > 0) {
128
+ this.#input = this.#input.slice(discardFrames * this.#channelCount);
129
+ this.#inputStartFrame += discardFrames;
130
+ }
131
+ }
132
+ if (final) {
133
+ this.#sealed = true;
134
+ this.#input = new Float32Array();
135
+ }
136
+ return Float32Array.from(emitted);
137
+ }
138
+ #expectedInputStart(outputStart) {
139
+ return Math.max(0, Math.min(this.#maximumInputStart, Math.round((outputStart * this.#inputFrames) / this.#outputFrames)));
140
+ }
141
+ #source(frame, channel) {
142
+ const bounded = Math.max(0, Math.min(this.#receivedFrames - 1, frame));
143
+ const local = bounded - this.#inputStartFrame;
144
+ if (local < 0)
145
+ throw new Error('Time stretch discarded a required source grain');
146
+ return this.#input[local * this.#channelCount + channel] ?? 0;
147
+ }
148
+ #shiftAccumulator(frames) {
149
+ const retainedFrames = Math.max(0, this.#grainFrames - frames);
150
+ this.#accumulator.copyWithin(0, frames * this.#channelCount);
151
+ this.#accumulator.fill(0, retainedFrames * this.#channelCount);
152
+ this.#normalization.copyWithin(0, frames);
153
+ this.#normalization.fill(0, retainedFrames);
154
+ }
155
+ }
156
+ export function pitchPreservingTimeStretch(options) {
157
+ positiveInteger(options.inputFrames, 'inputFrames');
158
+ positiveInteger(options.outputFrames, 'outputFrames');
159
+ positiveInteger(options.channelCount, 'channelCount');
160
+ if (options.input.length !== options.inputFrames * options.channelCount) {
161
+ throw new RangeError('input length does not match inputFrames × channelCount');
162
+ }
163
+ let input = options.input;
164
+ if (options.reverse === true) {
165
+ input = new Float32Array(options.input.length);
166
+ for (let frame = 0; frame < options.inputFrames; frame += 1) {
167
+ for (let channel = 0; channel < options.channelCount; channel += 1) {
168
+ input[frame * options.channelCount + channel] =
169
+ options.input[(options.inputFrames - 1 - frame) * options.channelCount + channel] ?? 0;
170
+ }
171
+ }
172
+ }
173
+ const processor = new StreamingPitchPreservingTimeStretch({
174
+ inputFrames: options.inputFrames,
175
+ outputFrames: options.outputFrames,
176
+ channelCount: options.channelCount,
177
+ ...(options.grainFrames === undefined ? {} : { grainFrames: options.grainFrames }),
178
+ });
179
+ return processor.push(input, true);
180
+ }
@@ -0,0 +1,30 @@
1
+ export interface TransferablePcmQueueSnapshot {
2
+ readonly capacityFrames: number;
3
+ readonly queuedFrames: number;
4
+ readonly availableWriteFrames: number;
5
+ readonly submittedBlocks: number;
6
+ readonly acknowledgedBlocks: number;
7
+ readonly peakQueuedFrames: number;
8
+ readonly closed: boolean;
9
+ }
10
+ export interface TransferablePcmBlock {
11
+ readonly id: number;
12
+ readonly generation: number;
13
+ readonly frameCount: number;
14
+ readonly channelCount: number;
15
+ readonly samples: Float32Array<ArrayBuffer>;
16
+ }
17
+ /** Main-thread ownership and backpressure for the non-SAB AudioWorklet path. */
18
+ export declare class TransferablePcmQueue {
19
+ #private;
20
+ readonly capacityFrames: number;
21
+ readonly channelCount: number;
22
+ constructor(capacityFrames: number, channelCount: number);
23
+ enqueue(input: Float32Array, generation: number): TransferablePcmBlock | undefined;
24
+ acknowledge(id: number): void;
25
+ flush(): void;
26
+ close(): void;
27
+ availableWriteFrames(): number;
28
+ snapshot(): TransferablePcmQueueSnapshot;
29
+ }
30
+ //# sourceMappingURL=transferable-pcm-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transferable-pcm-queue.d.ts","sourceRoot":"","sources":["../src/transferable-pcm-queue.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC,WAAW,CAAC,CAAC;CAC7C;AAED,gFAAgF;AAChF,qBAAa,oBAAoB;;aAUb,cAAc,EAAE,MAAM;aACtB,YAAY,EAAE,MAAM;gBADpB,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM;IAU/B,OAAO,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,oBAAoB,GAAG,SAAS;IAoBlF,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAQ7B,KAAK,IAAI,IAAI;IAKb,KAAK,IAAI,IAAI;IAKb,oBAAoB,IAAI,MAAM;IAI9B,QAAQ,IAAI,4BAA4B;CAWhD"}
@@ -0,0 +1,73 @@
1
+ /** Main-thread ownership and backpressure for the non-SAB AudioWorklet path. */
2
+ export class TransferablePcmQueue {
3
+ capacityFrames;
4
+ channelCount;
5
+ #pending = new Map();
6
+ #nextId = 1;
7
+ #queuedFrames = 0;
8
+ #submittedBlocks = 0;
9
+ #acknowledgedBlocks = 0;
10
+ #peakQueuedFrames = 0;
11
+ #closed = false;
12
+ constructor(capacityFrames, channelCount) {
13
+ this.capacityFrames = capacityFrames;
14
+ this.channelCount = channelCount;
15
+ if (!Number.isSafeInteger(capacityFrames) || capacityFrames <= 0) {
16
+ throw new RangeError('Transferable PCM capacityFrames must be a positive safe integer');
17
+ }
18
+ if (!Number.isSafeInteger(channelCount) || channelCount <= 0) {
19
+ throw new RangeError('Transferable PCM channelCount must be a positive safe integer');
20
+ }
21
+ }
22
+ enqueue(input, generation) {
23
+ if (this.#closed)
24
+ return undefined;
25
+ if (input.length % this.channelCount !== 0) {
26
+ throw new RangeError('Interleaved PCM length must be divisible by channelCount');
27
+ }
28
+ if (!Number.isSafeInteger(generation) || generation < 0) {
29
+ throw new RangeError('PCM generation must be a non-negative safe integer');
30
+ }
31
+ const frameCount = input.length / this.channelCount;
32
+ if (frameCount <= 0 || frameCount > this.availableWriteFrames())
33
+ return undefined;
34
+ const id = this.#nextId;
35
+ this.#nextId += 1;
36
+ const samples = input.slice();
37
+ this.#pending.set(id, frameCount);
38
+ this.#queuedFrames += frameCount;
39
+ this.#submittedBlocks += 1;
40
+ this.#peakQueuedFrames = Math.max(this.#peakQueuedFrames, this.#queuedFrames);
41
+ return { id, generation, frameCount, channelCount: this.channelCount, samples };
42
+ }
43
+ acknowledge(id) {
44
+ const frames = this.#pending.get(id);
45
+ if (frames === undefined)
46
+ return;
47
+ this.#pending.delete(id);
48
+ this.#queuedFrames -= frames;
49
+ this.#acknowledgedBlocks += 1;
50
+ }
51
+ flush() {
52
+ this.#pending.clear();
53
+ this.#queuedFrames = 0;
54
+ }
55
+ close() {
56
+ this.#closed = true;
57
+ this.flush();
58
+ }
59
+ availableWriteFrames() {
60
+ return this.capacityFrames - this.#queuedFrames;
61
+ }
62
+ snapshot() {
63
+ return {
64
+ capacityFrames: this.capacityFrames,
65
+ queuedFrames: this.#queuedFrames,
66
+ availableWriteFrames: this.availableWriteFrames(),
67
+ submittedBlocks: this.#submittedBlocks,
68
+ acknowledgedBlocks: this.#acknowledgedBlocks,
69
+ peakQueuedFrames: this.#peakQueuedFrames,
70
+ closed: this.#closed,
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,51 @@
1
+ import type { Disposable } from '@aelionsdk/core';
2
+ import { TransferablePcmQueue, type TransferablePcmQueueSnapshot } from './transferable-pcm-queue.js';
3
+ export interface TransferableClockReport {
4
+ readonly currentFrame: number;
5
+ readonly currentTime: number;
6
+ readonly generation: number;
7
+ readonly playedFrames: number;
8
+ readonly underrunFrames: number;
9
+ readonly queuedBlocks: number;
10
+ }
11
+ export interface TransferableAudioClockOptions {
12
+ readonly context?: AudioContext;
13
+ /** Host-resolved module URL for non-Vite or CDN deployments. */
14
+ readonly moduleUrl?: string | URL;
15
+ /** Requested hardware context rate when the clock owns its AudioContext. */
16
+ readonly sampleRate?: number;
17
+ readonly capacityFrames?: number;
18
+ readonly channelCount?: number;
19
+ readonly reportEveryFrames?: number;
20
+ }
21
+ export declare class TransferableAudioWorkletClock implements Disposable {
22
+ #private;
23
+ readonly context: AudioContext;
24
+ readonly queue: TransferablePcmQueue;
25
+ constructor(options?: TransferableAudioClockOptions);
26
+ get disposed(): boolean;
27
+ get ownsContext(): boolean;
28
+ get generation(): number;
29
+ get lastReport(): TransferableClockReport | undefined;
30
+ initialize(reportEveryFrames?: number): Promise<void>;
31
+ start(): Promise<void>;
32
+ enqueueInterleaved(input: Float32Array): boolean;
33
+ pause(): Promise<void>;
34
+ resume(): Promise<void>;
35
+ seek(timeUs: number): number;
36
+ nowUs(): number;
37
+ snapshot(): TransferablePcmQueueSnapshot;
38
+ dispose(): Promise<void>;
39
+ }
40
+ export type BrowserAudioClockSelection = {
41
+ readonly mode: 'shared-ring';
42
+ readonly reason: 'cross-origin-isolated';
43
+ } | {
44
+ readonly mode: 'transferable-queue';
45
+ readonly reason: 'shared-array-buffer-unavailable';
46
+ };
47
+ export declare function selectBrowserAudioTransport(environment: {
48
+ readonly crossOriginIsolated: boolean;
49
+ readonly sharedArrayBufferAvailable: boolean;
50
+ }): BrowserAudioClockSelection;
51
+ //# sourceMappingURL=transferable-worklet-clock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transferable-worklet-clock.d.ts","sourceRoot":"","sources":["../src/transferable-worklet-clock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD,OAAO,EACL,oBAAoB,EACpB,KAAK,4BAA4B,EAClC,MAAM,6BAA6B,CAAC;AAoBrC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,6BAA6B;IAC5C,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,qBAAa,6BAA8B,YAAW,UAAU;;IAC9D,SAAgB,OAAO,EAAE,YAAY,CAAC;IACtC,SAAgB,KAAK,EAAE,oBAAoB,CAAC;gBAazB,OAAO,GAAE,6BAAkC;IAa9D,IAAW,QAAQ,IAAI,OAAO,CAE7B;IAED,IAAW,WAAW,IAAI,OAAO,CAEhC;IAED,IAAW,UAAU,IAAI,MAAM,CAE9B;IAED,IAAW,UAAU,IAAI,uBAAuB,GAAG,SAAS,CAE3D;IAEY,UAAU,CACrB,iBAAiB,SAA2C,GAC3D,OAAO,CAAC,IAAI,CAAC;IAwDH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAM5B,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO;IAkB1C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAItB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAY5B,KAAK,IAAI,MAAM;IAQf,QAAQ,IAAI,4BAA4B;IAIxC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAyBhC;AAED,MAAM,MAAM,0BAA0B,GAClC;IAAE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,uBAAuB,CAAA;CAAE,GAC1E;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,iCAAiC,CAAA;CAAE,CAAC;AAEhG,wBAAgB,2BAA2B,CAAC,WAAW,EAAE;IACvD,QAAQ,CAAC,mBAAmB,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,0BAA0B,EAAE,OAAO,CAAC;CAC9C,GAAG,0BAA0B,CAI7B"}
@@ -0,0 +1,185 @@
1
+ import { TransferablePcmQueue, } from './transferable-pcm-queue.js';
2
+ async function withTimeout(promise, timeoutMs, operation) {
3
+ let timeoutId;
4
+ const timeout = new Promise((_resolve, reject) => {
5
+ timeoutId = globalThis.setTimeout(() => {
6
+ reject(new Error(`${operation} timed out after ${timeoutMs.toString()} ms`));
7
+ }, timeoutMs);
8
+ });
9
+ try {
10
+ return await Promise.race([promise, timeout]);
11
+ }
12
+ finally {
13
+ if (timeoutId !== undefined)
14
+ globalThis.clearTimeout(timeoutId);
15
+ }
16
+ }
17
+ export class TransferableAudioWorkletClock {
18
+ context;
19
+ queue;
20
+ #ownsContext;
21
+ #node;
22
+ #initializeTask;
23
+ #disposeTask;
24
+ #disposed = false;
25
+ #lifecycleGeneration = 0;
26
+ #generation = 0;
27
+ #timelineOriginUs = 0;
28
+ #contextOriginTime;
29
+ #lastReport;
30
+ #moduleUrl;
31
+ constructor(options = {}) {
32
+ this.#moduleUrl =
33
+ options.moduleUrl ?? new URL('./pcm-message-player.worklet.js', import.meta.url);
34
+ this.context =
35
+ options.context ??
36
+ new AudioContext({ latencyHint: 'playback', sampleRate: options.sampleRate ?? 48_000 });
37
+ this.#ownsContext = options.context === undefined;
38
+ this.queue = new TransferablePcmQueue(options.capacityFrames ?? this.context.sampleRate * 4, options.channelCount ?? 2);
39
+ }
40
+ get disposed() {
41
+ return this.#disposed;
42
+ }
43
+ get ownsContext() {
44
+ return this.#ownsContext;
45
+ }
46
+ get generation() {
47
+ return this.#generation;
48
+ }
49
+ get lastReport() {
50
+ return this.#lastReport;
51
+ }
52
+ async initialize(reportEveryFrames = Math.round(this.context.sampleRate / 20)) {
53
+ if (this.#disposed)
54
+ throw new ReferenceError('TransferableAudioWorkletClock is disposed');
55
+ if (this.#node !== undefined)
56
+ return;
57
+ const existing = this.#initializeTask;
58
+ if (existing !== undefined)
59
+ return existing;
60
+ const generation = this.#lifecycleGeneration;
61
+ const task = this.#initialize(reportEveryFrames, generation).finally(() => {
62
+ if (this.#initializeTask === task)
63
+ this.#initializeTask = undefined;
64
+ });
65
+ this.#initializeTask = task;
66
+ return task;
67
+ }
68
+ async #initialize(reportEveryFrames, generation) {
69
+ await withTimeout(this.context.audioWorklet.addModule(this.#moduleUrl), 5_000, 'AudioWorklet module initialization');
70
+ this.#throwIfInitializationStale(generation);
71
+ const node = new AudioWorkletNode(this.context, 'aelion-message-pcm-player', {
72
+ numberOfInputs: 0,
73
+ numberOfOutputs: 1,
74
+ outputChannelCount: [this.queue.channelCount],
75
+ processorOptions: {
76
+ channelCount: this.queue.channelCount,
77
+ generation: this.#generation,
78
+ reportEveryFrames,
79
+ },
80
+ });
81
+ let connected = false;
82
+ try {
83
+ node.port.addEventListener('message', event => {
84
+ const value = event.data;
85
+ if (value === null || typeof value !== 'object')
86
+ return;
87
+ const type = Reflect.get(value, 'type');
88
+ if (type === 'ack') {
89
+ const id = Reflect.get(value, 'id');
90
+ if (typeof id === 'number')
91
+ this.queue.acknowledge(id);
92
+ }
93
+ else if (type === 'clock') {
94
+ this.#lastReport = value;
95
+ }
96
+ });
97
+ node.port.start();
98
+ node.connect(this.context.destination);
99
+ connected = true;
100
+ this.#throwIfInitializationStale(generation);
101
+ this.#contextOriginTime = this.context.currentTime;
102
+ this.#node = node;
103
+ }
104
+ catch (error) {
105
+ if (connected)
106
+ node.disconnect();
107
+ node.port.close();
108
+ throw error;
109
+ }
110
+ }
111
+ async start() {
112
+ await this.initialize();
113
+ this.#node?.port.postMessage({ type: 'start', generation: this.#generation });
114
+ await this.context.resume();
115
+ }
116
+ enqueueInterleaved(input) {
117
+ if (this.#disposed)
118
+ throw new ReferenceError('TransferableAudioWorkletClock is disposed');
119
+ const block = this.queue.enqueue(input, this.#generation);
120
+ if (block === undefined)
121
+ return false;
122
+ this.#node?.port.postMessage({
123
+ type: 'block',
124
+ id: block.id,
125
+ generation: block.generation,
126
+ frameCount: block.frameCount,
127
+ channelCount: block.channelCount,
128
+ samples: block.samples,
129
+ }, [block.samples.buffer]);
130
+ return true;
131
+ }
132
+ async pause() {
133
+ if (this.context.state === 'running')
134
+ await this.context.suspend();
135
+ }
136
+ async resume() {
137
+ if (this.context.state !== 'running')
138
+ await this.context.resume();
139
+ }
140
+ seek(timeUs) {
141
+ if (!Number.isSafeInteger(timeUs) || timeUs < 0) {
142
+ throw new RangeError('Seek target must be a non-negative safe integer microsecond value');
143
+ }
144
+ this.#generation += 1;
145
+ this.#timelineOriginUs = timeUs;
146
+ this.#contextOriginTime = this.context.currentTime;
147
+ this.queue.flush();
148
+ this.#node?.port.postMessage({ type: 'seek', generation: this.#generation });
149
+ return this.#generation;
150
+ }
151
+ nowUs() {
152
+ const origin = this.#contextOriginTime ?? this.context.currentTime;
153
+ return (this.#timelineOriginUs +
154
+ Math.max(0, Math.round((this.context.currentTime - origin) * 1_000_000)));
155
+ }
156
+ snapshot() {
157
+ return this.queue.snapshot();
158
+ }
159
+ dispose() {
160
+ this.#disposeTask ??= this.#dispose();
161
+ return this.#disposeTask;
162
+ }
163
+ async #dispose() {
164
+ this.#disposed = true;
165
+ this.#lifecycleGeneration += 1;
166
+ this.queue.close();
167
+ this.#node?.port.postMessage({ type: 'close' });
168
+ this.#node?.disconnect();
169
+ this.#node?.port.close();
170
+ this.#node = undefined;
171
+ if (this.#ownsContext && this.context.state !== 'closed')
172
+ await this.context.close();
173
+ await this.#initializeTask?.catch(() => undefined);
174
+ }
175
+ #throwIfInitializationStale(generation) {
176
+ if (this.#disposed || generation !== this.#lifecycleGeneration) {
177
+ throw new DOMException('TransferableAudioWorkletClock initialization became stale', 'AbortError');
178
+ }
179
+ }
180
+ }
181
+ export function selectBrowserAudioTransport(environment) {
182
+ return environment.crossOriginIsolated && environment.sharedArrayBufferAvailable
183
+ ? { mode: 'shared-ring', reason: 'cross-origin-isolated' }
184
+ : { mode: 'transferable-queue', reason: 'shared-array-buffer-unavailable' };
185
+ }
@@ -0,0 +1,39 @@
1
+ import { type Disposable, type Rational } from '@aelionsdk/core';
2
+ export interface PlaybackClock {
3
+ nowUs(): number;
4
+ }
5
+ export interface ScheduledVideoFrame {
6
+ readonly generation: number;
7
+ readonly frameIndex: number;
8
+ readonly timestampUs: number;
9
+ readonly droppedFrames: number;
10
+ }
11
+ export interface AudioDrivenVideoSchedulerSnapshot {
12
+ readonly disposed: boolean;
13
+ readonly scheduled: boolean;
14
+ readonly rendering: boolean;
15
+ readonly ended: boolean;
16
+ readonly generation: number;
17
+ }
18
+ export interface AudioDrivenVideoSchedulerOptions {
19
+ readonly clock: PlaybackClock;
20
+ readonly frameRate: Rational;
21
+ readonly durationUs: number;
22
+ readonly onFrame: (frame: ScheduledVideoFrame) => void | Promise<void>;
23
+ readonly onEnd?: () => void;
24
+ readonly onError?: (error: unknown) => void;
25
+ readonly schedule?: (callback: FrameRequestCallback) => number;
26
+ readonly cancel?: (handle: number) => void;
27
+ }
28
+ export declare class AudioDrivenVideoScheduler implements Disposable {
29
+ #private;
30
+ constructor(options: AudioDrivenVideoSchedulerOptions);
31
+ get generation(): number;
32
+ get disposed(): boolean;
33
+ snapshot(): AudioDrivenVideoSchedulerSnapshot;
34
+ start(): void;
35
+ pause(): void;
36
+ seek(): number;
37
+ dispose(): void;
38
+ }
39
+ //# sourceMappingURL=video-scheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"video-scheduler.d.ts","sourceRoot":"","sources":["../src/video-scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,UAAU,EAAE,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEjG,MAAM,WAAW,aAAa;IAC5B,KAAK,IAAI,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,iCAAiC;IAChD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAC5C,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,oBAAoB,KAAK,MAAM,CAAC;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5C;AAED,qBAAa,yBAA0B,YAAW,UAAU;;gBAgBvC,OAAO,EAAE,gCAAgC;IAc5D,IAAW,UAAU,IAAI,MAAM,CAE9B;IAED,IAAW,QAAQ,IAAI,OAAO,CAE7B;IAEM,QAAQ,IAAI,iCAAiC;IAU7C,KAAK,IAAI,IAAI;IAOb,KAAK,IAAI,IAAI;IAMb,IAAI,IAAI,MAAM;IAOd,OAAO,IAAI,IAAI;CAiDvB"}