@camstack/addon-pipeline 1.1.19 → 1.1.21

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 (34) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/audio-codec-ffmpeg/index.js +1125 -0
  4. package/dist/audio-codec-ffmpeg/index.mjs +1107 -0
  5. package/dist/decoder-ffmpeg/index.js +543 -288
  6. package/dist/decoder-ffmpeg/index.mjs +542 -287
  7. package/dist/detection-pipeline/index.js +1 -1
  8. package/dist/detection-pipeline/index.mjs +1 -1
  9. package/dist/{dist-BgoBMCez.js → dist-BiP1gPeY.js} +20 -1
  10. package/dist/{dist-7Yx2dmuV.mjs → dist-tzhTTRq4.mjs} +20 -1
  11. package/dist/{ffmpeg-args-common-c3p-nWtU.mjs → frame-dropper-CwkBTPGV.mjs} +22 -22
  12. package/dist/motion-wasm/index.js +1 -1
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/pipeline-runner/index.js +1 -1
  15. package/dist/pipeline-runner/index.mjs +1 -1
  16. package/dist/recorder/index.js +24 -4
  17. package/dist/recorder/index.mjs +24 -4
  18. package/dist/stream-broker/_stub.js +35 -35
  19. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-w2pP0MYO.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D08qJTw_.mjs} +3 -3
  20. package/dist/stream-broker/{hostInit-BUEbBinp.mjs → hostInit-CGnMNtPl.mjs} +3 -3
  21. package/dist/stream-broker/index.js +487 -458
  22. package/dist/stream-broker/index.mjs +477 -448
  23. package/dist/stream-broker/remoteEntry.js +1 -1
  24. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CYmHXacX.js → MaskShapeCanvas-DI4BY7W2-CW4Qu4V6.js} +1 -1
  25. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-B-BkPQvX.js → MotionZonesSettings-NcxxQN8r-CHJ39tfi.js} +1 -1
  26. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DC3Jw85p.js → PrivacyMaskSettings-APgPLF7p-dInJ7kcz.js} +1 -1
  27. package/embed-dist/assets/{index-B0IQgci0.js → index-CKpVWKSG.js} +4 -4
  28. package/embed-dist/index.html +1 -1
  29. package/package.json +10 -10
  30. package/dist/audio-codec-nodeav/index.js +0 -310
  31. package/dist/audio-codec-nodeav/index.mjs +0 -305
  32. package/dist/codec-runtime-BOk-13PN.js +0 -202
  33. package/dist/codec-runtime-BsqlEjPi.mjs +0 -197
  34. package/dist/{ffmpeg-args-common-CASoNt42.js → frame-dropper-7RTo_YyG.js} +21 -21
@@ -0,0 +1,1107 @@
1
+ import { B as BaseAddon, _ as audioCodecCapability, z as errMsg } from "../dist-tzhTTRq4.mjs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ //#region src/audio-codec-ffmpeg/ffmpeg-audio-process.ts
5
+ /**
6
+ * Minimal ffmpeg subprocess wrapper shared by the audio decode + encode
7
+ * sessions of `audio-codec-ffmpeg`.
8
+ *
9
+ * Owns exactly the process concerns both directions need: spawn, stdin write
10
+ * with tolerated backpressure, stdout chunk delivery, and the kill sequence.
11
+ *
12
+ * The kill logic copies the CORRECTED pattern from `decoder-ffmpeg`'s
13
+ * `killFfmpeg`: SIGTERM, then SIGKILL after a grace — gated on
14
+ * `exitCode === null && signalCode === null`, NOT on `child.killed` (which Node
15
+ * sets true after ANY `kill()`, so `!child.killed` never fires and the SIGKILL
16
+ * would leak the process).
17
+ */
18
+ /** Grace period between SIGTERM and the escalated SIGKILL. */
19
+ var KILL_GRACE_MS = 500;
20
+ var FfmpegAudioProcess = class {
21
+ child = null;
22
+ logger;
23
+ opts;
24
+ killed = false;
25
+ constructor(opts) {
26
+ this.opts = opts;
27
+ this.logger = opts.logger;
28
+ this.spawn();
29
+ }
30
+ spawn() {
31
+ let child;
32
+ try {
33
+ child = spawn(this.opts.ffmpegPath, [...this.opts.args]);
34
+ } catch (err) {
35
+ this.logger.error("audio-codec-ffmpeg: spawn threw", { meta: { error: errMsg(err) } });
36
+ return;
37
+ }
38
+ this.child = child;
39
+ child.stdin?.on("error", () => {});
40
+ child.stdout?.on("data", (chunk) => this.opts.onStdout(chunk));
41
+ child.stderr?.on("data", (data) => {
42
+ const line = data.toString().trim();
43
+ if (line) this.logger.debug("audio-codec-ffmpeg stderr", { meta: { line } });
44
+ });
45
+ child.on("error", (err) => {
46
+ this.logger.error("audio-codec-ffmpeg: process error", { meta: { error: err.message } });
47
+ });
48
+ child.on("exit", (code, signal) => {
49
+ if (this.killed) return;
50
+ this.opts.onExit(code, signal);
51
+ });
52
+ }
53
+ /** Write encoded frames / PCM into ffmpeg stdin. Backpressure is tolerated. */
54
+ write(data) {
55
+ const stdin = this.child?.stdin;
56
+ if (!stdin) return;
57
+ try {
58
+ stdin.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
59
+ } catch {}
60
+ }
61
+ /** Close stdin so ffmpeg flushes and drains its remaining output. */
62
+ endStdin() {
63
+ try {
64
+ this.child?.stdin?.end();
65
+ } catch {}
66
+ }
67
+ /** SIGTERM then escalated SIGKILL — see class doc for the gating rationale. */
68
+ kill() {
69
+ const child = this.child;
70
+ if (!child) return;
71
+ this.killed = true;
72
+ this.child = null;
73
+ try {
74
+ child.stdin?.end();
75
+ } catch {}
76
+ try {
77
+ child.kill("SIGTERM");
78
+ } catch {}
79
+ const { pid } = child;
80
+ setTimeout(() => {
81
+ if (pid !== void 0 && child.exitCode === null && child.signalCode === null) try {
82
+ child.kill("SIGKILL");
83
+ } catch {}
84
+ }, KILL_GRACE_MS).unref?.();
85
+ }
86
+ };
87
+ //#endregion
88
+ //#region src/audio-codec-ffmpeg/audio-codec-args.ts
89
+ /**
90
+ * Low-latency flag set shared by both the decode and encode invocations.
91
+ *
92
+ * - `-hide_banner -nostats -loglevel error`: quiet stderr (no periodic counter).
93
+ * - `-fflags +nobuffer+flush_packets`: don't buffer input; flush output packets
94
+ * the moment they are ready (critical for the intercom encode path).
95
+ * - `-flags low_delay`: request the lowest-latency codec behaviour.
96
+ * - `-probesize 32 -analyzeduration 0`: skip stream probing — the input format
97
+ * is fully specified by `-f`/`-ar`/`-ac`, so probing only adds startup delay.
98
+ * - `-threads 1`: single-threaded — audio frames are tiny, and extra worker
99
+ * threads only add scheduling jitter to a real-time path.
100
+ */
101
+ var AUDIO_LOW_LATENCY_ARGS = [
102
+ "-hide_banner",
103
+ "-nostats",
104
+ "-loglevel",
105
+ "error",
106
+ "-fflags",
107
+ "+nobuffer+flush_packets",
108
+ "-flags",
109
+ "low_delay",
110
+ "-probesize",
111
+ "32",
112
+ "-analyzeduration",
113
+ "0",
114
+ "-threads",
115
+ "1"
116
+ ];
117
+ /**
118
+ * Normalise an SDP-reported codec name to the libav/ffmpeg name. Mirrors the
119
+ * `resolveCodecAlias` in `audio-codec-nodeav` so callers can pass the
120
+ * SDP-reported value verbatim.
121
+ */
122
+ function resolveAudioCodecAlias(codec) {
123
+ const c = codec.toLowerCase();
124
+ if (c === "mpeg4-generic") return "aac";
125
+ if (c === "l16") return "pcm_s16be";
126
+ return c;
127
+ }
128
+ /**
129
+ * codec → ffmpeg DECODE input format.
130
+ *
131
+ * | codec | -f | raw? | note |
132
+ * | ----------------------------- | ------ | ---- | ----------------------------- |
133
+ * | pcm_mulaw | mulaw | yes | G.711 µ-law |
134
+ * | pcm_alaw | alaw | yes | G.711 A-law |
135
+ * | pcm_s16be (l16) | s16be | yes | RTP L16 |
136
+ * | pcm_s16le | s16le | yes | |
137
+ * | g722 | g722 | yes | 16 kHz wideband |
138
+ * | aac / aac_latm / mpeg4-generic| aac | no | ADTS demuxer |
139
+ * | opus | ogg | no | ⚠ needs Ogg framing (see NOTE)|
140
+ *
141
+ * NOTE (opus decode): ffmpeg has no raw-packet Opus demuxer. `audio-codec-nodeav`
142
+ * feeds raw RTP Opus packets straight to the `AV_CODEC_ID_OPUS` decoder with no
143
+ * demuxer; a subprocess cannot do that from a pipe. We map to `ogg`, which is
144
+ * correct only if the pushed bytes are Ogg-Opus. Raw RTP Opus decode via the
145
+ * subprocess is a KNOWN limitation — see the addon report.
146
+ */
147
+ var DECODE_FORMAT_BY_CODEC = {
148
+ pcm_mulaw: {
149
+ inputFormat: "mulaw",
150
+ rawInput: true
151
+ },
152
+ pcm_alaw: {
153
+ inputFormat: "alaw",
154
+ rawInput: true
155
+ },
156
+ pcm_s16be: {
157
+ inputFormat: "s16be",
158
+ rawInput: true
159
+ },
160
+ pcm_s16le: {
161
+ inputFormat: "s16le",
162
+ rawInput: true
163
+ },
164
+ g722: {
165
+ inputFormat: "g722",
166
+ rawInput: true
167
+ },
168
+ aac: {
169
+ inputFormat: "aac",
170
+ rawInput: false
171
+ },
172
+ aac_latm: {
173
+ inputFormat: "aac",
174
+ rawInput: false
175
+ },
176
+ opus: {
177
+ inputFormat: "ogg",
178
+ rawInput: false
179
+ }
180
+ };
181
+ /**
182
+ * codec → ffmpeg ENCODE codec + container.
183
+ *
184
+ * | codec | -c:a | -f | bitrate? |
185
+ * | --------- | ---------- | ----- | -------- |
186
+ * | pcm_mulaw | pcm_mulaw | mulaw | no |
187
+ * | pcm_alaw | pcm_alaw | alaw | no |
188
+ * | g722 | g722 | g722 | no |
189
+ * | aac | aac | adts | yes |
190
+ * | opus | libopus | ogg | yes |
191
+ */
192
+ var ENCODE_FORMAT_BY_CODEC = {
193
+ pcm_mulaw: {
194
+ encoder: "pcm_mulaw",
195
+ outputFormat: "mulaw",
196
+ acceptsBitrate: false
197
+ },
198
+ pcm_alaw: {
199
+ encoder: "pcm_alaw",
200
+ outputFormat: "alaw",
201
+ acceptsBitrate: false
202
+ },
203
+ g722: {
204
+ encoder: "g722",
205
+ outputFormat: "g722",
206
+ acceptsBitrate: false
207
+ },
208
+ aac: {
209
+ encoder: "aac",
210
+ outputFormat: "adts",
211
+ acceptsBitrate: true
212
+ },
213
+ opus: {
214
+ encoder: "libopus",
215
+ outputFormat: "ogg",
216
+ acceptsBitrate: true
217
+ }
218
+ };
219
+ /** Resolve the DECODE input-format descriptor for a codec, or `null` if unknown. */
220
+ function decodeFormatForCodec(codec) {
221
+ return DECODE_FORMAT_BY_CODEC[resolveAudioCodecAlias(codec)] ?? null;
222
+ }
223
+ /** Resolve the ENCODE codec+container descriptor for a codec, or `null` if unknown. */
224
+ function encodeFormatForCodec(codec) {
225
+ return ENCODE_FORMAT_BY_CODEC[resolveAudioCodecAlias(codec)] ?? null;
226
+ }
227
+ /** Packed bytes-per-sample for a PCM format (s16le = 2, f32le = 4). */
228
+ function audioBytesPerSample(format) {
229
+ return format === "f32le" ? 4 : 2;
230
+ }
231
+ /**
232
+ * Build the ffmpeg argv for a DECODE session — encoded frames on `pipe:0`, raw
233
+ * PCM on `pipe:1`. PURE: no spawn, no env access.
234
+ *
235
+ * ffmpeg <low-latency> -f <inputFmt> [-ar <src> -ac <srcCh>] -i pipe:0 \
236
+ * -ar <target> -ac <targetCh> -f <s16le|f32le> pipe:1
237
+ *
238
+ * @throws if the codec has no known decode mapping.
239
+ */
240
+ function buildAudioDecodeArgs(config) {
241
+ const entry = decodeFormatForCodec(config.codec);
242
+ if (!entry) throw new Error(`audio-codec-ffmpeg: no decode format for codec '${config.codec}'`);
243
+ const outFormat = config.targetFormat ?? "s16le";
244
+ const inputRateChannel = entry.rawInput ? [
245
+ "-ar",
246
+ String(config.sourceSampleRate),
247
+ "-ac",
248
+ String(config.sourceChannels)
249
+ ] : [];
250
+ return [
251
+ ...AUDIO_LOW_LATENCY_ARGS,
252
+ "-f",
253
+ entry.inputFormat,
254
+ ...inputRateChannel,
255
+ "-i",
256
+ "pipe:0",
257
+ "-ar",
258
+ String(config.targetSampleRate),
259
+ "-ac",
260
+ String(config.targetChannels),
261
+ "-f",
262
+ outFormat,
263
+ "pipe:1"
264
+ ];
265
+ }
266
+ /**
267
+ * Build the ffmpeg argv for an ENCODE session — raw PCM on `pipe:0`, encoded
268
+ * bytes on `pipe:1`. PURE: no spawn, no env access.
269
+ *
270
+ * ffmpeg <low-latency> -f <s16le|f32le> -ar <src> -ac <srcCh> -i pipe:0 \
271
+ * -c:a <encoder> -ar <target> -ac <targetCh> [-b:a <k>k] \
272
+ * [-application lowdelay] -f <containerFmt> pipe:1
273
+ *
274
+ * For `libopus` the intercom-oriented `-application lowdelay` mode is added,
275
+ * trading a little quality for the lowest algorithmic latency.
276
+ *
277
+ * @throws if the codec has no known encode mapping.
278
+ */
279
+ function buildAudioEncodeArgs(config) {
280
+ const entry = encodeFormatForCodec(config.codec);
281
+ if (!entry) throw new Error(`audio-codec-ffmpeg: no encode format for codec '${config.codec}'`);
282
+ const inFormat = config.sourceFormat ?? "s16le";
283
+ const bitrateArgs = entry.acceptsBitrate && config.bitrateKbps !== void 0 ? ["-b:a", `${config.bitrateKbps}k`] : [];
284
+ const opusLowDelay = entry.encoder === "libopus" ? ["-application", "lowdelay"] : [];
285
+ return [
286
+ ...AUDIO_LOW_LATENCY_ARGS,
287
+ "-f",
288
+ inFormat,
289
+ "-ar",
290
+ String(config.sourceSampleRate),
291
+ "-ac",
292
+ String(config.sourceChannels),
293
+ "-i",
294
+ "pipe:0",
295
+ "-c:a",
296
+ entry.encoder,
297
+ "-ar",
298
+ String(config.targetSampleRate),
299
+ "-ac",
300
+ String(config.targetChannels),
301
+ ...bitrateArgs,
302
+ ...opusLowDelay,
303
+ "-f",
304
+ entry.outputFormat,
305
+ "pipe:1"
306
+ ];
307
+ }
308
+ //#endregion
309
+ //#region src/audio-codec-ffmpeg/ogg-opus-framer.ts
310
+ /**
311
+ * Ogg-Opus encapsulator for the `audio-codec-ffmpeg` decode path.
312
+ *
313
+ * Cameras / RTP deliver RAW Opus packets — one self-delimited Opus packet per
314
+ * `pushEncodedFrame`. ffmpeg has NO raw-Opus demuxer, so the subprocess cannot
315
+ * decode a bare packet stream from a pipe (unlike `audio-codec-nodeav`, which
316
+ * feeds raw packets straight to `AV_CODEC_ID_OPUS` in-process). To decode Opus
317
+ * out-of-process we must wrap the raw packets in a valid **Ogg-Opus** container
318
+ * so `ffmpeg -f ogg -i pipe:0` can demux + decode them.
319
+ *
320
+ * This framer turns a stream of raw Opus packets into a byte stream shaped per
321
+ * RFC 7845 (Ogg Encapsulation for Opus) + RFC 3533 (the Ogg container):
322
+ *
323
+ * Page 0 (BOS) : one "OpusHead" identification packet
324
+ * Page 1 : one "OpusTags" comment packet
325
+ * Page 2..n : one Opus audio packet each (one-packet-per-page = lowest
326
+ * latency, simplest lacing), granule = running 48 kHz sample
327
+ * total.
328
+ * flush() : optional final EOS page.
329
+ *
330
+ * The Ogg page CRC is NOT the common zlib CRC-32: it uses polynomial
331
+ * 0x04C11DB7 with init 0, NO input/output bit reflection, and NO final XOR,
332
+ * computed over the whole page with the CRC field zeroed (see `oggCrc32`).
333
+ */
334
+ /** Ogg capture pattern, "OggS". */
335
+ var OGG_CAPTURE_PATTERN = Buffer.from("OggS", "ascii");
336
+ /** Opus identification header magic, "OpusHead". */
337
+ var OPUS_HEAD_MAGIC = Buffer.from("OpusHead", "ascii");
338
+ /** Opus comment header magic, "OpusTags". */
339
+ var OPUS_TAGS_MAGIC = Buffer.from("OpusTags", "ascii");
340
+ /** Ogg page header byte offsets (before the segment table). */
341
+ var OGG_HEADER_FIXED_BYTES = 27;
342
+ /** Byte offset of the 4-byte CRC field within the fixed header. */
343
+ var OGG_CRC_OFFSET = 22;
344
+ var HEADER_TYPE_BOS = 2;
345
+ var HEADER_TYPE_EOS = 4;
346
+ /**
347
+ * OpusHead `pre-skip`: samples the decoder discards at stream start. 3840
348
+ * samples (80 ms at 48 kHz) is a common, safe over-estimate for libopus — it
349
+ * only trims a one-time startup transient and never drops steady-state audio.
350
+ * (The precise libopus encoder lookahead is 312 samples; for a live stream we
351
+ * do not need sample-accurate trimming, so the larger value is fine.)
352
+ */
353
+ var DEFAULT_OPUS_PRE_SKIP = 3840;
354
+ /** OpusHead `input sample rate` — informational per RFC 7845 (§5.1). */
355
+ var OPUS_INPUT_SAMPLE_RATE = 48e3;
356
+ /** Opus granule positions are ALWAYS counted at 48 kHz (RFC 7845 §4). */
357
+ var OPUS_GRANULE_SAMPLE_RATE = 48e3;
358
+ /**
359
+ * Opus frame size, in 48 kHz samples, indexed by the 5-bit TOC `config`
360
+ * (0..31). Derived from the frame-duration table in RFC 6716 §3.1:
361
+ *
362
+ * config 0-11 SILK NB/MB/WB → 10 / 20 / 40 / 60 ms
363
+ * config 12-15 Hybrid SWB/FB → 10 / 20 ms
364
+ * config 16-31 CELT NB/…/FB → 2.5 / 5 / 10 / 20 ms
365
+ *
366
+ * samples@48k = ms * 48 (2.5→120, 5→240, 10→480, 20→960, 40→1920, 60→2880).
367
+ */
368
+ var OPUS_FRAME_SAMPLES_48K = [
369
+ 480,
370
+ 960,
371
+ 1920,
372
+ 2880,
373
+ 480,
374
+ 960,
375
+ 1920,
376
+ 2880,
377
+ 480,
378
+ 960,
379
+ 1920,
380
+ 2880,
381
+ 480,
382
+ 960,
383
+ 480,
384
+ 960,
385
+ 120,
386
+ 240,
387
+ 480,
388
+ 960,
389
+ 120,
390
+ 240,
391
+ 480,
392
+ 960,
393
+ 120,
394
+ 240,
395
+ 480,
396
+ 960,
397
+ 120,
398
+ 240,
399
+ 480,
400
+ 960
401
+ ];
402
+ /**
403
+ * Precomputed Ogg CRC-32 lookup table (MSB-first, polynomial 0x04C11DB7).
404
+ *
405
+ * Each entry is the CRC of the single byte `i` placed in the top position of a
406
+ * 32-bit register, shifted out MSB-first with no reflection. This is the table
407
+ * form of the bit-serial algorithm used by `oggCrc32`.
408
+ */
409
+ var OGG_CRC_TABLE = (() => {
410
+ const table = new Uint32Array(256);
411
+ for (let i = 0; i < 256; i++) {
412
+ let r = i << 24 >>> 0;
413
+ for (let bit = 0; bit < 8; bit++) r = (r & 2147483648) !== 0 ? (r << 1 ^ 79764919) >>> 0 : r << 1 >>> 0;
414
+ table[i] = r >>> 0;
415
+ }
416
+ return table;
417
+ })();
418
+ /**
419
+ * Ogg page CRC-32 (RFC 3533 §4). Polynomial 0x04C11DB7, init 0, NO input or
420
+ * output reflection, NO final XOR — deliberately different from the reflected
421
+ * zlib/IEEE CRC-32. Computed over the ENTIRE page bytes with the 4-byte CRC
422
+ * field already zeroed.
423
+ */
424
+ function oggCrc32(data) {
425
+ let crc = 0;
426
+ for (let i = 0; i < data.length; i++) {
427
+ const idx = (crc >>> 24 ^ (data[i] ?? 0)) & 255;
428
+ crc = (crc << 8 >>> 0 ^ (OGG_CRC_TABLE[idx] ?? 0)) >>> 0;
429
+ }
430
+ return crc >>> 0;
431
+ }
432
+ /**
433
+ * Decode an Opus packet's total duration in samples from its TOC byte
434
+ * (RFC 6716 §3). Returns samples at `sampleRate` (default 48 kHz — the rate Ogg
435
+ * granule positions are counted in).
436
+ *
437
+ * TOC byte layout: `config` (bits 3-7), `s` stereo (bit 2), `c` frame-count
438
+ * code (bits 0-1):
439
+ * - code 0 → 1 frame
440
+ * - code 1 / 2 → 2 frames
441
+ * - code 3 → arbitrary; frame count = low 6 bits of the byte after the TOC.
442
+ *
443
+ * Returns 0 for an empty or malformed (code-3 with no count byte) packet.
444
+ */
445
+ function opusPacketSampleCount(packet, sampleRate = 48e3) {
446
+ if (packet.length < 1) return 0;
447
+ const toc = packet[0] ?? 0;
448
+ const config = toc >> 3;
449
+ const code = toc & 3;
450
+ const samplesPerFrame48k = OPUS_FRAME_SAMPLES_48K[config] ?? 0;
451
+ if (samplesPerFrame48k === 0) return 0;
452
+ let frameCount;
453
+ if (code === 0) frameCount = 1;
454
+ else if (code === 1 || code === 2) frameCount = 2;
455
+ else {
456
+ if (packet.length < 2) return 0;
457
+ frameCount = (packet[1] ?? 0) & 63;
458
+ }
459
+ const samples48k = samplesPerFrame48k * frameCount;
460
+ if (sampleRate === OPUS_GRANULE_SAMPLE_RATE) return samples48k;
461
+ return Math.round(samples48k * sampleRate / OPUS_GRANULE_SAMPLE_RATE);
462
+ }
463
+ /**
464
+ * Encode an Ogg lacing segment table for a single packet of length `len`
465
+ * (RFC 3533 §6): `floor(len/255)` bytes of 0xFF then one final byte `len%255`.
466
+ * A length that is an exact multiple of 255 therefore ends in a `0x00` lacing
467
+ * value — required so the demuxer knows the packet terminates on this page.
468
+ */
469
+ function buildLacing(len) {
470
+ const full = Math.floor(len / 255);
471
+ const table = Buffer.alloc(full + 1);
472
+ table.fill(255, 0, full);
473
+ table[full] = len % 255;
474
+ return table;
475
+ }
476
+ /**
477
+ * Turn a hash-derivable seed into a stable 32-bit Ogg bitstream serial. FNV-1a
478
+ * over the seed string keeps distinct sessions on distinct serials without
479
+ * requiring the caller to allocate one.
480
+ */
481
+ function serialFromSeed(seed) {
482
+ let hash = 2166136261;
483
+ for (let i = 0; i < seed.length; i++) {
484
+ hash ^= seed.charCodeAt(i) & 255;
485
+ hash = Math.imul(hash, 16777619);
486
+ }
487
+ return hash >>> 0;
488
+ }
489
+ /**
490
+ * Streaming Ogg-Opus encapsulator. Stateful: tracks page sequence numbers and
491
+ * the running 48 kHz granule position across calls. One instance per decode
492
+ * session; call {@link reset} to reuse it for a fresh stream.
493
+ */
494
+ var OggOpusFramer = class {
495
+ channelCount;
496
+ serial;
497
+ preSkip;
498
+ vendor;
499
+ pageSeq = 0;
500
+ granule = 0;
501
+ headersEmitted = false;
502
+ audioEmitted = false;
503
+ constructor(config) {
504
+ this.channelCount = config.channelCount;
505
+ this.serial = config.serial >>> 0;
506
+ this.preSkip = config.preSkip ?? 3840;
507
+ this.vendor = config.vendor ?? "camstack";
508
+ }
509
+ /**
510
+ * Build the two header pages (OpusHead BOS + OpusTags). Advances the page
511
+ * sequence to 2 so audio pages follow. Returns an empty buffer if the headers
512
+ * were already emitted (idempotent within a stream).
513
+ */
514
+ headerPages() {
515
+ if (this.headersEmitted) return Buffer.alloc(0);
516
+ this.headersEmitted = true;
517
+ const head = this.buildOpusHead();
518
+ const tags = this.buildOpusTags();
519
+ const headPage = this.buildPage(head, HEADER_TYPE_BOS, 0);
520
+ const tagsPage = this.buildPage(tags, 0, 0);
521
+ return Buffer.concat([headPage, tagsPage]);
522
+ }
523
+ /**
524
+ * Encapsulate one raw Opus packet as a single audio page, advancing the
525
+ * granule by the packet's decoded sample count. Header pages are emitted
526
+ * (and prepended) automatically on the first call, so the caller can simply
527
+ * write the returned bytes to ffmpeg stdin.
528
+ */
529
+ framePacket(packet) {
530
+ const prefix = this.headersEmitted ? Buffer.alloc(0) : this.headerPages();
531
+ this.granule += opusPacketSampleCount(packet, OPUS_GRANULE_SAMPLE_RATE);
532
+ this.audioEmitted = true;
533
+ const page = this.buildPage(packet, 0, this.granule);
534
+ return prefix.length > 0 ? Buffer.concat([prefix, page]) : page;
535
+ }
536
+ /**
537
+ * Emit a final EOS page terminating the logical bitstream. Returns `null`
538
+ * when no audio was written (nothing to terminate). The EOS page carries no
539
+ * packet data (0 segments) and the final granule position.
540
+ */
541
+ flush() {
542
+ if (!this.audioEmitted) return null;
543
+ return this.buildPage(null, HEADER_TYPE_EOS, this.granule);
544
+ }
545
+ /** Reset all page/granule state so the instance can frame a fresh stream. */
546
+ reset() {
547
+ this.pageSeq = 0;
548
+ this.granule = 0;
549
+ this.headersEmitted = false;
550
+ this.audioEmitted = false;
551
+ }
552
+ /** OpusHead identification packet (RFC 7845 §5.1) — 19 bytes for family 0. */
553
+ buildOpusHead() {
554
+ const buf = Buffer.alloc(19);
555
+ OPUS_HEAD_MAGIC.copy(buf, 0);
556
+ buf.writeUInt8(1, 8);
557
+ buf.writeUInt8(this.channelCount, 9);
558
+ buf.writeUInt16LE(this.preSkip & 65535, 10);
559
+ buf.writeUInt32LE(OPUS_INPUT_SAMPLE_RATE, 12);
560
+ buf.writeInt16LE(0, 16);
561
+ buf.writeUInt8(0, 18);
562
+ return buf;
563
+ }
564
+ /** OpusTags comment packet (RFC 7845 §5.2) — magic, vendor, 0 comments. */
565
+ buildOpusTags() {
566
+ const vendor = Buffer.from(this.vendor, "utf8");
567
+ const buf = Buffer.alloc(12 + vendor.length + 4);
568
+ OPUS_TAGS_MAGIC.copy(buf, 0);
569
+ buf.writeUInt32LE(vendor.length, 8);
570
+ vendor.copy(buf, 12);
571
+ buf.writeUInt32LE(0, 12 + vendor.length);
572
+ return buf;
573
+ }
574
+ /**
575
+ * Assemble one Ogg page around a single packet (or none, for an EOS
576
+ * terminator), compute its CRC, and return the framed bytes. Keeps to a
577
+ * single packet per page so the segment count never approaches the 255 limit
578
+ * (an Opus packet is ≤ ~1275 bytes → ≤ 6 lacing segments).
579
+ */
580
+ buildPage(packet, headerType, granule) {
581
+ const lacing = packet !== null ? buildLacing(packet.length) : Buffer.alloc(0);
582
+ const segmentCount = lacing.length;
583
+ const bodyLen = packet !== null ? packet.length : 0;
584
+ const page = Buffer.alloc(OGG_HEADER_FIXED_BYTES + segmentCount + bodyLen);
585
+ OGG_CAPTURE_PATTERN.copy(page, 0);
586
+ page.writeUInt8(0, 4);
587
+ page.writeUInt8(headerType & 7, 5);
588
+ this.writeGranule(page, granule, 6);
589
+ page.writeUInt32LE(this.serial, 14);
590
+ page.writeUInt32LE(this.pageSeq >>> 0, 18);
591
+ page.writeUInt32LE(0, OGG_CRC_OFFSET);
592
+ page.writeUInt8(segmentCount, 26);
593
+ lacing.copy(page, OGG_HEADER_FIXED_BYTES);
594
+ if (packet !== null) packet.copy(page, OGG_HEADER_FIXED_BYTES + segmentCount);
595
+ const crc = oggCrc32(page);
596
+ page.writeUInt32LE(crc, OGG_CRC_OFFSET);
597
+ this.pageSeq = this.pageSeq + 1 >>> 0;
598
+ return page;
599
+ }
600
+ /**
601
+ * Write a 64-bit little-endian granule position. Values fit comfortably in a
602
+ * JS safe integer for any realistic stream duration (2^53 samples @48k ≈ 5940
603
+ * years), so a split 32-bit lo/hi write is exact and avoids BigInt on the
604
+ * hot path.
605
+ */
606
+ writeGranule(page, granule, offset) {
607
+ const lo = granule >>> 0;
608
+ const hi = Math.floor(granule / 4294967296) >>> 0;
609
+ page.writeUInt32LE(lo, offset);
610
+ page.writeUInt32LE(hi, offset + 4);
611
+ }
612
+ };
613
+ //#endregion
614
+ //#region src/audio-codec-ffmpeg/ffmpeg-audio-decode-session.ts
615
+ var FfmpegAudioDecodeSession = class {
616
+ logger;
617
+ process;
618
+ outFormat;
619
+ bytesPerFrame;
620
+ targetSampleRate;
621
+ targetChannels;
622
+ onChunk;
623
+ /**
624
+ * Ogg-Opus encapsulator, present ONLY when the codec is Opus. Raw Opus
625
+ * packets have no ffmpeg demuxer, so each pushed packet is wrapped in an
626
+ * Ogg page (with OpusHead/OpusTags emitted before the first) so
627
+ * `ffmpeg -f ogg -i pipe:0` can demux + decode. `null` for all other codecs,
628
+ * which pipe their encoded frames straight through.
629
+ */
630
+ oggFramer;
631
+ residual = Buffer.alloc(0);
632
+ nextPts = 0;
633
+ destroyed = false;
634
+ constructor(params, logger, onChunk) {
635
+ this.logger = logger;
636
+ this.onChunk = onChunk;
637
+ this.outFormat = params.targetFormat ?? "s16le";
638
+ this.targetSampleRate = params.targetSampleRate;
639
+ this.targetChannels = params.targetChannels;
640
+ this.bytesPerFrame = Math.max(1, params.targetChannels * audioBytesPerSample(this.outFormat));
641
+ this.oggFramer = resolveAudioCodecAlias(params.codec) === "opus" ? new OggOpusFramer({
642
+ channelCount: Math.max(1, params.sourceChannels),
643
+ serial: serialFromSeed(params.oggSerialSeed ?? "audio-codec-ffmpeg-opus")
644
+ }) : null;
645
+ const args = buildAudioDecodeArgs(params);
646
+ this.process = new FfmpegAudioProcess({
647
+ ffmpegPath: params.ffmpegPath,
648
+ args,
649
+ logger,
650
+ onStdout: (chunk) => this.handleStdout(chunk),
651
+ onExit: (code, signal) => {
652
+ if (this.destroyed) return;
653
+ this.logger.warn("audio-codec-ffmpeg: decode child exited", { meta: {
654
+ code,
655
+ signal
656
+ } });
657
+ }
658
+ });
659
+ }
660
+ /** Push one encoded audio frame into ffmpeg stdin. */
661
+ pushEncoded(data) {
662
+ if (this.destroyed) return;
663
+ if (this.oggFramer) {
664
+ const packet = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
665
+ this.process.write(this.oggFramer.framePacket(packet));
666
+ return;
667
+ }
668
+ this.process.write(data);
669
+ }
670
+ handleStdout(chunk) {
671
+ if (this.destroyed) return;
672
+ const buf = this.residual.length === 0 ? chunk : Buffer.concat([this.residual, chunk]);
673
+ const frameBytes = this.bytesPerFrame;
674
+ const usable = buf.length - buf.length % frameBytes;
675
+ if (usable <= 0) {
676
+ this.residual = buf;
677
+ return;
678
+ }
679
+ const emit = buf.subarray(0, usable);
680
+ this.residual = usable < buf.length ? Buffer.from(buf.subarray(usable)) : Buffer.alloc(0);
681
+ const out = new Uint8Array(new ArrayBuffer(usable));
682
+ out.set(emit);
683
+ const samples = usable / frameBytes;
684
+ const pts = this.nextPts;
685
+ this.nextPts = pts + Math.round(samples * 1e3 / this.targetSampleRate);
686
+ this.onChunk({
687
+ data: out,
688
+ sampleRate: this.targetSampleRate,
689
+ channels: this.targetChannels,
690
+ format: this.outFormat,
691
+ pts
692
+ });
693
+ }
694
+ destroy() {
695
+ if (this.destroyed) return;
696
+ this.destroyed = true;
697
+ if (this.oggFramer) {
698
+ const eos = this.oggFramer.flush();
699
+ if (eos) this.process.write(eos);
700
+ }
701
+ this.process.kill();
702
+ this.residual = Buffer.alloc(0);
703
+ }
704
+ };
705
+ //#endregion
706
+ //#region src/audio-codec-ffmpeg/ffmpeg-audio-encode-session.ts
707
+ var FfmpegAudioEncodeSession = class {
708
+ logger;
709
+ process;
710
+ codec;
711
+ onChunk;
712
+ nextPts = 0;
713
+ destroyed = false;
714
+ constructor(params, logger, onChunk) {
715
+ this.logger = logger;
716
+ this.onChunk = onChunk;
717
+ this.codec = resolveAudioCodecAlias(params.codec);
718
+ const args = buildAudioEncodeArgs(params);
719
+ this.process = new FfmpegAudioProcess({
720
+ ffmpegPath: params.ffmpegPath,
721
+ args,
722
+ logger,
723
+ onStdout: (chunk) => this.handleStdout(chunk),
724
+ onExit: (code, signal) => {
725
+ if (this.destroyed) return;
726
+ this.logger.warn("audio-codec-ffmpeg: encode child exited", { meta: {
727
+ code,
728
+ signal
729
+ } });
730
+ }
731
+ });
732
+ }
733
+ /** Push one PCM chunk into ffmpeg stdin. */
734
+ pushPcm(data) {
735
+ if (this.destroyed) return;
736
+ this.process.write(data);
737
+ }
738
+ /** Close stdin so ffmpeg flushes and drains the remaining encoded output. */
739
+ flush() {
740
+ if (this.destroyed) return;
741
+ this.process.endStdin();
742
+ }
743
+ handleStdout(chunk) {
744
+ if (this.destroyed || chunk.length === 0) return;
745
+ const out = new Uint8Array(new ArrayBuffer(chunk.length));
746
+ out.set(chunk);
747
+ const pts = this.nextPts;
748
+ this.nextPts = pts + 1;
749
+ this.onChunk({
750
+ data: out,
751
+ codec: this.codec,
752
+ pts,
753
+ frameComplete: true
754
+ });
755
+ }
756
+ destroy() {
757
+ if (this.destroyed) return;
758
+ this.destroyed = true;
759
+ this.process.kill();
760
+ }
761
+ };
762
+ //#endregion
763
+ //#region src/audio-codec-ffmpeg/addon/index.ts
764
+ var CODEC_CATALOG = [
765
+ {
766
+ codec: "pcm_mulaw",
767
+ canDecode: true,
768
+ canEncode: true,
769
+ label: "PCM µ-law (G.711)"
770
+ },
771
+ {
772
+ codec: "pcm_alaw",
773
+ canDecode: true,
774
+ canEncode: true,
775
+ label: "PCM A-law (G.711)"
776
+ },
777
+ {
778
+ codec: "g722",
779
+ canDecode: true,
780
+ canEncode: true,
781
+ label: "G.722"
782
+ },
783
+ {
784
+ codec: "aac",
785
+ canDecode: true,
786
+ canEncode: true,
787
+ label: "AAC"
788
+ },
789
+ {
790
+ codec: "aac_latm",
791
+ canDecode: true,
792
+ canEncode: false,
793
+ label: "AAC LATM"
794
+ },
795
+ {
796
+ codec: "mpeg4-generic",
797
+ canDecode: true,
798
+ canEncode: false,
799
+ label: "AAC (MPEG4-GENERIC)"
800
+ },
801
+ {
802
+ codec: "opus",
803
+ canDecode: true,
804
+ canEncode: true,
805
+ label: "Opus"
806
+ }
807
+ ];
808
+ var DEFAULT_IDLE_MS = 3e4;
809
+ var REAPER_INTERVAL_MS = 5e3;
810
+ /**
811
+ * Grace wait in `flushEncode` for ffmpeg to drain its encoder after stdin is
812
+ * closed. This is a graceful-teardown path (not the real-time push path), so a
813
+ * short wait is acceptable to capture the encoder's tail output.
814
+ */
815
+ var FLUSH_DRAIN_MS = 60;
816
+ var DEFAULT_GLOBAL_CONFIG = { defaultIdleMs: DEFAULT_IDLE_MS };
817
+ /**
818
+ * Audio codec I/O box backed by an **ffmpeg subprocess** (Phase B replacement
819
+ * for `audio-codec-nodeav`, which runs libavcodec + libswresample in-process).
820
+ *
821
+ * Each `createDecodeSession` / `createEncodeSession` spawns its own ffmpeg
822
+ * child, so consumers never share a resampler and a codec crash is isolated to
823
+ * the child — the addon runner survives. Cap surface + session bookkeeping
824
+ * mirror the node-av addon exactly; only the codec backend changed.
825
+ *
826
+ * This addon is the cap's `preferredProvider` (declared on
827
+ * `audio-codec.cap.ts`), so with no operator override it wins the singleton
828
+ * active slot.
829
+ */
830
+ var AudioCodecFfmpegAddon = class extends BaseAddon {
831
+ sessions = /* @__PURE__ */ new Map();
832
+ reaperTimer = null;
833
+ logger = null;
834
+ /**
835
+ * The ffmpeg binary every session spawns — resolved once at init from the
836
+ * operator `ffmpeg.binaryPath` override, else provisioned via
837
+ * `ctx.deps.ensureFfmpeg()`. Same binary the broker / recorder / decoder use.
838
+ */
839
+ ffmpegPath = "ffmpeg";
840
+ constructor() {
841
+ super(DEFAULT_GLOBAL_CONFIG);
842
+ }
843
+ async onInitialize() {
844
+ this.logger = this.ctx.logger;
845
+ this.ffmpegPath = await this.resolveFfmpegBinaryPath();
846
+ this.reaperTimer = setInterval(() => this.reapIdleSessions(), REAPER_INTERVAL_MS);
847
+ if (typeof this.reaperTimer.unref === "function") this.reaperTimer.unref();
848
+ return [{
849
+ capability: audioCodecCapability,
850
+ provider: this
851
+ }];
852
+ }
853
+ async onShutdown() {
854
+ if (this.reaperTimer) {
855
+ clearInterval(this.reaperTimer);
856
+ this.reaperTimer = null;
857
+ }
858
+ for (const s of this.sessions.values()) this.disposeSession(s);
859
+ this.sessions.clear();
860
+ }
861
+ /**
862
+ * Resolve the ffmpeg binary the sessions spawn.
863
+ *
864
+ * Precedence (mirrors `decoder-ffmpeg`):
865
+ * 1. Operator override in the cluster `ffmpeg` config section (`binaryPath`).
866
+ * 2. Otherwise `ctx.deps.ensureFfmpeg()` — provisions a portable static
867
+ * ffmpeg into the node's deps dir when the system has none.
868
+ *
869
+ * On failure, logs a loud ERROR and falls back to PATH `ffmpeg`; the addon
870
+ * still registers the cap and a later spawn re-runs against the same path.
871
+ */
872
+ async resolveFfmpegBinaryPath() {
873
+ try {
874
+ const bp = (await this.ctx.settings?.getSection("ffmpeg") ?? {})["binaryPath"];
875
+ if (typeof bp === "string" && bp.trim().length > 0) return bp;
876
+ } catch {}
877
+ try {
878
+ return await this.ctx.deps.ensureFfmpeg();
879
+ } catch (err) {
880
+ this.ctx.logger.error("audio-codec-ffmpeg: ensureFfmpeg() failed to provision ffmpeg — falling back to PATH \"ffmpeg\"", { meta: { error: errMsg(err) } });
881
+ return "ffmpeg";
882
+ }
883
+ }
884
+ async listSupportedCodecs() {
885
+ return CODEC_CATALOG.map((e) => ({
886
+ codec: e.codec,
887
+ canDecode: e.canDecode,
888
+ canEncode: e.canEncode,
889
+ ...e.label ? { label: e.label } : {}
890
+ }));
891
+ }
892
+ async canHandle(input) {
893
+ const resolved = resolveAudioCodecAlias(input.codec);
894
+ const entry = CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === resolved);
895
+ if (!entry) return false;
896
+ return input.kind === "decode" ? entry.canDecode : entry.canEncode;
897
+ }
898
+ async createDecodeSession(input) {
899
+ const codec = resolveAudioCodecAlias(input.codec);
900
+ const entry = CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === codec);
901
+ if (!entry || !entry.canDecode) throw new Error(`audio-codec-ffmpeg: decode unsupported for codec '${input.codec}'`);
902
+ const sessionId = `dec-${randomUUID()}`;
903
+ const state = {
904
+ sessionId,
905
+ kind: "decode",
906
+ config: {
907
+ ...input,
908
+ codec
909
+ },
910
+ ...input.tag ? { tag: input.tag } : {},
911
+ createdAtMs: Date.now(),
912
+ lastActivityMs: Date.now(),
913
+ framesIn: 0,
914
+ framesOut: 0,
915
+ pcmQueue: [],
916
+ session: null
917
+ };
918
+ state.session = this.spawnDecodeSession(state);
919
+ this.sessions.set(sessionId, state);
920
+ this.logger?.info("audio-codec-ffmpeg: decode session created", {
921
+ tags: { sessionId },
922
+ meta: {
923
+ codec,
924
+ target: `${input.targetSampleRate}Hz×${input.targetChannels}`
925
+ }
926
+ });
927
+ return {
928
+ sessionId,
929
+ nodeId: this.ctx.kernel.localNodeId ?? "local"
930
+ };
931
+ }
932
+ async createEncodeSession(input) {
933
+ const codec = resolveAudioCodecAlias(input.codec);
934
+ const entry = CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === codec);
935
+ if (!entry || !entry.canEncode) throw new Error(`audio-codec-ffmpeg: encode unsupported for codec '${input.codec}'`);
936
+ const sessionId = `enc-${randomUUID()}`;
937
+ const state = {
938
+ sessionId,
939
+ kind: "encode",
940
+ config: {
941
+ ...input,
942
+ codec
943
+ },
944
+ ...input.tag ? { tag: input.tag } : {},
945
+ createdAtMs: Date.now(),
946
+ lastActivityMs: Date.now(),
947
+ framesIn: 0,
948
+ framesOut: 0,
949
+ encodedQueue: [],
950
+ session: null
951
+ };
952
+ state.session = this.spawnEncodeSession(state);
953
+ this.sessions.set(sessionId, state);
954
+ this.logger?.info("audio-codec-ffmpeg: encode session created", {
955
+ tags: { sessionId },
956
+ meta: {
957
+ codec,
958
+ target: `${input.targetSampleRate}Hz×${input.targetChannels}`
959
+ }
960
+ });
961
+ return {
962
+ sessionId,
963
+ nodeId: this.ctx.kernel.localNodeId ?? "local"
964
+ };
965
+ }
966
+ async closeSession(input) {
967
+ const s = this.sessions.get(input.sessionId);
968
+ if (!s) return;
969
+ this.disposeSession(s);
970
+ this.sessions.delete(input.sessionId);
971
+ }
972
+ async pushEncodedFrame(input) {
973
+ const s = this.sessions.get(input.sessionId);
974
+ if (!s || s.kind !== "decode") throw new Error(`audio-codec-ffmpeg: decode session '${input.sessionId}' not found`);
975
+ s.lastActivityMs = Date.now();
976
+ s.framesIn++;
977
+ if (!s.session) s.session = this.spawnDecodeSession(s);
978
+ s.session.pushEncoded(input.data);
979
+ }
980
+ async pullPcm(input) {
981
+ const s = this.sessions.get(input.sessionId);
982
+ if (!s || s.kind !== "decode") throw new Error(`audio-codec-ffmpeg: decode session '${input.sessionId}' not found`);
983
+ s.lastActivityMs = Date.now();
984
+ const out = s.pcmQueue.splice(0, input.maxCount);
985
+ s.framesOut += out.length;
986
+ return out;
987
+ }
988
+ async pushPcm(input) {
989
+ const s = this.sessions.get(input.sessionId);
990
+ if (!s || s.kind !== "encode") throw new Error(`audio-codec-ffmpeg: encode session '${input.sessionId}' not found`);
991
+ s.lastActivityMs = Date.now();
992
+ s.framesIn++;
993
+ if (!s.session) s.session = this.spawnEncodeSession(s);
994
+ s.session.pushPcm(input.data);
995
+ }
996
+ async pullEncoded(input) {
997
+ const s = this.sessions.get(input.sessionId);
998
+ if (!s || s.kind !== "encode") throw new Error(`audio-codec-ffmpeg: encode session '${input.sessionId}' not found`);
999
+ s.lastActivityMs = Date.now();
1000
+ const out = s.encodedQueue.splice(0, input.maxCount);
1001
+ s.framesOut += out.length;
1002
+ return out;
1003
+ }
1004
+ async flushEncode(input) {
1005
+ const s = this.sessions.get(input.sessionId);
1006
+ if (!s || s.kind !== "encode") throw new Error(`audio-codec-ffmpeg: encode session '${input.sessionId}' not found`);
1007
+ s.lastActivityMs = Date.now();
1008
+ s.session?.flush();
1009
+ await new Promise((resolve) => setTimeout(resolve, FLUSH_DRAIN_MS));
1010
+ const out = s.encodedQueue.splice(0);
1011
+ s.framesOut += out.length;
1012
+ return out;
1013
+ }
1014
+ async listActiveSessions() {
1015
+ return [...this.sessions.values()].map((s) => ({
1016
+ sessionId: s.sessionId,
1017
+ kind: s.kind,
1018
+ codec: s.config.codec,
1019
+ sourceSampleRate: s.config.sourceSampleRate,
1020
+ sourceChannels: s.config.sourceChannels,
1021
+ targetSampleRate: s.config.targetSampleRate,
1022
+ targetChannels: s.config.targetChannels,
1023
+ format: this.resolveFormat(s),
1024
+ ...s.tag ? { tag: s.tag } : {},
1025
+ createdAtMs: s.createdAtMs,
1026
+ lastActivityMs: s.lastActivityMs,
1027
+ framesIn: s.framesIn,
1028
+ framesOut: s.framesOut
1029
+ }));
1030
+ }
1031
+ spawnDecodeSession(s) {
1032
+ const logger = this.logger ?? this.ctx.logger;
1033
+ return new FfmpegAudioDecodeSession({
1034
+ codec: s.config.codec,
1035
+ sourceSampleRate: s.config.sourceSampleRate,
1036
+ sourceChannels: s.config.sourceChannels,
1037
+ targetSampleRate: s.config.targetSampleRate,
1038
+ targetChannels: s.config.targetChannels,
1039
+ targetFormat: this.pcmFormat(s.config.targetFormat),
1040
+ ffmpegPath: this.ffmpegPath,
1041
+ oggSerialSeed: s.sessionId
1042
+ }, logger, (chunk) => {
1043
+ s.pcmQueue.push(chunk);
1044
+ });
1045
+ }
1046
+ spawnEncodeSession(s) {
1047
+ const logger = this.logger ?? this.ctx.logger;
1048
+ return new FfmpegAudioEncodeSession({
1049
+ codec: s.config.codec,
1050
+ sourceSampleRate: s.config.sourceSampleRate,
1051
+ sourceChannels: s.config.sourceChannels,
1052
+ sourceFormat: this.pcmFormat(s.config.sourceFormat),
1053
+ targetSampleRate: s.config.targetSampleRate,
1054
+ targetChannels: s.config.targetChannels,
1055
+ ...s.config.bitrateKbps !== void 0 ? { bitrateKbps: s.config.bitrateKbps } : {},
1056
+ ffmpegPath: this.ffmpegPath
1057
+ }, logger, (chunk) => {
1058
+ s.encodedQueue.push(chunk);
1059
+ });
1060
+ }
1061
+ /** Narrow the cap's PCM format enum to the two ffmpeg sessions produce/read. */
1062
+ pcmFormat(format) {
1063
+ return format === "f32le" ? "f32le" : "s16le";
1064
+ }
1065
+ resolveFormat(s) {
1066
+ if (s.kind === "decode") return this.pcmFormat(s.config.targetFormat);
1067
+ return this.pcmFormat(s.config.sourceFormat);
1068
+ }
1069
+ reapIdleSessions() {
1070
+ const now = Date.now();
1071
+ for (const [id, s] of this.sessions) {
1072
+ const limit = s.config.idleMs ?? this.config.defaultIdleMs ?? DEFAULT_IDLE_MS;
1073
+ if (now - s.lastActivityMs > limit) {
1074
+ this.logger?.info("audio-codec-ffmpeg: reaping idle session", {
1075
+ tags: { sessionId: id },
1076
+ meta: {
1077
+ kind: s.kind,
1078
+ idleMs: now - s.lastActivityMs,
1079
+ limit
1080
+ }
1081
+ });
1082
+ try {
1083
+ this.disposeSession(s);
1084
+ } catch (err) {
1085
+ this.logger?.warn("audio-codec-ffmpeg: dispose failed during reap", {
1086
+ tags: { sessionId: id },
1087
+ meta: { error: errMsg(err) }
1088
+ });
1089
+ }
1090
+ this.sessions.delete(id);
1091
+ }
1092
+ }
1093
+ }
1094
+ disposeSession(s) {
1095
+ try {
1096
+ s.session?.destroy();
1097
+ } catch (err) {
1098
+ this.logger?.warn("audio-codec-ffmpeg: session destroy failed", {
1099
+ tags: { sessionId: s.sessionId },
1100
+ meta: { error: errMsg(err) }
1101
+ });
1102
+ }
1103
+ s.session = null;
1104
+ }
1105
+ };
1106
+ //#endregion
1107
+ export { AUDIO_LOW_LATENCY_ARGS, AudioCodecFfmpegAddon, AudioCodecFfmpegAddon as default, DEFAULT_OPUS_PRE_SKIP, OggOpusFramer, audioBytesPerSample, buildAudioDecodeArgs, buildAudioEncodeArgs, buildLacing, decodeFormatForCodec, encodeFormatForCodec, oggCrc32, opusPacketSampleCount, resolveAudioCodecAlias, serialFromSeed };