@camstack/addon-decoder-nodeav 1.1.3 → 1.1.4

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 (3) hide show
  1. package/dist/index.js +891 -39
  2. package/dist/index.mjs +887 -40
  3. package/package.json +6 -2
package/dist/index.mjs CHANGED
@@ -12793,42 +12793,79 @@ var SessionInventoryEntrySchema = object({
12793
12793
  framesIn: number(),
12794
12794
  framesOut: number()
12795
12795
  });
12796
- method(_void(), array(AudioCodecInfoSchema).readonly()), method(object({
12797
- codec: string(),
12798
- kind: _enum(["decode", "encode"])
12799
- }), boolean()), method(AudioDecodeSessionConfigSchema, object({
12800
- sessionId: string(),
12801
- nodeId: string()
12802
- }), { kind: "mutation" }), method(AudioEncodeSessionConfigSchema, object({
12803
- sessionId: string(),
12804
- nodeId: string()
12805
- }), { kind: "mutation" }), method(object({
12806
- sessionId: string(),
12807
- nodeId: string().optional()
12808
- }), _void(), { kind: "mutation" }), method(object({
12809
- sessionId: string(),
12810
- nodeId: string().optional(),
12811
- data: _instanceof(Uint8Array),
12812
- /** Source PTS in milliseconds. Synthesised when omitted. */
12813
- pts: number().optional()
12814
- }), _void(), { kind: "mutation" }), method(object({
12815
- sessionId: string(),
12816
- nodeId: string().optional(),
12817
- maxCount: number().int().positive().default(8)
12818
- }), array(AudioPcmChunkSchema)), method(object({
12819
- sessionId: string(),
12820
- nodeId: string().optional(),
12821
- data: _instanceof(Uint8Array),
12822
- /** Source PTS in milliseconds. */
12823
- pts: number().optional()
12824
- }), _void(), { kind: "mutation" }), method(object({
12825
- sessionId: string(),
12826
- nodeId: string().optional(),
12827
- maxCount: number().int().positive().default(8)
12828
- }), array(AudioEncodedChunkSchema)), method(object({
12829
- sessionId: string(),
12830
- nodeId: string().optional()
12831
- }), array(AudioEncodedChunkSchema), { kind: "mutation" }), method(_void(), array(SessionInventoryEntrySchema).readonly());
12796
+ /**
12797
+ * audio-codec — bidirectional PCM ↔ encoded audio I/O box.
12798
+ *
12799
+ * Independent per-consumer sessions. The provider runs decode + resample
12800
+ * (or resample + encode) inside the session so a 16kHz mono ASA
12801
+ * subscriber and a 48kHz stereo WebRTC subscriber on the same source
12802
+ * stream don't share resamplers.
12803
+ *
12804
+ * Singleton on each node. Decoder and encoder live in the same provider
12805
+ * because they share the underlying libav contexts (node-av today,
12806
+ * pluggable later) — operators always install one or the other together.
12807
+ */
12808
+ var audioCodecCapability = {
12809
+ name: "audio-codec",
12810
+ scope: "system",
12811
+ mode: "singleton",
12812
+ preferredProvider: "decoder-nodeav",
12813
+ methods: {
12814
+ /** Probe the local runtime and return the supported codec matrix. */
12815
+ listSupportedCodecs: method(_void(), array(AudioCodecInfoSchema).readonly()),
12816
+ /** Cheap predicate — does the runtime support `(codec, kind)`? */
12817
+ canHandle: method(object({
12818
+ codec: string(),
12819
+ kind: _enum(["decode", "encode"])
12820
+ }), boolean()),
12821
+ createDecodeSession: method(AudioDecodeSessionConfigSchema, object({
12822
+ sessionId: string(),
12823
+ nodeId: string()
12824
+ }), { kind: "mutation" }),
12825
+ createEncodeSession: method(AudioEncodeSessionConfigSchema, object({
12826
+ sessionId: string(),
12827
+ nodeId: string()
12828
+ }), { kind: "mutation" }),
12829
+ closeSession: method(object({
12830
+ sessionId: string(),
12831
+ nodeId: string().optional()
12832
+ }), _void(), { kind: "mutation" }),
12833
+ /** Push one encoded audio frame into a decode session. */
12834
+ pushEncodedFrame: method(object({
12835
+ sessionId: string(),
12836
+ nodeId: string().optional(),
12837
+ data: _instanceof(Uint8Array),
12838
+ /** Source PTS in milliseconds. Synthesised when omitted. */
12839
+ pts: number().optional()
12840
+ }), _void(), { kind: "mutation" }),
12841
+ /** Pull up to `maxCount` PCM chunks from a decode session. */
12842
+ pullPcm: method(object({
12843
+ sessionId: string(),
12844
+ nodeId: string().optional(),
12845
+ maxCount: number().int().positive().default(8)
12846
+ }), array(AudioPcmChunkSchema)),
12847
+ /** Push one PCM chunk into an encode session. */
12848
+ pushPcm: method(object({
12849
+ sessionId: string(),
12850
+ nodeId: string().optional(),
12851
+ data: _instanceof(Uint8Array),
12852
+ /** Source PTS in milliseconds. */
12853
+ pts: number().optional()
12854
+ }), _void(), { kind: "mutation" }),
12855
+ /** Pull up to `maxCount` encoded chunks from an encode session. */
12856
+ pullEncoded: method(object({
12857
+ sessionId: string(),
12858
+ nodeId: string().optional(),
12859
+ maxCount: number().int().positive().default(8)
12860
+ }), array(AudioEncodedChunkSchema)),
12861
+ /** Flush any pending encoded output (call before close on graceful tear). */
12862
+ flushEncode: method(object({
12863
+ sessionId: string(),
12864
+ nodeId: string().optional()
12865
+ }), array(AudioEncodedChunkSchema), { kind: "mutation" }),
12866
+ listActiveSessions: method(_void(), array(SessionInventoryEntrySchema).readonly())
12867
+ }
12868
+ };
12832
12869
  var AuthResultSchema = object({
12833
12870
  userId: string(),
12834
12871
  username: string(),
@@ -24296,6 +24333,788 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
24296
24333
  }
24297
24334
  };
24298
24335
  //#endregion
24336
+ //#region src/audio-codec/codec-catalog.ts
24337
+ /**
24338
+ * Normalise an SDP-reported codec name to the libav/ffmpeg name. Mirrors
24339
+ * `audio-codec-ffmpeg`'s `resolveAudioCodecAlias` so callers can pass the
24340
+ * SDP-reported value verbatim.
24341
+ */
24342
+ function resolveAudioCodecAlias(codec) {
24343
+ const c = codec.toLowerCase();
24344
+ if (c === "mpeg4-generic") return "aac";
24345
+ if (c === "l16") return "pcm_s16be";
24346
+ return c;
24347
+ }
24348
+ /**
24349
+ * Supported codec matrix. `aac_latm` / `mpeg4-generic` are decode-only (they
24350
+ * exist only as camera-side inbound streams; the intercom back-channel encodes
24351
+ * plain `aac`). Kept byte-for-byte in step with the ffmpeg addon's catalogue.
24352
+ */
24353
+ var CODEC_CATALOG = [
24354
+ {
24355
+ codec: "pcm_mulaw",
24356
+ canDecode: true,
24357
+ canEncode: true,
24358
+ label: "PCM µ-law (G.711)"
24359
+ },
24360
+ {
24361
+ codec: "pcm_alaw",
24362
+ canDecode: true,
24363
+ canEncode: true,
24364
+ label: "PCM A-law (G.711)"
24365
+ },
24366
+ {
24367
+ codec: "g722",
24368
+ canDecode: true,
24369
+ canEncode: true,
24370
+ label: "G.722"
24371
+ },
24372
+ {
24373
+ codec: "aac",
24374
+ canDecode: true,
24375
+ canEncode: true,
24376
+ label: "AAC"
24377
+ },
24378
+ {
24379
+ codec: "aac_latm",
24380
+ canDecode: true,
24381
+ canEncode: false,
24382
+ label: "AAC LATM"
24383
+ },
24384
+ {
24385
+ codec: "mpeg4-generic",
24386
+ canDecode: true,
24387
+ canEncode: false,
24388
+ label: "AAC (MPEG4-GENERIC)"
24389
+ },
24390
+ {
24391
+ codec: "opus",
24392
+ canDecode: true,
24393
+ canEncode: true,
24394
+ label: "Opus"
24395
+ }
24396
+ ];
24397
+ /** Resolve the catalogue entry for a codec name (alias-normalised). */
24398
+ function catalogEntryFor(codec) {
24399
+ const resolved = resolveAudioCodecAlias(codec);
24400
+ return CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === resolved) ?? null;
24401
+ }
24402
+ /** Cheap predicate — is `(codec, kind)` in the catalogue? */
24403
+ function codecSupports(codec, kind) {
24404
+ const entry = catalogEntryFor(codec);
24405
+ if (!entry) return false;
24406
+ return kind === "decode" ? entry.canDecode : entry.canEncode;
24407
+ }
24408
+ /**
24409
+ * Resolve an (alias-normalised) codec name to its canonical libav key, or
24410
+ * `null` for names outside this addon's SDP audio matrix. Kept pure (string
24411
+ * only) — the branded `AVCodecID` lookup lives in `nodeav-av-types.ts` where
24412
+ * node-av's constants are in scope. G.722 maps to `AV_CODEC_ID_ADPCM_G722`.
24413
+ */
24414
+ function resolveCodecKey(codec) {
24415
+ switch (resolveAudioCodecAlias(codec)) {
24416
+ case "aac": return "aac";
24417
+ case "aac_latm": return "aac_latm";
24418
+ case "opus": return "opus";
24419
+ case "pcm_alaw": return "pcm_alaw";
24420
+ case "pcm_mulaw": return "pcm_mulaw";
24421
+ case "g722": return "g722";
24422
+ default: return null;
24423
+ }
24424
+ }
24425
+ //#endregion
24426
+ //#region src/audio-codec/nodeav-av-types.ts
24427
+ /**
24428
+ * Resolve a codec name to its branded libav `AVCodecID` using the real
24429
+ * constants. Returns `null` for names outside this addon's matrix. G.722 maps
24430
+ * to `AV_CODEC_ID_ADPCM_G722` (there is no plain `AV_CODEC_ID_G722`).
24431
+ */
24432
+ function resolveAvCodecId(codec, consts) {
24433
+ switch (resolveCodecKey(codec)) {
24434
+ case "aac": return consts.AV_CODEC_ID_AAC;
24435
+ case "aac_latm": return consts.AV_CODEC_ID_AAC_LATM;
24436
+ case "opus": return consts.AV_CODEC_ID_OPUS;
24437
+ case "pcm_alaw": return consts.AV_CODEC_ID_PCM_ALAW;
24438
+ case "pcm_mulaw": return consts.AV_CODEC_ID_PCM_MULAW;
24439
+ case "g722": return consts.AV_CODEC_ID_ADPCM_G722;
24440
+ case null: return null;
24441
+ }
24442
+ }
24443
+ /** Build a canonical mono/stereo (or bare-mask) channel layout. */
24444
+ function buildChannelLayout(consts, channels) {
24445
+ if (channels === 1) return {
24446
+ nbChannels: 1,
24447
+ order: consts.AV_CHANNEL_ORDER_NATIVE,
24448
+ mask: consts.AV_CH_LAYOUT_MONO
24449
+ };
24450
+ if (channels === 2) return {
24451
+ nbChannels: 2,
24452
+ order: consts.AV_CHANNEL_ORDER_NATIVE,
24453
+ mask: consts.AV_CH_LAYOUT_STEREO
24454
+ };
24455
+ return {
24456
+ nbChannels: channels,
24457
+ order: consts.AV_CHANNEL_ORDER_NATIVE,
24458
+ mask: 0n
24459
+ };
24460
+ }
24461
+ //#endregion
24462
+ //#region src/audio-codec/nodeav-audio-decode-session.ts
24463
+ function targetSampleFmt(consts, format) {
24464
+ return format === "f32le" ? consts.AV_SAMPLE_FMT_FLT : consts.AV_SAMPLE_FMT_S16;
24465
+ }
24466
+ function bytesPerSample(format) {
24467
+ return format === "f32le" ? 4 : 2;
24468
+ }
24469
+ var NodeAvAudioDecodeSession = class {
24470
+ runtime;
24471
+ cfg;
24472
+ logger;
24473
+ onPcm;
24474
+ ctx;
24475
+ swr;
24476
+ packet;
24477
+ inFrame;
24478
+ outFrame;
24479
+ outLayout;
24480
+ outSampleFmt;
24481
+ outBytesPerSample;
24482
+ outFormat;
24483
+ nextPts = 0;
24484
+ closed = false;
24485
+ /**
24486
+ * Synchronously build a decode runtime from the already-loaded node-av
24487
+ * runtime. Throws (freeing anything already allocated) on any libav error so
24488
+ * the addon surfaces a clean "decode session failed" without leaking a
24489
+ * half-open context.
24490
+ */
24491
+ constructor(runtime, cfg, logger, onPcm) {
24492
+ this.runtime = runtime;
24493
+ this.cfg = cfg;
24494
+ this.logger = logger;
24495
+ this.onPcm = onPcm;
24496
+ this.outFormat = cfg.targetFormat;
24497
+ this.outBytesPerSample = bytesPerSample(cfg.targetFormat);
24498
+ const { nav, consts } = runtime;
24499
+ const codecId = resolveAvCodecId(cfg.codec, consts);
24500
+ if (codecId === null) throw new Error(`audio-codec-nodeav: unknown codec '${cfg.codec}' for decode`);
24501
+ const codec = nav.Codec.findDecoder(codecId);
24502
+ if (!codec) throw new Error(`audio-codec-nodeav: decoder not registered for '${cfg.codec}'`);
24503
+ const ctx = new nav.CodecContext();
24504
+ ctx.allocContext3(codec);
24505
+ ctx.sampleRate = cfg.sourceSampleRate;
24506
+ const inLayout = buildChannelLayout(consts, cfg.sourceChannels);
24507
+ ctx.channelLayout = inLayout;
24508
+ if (cfg.extraData && cfg.extraData.byteLength > 0) ctx.extraData = Buffer.from(cfg.extraData);
24509
+ const openRet = ctx.open2Sync(codec, null);
24510
+ if (openRet < 0) {
24511
+ ctx.freeContext();
24512
+ throw new Error(`audio-codec-nodeav: open2 failed for '${cfg.codec}' (ret=${openRet})`);
24513
+ }
24514
+ const inSampleFmt = ctx.sampleFormat;
24515
+ this.outSampleFmt = targetSampleFmt(consts, cfg.targetFormat);
24516
+ this.outLayout = buildChannelLayout(consts, cfg.targetChannels);
24517
+ const swr = new nav.SoftwareResampleContext();
24518
+ const allocRet = swr.allocSetOpts2(this.outLayout, this.outSampleFmt, cfg.targetSampleRate, inLayout, inSampleFmt, cfg.sourceSampleRate);
24519
+ if (allocRet < 0) {
24520
+ ctx.freeContext();
24521
+ throw new Error(`audio-codec-nodeav: swr allocSetOpts2 failed (ret=${allocRet})`);
24522
+ }
24523
+ const initRet = swr.init();
24524
+ if (initRet < 0) {
24525
+ swr.free();
24526
+ ctx.freeContext();
24527
+ throw new Error(`audio-codec-nodeav: swr init failed (ret=${initRet})`);
24528
+ }
24529
+ const packet = new nav.Packet();
24530
+ packet.alloc();
24531
+ const inFrame = new nav.Frame();
24532
+ inFrame.alloc();
24533
+ const outFrame = new nav.Frame();
24534
+ outFrame.alloc();
24535
+ this.ctx = ctx;
24536
+ this.swr = swr;
24537
+ this.packet = packet;
24538
+ this.inFrame = inFrame;
24539
+ this.outFrame = outFrame;
24540
+ }
24541
+ /** Decode one encoded access unit, emitting resampled PCM via `onPcm`. */
24542
+ pushEncoded(data, pts) {
24543
+ if (this.closed) return;
24544
+ const { consts } = this.runtime;
24545
+ this.packet.data = Buffer.from(data);
24546
+ if (pts !== void 0) {
24547
+ const p = BigInt(Math.round(pts));
24548
+ this.packet.pts = p;
24549
+ this.packet.dts = p;
24550
+ }
24551
+ const sendRet = this.ctx.sendPacketSync(this.packet);
24552
+ if (sendRet < 0 && sendRet !== consts.AVERROR_EAGAIN) throw new Error(`audio-codec-nodeav: sendPacket failed (ret=${sendRet})`);
24553
+ for (;;) {
24554
+ const recvRet = this.ctx.receiveFrameSync(this.inFrame);
24555
+ if (recvRet === consts.AVERROR_EAGAIN || recvRet === consts.AVERROR_EOF) break;
24556
+ if (recvRet < 0) throw new Error(`audio-codec-nodeav: receiveFrame failed (ret=${recvRet})`);
24557
+ try {
24558
+ const chunk = this.resampleFrame(this.inFrame);
24559
+ if (chunk) this.onPcm(chunk);
24560
+ } finally {
24561
+ this.inFrame.unref();
24562
+ }
24563
+ }
24564
+ }
24565
+ resampleFrame(inFrame) {
24566
+ const outSamples = this.swr.getOutSamples(inFrame.nbSamples);
24567
+ if (outSamples <= 0) return null;
24568
+ this.outFrame.unref();
24569
+ this.outFrame.format = this.outSampleFmt;
24570
+ this.outFrame.sampleRate = this.cfg.targetSampleRate;
24571
+ this.outFrame.channelLayout = this.outLayout;
24572
+ this.outFrame.nbSamples = outSamples;
24573
+ const bufRet = this.outFrame.getBuffer(0);
24574
+ if (bufRet < 0) throw new Error(`audio-codec-nodeav: outFrame.getBuffer failed (ret=${bufRet})`);
24575
+ const convRet = this.swr.convertFrame(this.outFrame, inFrame);
24576
+ if (convRet < 0) throw new Error(`audio-codec-nodeav: swr.convertFrame failed (ret=${convRet})`);
24577
+ const produced = this.outFrame.nbSamples;
24578
+ if (produced <= 0) return null;
24579
+ const planes = this.outFrame.extendedData;
24580
+ const plane0 = planes && planes.length > 0 ? planes[0] : null;
24581
+ if (!plane0) return null;
24582
+ const bytes = produced * this.cfg.targetChannels * this.outBytesPerSample;
24583
+ const out = new Uint8Array(new ArrayBuffer(bytes));
24584
+ out.set(plane0.subarray(0, bytes));
24585
+ const ptsMs = this.nextPts;
24586
+ this.nextPts = ptsMs + Math.round(produced * 1e3 / this.cfg.targetSampleRate);
24587
+ return {
24588
+ data: out,
24589
+ sampleRate: this.cfg.targetSampleRate,
24590
+ channels: this.cfg.targetChannels,
24591
+ format: this.outFormat,
24592
+ pts: ptsMs
24593
+ };
24594
+ }
24595
+ /** Release every native handle. Idempotent; safe to call from a reaper. */
24596
+ destroy() {
24597
+ if (this.closed) return;
24598
+ this.closed = true;
24599
+ this.freeQuietly(() => this.outFrame.free(), "outFrame");
24600
+ this.freeQuietly(() => this.inFrame.free(), "inFrame");
24601
+ this.freeQuietly(() => this.packet.free(), "packet");
24602
+ this.freeQuietly(() => this.swr.free(), "swr");
24603
+ this.freeQuietly(() => this.ctx.freeContext(), "codecContext");
24604
+ }
24605
+ freeQuietly(fn, what) {
24606
+ try {
24607
+ fn();
24608
+ } catch (err) {
24609
+ this.logger.warn("audio-codec-nodeav: decode handle free failed", { meta: {
24610
+ what,
24611
+ error: err instanceof Error ? err.message : String(err)
24612
+ } });
24613
+ }
24614
+ }
24615
+ };
24616
+ //#endregion
24617
+ //#region src/audio-codec/nodeav-audio-encode-session.ts
24618
+ function sourceSampleFmt(consts, format) {
24619
+ return format === "f32le" ? consts.AV_SAMPLE_FMT_FLT : consts.AV_SAMPLE_FMT_S16;
24620
+ }
24621
+ function sourceBytesPerSample(format) {
24622
+ return format === "f32le" ? 4 : 2;
24623
+ }
24624
+ var NodeAvAudioEncodeSession = class {
24625
+ runtime;
24626
+ cfg;
24627
+ logger;
24628
+ onChunk;
24629
+ codecName;
24630
+ ctx;
24631
+ swr;
24632
+ fifo;
24633
+ packet;
24634
+ outLayout;
24635
+ encoderSampleFmt;
24636
+ srcSampleFmt;
24637
+ srcBytesPerSample;
24638
+ /** Encoder-required samples per frame; `<= 0` means variable (drain all). */
24639
+ frameSize;
24640
+ ptsSamples = 0;
24641
+ closed = false;
24642
+ constructor(runtime, cfg, logger, onChunk) {
24643
+ this.runtime = runtime;
24644
+ this.cfg = cfg;
24645
+ this.logger = logger;
24646
+ this.onChunk = onChunk;
24647
+ this.codecName = resolveAudioCodecAlias(cfg.codec);
24648
+ this.srcSampleFmt = sourceSampleFmt(runtime.consts, cfg.sourceFormat);
24649
+ this.srcBytesPerSample = sourceBytesPerSample(cfg.sourceFormat);
24650
+ const { nav, consts } = runtime;
24651
+ const codecId = resolveAvCodecId(cfg.codec, consts);
24652
+ if (codecId === null) throw new Error(`audio-codec-nodeav: unknown codec '${cfg.codec}' for encode`);
24653
+ const codec = nav.Codec.findEncoder(codecId);
24654
+ if (!codec) throw new Error(`audio-codec-nodeav: encoder not registered for '${cfg.codec}'`);
24655
+ const fmts = codec.sampleFormats;
24656
+ this.encoderSampleFmt = fmts && fmts.length > 0 ? fmts[0] : consts.AV_SAMPLE_FMT_S16;
24657
+ this.outLayout = buildChannelLayout(consts, cfg.targetChannels);
24658
+ const ctx = new nav.CodecContext();
24659
+ ctx.allocContext3(codec);
24660
+ ctx.sampleRate = cfg.targetSampleRate;
24661
+ ctx.channelLayout = this.outLayout;
24662
+ ctx.sampleFormat = this.encoderSampleFmt;
24663
+ if (cfg.bitrateKbps !== void 0) ctx.bitRate = BigInt(Math.round(cfg.bitrateKbps * 1e3));
24664
+ const openRet = ctx.open2Sync(codec, null);
24665
+ if (openRet < 0) {
24666
+ ctx.freeContext();
24667
+ throw new Error(`audio-codec-nodeav: encoder open2 failed for '${cfg.codec}' (ret=${openRet})`);
24668
+ }
24669
+ this.frameSize = ctx.frameSize > 0 ? ctx.frameSize : 0;
24670
+ const swr = new nav.SoftwareResampleContext();
24671
+ const inLayout = buildChannelLayout(consts, cfg.sourceChannels);
24672
+ const allocRet = swr.allocSetOpts2(this.outLayout, this.encoderSampleFmt, cfg.targetSampleRate, inLayout, this.srcSampleFmt, cfg.sourceSampleRate);
24673
+ if (allocRet < 0) {
24674
+ ctx.freeContext();
24675
+ throw new Error(`audio-codec-nodeav: encode swr allocSetOpts2 failed (ret=${allocRet})`);
24676
+ }
24677
+ if (swr.init() < 0) {
24678
+ swr.free();
24679
+ ctx.freeContext();
24680
+ throw new Error("audio-codec-nodeav: encode swr init failed");
24681
+ }
24682
+ const fifo = new nav.AudioFifo();
24683
+ fifo.alloc(this.encoderSampleFmt, cfg.targetChannels, this.frameSize > 0 ? this.frameSize : 1024);
24684
+ const packet = new nav.Packet();
24685
+ packet.alloc();
24686
+ this.ctx = ctx;
24687
+ this.swr = swr;
24688
+ this.fifo = fifo;
24689
+ this.packet = packet;
24690
+ }
24691
+ /** Push one interleaved PCM chunk; emits encoded packets via `onChunk`. */
24692
+ pushPcm(data) {
24693
+ if (this.closed || data.byteLength === 0) return;
24694
+ const frameBytes = this.cfg.sourceChannels * this.srcBytesPerSample;
24695
+ const inSamples = Math.floor(data.byteLength / frameBytes);
24696
+ if (inSamples <= 0) return;
24697
+ this.resampleIntoFifo(data, inSamples);
24698
+ this.drainFifo(false);
24699
+ }
24700
+ /**
24701
+ * Flush the encoder: drain any partial frame left in the FIFO, then signal
24702
+ * end-of-stream so the codec emits its tail packets. Called before a graceful
24703
+ * close.
24704
+ */
24705
+ flush() {
24706
+ if (this.closed) return;
24707
+ this.drainFifo(true);
24708
+ try {
24709
+ if (this.ctx.sendFrameSync(null) >= 0) this.drainEncoder();
24710
+ } catch (err) {
24711
+ this.logger.warn("audio-codec-nodeav: encode flush failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
24712
+ }
24713
+ }
24714
+ resampleIntoFifo(data, inSamples) {
24715
+ const { nav } = this.runtime;
24716
+ const inFrame = new nav.Frame();
24717
+ inFrame.alloc();
24718
+ const outFrame = new nav.Frame();
24719
+ outFrame.alloc();
24720
+ try {
24721
+ inFrame.format = this.srcSampleFmt;
24722
+ inFrame.sampleRate = this.cfg.sourceSampleRate;
24723
+ inFrame.channelLayout = buildChannelLayout(this.runtime.consts, this.cfg.sourceChannels);
24724
+ inFrame.nbSamples = inSamples;
24725
+ const inBufRet = inFrame.getBuffer(0);
24726
+ if (inBufRet < 0) throw new Error(`inFrame.getBuffer failed (ret=${inBufRet})`);
24727
+ const planes = inFrame.extendedData;
24728
+ const plane0 = planes && planes.length > 0 ? planes[0] : null;
24729
+ if (!plane0) throw new Error("inFrame has no data plane");
24730
+ plane0.set(data.subarray(0, inSamples * this.cfg.sourceChannels * this.srcBytesPerSample));
24731
+ const outSamples = this.swr.getOutSamples(inSamples);
24732
+ if (outSamples <= 0) return;
24733
+ outFrame.format = this.encoderSampleFmt;
24734
+ outFrame.sampleRate = this.cfg.targetSampleRate;
24735
+ outFrame.channelLayout = this.outLayout;
24736
+ outFrame.nbSamples = outSamples;
24737
+ const outBufRet = outFrame.getBuffer(0);
24738
+ if (outBufRet < 0) throw new Error(`outFrame.getBuffer failed (ret=${outBufRet})`);
24739
+ const convRet = this.swr.convertFrame(outFrame, inFrame);
24740
+ if (convRet < 0) throw new Error(`swr.convertFrame failed (ret=${convRet})`);
24741
+ const produced = outFrame.nbSamples;
24742
+ if (produced <= 0) return;
24743
+ const outPlanes = outFrame.extendedData;
24744
+ if (!outPlanes || outPlanes.length === 0) return;
24745
+ this.fifo.writeSync(outPlanes, produced);
24746
+ } finally {
24747
+ inFrame.free();
24748
+ outFrame.free();
24749
+ }
24750
+ }
24751
+ /**
24752
+ * Pull `frameSize`-sized frames out of the FIFO and encode them. When
24753
+ * `flushTail` is set, also encode the final short frame (< frameSize) so no
24754
+ * tail samples are dropped on close.
24755
+ */
24756
+ drainFifo(flushTail) {
24757
+ const chunk = this.frameSize > 0 ? this.frameSize : this.fifo.size;
24758
+ if (chunk <= 0) return;
24759
+ while (this.fifo.size >= chunk && chunk > 0) {
24760
+ this.encodeFromFifo(chunk);
24761
+ if (this.frameSize <= 0) break;
24762
+ }
24763
+ if (flushTail && this.fifo.size > 0) this.encodeFromFifo(this.fifo.size);
24764
+ }
24765
+ encodeFromFifo(nbSamples) {
24766
+ if (nbSamples <= 0) return;
24767
+ const { nav } = this.runtime;
24768
+ const frame = new nav.Frame();
24769
+ frame.alloc();
24770
+ try {
24771
+ frame.format = this.encoderSampleFmt;
24772
+ frame.sampleRate = this.cfg.targetSampleRate;
24773
+ frame.channelLayout = this.outLayout;
24774
+ frame.nbSamples = nbSamples;
24775
+ const bufRet = frame.getBuffer(0);
24776
+ if (bufRet < 0) throw new Error(`encodeFrame.getBuffer failed (ret=${bufRet})`);
24777
+ const planes = frame.extendedData;
24778
+ if (!planes || planes.length === 0) throw new Error("encodeFrame has no data plane");
24779
+ const read = this.fifo.readSync(planes, nbSamples);
24780
+ if (read <= 0) return;
24781
+ frame.nbSamples = read;
24782
+ frame.pts = BigInt(this.ptsSamples);
24783
+ this.ptsSamples += read;
24784
+ const sendRet = this.ctx.sendFrameSync(frame);
24785
+ if (sendRet < 0) throw new Error(`sendFrame failed (ret=${sendRet})`);
24786
+ this.drainEncoder();
24787
+ } finally {
24788
+ frame.free();
24789
+ }
24790
+ }
24791
+ drainEncoder() {
24792
+ const { consts } = this.runtime;
24793
+ for (;;) {
24794
+ const recvRet = this.ctx.receivePacketSync(this.packet);
24795
+ if (recvRet === consts.AVERROR_EAGAIN || recvRet === consts.AVERROR_EOF) break;
24796
+ if (recvRet < 0) throw new Error(`audio-codec-nodeav: receivePacket failed (ret=${recvRet})`);
24797
+ try {
24798
+ const payload = this.packet.data;
24799
+ if (payload && payload.byteLength > 0) {
24800
+ const out = new Uint8Array(new ArrayBuffer(payload.byteLength));
24801
+ out.set(payload);
24802
+ const pktPts = this.packet.pts;
24803
+ const ptsMs = pktPts >= 0n ? Math.round(Number(pktPts) * 1e3 / this.cfg.targetSampleRate) : 0;
24804
+ this.onChunk({
24805
+ data: out,
24806
+ codec: this.codecName,
24807
+ pts: ptsMs,
24808
+ frameComplete: true
24809
+ });
24810
+ }
24811
+ } finally {
24812
+ this.packet.unref();
24813
+ }
24814
+ }
24815
+ }
24816
+ destroy() {
24817
+ if (this.closed) return;
24818
+ this.closed = true;
24819
+ this.freeQuietly(() => this.packet.free(), "packet");
24820
+ this.freeQuietly(() => this.fifo.free(), "fifo");
24821
+ this.freeQuietly(() => this.swr.free(), "swr");
24822
+ this.freeQuietly(() => this.ctx.freeContext(), "codecContext");
24823
+ }
24824
+ freeQuietly(fn, what) {
24825
+ try {
24826
+ fn();
24827
+ } catch (err) {
24828
+ this.logger.warn("audio-codec-nodeav: encode handle free failed", { meta: {
24829
+ what,
24830
+ error: err instanceof Error ? err.message : String(err)
24831
+ } });
24832
+ }
24833
+ }
24834
+ };
24835
+ //#endregion
24836
+ //#region src/audio-codec/provider.ts
24837
+ var DEFAULT_IDLE_MS = 3e4;
24838
+ var MAX_PCM_QUEUE_CHUNKS = 500;
24839
+ var REAPER_INTERVAL_MS = 5e3;
24840
+ /** Grace wait for the encoder to drain its tail after `flushEncode`. */
24841
+ var FLUSH_DRAIN_MS = 60;
24842
+ /**
24843
+ * Audio codec I/O box backed by **node-av's in-process libavcodec +
24844
+ * libswresample bindings** — the native counterpart to `audio-codec-ffmpeg`.
24845
+ * Decode/encode run in this addon's process (no subprocess), feeding raw
24846
+ * depacketized access units straight into a `CodecContext` (no ADTS/Ogg
24847
+ * container shim needed).
24848
+ *
24849
+ * This is a PLAIN provider (not a `BaseAddon`): it is owned and driven by the
24850
+ * `decoder-nodeav` addon, which loads the shared node-av runtime once and
24851
+ * hands it in. When node-av cannot be loaded on this node the owning addon
24852
+ * never constructs this provider, so the singleton slot falls back to
24853
+ * `audio-codec-ffmpeg`.
24854
+ */
24855
+ var NodeAvAudioCodecProvider = class {
24856
+ deps;
24857
+ sessions = /* @__PURE__ */ new Map();
24858
+ reaperTimer = null;
24859
+ constructor(deps) {
24860
+ this.deps = deps;
24861
+ }
24862
+ /** Arm the idle-session reaper. Called by the owning addon after construction. */
24863
+ start() {
24864
+ this.reaperTimer = setInterval(() => this.reapIdleSessions(), REAPER_INTERVAL_MS);
24865
+ if (typeof this.reaperTimer.unref === "function") this.reaperTimer.unref();
24866
+ }
24867
+ /** Stop the reaper and dispose every live session. Called on addon shutdown. */
24868
+ stop() {
24869
+ if (this.reaperTimer) {
24870
+ clearInterval(this.reaperTimer);
24871
+ this.reaperTimer = null;
24872
+ }
24873
+ for (const s of this.sessions.values()) this.disposeSession(s);
24874
+ this.sessions.clear();
24875
+ }
24876
+ async listSupportedCodecs() {
24877
+ return CODEC_CATALOG.map((e) => ({
24878
+ codec: e.codec,
24879
+ canDecode: e.canDecode,
24880
+ canEncode: e.canEncode,
24881
+ ...e.label ? { label: e.label } : {}
24882
+ }));
24883
+ }
24884
+ async canHandle(input) {
24885
+ return codecSupports(input.codec, input.kind);
24886
+ }
24887
+ async createDecodeSession(input) {
24888
+ const codec = resolveAudioCodecAlias(input.codec);
24889
+ const entry = catalogEntryFor(input.codec);
24890
+ if (!entry || !entry.canDecode) throw new Error(`audio-codec-nodeav: decode unsupported for codec '${input.codec}'`);
24891
+ const sessionId = `dec-${randomUUID()}`;
24892
+ const state = {
24893
+ sessionId,
24894
+ kind: "decode",
24895
+ config: {
24896
+ ...input,
24897
+ codec
24898
+ },
24899
+ ...input.tag ? { tag: input.tag } : {},
24900
+ createdAtMs: Date.now(),
24901
+ lastActivityMs: Date.now(),
24902
+ framesIn: 0,
24903
+ framesOut: 0,
24904
+ pcmQueue: [],
24905
+ session: null
24906
+ };
24907
+ state.session = this.spawnDecodeSession(state);
24908
+ this.sessions.set(sessionId, state);
24909
+ this.deps.logger.info("audio-codec-nodeav: decode session created", {
24910
+ tags: { sessionId },
24911
+ meta: {
24912
+ codec,
24913
+ target: `${input.targetSampleRate}Hz×${input.targetChannels}`
24914
+ }
24915
+ });
24916
+ return {
24917
+ sessionId,
24918
+ nodeId: this.deps.resolveLocalNodeId()
24919
+ };
24920
+ }
24921
+ async createEncodeSession(input) {
24922
+ const codec = resolveAudioCodecAlias(input.codec);
24923
+ const entry = catalogEntryFor(input.codec);
24924
+ if (!entry || !entry.canEncode) throw new Error(`audio-codec-nodeav: encode unsupported for codec '${input.codec}'`);
24925
+ const sessionId = `enc-${randomUUID()}`;
24926
+ const state = {
24927
+ sessionId,
24928
+ kind: "encode",
24929
+ config: {
24930
+ ...input,
24931
+ codec
24932
+ },
24933
+ ...input.tag ? { tag: input.tag } : {},
24934
+ createdAtMs: Date.now(),
24935
+ lastActivityMs: Date.now(),
24936
+ framesIn: 0,
24937
+ framesOut: 0,
24938
+ encodedQueue: [],
24939
+ session: null
24940
+ };
24941
+ state.session = this.spawnEncodeSession(state);
24942
+ this.sessions.set(sessionId, state);
24943
+ this.deps.logger.info("audio-codec-nodeav: encode session created", {
24944
+ tags: { sessionId },
24945
+ meta: {
24946
+ codec,
24947
+ target: `${input.targetSampleRate}Hz×${input.targetChannels}`
24948
+ }
24949
+ });
24950
+ return {
24951
+ sessionId,
24952
+ nodeId: this.deps.resolveLocalNodeId()
24953
+ };
24954
+ }
24955
+ async closeSession(input) {
24956
+ const s = this.sessions.get(input.sessionId);
24957
+ if (!s) return;
24958
+ this.disposeSession(s);
24959
+ this.sessions.delete(input.sessionId);
24960
+ }
24961
+ async pushEncodedFrame(input) {
24962
+ const s = this.sessions.get(input.sessionId);
24963
+ if (!s || s.kind !== "decode") throw new Error(`audio-codec-nodeav: decode session '${input.sessionId}' not found`);
24964
+ s.lastActivityMs = Date.now();
24965
+ s.framesIn++;
24966
+ if (!s.session) s.session = this.spawnDecodeSession(s);
24967
+ s.session.pushEncoded(input.data, input.pts);
24968
+ }
24969
+ async pullPcm(input) {
24970
+ const s = this.sessions.get(input.sessionId);
24971
+ if (!s || s.kind !== "decode") throw new Error(`audio-codec-nodeav: decode session '${input.sessionId}' not found`);
24972
+ s.lastActivityMs = Date.now();
24973
+ const out = s.pcmQueue.splice(0, input.maxCount);
24974
+ s.framesOut += out.length;
24975
+ return out;
24976
+ }
24977
+ async pushPcm(input) {
24978
+ const s = this.sessions.get(input.sessionId);
24979
+ if (!s || s.kind !== "encode") throw new Error(`audio-codec-nodeav: encode session '${input.sessionId}' not found`);
24980
+ s.lastActivityMs = Date.now();
24981
+ s.framesIn++;
24982
+ if (!s.session) s.session = this.spawnEncodeSession(s);
24983
+ s.session.pushPcm(input.data);
24984
+ }
24985
+ async pullEncoded(input) {
24986
+ const s = this.sessions.get(input.sessionId);
24987
+ if (!s || s.kind !== "encode") throw new Error(`audio-codec-nodeav: encode session '${input.sessionId}' not found`);
24988
+ s.lastActivityMs = Date.now();
24989
+ const out = s.encodedQueue.splice(0, input.maxCount);
24990
+ s.framesOut += out.length;
24991
+ return out;
24992
+ }
24993
+ async flushEncode(input) {
24994
+ const s = this.sessions.get(input.sessionId);
24995
+ if (!s || s.kind !== "encode") throw new Error(`audio-codec-nodeav: encode session '${input.sessionId}' not found`);
24996
+ s.lastActivityMs = Date.now();
24997
+ s.session?.flush();
24998
+ await new Promise((resolve) => setTimeout(resolve, FLUSH_DRAIN_MS));
24999
+ const out = s.encodedQueue.splice(0);
25000
+ s.framesOut += out.length;
25001
+ return out;
25002
+ }
25003
+ async listActiveSessions() {
25004
+ return [...this.sessions.values()].map((s) => ({
25005
+ sessionId: s.sessionId,
25006
+ kind: s.kind,
25007
+ codec: s.config.codec,
25008
+ sourceSampleRate: s.config.sourceSampleRate,
25009
+ sourceChannels: s.config.sourceChannels,
25010
+ targetSampleRate: s.config.targetSampleRate,
25011
+ targetChannels: s.config.targetChannels,
25012
+ format: this.resolveFormat(s),
25013
+ ...s.tag ? { tag: s.tag } : {},
25014
+ createdAtMs: s.createdAtMs,
25015
+ lastActivityMs: s.lastActivityMs,
25016
+ framesIn: s.framesIn,
25017
+ framesOut: s.framesOut
25018
+ }));
25019
+ }
25020
+ spawnDecodeSession(s) {
25021
+ return new NodeAvAudioDecodeSession(this.deps.runtime, {
25022
+ codec: s.config.codec,
25023
+ sourceSampleRate: s.config.sourceSampleRate,
25024
+ sourceChannels: s.config.sourceChannels,
25025
+ ...s.config.extraData ? { extraData: s.config.extraData } : {},
25026
+ targetSampleRate: s.config.targetSampleRate,
25027
+ targetChannels: s.config.targetChannels,
25028
+ targetFormat: this.pcmFormat(s.config.targetFormat)
25029
+ }, this.deps.logger, (chunk) => {
25030
+ s.pcmQueue.push(chunk);
25031
+ if (s.pcmQueue.length > MAX_PCM_QUEUE_CHUNKS) s.pcmQueue.splice(0, s.pcmQueue.length - MAX_PCM_QUEUE_CHUNKS);
25032
+ });
25033
+ }
25034
+ spawnEncodeSession(s) {
25035
+ return new NodeAvAudioEncodeSession(this.deps.runtime, {
25036
+ codec: s.config.codec,
25037
+ sourceSampleRate: s.config.sourceSampleRate,
25038
+ sourceChannels: s.config.sourceChannels,
25039
+ sourceFormat: this.pcmFormat(s.config.sourceFormat),
25040
+ targetSampleRate: s.config.targetSampleRate,
25041
+ targetChannels: s.config.targetChannels,
25042
+ ...s.config.bitrateKbps !== void 0 ? { bitrateKbps: s.config.bitrateKbps } : {}
25043
+ }, this.deps.logger, (chunk) => {
25044
+ s.encodedQueue.push(chunk);
25045
+ });
25046
+ }
25047
+ pcmFormat(format) {
25048
+ return format === "f32le" ? "f32le" : "s16le";
25049
+ }
25050
+ resolveFormat(s) {
25051
+ if (s.kind === "decode") return this.pcmFormat(s.config.targetFormat);
25052
+ return this.pcmFormat(s.config.sourceFormat);
25053
+ }
25054
+ reapIdleSessions() {
25055
+ const now = Date.now();
25056
+ for (const [id, s] of this.sessions) {
25057
+ const limit = s.config.idleMs ?? DEFAULT_IDLE_MS;
25058
+ if (now - s.lastActivityMs > limit) {
25059
+ this.deps.logger.info("audio-codec-nodeav: reaping idle session", {
25060
+ tags: { sessionId: id },
25061
+ meta: {
25062
+ kind: s.kind,
25063
+ idleMs: now - s.lastActivityMs,
25064
+ limit
25065
+ }
25066
+ });
25067
+ try {
25068
+ this.disposeSession(s);
25069
+ } catch (err) {
25070
+ this.deps.logger.warn("audio-codec-nodeav: dispose failed during reap", {
25071
+ tags: { sessionId: id },
25072
+ meta: { error: errMsg(err) }
25073
+ });
25074
+ }
25075
+ this.sessions.delete(id);
25076
+ }
25077
+ }
25078
+ }
25079
+ disposeSession(s) {
25080
+ try {
25081
+ s.session?.destroy();
25082
+ } catch (err) {
25083
+ this.deps.logger.warn("audio-codec-nodeav: session destroy failed", {
25084
+ tags: { sessionId: s.sessionId },
25085
+ meta: { error: errMsg(err) }
25086
+ });
25087
+ }
25088
+ s.session = null;
25089
+ }
25090
+ };
25091
+ //#endregion
25092
+ //#region src/audio-codec/nodeav-runtime-loader.ts
25093
+ var _runtime = null;
25094
+ /**
25095
+ * Load (or return the cached) node-av runtime. Rejects if the native binding
25096
+ * cannot be loaded on this node — the caller treats that as "node-av
25097
+ * unavailable" and declines to register the cap so the singleton falls back to
25098
+ * `audio-codec-ffmpeg`.
25099
+ */
25100
+ async function loadNodeAvRuntime() {
25101
+ if (_runtime) return _runtime;
25102
+ const [nav, consts] = await Promise.all([import("node-av"), import("node-av/constants")]);
25103
+ _runtime = {
25104
+ nav,
25105
+ consts
25106
+ };
25107
+ return _runtime;
25108
+ }
25109
+ /**
25110
+ * The already-loaded runtime, or `null` if `loadNodeAvRuntime` has not resolved
25111
+ * yet. Session runtimes read this synchronously — the addon guarantees it is
25112
+ * populated before any session is created.
25113
+ */
25114
+ function peekNodeAvRuntime() {
25115
+ return _runtime;
25116
+ }
25117
+ //#endregion
24299
25118
  //#region src/addon/index.ts
24300
25119
  var FRAME_BUFFER_CAPACITY = 32;
24301
25120
  var DecoderNodeAvAddon = class extends BaseAddon {
@@ -24321,6 +25140,12 @@ var DecoderNodeAvAddon = class extends BaseAddon {
24321
25140
  /** Running `getFrame` hit/miss counters surfaced via `getShmStats`. */
24322
25141
  getFrameHits = 0;
24323
25142
  getFrameMisses = 0;
25143
+ /**
25144
+ * node-av audio-codec provider — registered independently of the video
25145
+ * decoder backend gate whenever node-av loads on this node, so this single
25146
+ * addon provides both the `decoder` and `audio-codec` caps.
25147
+ */
25148
+ audioProvider = null;
24324
25149
  constructor() {
24325
25150
  super(DEFAULT_DECODER_HWACCEL_CONFIG);
24326
25151
  }
@@ -24355,20 +25180,40 @@ var DecoderNodeAvAddon = class extends BaseAddon {
24355
25180
  }] });
24356
25181
  }
24357
25182
  async onInitialize() {
25183
+ const registrations = [];
25184
+ let runtime = null;
25185
+ try {
25186
+ runtime = await loadNodeAvRuntime();
25187
+ } catch (err) {
25188
+ this.ctx.logger.error("decoder-nodeav: node-av failed to load — no audio-codec provider", { meta: { error: err instanceof Error ? err.message : String(err) } });
25189
+ }
25190
+ if (runtime) {
25191
+ this.audioProvider = new NodeAvAudioCodecProvider({
25192
+ logger: this.ctx.logger,
25193
+ runtime,
25194
+ resolveLocalNodeId: () => this.resolveLocalNodeId()
25195
+ });
25196
+ this.audioProvider.start();
25197
+ registrations.push({
25198
+ capability: audioCodecCapability,
25199
+ provider: this.audioProvider
25200
+ });
25201
+ }
24358
25202
  const backend = await resolveDecoderBackend(this.ctx.api, this.resolveLocalNodeId(), this.ctx.logger);
24359
25203
  if (backend !== "nodeav") {
24360
25204
  this.ctx.logger.info("node-av decoder: this node selects a different decoder backend — standing down (no decoder provider registered)", { meta: { selectedBackend: backend } });
24361
- return [];
25205
+ return registrations;
24362
25206
  }
24363
25207
  this.ctx.logger.info("node-av decoder addon initialized", { meta: { selectedBackend: backend } });
24364
25208
  this.frameReaders = new FrameRingReaderCache(this.ctx.logger);
24365
25209
  if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
24366
25210
  this.ctx.logger.warn("nodeav: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
24367
25211
  });
24368
- return [{
25212
+ registrations.push({
24369
25213
  capability: decoderCapability,
24370
25214
  provider: this
24371
- }];
25215
+ });
25216
+ return registrations;
24372
25217
  }
24373
25218
  /**
24374
25219
  * Resolve the effective hwaccel backend for a new session — from THIS addon's
@@ -24614,6 +25459,8 @@ var DecoderNodeAvAddon = class extends BaseAddon {
24614
25459
  };
24615
25460
  }
24616
25461
  async onShutdown() {
25462
+ this.audioProvider?.stop();
25463
+ this.audioProvider = null;
24617
25464
  this.ctx.logger.info("node-av decoder addon shutdown — destroying all sessions");
24618
25465
  const destroyPromises = [];
24619
25466
  for (const [sessionId, session] of this.sessions) {
@@ -24632,4 +25479,4 @@ var DecoderNodeAvAddon = class extends BaseAddon {
24632
25479
  }
24633
25480
  };
24634
25481
  //#endregion
24635
- export { DecoderFrameRingSink, DecoderNodeAvAddon, DecoderNodeAvAddon as default, NodeAvDecoderSession, makeSegmentName };
25482
+ export { DecoderFrameRingSink, DecoderNodeAvAddon, DecoderNodeAvAddon as default, NodeAvAudioCodecProvider, NodeAvAudioDecodeSession, NodeAvAudioEncodeSession, NodeAvDecoderSession, loadNodeAvRuntime, makeSegmentName, peekNodeAvRuntime };