@kuralle-syrinx/cf-agents 3.1.0 → 4.0.0
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/README.md +30 -3
- package/package.json +8 -8
- package/src/build-session.ts +48 -2
- package/src/durable-history.ts +79 -0
- package/src/index.ts +4 -0
- package/src/r2-recorder.test.ts +88 -3
- package/src/r2-recorder.ts +256 -76
- package/src/with-voice.test.ts +326 -5
- package/src/with-voice.ts +195 -6
package/src/r2-recorder.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
//
|
|
3
3
|
// R2-backed implementation of the transport EdgeRecorder. Taps inbound caller
|
|
4
|
-
// audio and outbound TTS audio and
|
|
5
|
-
// -
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// -
|
|
4
|
+
// audio and outbound TTS audio and writes to an R2 bucket:
|
|
5
|
+
// - user.wav / assistant.wav : the per-speaker stems, time-aligned by wall-clock
|
|
6
|
+
// (user chunks at their wall offset; assistant re-anchored to
|
|
7
|
+
// playout) with silence filling the gaps — the durable artifacts.
|
|
8
|
+
// - conversation.wav : best-effort stereo mix (user = left, assistant = right),
|
|
9
|
+
// written only for short calls that stayed wholly in DO RAM;
|
|
10
|
+
// omitted (flagged in the manifest) once a stem streams out.
|
|
9
11
|
// - manifest.json : durations / byte lengths / truncation flags.
|
|
10
12
|
// Mirrors the Node `voice-recorder` conversation-track approach (wall-clock byte
|
|
11
|
-
// offsets + stereo interleave) but stays edge-safe (no node:fs).
|
|
12
|
-
//
|
|
13
|
-
//
|
|
13
|
+
// offsets + stereo interleave) but stays edge-safe (no node:fs).
|
|
14
|
+
//
|
|
15
|
+
// Memory: the DO has ~128 MB. Rather than buffer the whole call and gap-fill the full
|
|
16
|
+
// wall-clock length at finalize (which OOMs long/mostly-silent calls), each stem is
|
|
17
|
+
// gap-filled INCREMENTALLY and streamed to R2 via multipart upload: bytes accumulate
|
|
18
|
+
// into a bounded buffer and flush as 5 MiB parts, so retained memory stays ~O(part size)
|
|
19
|
+
// per stem regardless of call length. Short calls (< one part) never open a multipart and
|
|
20
|
+
// are written with a single put, exactly as before.
|
|
14
21
|
|
|
15
22
|
import type { EdgeRecorder } from "@kuralle-syrinx/server-websocket/edge";
|
|
16
23
|
import { interleaveStereoPcm16, pcm16ToWav } from "@kuralle-syrinx/recorder/wav";
|
|
@@ -21,40 +28,93 @@ export interface R2EdgeRecorderOptions {
|
|
|
21
28
|
readonly startedAtMs: number;
|
|
22
29
|
/** Object key prefix. Default "recordings". */
|
|
23
30
|
readonly keyPrefix?: string;
|
|
24
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Optional per-stream truncation cap on captured audio bytes. Past it, audio is
|
|
33
|
+
* dropped and the stem is flagged `truncated`. Default: unlimited — memory is bounded
|
|
34
|
+
* by streaming, not by retention, so any length records without OOM.
|
|
35
|
+
*/
|
|
25
36
|
readonly maxBytesPerStream?: number;
|
|
26
37
|
/** Injectable clock (test seam). Defaults to Date.now. */
|
|
27
38
|
readonly now?: () => number;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
|
|
41
|
+
// R2 requires every multipart part except the last to be at least 5 MiB. We flush at
|
|
42
|
+
// exactly that: bigger parts waste RAM, smaller ones are rejected.
|
|
43
|
+
const PART_SIZE_BYTES = 5 * 1024 * 1024;
|
|
44
|
+
// Emit long silence gaps in small slices so a multi-minute gap never allocates its full
|
|
45
|
+
// wall-clock length at once (that was the OOM).
|
|
46
|
+
const SILENCE_SLICE_BYTES = 64 * 1024;
|
|
47
|
+
|
|
48
|
+
/** A FIFO byte queue that hands back exact-length runs without holding the whole call. */
|
|
49
|
+
class PartBuffer {
|
|
50
|
+
#chunks: Uint8Array[] = [];
|
|
51
|
+
#size = 0;
|
|
52
|
+
|
|
53
|
+
push(bytes: Uint8Array): void {
|
|
54
|
+
if (bytes.byteLength === 0) return;
|
|
55
|
+
this.#chunks.push(bytes);
|
|
56
|
+
this.#size += bytes.byteLength;
|
|
57
|
+
}
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
get size(): number {
|
|
60
|
+
return this.#size;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Remove and return the first `n` bytes (caller guarantees n <= size). */
|
|
64
|
+
take(n: number): Uint8Array {
|
|
65
|
+
const out = new Uint8Array(n);
|
|
66
|
+
let filled = 0;
|
|
67
|
+
while (filled < n) {
|
|
68
|
+
const head = this.#chunks[0]!;
|
|
69
|
+
const need = n - filled;
|
|
70
|
+
if (head.byteLength <= need) {
|
|
71
|
+
out.set(head, filled);
|
|
72
|
+
filled += head.byteLength;
|
|
73
|
+
this.#chunks.shift();
|
|
74
|
+
} else {
|
|
75
|
+
out.set(head.subarray(0, need), filled);
|
|
76
|
+
this.#chunks[0] = head.subarray(need);
|
|
77
|
+
filled += need;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
this.#size -= n;
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
drain(): Uint8Array {
|
|
85
|
+
return this.take(this.#size);
|
|
86
|
+
}
|
|
35
87
|
}
|
|
36
88
|
|
|
37
|
-
interface
|
|
38
|
-
|
|
89
|
+
interface Stem {
|
|
90
|
+
readonly key: string;
|
|
91
|
+
readonly buf: PartBuffer;
|
|
92
|
+
/** Deferred part-1 payload (the first PART_SIZE bytes) once the stem goes multipart. */
|
|
93
|
+
head: Uint8Array | null;
|
|
94
|
+
multipart: boolean;
|
|
95
|
+
uploadId: string | null;
|
|
96
|
+
parts: R2UploadedPart[];
|
|
97
|
+
partSeq: number; // next partNumber for streamed middle parts (part 1 reserved for the header)
|
|
98
|
+
tail: Promise<void>; // serialises the async create/upload chain fired from sync callbacks
|
|
39
99
|
cursorBytes: number; // end of the last placed chunk on the wall-clock timeline
|
|
40
100
|
dataBytes: number; // actual audio bytes captured (for the cap)
|
|
41
101
|
sampleRateHz: number;
|
|
42
102
|
truncated: boolean;
|
|
43
103
|
}
|
|
44
104
|
|
|
45
|
-
function emptyStream(): StreamBuffer {
|
|
46
|
-
return { chunks: [], cursorBytes: 0, dataBytes: 0, sampleRateHz: 16000, truncated: false };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
105
|
export class R2EdgeRecorder implements EdgeRecorder {
|
|
50
|
-
readonly #
|
|
51
|
-
readonly #
|
|
52
|
-
readonly #
|
|
106
|
+
readonly #prefix: string;
|
|
107
|
+
readonly #user: Stem;
|
|
108
|
+
readonly #assistant: Stem;
|
|
109
|
+
readonly #maxBytes: number | undefined;
|
|
53
110
|
readonly #now: () => number;
|
|
54
111
|
#finalized = false;
|
|
55
112
|
|
|
56
113
|
constructor(private readonly opts: R2EdgeRecorderOptions) {
|
|
57
|
-
this.#
|
|
114
|
+
this.#prefix = `${opts.keyPrefix ?? "recordings"}/${opts.sessionId}/${opts.startedAtMs}`;
|
|
115
|
+
this.#user = this.#emptyStem(`${this.#prefix}/user.wav`);
|
|
116
|
+
this.#assistant = this.#emptyStem(`${this.#prefix}/assistant.wav`);
|
|
117
|
+
this.#maxBytes = opts.maxBytesPerStream;
|
|
58
118
|
this.#now = opts.now ?? Date.now;
|
|
59
119
|
}
|
|
60
120
|
|
|
@@ -71,63 +131,158 @@ export class R2EdgeRecorder implements EdgeRecorder {
|
|
|
71
131
|
this.#finalized = true;
|
|
72
132
|
if (this.#user.dataBytes === 0 && this.#assistant.dataBytes === 0) return;
|
|
73
133
|
|
|
74
|
-
const prefix = `${this.opts.keyPrefix ?? "recordings"}/${this.opts.sessionId}/${this.opts.startedAtMs}`;
|
|
75
134
|
const rate = this.#user.sampleRateHz; // conversation timeline runs at the user (input) rate
|
|
76
135
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
136
|
+
// Close each stem sequentially (never hold both full WAV copies at once). A stem that
|
|
137
|
+
// never streamed hands back its full mono PCM so a best-effort stereo mix can be built.
|
|
138
|
+
const userMono = await this.#closeStem(this.#user);
|
|
139
|
+
const assistantMono = await this.#closeStem(this.#assistant);
|
|
140
|
+
|
|
141
|
+
const conversation =
|
|
142
|
+
userMono && assistantMono
|
|
143
|
+
? await this.#writeStereo(userMono, assistantMono, rate)
|
|
144
|
+
: {
|
|
145
|
+
path: `${this.#prefix}/conversation.wav`,
|
|
146
|
+
sampleRateHz: rate,
|
|
147
|
+
channels: 2 as const,
|
|
148
|
+
encoding: "pcm_s16le" as const,
|
|
149
|
+
byteLength: 0,
|
|
150
|
+
durationMs: 0,
|
|
151
|
+
omitted: true as const,
|
|
152
|
+
};
|
|
84
153
|
|
|
85
154
|
const manifest = {
|
|
86
155
|
schemaVersion: 1 as const,
|
|
87
156
|
sessionId: meta.sessionId,
|
|
88
157
|
startedAtMs: this.opts.startedAtMs,
|
|
89
158
|
closedAtMs: meta.closedAtMs,
|
|
90
|
-
conversation
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
channels: 2 as const,
|
|
94
|
-
encoding: "pcm_s16le" as const,
|
|
95
|
-
byteLength: conversation.byteLength,
|
|
96
|
-
durationMs: rate > 0 ? Math.round((conversation.byteLength / 4 / rate) * 1000) : 0,
|
|
97
|
-
},
|
|
98
|
-
user: this.#describe(this.#user, `${prefix}/user.wav`),
|
|
99
|
-
assistant: this.#describe(this.#assistant, `${prefix}/assistant.wav`),
|
|
159
|
+
conversation,
|
|
160
|
+
user: this.#describe(this.#user),
|
|
161
|
+
assistant: this.#describe(this.#assistant),
|
|
100
162
|
};
|
|
101
163
|
|
|
102
|
-
await
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}),
|
|
106
|
-
this.opts.bucket.put(`${prefix}/user.wav`, pcm16ToWav(userPcm, this.#user.sampleRateHz, 1), {
|
|
107
|
-
httpMetadata: { contentType: "audio/wav" },
|
|
108
|
-
}),
|
|
109
|
-
this.opts.bucket.put(`${prefix}/assistant.wav`, pcm16ToWav(assistantRaw, this.#assistant.sampleRateHz, 1), {
|
|
110
|
-
httpMetadata: { contentType: "audio/wav" },
|
|
111
|
-
}),
|
|
112
|
-
this.opts.bucket.put(`${prefix}/manifest.json`, JSON.stringify(manifest, null, 2), {
|
|
113
|
-
httpMetadata: { contentType: "application/json" },
|
|
114
|
-
}),
|
|
115
|
-
]);
|
|
164
|
+
await this.opts.bucket.put(`${this.#prefix}/manifest.json`, JSON.stringify(manifest, null, 2), {
|
|
165
|
+
httpMetadata: { contentType: "application/json" },
|
|
166
|
+
});
|
|
116
167
|
}
|
|
117
168
|
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
buf
|
|
169
|
+
#emptyStem(key: string): Stem {
|
|
170
|
+
return {
|
|
171
|
+
key,
|
|
172
|
+
buf: new PartBuffer(),
|
|
173
|
+
head: null,
|
|
174
|
+
multipart: false,
|
|
175
|
+
uploadId: null,
|
|
176
|
+
parts: [],
|
|
177
|
+
partSeq: 2,
|
|
178
|
+
tail: Promise.resolve(),
|
|
179
|
+
cursorBytes: 0,
|
|
180
|
+
dataBytes: 0,
|
|
181
|
+
sampleRateHz: 16000,
|
|
182
|
+
truncated: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#append(stem: Stem, audio: Uint8Array, sampleRateHz: number): void {
|
|
187
|
+
stem.sampleRateHz = sampleRateHz;
|
|
188
|
+
if (this.#maxBytes !== undefined && stem.dataBytes + audio.byteLength > this.#maxBytes) {
|
|
189
|
+
stem.truncated = true;
|
|
122
190
|
return;
|
|
123
191
|
}
|
|
124
|
-
// Anchor each chunk at its wall-clock position so the two speakers line up on a
|
|
125
|
-
//
|
|
192
|
+
// Anchor each chunk at its wall-clock position so the two speakers line up on a shared
|
|
193
|
+
// timeline; never overlap the previous chunk. Emit the intervening silence + the chunk
|
|
194
|
+
// incrementally so the timeline is never materialised whole.
|
|
126
195
|
const wallOffset = this.#wallOffsetBytes(sampleRateHz);
|
|
127
|
-
const offsetBytes = Math.max(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
196
|
+
const offsetBytes = Math.max(stem.cursorBytes, wallOffset);
|
|
197
|
+
const gap = offsetBytes - stem.cursorBytes;
|
|
198
|
+
if (gap > 0) this.#emitSilence(stem, gap);
|
|
199
|
+
this.#emit(stem, audio.slice());
|
|
200
|
+
stem.cursorBytes = offsetBytes + audio.byteLength;
|
|
201
|
+
stem.dataBytes += audio.byteLength;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#emitSilence(stem: Stem, bytes: number): void {
|
|
205
|
+
let remaining = bytes;
|
|
206
|
+
while (remaining > 0) {
|
|
207
|
+
const slice = Math.min(remaining, SILENCE_SLICE_BYTES);
|
|
208
|
+
this.#emit(stem, new Uint8Array(slice)); // zero = PCM16 silence
|
|
209
|
+
remaining -= slice;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#emit(stem: Stem, bytes: Uint8Array): void {
|
|
214
|
+
if (bytes.byteLength === 0) return;
|
|
215
|
+
stem.buf.push(bytes);
|
|
216
|
+
if (!stem.multipart && stem.buf.size >= PART_SIZE_BYTES) {
|
|
217
|
+
// Commit to multipart: retain the first part's worth as the deferred part 1 (its WAV
|
|
218
|
+
// header needs the final total length, known only at finalize), then stream the rest.
|
|
219
|
+
stem.head = stem.buf.take(PART_SIZE_BYTES);
|
|
220
|
+
stem.multipart = true;
|
|
221
|
+
this.#enqueueCreate(stem);
|
|
222
|
+
}
|
|
223
|
+
if (stem.multipart) {
|
|
224
|
+
while (stem.buf.size >= PART_SIZE_BYTES) {
|
|
225
|
+
this.#enqueueUpload(stem, stem.partSeq++, stem.buf.take(PART_SIZE_BYTES));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#enqueueCreate(stem: Stem): void {
|
|
231
|
+
stem.tail = stem.tail.then(async () => {
|
|
232
|
+
const mpu = await this.opts.bucket.createMultipartUpload(stem.key, {
|
|
233
|
+
httpMetadata: { contentType: "audio/wav" },
|
|
234
|
+
});
|
|
235
|
+
stem.uploadId = mpu.uploadId;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#enqueueUpload(stem: Stem, partNumber: number, body: Uint8Array): void {
|
|
240
|
+
stem.tail = stem.tail.then(async () => {
|
|
241
|
+
const mpu = this.opts.bucket.resumeMultipartUpload(stem.key, stem.uploadId!);
|
|
242
|
+
stem.parts.push(await mpu.uploadPart(partNumber, body));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Flush a stem to R2. Returns its full mono PCM if it stayed in RAM, else null. */
|
|
247
|
+
async #closeStem(stem: Stem): Promise<Uint8Array | null> {
|
|
248
|
+
if (!stem.multipart) {
|
|
249
|
+
const mono = stem.buf.drain();
|
|
250
|
+
await this.opts.bucket.put(stem.key, pcm16ToWav(mono, stem.sampleRateHz, 1), {
|
|
251
|
+
httpMetadata: { contentType: "audio/wav" },
|
|
252
|
+
});
|
|
253
|
+
return mono;
|
|
254
|
+
}
|
|
255
|
+
await stem.tail; // drain the queued create + middle-part uploads
|
|
256
|
+
const mpu = this.opts.bucket.resumeMultipartUpload(stem.key, stem.uploadId!);
|
|
257
|
+
// Part 1 = the WAV header (now that the total data length is known) + the retained head.
|
|
258
|
+
const head = stem.head ?? new Uint8Array(0);
|
|
259
|
+
const part1 = concat(wavHeader(stem.cursorBytes, stem.sampleRateHz, 1), head);
|
|
260
|
+
stem.parts.push(await mpu.uploadPart(1, part1));
|
|
261
|
+
const remainder = stem.buf.drain();
|
|
262
|
+
if (remainder.byteLength > 0) stem.parts.push(await mpu.uploadPart(stem.partSeq++, remainder));
|
|
263
|
+
stem.parts.sort((a, b) => a.partNumber - b.partNumber);
|
|
264
|
+
await mpu.complete(stem.parts);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async #writeStereo(userPcm: Uint8Array, assistantMono: Uint8Array, rate: number) {
|
|
269
|
+
const assistantPcm =
|
|
270
|
+
this.#assistant.sampleRateHz === rate
|
|
271
|
+
? assistantMono
|
|
272
|
+
: resamplePcm16(assistantMono, this.#assistant.sampleRateHz, rate);
|
|
273
|
+
const stereo = interleaveStereoPcm16(userPcm, assistantPcm);
|
|
274
|
+
await this.opts.bucket.put(`${this.#prefix}/conversation.wav`, pcm16ToWav(stereo, rate, 2), {
|
|
275
|
+
httpMetadata: { contentType: "audio/wav" },
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
path: `${this.#prefix}/conversation.wav`,
|
|
279
|
+
sampleRateHz: rate,
|
|
280
|
+
channels: 2 as const,
|
|
281
|
+
encoding: "pcm_s16le" as const,
|
|
282
|
+
byteLength: stereo.byteLength,
|
|
283
|
+
durationMs: rate > 0 ? Math.round((stereo.byteLength / 4 / rate) * 1000) : 0,
|
|
284
|
+
omitted: false as const,
|
|
285
|
+
};
|
|
131
286
|
}
|
|
132
287
|
|
|
133
288
|
#wallOffsetBytes(sampleRateHz: number): number {
|
|
@@ -136,26 +291,52 @@ export class R2EdgeRecorder implements EdgeRecorder {
|
|
|
136
291
|
return bytes - (bytes % 2);
|
|
137
292
|
}
|
|
138
293
|
|
|
139
|
-
#describe(
|
|
294
|
+
#describe(stem: Stem) {
|
|
140
295
|
return {
|
|
141
|
-
path,
|
|
142
|
-
sampleRateHz:
|
|
296
|
+
path: stem.key,
|
|
297
|
+
sampleRateHz: stem.sampleRateHz,
|
|
143
298
|
encoding: "pcm_s16le" as const,
|
|
144
299
|
channels: 1 as const,
|
|
145
|
-
byteLength:
|
|
146
|
-
durationMs:
|
|
147
|
-
truncated:
|
|
300
|
+
byteLength: stem.cursorBytes,
|
|
301
|
+
durationMs: stem.sampleRateHz > 0 ? Math.round((stem.cursorBytes / 2 / stem.sampleRateHz) * 1000) : 0,
|
|
302
|
+
truncated: stem.truncated,
|
|
148
303
|
};
|
|
149
304
|
}
|
|
150
305
|
}
|
|
151
306
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
307
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
308
|
+
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
309
|
+
out.set(a, 0);
|
|
310
|
+
out.set(b, a.byteLength);
|
|
156
311
|
return out;
|
|
157
312
|
}
|
|
158
313
|
|
|
314
|
+
/** Build the 44-byte canonical WAV (RIFF/PCM) header for a stream of `dataBytes` PCM16LE. */
|
|
315
|
+
function wavHeader(dataBytes: number, sampleRateHz: number, channels: number): Uint8Array {
|
|
316
|
+
const blockAlign = channels * 2; // 16-bit samples
|
|
317
|
+
const byteRate = sampleRateHz * blockAlign;
|
|
318
|
+
const out = new Uint8Array(44);
|
|
319
|
+
const view = new DataView(out.buffer);
|
|
320
|
+
writeAscii(view, 0, "RIFF");
|
|
321
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
322
|
+
writeAscii(view, 8, "WAVE");
|
|
323
|
+
writeAscii(view, 12, "fmt ");
|
|
324
|
+
view.setUint32(16, 16, true);
|
|
325
|
+
view.setUint16(20, 1, true); // PCM
|
|
326
|
+
view.setUint16(22, channels, true);
|
|
327
|
+
view.setUint32(24, sampleRateHz, true);
|
|
328
|
+
view.setUint32(28, byteRate, true);
|
|
329
|
+
view.setUint16(32, blockAlign, true);
|
|
330
|
+
view.setUint16(34, 16, true);
|
|
331
|
+
writeAscii(view, 36, "data");
|
|
332
|
+
view.setUint32(40, dataBytes, true);
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function writeAscii(view: DataView, offset: number, text: string): void {
|
|
337
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
338
|
+
}
|
|
339
|
+
|
|
159
340
|
function resamplePcm16(pcm: Uint8Array, fromHz: number, toHz: number): Uint8Array {
|
|
160
341
|
if (fromHz === toHz || pcm.byteLength === 0) return pcm;
|
|
161
342
|
const src = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
|
@@ -174,4 +355,3 @@ function resamplePcm16(pcm: Uint8Array, fromHz: number, toHz: number): Uint8Arra
|
|
|
174
355
|
}
|
|
175
356
|
return out;
|
|
176
357
|
}
|
|
177
|
-
|