@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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { throwIfAborted } from '@aelionsdk/core';
|
|
2
|
+
export function applyChannelMatrix(input, matrix) {
|
|
3
|
+
if (!Number.isSafeInteger(matrix.inputChannels) ||
|
|
4
|
+
!Number.isSafeInteger(matrix.outputChannels) ||
|
|
5
|
+
matrix.inputChannels <= 0 ||
|
|
6
|
+
matrix.outputChannels <= 0 ||
|
|
7
|
+
matrix.gains.length !== matrix.inputChannels * matrix.outputChannels ||
|
|
8
|
+
input.length % matrix.inputChannels !== 0) {
|
|
9
|
+
throw new RangeError('Invalid channel matrix or interleaved PCM length');
|
|
10
|
+
}
|
|
11
|
+
const frames = input.length / matrix.inputChannels;
|
|
12
|
+
const output = new Float32Array(frames * matrix.outputChannels);
|
|
13
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
14
|
+
for (let outputChannel = 0; outputChannel < matrix.outputChannels; outputChannel += 1) {
|
|
15
|
+
let sample = 0;
|
|
16
|
+
for (let inputChannel = 0; inputChannel < matrix.inputChannels; inputChannel += 1) {
|
|
17
|
+
sample +=
|
|
18
|
+
(input[frame * matrix.inputChannels + inputChannel] ?? 0) *
|
|
19
|
+
(matrix.gains[outputChannel * matrix.inputChannels + inputChannel] ?? 0);
|
|
20
|
+
}
|
|
21
|
+
output[frame * matrix.outputChannels + outputChannel] = sample;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return output;
|
|
25
|
+
}
|
|
26
|
+
function smoothingCoefficient(durationUs, sampleRate) {
|
|
27
|
+
return durationUs <= 0 ? 0 : Math.exp(-1 / ((durationUs / 1_000_000) * sampleRate));
|
|
28
|
+
}
|
|
29
|
+
export class SidechainDucker {
|
|
30
|
+
#options;
|
|
31
|
+
#delay;
|
|
32
|
+
#attack;
|
|
33
|
+
#release;
|
|
34
|
+
#delayFrame = 0;
|
|
35
|
+
#gain = 1;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
if (!Number.isSafeInteger(options.sampleRate) ||
|
|
38
|
+
!Number.isSafeInteger(options.channelCount) ||
|
|
39
|
+
options.sampleRate <= 0 ||
|
|
40
|
+
options.channelCount <= 0 ||
|
|
41
|
+
options.reductionDb > 0 ||
|
|
42
|
+
options.attackUs < 0 ||
|
|
43
|
+
options.releaseUs < 0 ||
|
|
44
|
+
options.lookaheadUs < 0) {
|
|
45
|
+
throw new RangeError('Invalid sidechain ducking options');
|
|
46
|
+
}
|
|
47
|
+
this.#options = options;
|
|
48
|
+
const lookaheadFrames = Math.ceil((options.lookaheadUs * options.sampleRate) / 1_000_000);
|
|
49
|
+
this.#delay = new Float32Array(lookaheadFrames * options.channelCount);
|
|
50
|
+
this.#attack = smoothingCoefficient(options.attackUs, options.sampleRate);
|
|
51
|
+
this.#release = smoothingCoefficient(options.releaseUs, options.sampleRate);
|
|
52
|
+
}
|
|
53
|
+
get latencyFrames() {
|
|
54
|
+
return this.#delay.length / this.#options.channelCount;
|
|
55
|
+
}
|
|
56
|
+
reset() {
|
|
57
|
+
this.#delay.fill(0);
|
|
58
|
+
this.#delayFrame = 0;
|
|
59
|
+
this.#gain = 1;
|
|
60
|
+
}
|
|
61
|
+
process(program, sidechain) {
|
|
62
|
+
const frames = sidechain.length;
|
|
63
|
+
if (program.length !== frames * this.#options.channelCount) {
|
|
64
|
+
throw new RangeError('Sidechain must contain one mono sample per program frame');
|
|
65
|
+
}
|
|
66
|
+
const output = new Float32Array(program.length);
|
|
67
|
+
const minimumGain = 10 ** (this.#options.reductionDb / 20);
|
|
68
|
+
const threshold = 10 ** (this.#options.thresholdDb / 20);
|
|
69
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
70
|
+
const detector = Math.abs(sidechain[frame] ?? 0);
|
|
71
|
+
const target = detector > threshold ? minimumGain : 1;
|
|
72
|
+
const coefficient = target < this.#gain ? this.#attack : this.#release;
|
|
73
|
+
this.#gain = target + coefficient * (this.#gain - target);
|
|
74
|
+
for (let channel = 0; channel < this.#options.channelCount; channel += 1) {
|
|
75
|
+
const inputSample = program[frame * this.#options.channelCount + channel] ?? 0;
|
|
76
|
+
if (this.latencyFrames === 0) {
|
|
77
|
+
output[frame * this.#options.channelCount + channel] = inputSample * this.#gain;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const delayIndex = this.#delayFrame * this.#options.channelCount + channel;
|
|
81
|
+
output[frame * this.#options.channelCount + channel] =
|
|
82
|
+
(this.#delay[delayIndex] ?? 0) * this.#gain;
|
|
83
|
+
this.#delay[delayIndex] = inputSample;
|
|
84
|
+
}
|
|
85
|
+
if (this.latencyFrames > 0) {
|
|
86
|
+
this.#delayFrame = (this.#delayFrame + 1) % this.latencyFrames;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return output;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function decibels(value) {
|
|
93
|
+
return value <= 0 ? Number.NEGATIVE_INFINITY : 20 * Math.log10(value);
|
|
94
|
+
}
|
|
95
|
+
/** Deterministic EBU-style block gating with 4× linear true-peak estimation. */
|
|
96
|
+
export function analyzeLoudness(pcm, sampleRate, channelCount) {
|
|
97
|
+
if (!Number.isSafeInteger(sampleRate) ||
|
|
98
|
+
!Number.isSafeInteger(channelCount) ||
|
|
99
|
+
sampleRate <= 0 ||
|
|
100
|
+
channelCount <= 0 ||
|
|
101
|
+
pcm.length % channelCount !== 0) {
|
|
102
|
+
throw new RangeError('Invalid loudness PCM format');
|
|
103
|
+
}
|
|
104
|
+
const frames = pcm.length / channelCount;
|
|
105
|
+
const blockFrames = Math.max(1, Math.round(sampleRate * 0.4));
|
|
106
|
+
const energies = [];
|
|
107
|
+
let samplePeak = 0;
|
|
108
|
+
let truePeak = 0;
|
|
109
|
+
for (let start = 0; start < frames; start += blockFrames) {
|
|
110
|
+
const end = Math.min(frames, start + blockFrames);
|
|
111
|
+
let sumSquares = 0;
|
|
112
|
+
let count = 0;
|
|
113
|
+
for (let frame = start; frame < end; frame += 1) {
|
|
114
|
+
for (let channel = 0; channel < channelCount; channel += 1) {
|
|
115
|
+
const current = pcm[frame * channelCount + channel] ?? 0;
|
|
116
|
+
const next = pcm[Math.min(frames - 1, frame + 1) * channelCount + channel] ?? current;
|
|
117
|
+
samplePeak = Math.max(samplePeak, Math.abs(current));
|
|
118
|
+
for (let phase = 0; phase < 4; phase += 1) {
|
|
119
|
+
truePeak = Math.max(truePeak, Math.abs(current + ((next - current) * phase) / 4));
|
|
120
|
+
}
|
|
121
|
+
sumSquares += current * current;
|
|
122
|
+
count += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
energies.push(count === 0 ? 0 : sumSquares / count);
|
|
126
|
+
}
|
|
127
|
+
const lufs = (energy) => energy <= 0 ? Number.NEGATIVE_INFINITY : -0.691 + 10 * Math.log10(energy);
|
|
128
|
+
const absoluteGated = energies.filter(energy => lufs(energy) >= -70);
|
|
129
|
+
const ungatedEnergy = absoluteGated.reduce((sum, value) => sum + value, 0) / Math.max(1, absoluteGated.length);
|
|
130
|
+
const relativeThreshold = lufs(ungatedEnergy) - 10;
|
|
131
|
+
const gated = absoluteGated.filter(energy => lufs(energy) >= relativeThreshold);
|
|
132
|
+
const integratedEnergy = gated.reduce((sum, value) => sum + value, 0) / Math.max(1, gated.length);
|
|
133
|
+
return {
|
|
134
|
+
integratedLufs: lufs(integratedEnergy),
|
|
135
|
+
ungatedLufs: lufs(ungatedEnergy),
|
|
136
|
+
truePeakDbtp: decibels(truePeak),
|
|
137
|
+
samplePeakDbfs: decibels(samplePeak),
|
|
138
|
+
gatedBlocks: gated.length,
|
|
139
|
+
totalBlocks: energies.length,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export class TruePeakLimiter {
|
|
143
|
+
#channels;
|
|
144
|
+
#ceiling;
|
|
145
|
+
#release;
|
|
146
|
+
#delay;
|
|
147
|
+
#frame = 0;
|
|
148
|
+
#gain = 1;
|
|
149
|
+
constructor(options) {
|
|
150
|
+
if (options.sampleRate <= 0 || options.channelCount <= 0) {
|
|
151
|
+
throw new RangeError('Invalid limiter format');
|
|
152
|
+
}
|
|
153
|
+
this.#channels = options.channelCount;
|
|
154
|
+
this.#ceiling = 10 ** ((options.ceilingDbtp ?? -1) / 20);
|
|
155
|
+
this.#release = smoothingCoefficient(options.releaseUs ?? 100_000, options.sampleRate);
|
|
156
|
+
const frames = Math.max(0, Math.ceil(((options.lookaheadUs ?? 5_000) * options.sampleRate) / 1_000_000));
|
|
157
|
+
this.#delay = new Float32Array(frames * options.channelCount);
|
|
158
|
+
}
|
|
159
|
+
get latencyFrames() {
|
|
160
|
+
return this.#delay.length / this.#channels;
|
|
161
|
+
}
|
|
162
|
+
process(input) {
|
|
163
|
+
if (input.length % this.#channels !== 0)
|
|
164
|
+
throw new RangeError('Invalid limiter PCM length');
|
|
165
|
+
const output = new Float32Array(input.length);
|
|
166
|
+
for (let frame = 0; frame < input.length / this.#channels; frame += 1) {
|
|
167
|
+
let peak = 0;
|
|
168
|
+
for (let channel = 0; channel < this.#channels; channel += 1) {
|
|
169
|
+
peak = Math.max(peak, Math.abs(input[frame * this.#channels + channel] ?? 0));
|
|
170
|
+
}
|
|
171
|
+
const target = peak > this.#ceiling ? this.#ceiling / peak : 1;
|
|
172
|
+
this.#gain = target < this.#gain ? target : target + this.#release * (this.#gain - target);
|
|
173
|
+
for (let channel = 0; channel < this.#channels; channel += 1) {
|
|
174
|
+
const inputSample = input[frame * this.#channels + channel] ?? 0;
|
|
175
|
+
if (this.latencyFrames === 0) {
|
|
176
|
+
output[frame * this.#channels + channel] = inputSample * this.#gain;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const index = this.#frame * this.#channels + channel;
|
|
180
|
+
output[frame * this.#channels + channel] = (this.#delay[index] ?? 0) * this.#gain;
|
|
181
|
+
this.#delay[index] = inputSample;
|
|
182
|
+
}
|
|
183
|
+
if (this.latencyFrames > 0)
|
|
184
|
+
this.#frame = (this.#frame + 1) % this.latencyFrames;
|
|
185
|
+
}
|
|
186
|
+
return output;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Incremental variant of analyzeLoudness for bounded-memory export prepasses. */
|
|
190
|
+
export class StreamingLoudnessAnalyzer {
|
|
191
|
+
#channelCount;
|
|
192
|
+
#blockFrames;
|
|
193
|
+
#energies = [];
|
|
194
|
+
#carry = [];
|
|
195
|
+
#previous;
|
|
196
|
+
#samplePeak = 0;
|
|
197
|
+
#truePeak = 0;
|
|
198
|
+
constructor(sampleRate, channelCount) {
|
|
199
|
+
if (!Number.isSafeInteger(sampleRate) ||
|
|
200
|
+
!Number.isSafeInteger(channelCount) ||
|
|
201
|
+
sampleRate <= 0 ||
|
|
202
|
+
channelCount <= 0) {
|
|
203
|
+
throw new RangeError('Invalid loudness PCM format');
|
|
204
|
+
}
|
|
205
|
+
this.#channelCount = channelCount;
|
|
206
|
+
this.#blockFrames = Math.max(1, Math.round(sampleRate * 0.4));
|
|
207
|
+
this.#previous = Array.from({ length: channelCount }, () => 0);
|
|
208
|
+
}
|
|
209
|
+
process(pcm) {
|
|
210
|
+
if (pcm.length % this.#channelCount !== 0) {
|
|
211
|
+
throw new RangeError('Invalid loudness PCM block length');
|
|
212
|
+
}
|
|
213
|
+
for (let index = 0; index < pcm.length; index += 1) {
|
|
214
|
+
const sample = pcm[index] ?? 0;
|
|
215
|
+
const channel = index % this.#channelCount;
|
|
216
|
+
const previous = this.#previous[channel] ?? sample;
|
|
217
|
+
this.#samplePeak = Math.max(this.#samplePeak, Math.abs(sample));
|
|
218
|
+
for (let phase = 1; phase <= 4; phase += 1) {
|
|
219
|
+
this.#truePeak = Math.max(this.#truePeak, Math.abs(previous + ((sample - previous) * phase) / 4));
|
|
220
|
+
}
|
|
221
|
+
this.#previous[channel] = sample;
|
|
222
|
+
this.#carry.push(sample);
|
|
223
|
+
if (this.#carry.length === this.#blockFrames * this.#channelCount) {
|
|
224
|
+
this.#commitCarry();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
finish() {
|
|
229
|
+
if (this.#carry.length > 0)
|
|
230
|
+
this.#commitCarry();
|
|
231
|
+
const lufs = (energy) => energy <= 0 ? Number.NEGATIVE_INFINITY : -0.691 + 10 * Math.log10(energy);
|
|
232
|
+
const absoluteGated = this.#energies.filter(energy => lufs(energy) >= -70);
|
|
233
|
+
const ungatedEnergy = absoluteGated.reduce((sum, value) => sum + value, 0) / Math.max(1, absoluteGated.length);
|
|
234
|
+
const relativeThreshold = lufs(ungatedEnergy) - 10;
|
|
235
|
+
const gated = absoluteGated.filter(energy => lufs(energy) >= relativeThreshold);
|
|
236
|
+
const integratedEnergy = gated.reduce((sum, value) => sum + value, 0) / Math.max(1, gated.length);
|
|
237
|
+
return {
|
|
238
|
+
integratedLufs: lufs(integratedEnergy),
|
|
239
|
+
ungatedLufs: lufs(ungatedEnergy),
|
|
240
|
+
truePeakDbtp: decibels(this.#truePeak),
|
|
241
|
+
samplePeakDbfs: decibels(this.#samplePeak),
|
|
242
|
+
gatedBlocks: gated.length,
|
|
243
|
+
totalBlocks: this.#energies.length,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
#commitCarry() {
|
|
247
|
+
let sumSquares = 0;
|
|
248
|
+
for (const sample of this.#carry)
|
|
249
|
+
sumSquares += sample * sample;
|
|
250
|
+
this.#energies.push(sumSquares / Math.max(1, this.#carry.length));
|
|
251
|
+
this.#carry.length = 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export async function buildWaveformPeaks(options) {
|
|
255
|
+
const maxPoints = options.maxPoints ?? 100_000;
|
|
256
|
+
if (options.sampleRate <= 0 ||
|
|
257
|
+
options.channelCount <= 0 ||
|
|
258
|
+
!Number.isSafeInteger(options.totalFrames) ||
|
|
259
|
+
options.totalFrames < 0 ||
|
|
260
|
+
!Number.isSafeInteger(maxPoints) ||
|
|
261
|
+
maxPoints <= 0) {
|
|
262
|
+
throw new RangeError('Invalid waveform options');
|
|
263
|
+
}
|
|
264
|
+
const requestedWindow = options.windowFrames ?? Math.max(1, Math.round(options.sampleRate / 100));
|
|
265
|
+
const windowFrames = Math.max(requestedWindow, Math.ceil(options.totalFrames / maxPoints));
|
|
266
|
+
if (!Number.isSafeInteger(windowFrames) || windowFrames <= 0) {
|
|
267
|
+
throw new RangeError('windowFrames must be a positive safe integer');
|
|
268
|
+
}
|
|
269
|
+
const peaks = [];
|
|
270
|
+
for (let startFrame = 0; startFrame < options.totalFrames; startFrame += windowFrames) {
|
|
271
|
+
throwIfAborted(options.signal, 'Waveform peak generation');
|
|
272
|
+
const frameCount = Math.min(windowFrames, options.totalFrames - startFrame);
|
|
273
|
+
const pcm = await options.readFrames(startFrame, frameCount, options.signal);
|
|
274
|
+
if (pcm.length !== frameCount * options.channelCount) {
|
|
275
|
+
throw new RangeError('Waveform source returned an unexpected PCM length');
|
|
276
|
+
}
|
|
277
|
+
const minimum = Array.from({ length: options.channelCount }, () => Number.POSITIVE_INFINITY);
|
|
278
|
+
const maximum = Array.from({ length: options.channelCount }, () => Number.NEGATIVE_INFINITY);
|
|
279
|
+
const squares = Array.from({ length: options.channelCount }, () => 0);
|
|
280
|
+
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
281
|
+
for (let channel = 0; channel < options.channelCount; channel += 1) {
|
|
282
|
+
const sample = pcm[frame * options.channelCount + channel] ?? 0;
|
|
283
|
+
minimum[channel] = Math.min(minimum[channel] ?? sample, sample);
|
|
284
|
+
maximum[channel] = Math.max(maximum[channel] ?? sample, sample);
|
|
285
|
+
squares[channel] = (squares[channel] ?? 0) + sample * sample;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
peaks.push({
|
|
289
|
+
startFrame,
|
|
290
|
+
frameCount,
|
|
291
|
+
min: minimum,
|
|
292
|
+
max: maximum,
|
|
293
|
+
rms: squares.map(sum => Math.sqrt(sum / frameCount)),
|
|
294
|
+
});
|
|
295
|
+
options.onProgress?.((startFrame + frameCount) / Math.max(1, options.totalFrames));
|
|
296
|
+
}
|
|
297
|
+
if (options.totalFrames === 0)
|
|
298
|
+
options.onProgress?.(1);
|
|
299
|
+
return {
|
|
300
|
+
sampleRate: options.sampleRate,
|
|
301
|
+
channelCount: options.channelCount,
|
|
302
|
+
totalFrames: options.totalFrames,
|
|
303
|
+
windowFrames,
|
|
304
|
+
peaks,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function invertFrameRanges(ranges, totalFrames) {
|
|
308
|
+
const result = [];
|
|
309
|
+
let cursor = 0;
|
|
310
|
+
for (const range of ranges) {
|
|
311
|
+
if (range.startFrame > cursor) {
|
|
312
|
+
result.push({ startFrame: cursor, frameCount: range.startFrame - cursor });
|
|
313
|
+
}
|
|
314
|
+
cursor = Math.max(cursor, range.startFrame + range.frameCount);
|
|
315
|
+
}
|
|
316
|
+
if (cursor < totalFrames)
|
|
317
|
+
result.push({ startFrame: cursor, frameCount: totalFrames - cursor });
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
/** Detect audible ranges with bounded reads and deterministic window merging. */
|
|
321
|
+
export async function detectSilence(options) {
|
|
322
|
+
const thresholdDb = options.thresholdDb ?? -45;
|
|
323
|
+
const minimumSilenceUs = options.minimumSilenceUs ?? 250_000;
|
|
324
|
+
const paddingUs = options.paddingUs ?? 20_000;
|
|
325
|
+
const windowFrames = options.windowFrames ?? 1_024;
|
|
326
|
+
if (!Number.isSafeInteger(options.sampleRate) ||
|
|
327
|
+
!Number.isSafeInteger(options.channelCount) ||
|
|
328
|
+
!Number.isSafeInteger(options.totalFrames) ||
|
|
329
|
+
!Number.isSafeInteger(windowFrames) ||
|
|
330
|
+
options.sampleRate <= 0 ||
|
|
331
|
+
options.channelCount <= 0 ||
|
|
332
|
+
options.totalFrames < 0 ||
|
|
333
|
+
windowFrames <= 0 ||
|
|
334
|
+
!Number.isFinite(thresholdDb) ||
|
|
335
|
+
minimumSilenceUs < 0 ||
|
|
336
|
+
paddingUs < 0) {
|
|
337
|
+
throw new RangeError('Invalid silence detection options');
|
|
338
|
+
}
|
|
339
|
+
const threshold = 10 ** (thresholdDb / 20);
|
|
340
|
+
const audibleWindows = [];
|
|
341
|
+
for (let startFrame = 0; startFrame < options.totalFrames; startFrame += windowFrames) {
|
|
342
|
+
throwIfAborted(options.signal, 'Silence detection');
|
|
343
|
+
const frameCount = Math.min(windowFrames, options.totalFrames - startFrame);
|
|
344
|
+
const pcm = await options.readFrames(startFrame, frameCount, options.signal);
|
|
345
|
+
if (pcm.length !== frameCount * options.channelCount) {
|
|
346
|
+
throw new RangeError('Silence source returned an unexpected PCM length');
|
|
347
|
+
}
|
|
348
|
+
let sumSquares = 0;
|
|
349
|
+
for (const sample of pcm)
|
|
350
|
+
sumSquares += sample * sample;
|
|
351
|
+
const rms = Math.sqrt(sumSquares / Math.max(1, pcm.length));
|
|
352
|
+
if (rms > threshold)
|
|
353
|
+
audibleWindows.push({ startFrame, frameCount });
|
|
354
|
+
options.onProgress?.((startFrame + frameCount) / Math.max(1, options.totalFrames));
|
|
355
|
+
}
|
|
356
|
+
const minimumSilenceFrames = Math.round((minimumSilenceUs * options.sampleRate) / 1_000_000);
|
|
357
|
+
const paddingFrames = Math.round((paddingUs * options.sampleRate) / 1_000_000);
|
|
358
|
+
const nonSilent = [];
|
|
359
|
+
for (const window of audibleWindows) {
|
|
360
|
+
const startFrame = Math.max(0, window.startFrame - paddingFrames);
|
|
361
|
+
const endFrame = Math.min(options.totalFrames, window.startFrame + window.frameCount + paddingFrames);
|
|
362
|
+
const previous = nonSilent.at(-1);
|
|
363
|
+
if (previous !== undefined &&
|
|
364
|
+
startFrame - (previous.startFrame + previous.frameCount) <= minimumSilenceFrames) {
|
|
365
|
+
nonSilent[nonSilent.length - 1] = {
|
|
366
|
+
startFrame: previous.startFrame,
|
|
367
|
+
frameCount: endFrame - previous.startFrame,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
nonSilent.push({ startFrame, frameCount: endFrame - startFrame });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const silent = invertFrameRanges(nonSilent, options.totalFrames);
|
|
375
|
+
if (options.totalFrames === 0)
|
|
376
|
+
options.onProgress?.(1);
|
|
377
|
+
return {
|
|
378
|
+
sampleRate: options.sampleRate,
|
|
379
|
+
channelCount: options.channelCount,
|
|
380
|
+
totalFrames: options.totalFrames,
|
|
381
|
+
thresholdDb,
|
|
382
|
+
nonSilent,
|
|
383
|
+
silent,
|
|
384
|
+
removedFrames: silent.reduce((sum, range) => sum + range.frameCount, 0),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface StreamingPcmResamplerOptions {
|
|
2
|
+
readonly inputSampleRate: number;
|
|
3
|
+
readonly outputSampleRate: number;
|
|
4
|
+
readonly channelCount: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Deterministic, chunk-boundary-independent linear PCM resampler.
|
|
8
|
+
*
|
|
9
|
+
* The phase is represented with integer sample counts instead of an accumulated
|
|
10
|
+
* float, so a long render produces the same samples regardless of how providers
|
|
11
|
+
* split their PCM blocks. `push(..., true)` flushes the exact finite-duration
|
|
12
|
+
* tail and seals the instance.
|
|
13
|
+
*/
|
|
14
|
+
export declare class StreamingPcmResampler {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(options: StreamingPcmResamplerOptions);
|
|
17
|
+
get inputFrames(): number;
|
|
18
|
+
get outputFrames(): number;
|
|
19
|
+
push(interleaved: Float32Array, final?: boolean): Float32Array;
|
|
20
|
+
}
|
|
21
|
+
export declare function resampleInterleavedPcm(input: Float32Array, options: StreamingPcmResamplerOptions): Float32Array;
|
|
22
|
+
//# sourceMappingURL=resampler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resampler.d.ts","sourceRoot":"","sources":["../src/resampler.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAiBD;;;;;;;GAOG;AACH,qBAAa,qBAAqB;;gBAUb,OAAO,EAAE,4BAA4B;IAUxD,IAAW,WAAW,IAAI,MAAM,CAE/B;IAED,IAAW,YAAY,IAAI,MAAM,CAEhC;IAEM,IAAI,CAAC,WAAW,EAAE,YAAY,EAAE,KAAK,UAAQ,GAAG,YAAY;CAyDpE;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,4BAA4B,GACpC,YAAY,CAEd"}
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
* Deterministic, chunk-boundary-independent linear PCM resampler.
|
|
18
|
+
*
|
|
19
|
+
* The phase is represented with integer sample counts instead of an accumulated
|
|
20
|
+
* float, so a long render produces the same samples regardless of how providers
|
|
21
|
+
* split their PCM blocks. `push(..., true)` flushes the exact finite-duration
|
|
22
|
+
* tail and seals the instance.
|
|
23
|
+
*/
|
|
24
|
+
export class StreamingPcmResampler {
|
|
25
|
+
#inputSampleRate;
|
|
26
|
+
#outputSampleRate;
|
|
27
|
+
#channelCount;
|
|
28
|
+
#buffer = new Float32Array();
|
|
29
|
+
#bufferStartFrame = 0;
|
|
30
|
+
#inputFrames = 0;
|
|
31
|
+
#outputFrames = 0;
|
|
32
|
+
#sealed = false;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
positiveInteger(options.inputSampleRate, 'inputSampleRate');
|
|
35
|
+
positiveInteger(options.outputSampleRate, 'outputSampleRate');
|
|
36
|
+
positiveInteger(options.channelCount, 'channelCount');
|
|
37
|
+
if (options.channelCount > 8)
|
|
38
|
+
throw new RangeError('channelCount must not exceed 8');
|
|
39
|
+
this.#inputSampleRate = options.inputSampleRate;
|
|
40
|
+
this.#outputSampleRate = options.outputSampleRate;
|
|
41
|
+
this.#channelCount = options.channelCount;
|
|
42
|
+
}
|
|
43
|
+
get inputFrames() {
|
|
44
|
+
return this.#inputFrames;
|
|
45
|
+
}
|
|
46
|
+
get outputFrames() {
|
|
47
|
+
return this.#outputFrames;
|
|
48
|
+
}
|
|
49
|
+
push(interleaved, final = false) {
|
|
50
|
+
if (this.#sealed)
|
|
51
|
+
throw new ReferenceError('PCM resampler is sealed');
|
|
52
|
+
if (interleaved.length % this.#channelCount !== 0) {
|
|
53
|
+
throw new RangeError('PCM length must be divisible by channelCount');
|
|
54
|
+
}
|
|
55
|
+
this.#buffer = concat(this.#buffer, interleaved);
|
|
56
|
+
this.#inputFrames += interleaved.length / this.#channelCount;
|
|
57
|
+
const maximumOutputFrames = final
|
|
58
|
+
? Number((BigInt(this.#inputFrames) * BigInt(this.#outputSampleRate)) /
|
|
59
|
+
BigInt(this.#inputSampleRate))
|
|
60
|
+
: Number.MAX_SAFE_INTEGER;
|
|
61
|
+
const samples = [];
|
|
62
|
+
while (this.#outputFrames < maximumOutputFrames) {
|
|
63
|
+
const positionNumerator = BigInt(this.#outputFrames) * BigInt(this.#inputSampleRate);
|
|
64
|
+
const sourceFrame = Number(positionNumerator / BigInt(this.#outputSampleRate));
|
|
65
|
+
const fractionNumerator = Number(positionNumerator % BigInt(this.#outputSampleRate));
|
|
66
|
+
const needsNext = fractionNumerator !== 0;
|
|
67
|
+
if (sourceFrame >= this.#inputFrames ||
|
|
68
|
+
(!final && needsNext && sourceFrame + 1 >= this.#inputFrames)) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const localFrame = sourceFrame - this.#bufferStartFrame;
|
|
72
|
+
if (localFrame < 0)
|
|
73
|
+
throw new Error('PCM resampler discarded a required source frame');
|
|
74
|
+
const nextLocalFrame = Math.min(this.#buffer.length / this.#channelCount - 1, localFrame + 1);
|
|
75
|
+
const fraction = fractionNumerator / this.#outputSampleRate;
|
|
76
|
+
for (let channel = 0; channel < this.#channelCount; channel += 1) {
|
|
77
|
+
const first = this.#buffer[localFrame * this.#channelCount + channel] ?? 0;
|
|
78
|
+
const next = this.#buffer[nextLocalFrame * this.#channelCount + channel] ?? first;
|
|
79
|
+
samples.push(first + (next - first) * fraction);
|
|
80
|
+
}
|
|
81
|
+
this.#outputFrames += 1;
|
|
82
|
+
}
|
|
83
|
+
const nextPositionNumerator = BigInt(this.#outputFrames) * BigInt(this.#inputSampleRate);
|
|
84
|
+
const firstRequiredFrame = Number(nextPositionNumerator / BigInt(this.#outputSampleRate));
|
|
85
|
+
const discardFrames = Math.max(0, Math.min(this.#buffer.length / this.#channelCount, firstRequiredFrame - this.#bufferStartFrame));
|
|
86
|
+
if (discardFrames > 0) {
|
|
87
|
+
this.#buffer = this.#buffer.slice(discardFrames * this.#channelCount);
|
|
88
|
+
this.#bufferStartFrame += discardFrames;
|
|
89
|
+
}
|
|
90
|
+
if (final) {
|
|
91
|
+
this.#sealed = true;
|
|
92
|
+
this.#buffer = new Float32Array();
|
|
93
|
+
}
|
|
94
|
+
return Float32Array.from(samples);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function resampleInterleavedPcm(input, options) {
|
|
98
|
+
return new StreamingPcmResampler(options).push(input, true);
|
|
99
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface PitchPreservingTimeStretchOptions {
|
|
2
|
+
readonly input: Float32Array;
|
|
3
|
+
readonly inputFrames: number;
|
|
4
|
+
readonly outputFrames: number;
|
|
5
|
+
readonly channelCount: number;
|
|
6
|
+
readonly reverse?: boolean;
|
|
7
|
+
/** Analysis grain size. The implementation clamps it to the available input. */
|
|
8
|
+
readonly grainFrames?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface StreamingPitchPreservingTimeStretchOptions {
|
|
11
|
+
readonly inputFrames: number;
|
|
12
|
+
readonly outputFrames: number;
|
|
13
|
+
readonly channelCount: number;
|
|
14
|
+
readonly grainFrames?: number;
|
|
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 declare class StreamingPitchPreservingTimeStretch {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(options: StreamingPitchPreservingTimeStretchOptions);
|
|
27
|
+
push(interleaved: Float32Array, final?: boolean): Float32Array;
|
|
28
|
+
}
|
|
29
|
+
export declare function pitchPreservingTimeStretch(options: PitchPreservingTimeStretchOptions): Float32Array;
|
|
30
|
+
//# sourceMappingURL=time-stretch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time-stretch.d.ts","sourceRoot":"","sources":["../src/time-stretch.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,iCAAiC;IAChD,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,gFAAgF;IAChF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,0CAA0C;IACzD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAiBD;;;;;;;GAOG;AACH,qBAAa,mCAAmC;;gBAiB3B,OAAO,EAAE,0CAA0C;IAkB/D,IAAI,CAAC,WAAW,EAAE,YAAY,EAAE,KAAK,UAAQ,GAAG,YAAY;CAmHpE;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,YAAY,CAwBd"}
|