@camstack/addon-decoder-nodeav 1.1.2 → 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.
- package/dist/index.js +891 -39
- package/dist/index.mjs +887 -40
- 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
|
-
|
|
12797
|
-
|
|
12798
|
-
|
|
12799
|
-
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
|
|
12805
|
-
|
|
12806
|
-
|
|
12807
|
-
|
|
12808
|
-
|
|
12809
|
-
|
|
12810
|
-
|
|
12811
|
-
|
|
12812
|
-
|
|
12813
|
-
|
|
12814
|
-
|
|
12815
|
-
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
12822
|
-
|
|
12823
|
-
|
|
12824
|
-
}),
|
|
12825
|
-
|
|
12826
|
-
|
|
12827
|
-
|
|
12828
|
-
}),
|
|
12829
|
-
|
|
12830
|
-
|
|
12831
|
-
|
|
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
|
-
|
|
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 };
|