@camstack/addon-decoder-ffmpeg 1.1.4 → 1.1.6
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 +1579 -45
- package/dist/index.mjs +1580 -46
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ Object.defineProperties(exports, {
|
|
|
5
5
|
let node_crypto = require("node:crypto");
|
|
6
6
|
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
7
7
|
let node_child_process = require("node:child_process");
|
|
8
|
+
let node_fs = require("node:fs");
|
|
8
9
|
//#region ../../node_modules/zod/v4/core/core.js
|
|
9
10
|
var _a$1;
|
|
10
11
|
function $constructor(name, initializer, params) {
|
|
@@ -4634,7 +4635,7 @@ function _instanceof(cls, params = {}) {
|
|
|
4634
4635
|
return inst;
|
|
4635
4636
|
}
|
|
4636
4637
|
//#endregion
|
|
4637
|
-
//#region ../types/dist/sleep-
|
|
4638
|
+
//#region ../types/dist/sleep-CZDdRBua.mjs
|
|
4638
4639
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
4639
4640
|
EventCategory["SystemBoot"] = "system.boot";
|
|
4640
4641
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -7241,7 +7242,16 @@ var DecoderStatsSchema = object({
|
|
|
7241
7242
|
inputFps: number(),
|
|
7242
7243
|
outputFps: number(),
|
|
7243
7244
|
avgDecodeTimeMs: number(),
|
|
7244
|
-
droppedFrames: number()
|
|
7245
|
+
droppedFrames: number(),
|
|
7246
|
+
/**
|
|
7247
|
+
* Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
|
|
7248
|
+
* lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
|
|
7249
|
+
* the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
|
|
7250
|
+
* is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
|
|
7251
|
+
*/
|
|
7252
|
+
lagMs: number().optional(),
|
|
7253
|
+
effectiveFps: number().optional(),
|
|
7254
|
+
adaptiveFps: number().optional()
|
|
7245
7255
|
});
|
|
7246
7256
|
var DecoderSessionConfigSchema = object({
|
|
7247
7257
|
codec: string(),
|
|
@@ -7282,7 +7292,15 @@ var DecoderSessionConfigSchema = object({
|
|
|
7282
7292
|
* other — `pullFrames` returns nothing for an `'shm'` session and
|
|
7283
7293
|
* `pullHandles` returns nothing for a `'callback'` session.
|
|
7284
7294
|
*/
|
|
7285
|
-
frameSink: _enum(["callback", "shm"]).default("callback")
|
|
7295
|
+
frameSink: _enum(["callback", "shm"]).default("callback"),
|
|
7296
|
+
/**
|
|
7297
|
+
* Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
|
|
7298
|
+
* a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
|
|
7299
|
+
* real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
|
|
7300
|
+
* stream-broker's `streamingDebug` gate — off by default so production logs
|
|
7301
|
+
* stay quiet and the emit path pays zero per-frame cost when disabled.
|
|
7302
|
+
*/
|
|
7303
|
+
debug: boolean().optional()
|
|
7286
7304
|
});
|
|
7287
7305
|
var EncodeProfileSchema = object({
|
|
7288
7306
|
video: object({
|
|
@@ -9440,6 +9458,75 @@ DeviceType.Cover, method(object({ deviceId: number().int().nonnegative() }), _vo
|
|
|
9440
9458
|
auth: "admin"
|
|
9441
9459
|
});
|
|
9442
9460
|
/**
|
|
9461
|
+
* Vendor-neutral day/night (IR-cut) control — the per-camera config cap
|
|
9462
|
+
* shared by reolink / hikvision / amcrest. Models the common firmware
|
|
9463
|
+
* surface: the IR-cut switching MODE plus the two knobs that gate it
|
|
9464
|
+
* (photocell `sensitivity` + `switchDelaySec`). Each vendor maps these
|
|
9465
|
+
* onto its own ISAPI / Baichuan / Dahua-CGI fields; the cap standardises
|
|
9466
|
+
* the shape so ONE derived-form renders every camera.
|
|
9467
|
+
*
|
|
9468
|
+
* Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
|
|
9469
|
+
* `getOptions` advertises per-camera availability, `getStatus` (auto-
|
|
9470
|
+
* injected from `status`) reports the live values, and a single
|
|
9471
|
+
* `setSettings` mutation applies a partial change. No hand-written
|
|
9472
|
+
* settings-contribution methods — the framework derives the UI + save
|
|
9473
|
+
* routing from this surface.
|
|
9474
|
+
*/
|
|
9475
|
+
/** IR-cut switching mode. `schedule` = time-of-day table configured on the camera. */
|
|
9476
|
+
var DayNightModeSchema = _enum([
|
|
9477
|
+
"auto",
|
|
9478
|
+
"day",
|
|
9479
|
+
"night",
|
|
9480
|
+
"schedule"
|
|
9481
|
+
]);
|
|
9482
|
+
/** Normalized numeric range descriptor — `{ min, max, step }` per the
|
|
9483
|
+
* getOptions availability convention. Normalized values are 0–100. */
|
|
9484
|
+
var NormalizedRangeSchema$1 = object({
|
|
9485
|
+
min: number(),
|
|
9486
|
+
max: number(),
|
|
9487
|
+
step: number()
|
|
9488
|
+
});
|
|
9489
|
+
object({
|
|
9490
|
+
mode: DayNightModeSchema,
|
|
9491
|
+
/** IR-cut trigger sensitivity, NORMALIZED 0–100 (higher = switches to night sooner). */
|
|
9492
|
+
sensitivity: number().optional(),
|
|
9493
|
+
/** Delay before the IR-cut filter flips, in seconds. */
|
|
9494
|
+
switchDelaySec: number().optional(),
|
|
9495
|
+
lastFetchedAt: number()
|
|
9496
|
+
});
|
|
9497
|
+
/**
|
|
9498
|
+
* Per-camera availability descriptor — drives which controls the admin UI
|
|
9499
|
+
* renders. Booleans as `supportsX`; numeric ranges as `{ min, max, step }`
|
|
9500
|
+
* (normalized 0–100); the mode choice-set as an array. A provider returns
|
|
9501
|
+
* honest, camera-probed values — never hardcoded.
|
|
9502
|
+
*/
|
|
9503
|
+
var DayNightOptionsSchema = object({
|
|
9504
|
+
/** Modes this camera accepts. Empty → the camera has no configurable day/night mode. */
|
|
9505
|
+
modes: array(DayNightModeSchema),
|
|
9506
|
+
supportsSensitivity: boolean(),
|
|
9507
|
+
/** Present when `supportsSensitivity` — the normalized 0–100 range. */
|
|
9508
|
+
sensitivity: NormalizedRangeSchema$1.optional(),
|
|
9509
|
+
supportsSwitchDelay: boolean(),
|
|
9510
|
+
/** Present when `supportsSwitchDelay` — the allowed delay range in seconds. */
|
|
9511
|
+
switchDelaySec: NormalizedRangeSchema$1.optional()
|
|
9512
|
+
});
|
|
9513
|
+
/**
|
|
9514
|
+
* Partial change to the day/night config — every field optional. A
|
|
9515
|
+
* provider ignores fields it does not support.
|
|
9516
|
+
*/
|
|
9517
|
+
var DayNightSettingsPatchSchema = object({
|
|
9518
|
+
mode: DayNightModeSchema.optional(),
|
|
9519
|
+
sensitivity: number().optional(),
|
|
9520
|
+
switchDelaySec: number().optional()
|
|
9521
|
+
});
|
|
9522
|
+
DeviceType.Camera, method(object({ deviceId: number() }), DayNightOptionsSchema), method(object({
|
|
9523
|
+
deviceId: number(),
|
|
9524
|
+
settings: DayNightSettingsPatchSchema
|
|
9525
|
+
}), _void(), {
|
|
9526
|
+
kind: "mutation",
|
|
9527
|
+
auth: "admin"
|
|
9528
|
+
});
|
|
9529
|
+
/**
|
|
9443
9530
|
* Identity envelope for a device's upstream-system metadata.
|
|
9444
9531
|
*
|
|
9445
9532
|
* Two jobs:
|
|
@@ -9795,6 +9882,130 @@ object({
|
|
|
9795
9882
|
});
|
|
9796
9883
|
DeviceType.Image;
|
|
9797
9884
|
/**
|
|
9885
|
+
* Vendor-neutral image / picture-adjustment cap — the per-camera config
|
|
9886
|
+
* cap shared by reolink / hikvision / amcrest. Models the common ISP
|
|
9887
|
+
* surface: the four picture sliders (brightness / contrast / saturation /
|
|
9888
|
+
* sharpness), orientation (mirror / flip / rotate), white-balance,
|
|
9889
|
+
* exposure and backlight-compensation modes.
|
|
9890
|
+
*
|
|
9891
|
+
* NORMALIZATION: every slider is a normalized int 0–100. Vendors expose
|
|
9892
|
+
* these natively as 0–100 or 0–255 (or other ranges); each provider maps
|
|
9893
|
+
* its native range to/from this normalized 0–100 space so the cap surface
|
|
9894
|
+
* (and the derived form) is identical across cameras. `warmth` (manual
|
|
9895
|
+
* white-balance) is likewise normalized 0–100.
|
|
9896
|
+
*
|
|
9897
|
+
* Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
|
|
9898
|
+
* `getOptions` advertises per-camera availability, `getStatus` (auto-
|
|
9899
|
+
* injected from `status`) reports the live values, and a single
|
|
9900
|
+
* `setSettings` mutation applies a partial change. No hand-written
|
|
9901
|
+
* settings-contribution methods — the framework derives the UI + save
|
|
9902
|
+
* routing from this surface.
|
|
9903
|
+
*/
|
|
9904
|
+
/** Sensor/image rotation, degrees clockwise. */
|
|
9905
|
+
var ImageRotateSchema = _enum([
|
|
9906
|
+
"0",
|
|
9907
|
+
"90",
|
|
9908
|
+
"180",
|
|
9909
|
+
"270"
|
|
9910
|
+
]);
|
|
9911
|
+
/** White-balance mode. `manual` unlocks the normalized `warmth` knob. */
|
|
9912
|
+
var WhiteBalanceModeSchema = _enum(["auto", "manual"]);
|
|
9913
|
+
/** Exposure mode. */
|
|
9914
|
+
var ExposureModeSchema = _enum(["auto", "manual"]);
|
|
9915
|
+
/**
|
|
9916
|
+
* Backlight-compensation mode:
|
|
9917
|
+
* - `off` — disabled
|
|
9918
|
+
* - `blc` — backlight compensation
|
|
9919
|
+
* - `wdr` — wide dynamic range
|
|
9920
|
+
* - `hlc` — highlight compensation
|
|
9921
|
+
*/
|
|
9922
|
+
var BacklightModeSchema = _enum([
|
|
9923
|
+
"off",
|
|
9924
|
+
"blc",
|
|
9925
|
+
"wdr",
|
|
9926
|
+
"hlc"
|
|
9927
|
+
]);
|
|
9928
|
+
/** Normalized numeric range descriptor — `{ min, max, step }` per the
|
|
9929
|
+
* getOptions availability convention. Slider values are normalized 0–100. */
|
|
9930
|
+
var NormalizedRangeSchema = object({
|
|
9931
|
+
min: number(),
|
|
9932
|
+
max: number(),
|
|
9933
|
+
step: number()
|
|
9934
|
+
});
|
|
9935
|
+
object({
|
|
9936
|
+
/** Normalized 0–100. */
|
|
9937
|
+
brightness: number().optional(),
|
|
9938
|
+
/** Normalized 0–100. */
|
|
9939
|
+
contrast: number().optional(),
|
|
9940
|
+
/** Normalized 0–100. */
|
|
9941
|
+
saturation: number().optional(),
|
|
9942
|
+
/** Normalized 0–100. */
|
|
9943
|
+
sharpness: number().optional(),
|
|
9944
|
+
mirror: boolean().optional(),
|
|
9945
|
+
flip: boolean().optional(),
|
|
9946
|
+
rotate: ImageRotateSchema.optional(),
|
|
9947
|
+
whiteBalance: WhiteBalanceModeSchema.optional(),
|
|
9948
|
+
/** Manual white-balance warmth, NORMALIZED 0–100. Meaningful only when `whiteBalance === 'manual'`. */
|
|
9949
|
+
warmth: number().optional(),
|
|
9950
|
+
exposureMode: ExposureModeSchema.optional(),
|
|
9951
|
+
backlightMode: BacklightModeSchema.optional(),
|
|
9952
|
+
lastFetchedAt: number()
|
|
9953
|
+
});
|
|
9954
|
+
/**
|
|
9955
|
+
* Per-camera availability descriptor — drives which controls the admin UI
|
|
9956
|
+
* renders. Booleans as `supportsX`; numeric ranges as `{ min, max, step }`
|
|
9957
|
+
* (the normalized 0–100 range); enums as arrays of supported values (empty
|
|
9958
|
+
* array → control hidden). A provider returns honest, camera-probed values
|
|
9959
|
+
* — never hardcoded.
|
|
9960
|
+
*/
|
|
9961
|
+
var ImageSettingsOptionsSchema = object({
|
|
9962
|
+
supportsBrightness: boolean(),
|
|
9963
|
+
brightness: NormalizedRangeSchema.optional(),
|
|
9964
|
+
supportsContrast: boolean(),
|
|
9965
|
+
contrast: NormalizedRangeSchema.optional(),
|
|
9966
|
+
supportsSaturation: boolean(),
|
|
9967
|
+
saturation: NormalizedRangeSchema.optional(),
|
|
9968
|
+
supportsSharpness: boolean(),
|
|
9969
|
+
sharpness: NormalizedRangeSchema.optional(),
|
|
9970
|
+
supportsMirror: boolean(),
|
|
9971
|
+
supportsFlip: boolean(),
|
|
9972
|
+
/** Supported rotation values. Empty → rotation not configurable. */
|
|
9973
|
+
rotateOptions: array(ImageRotateSchema),
|
|
9974
|
+
/** Supported white-balance modes. Empty → white-balance not configurable. */
|
|
9975
|
+
whiteBalanceModes: array(WhiteBalanceModeSchema),
|
|
9976
|
+
supportsWarmth: boolean(),
|
|
9977
|
+
/** Present when `supportsWarmth` — the normalized 0–100 range. */
|
|
9978
|
+
warmth: NormalizedRangeSchema.optional(),
|
|
9979
|
+
/** Supported exposure modes. Empty → exposure not configurable. */
|
|
9980
|
+
exposureModes: array(ExposureModeSchema),
|
|
9981
|
+
/** Supported backlight modes. Empty → backlight-compensation not configurable. */
|
|
9982
|
+
backlightModes: array(BacklightModeSchema)
|
|
9983
|
+
});
|
|
9984
|
+
/**
|
|
9985
|
+
* Partial change to the image config — every field optional. Slider values
|
|
9986
|
+
* are normalized 0–100. A provider ignores fields it does not support.
|
|
9987
|
+
*/
|
|
9988
|
+
var ImageSettingsPatchSchema = object({
|
|
9989
|
+
brightness: number().optional(),
|
|
9990
|
+
contrast: number().optional(),
|
|
9991
|
+
saturation: number().optional(),
|
|
9992
|
+
sharpness: number().optional(),
|
|
9993
|
+
mirror: boolean().optional(),
|
|
9994
|
+
flip: boolean().optional(),
|
|
9995
|
+
rotate: ImageRotateSchema.optional(),
|
|
9996
|
+
whiteBalance: WhiteBalanceModeSchema.optional(),
|
|
9997
|
+
warmth: number().optional(),
|
|
9998
|
+
exposureMode: ExposureModeSchema.optional(),
|
|
9999
|
+
backlightMode: BacklightModeSchema.optional()
|
|
10000
|
+
});
|
|
10001
|
+
DeviceType.Camera, method(object({ deviceId: number() }), ImageSettingsOptionsSchema), method(object({
|
|
10002
|
+
deviceId: number(),
|
|
10003
|
+
settings: ImageSettingsPatchSchema
|
|
10004
|
+
}), _void(), {
|
|
10005
|
+
kind: "mutation",
|
|
10006
|
+
auth: "admin"
|
|
10007
|
+
});
|
|
10008
|
+
/**
|
|
9798
10009
|
* Robotic lawn-mower cap. Models HA `lawn_mower.*` entities — anything
|
|
9799
10010
|
* with a mowing lifecycle plus a dock action.
|
|
9800
10011
|
*
|
|
@@ -10689,6 +10900,16 @@ var RunnerCameraConfigSchema = object({
|
|
|
10689
10900
|
* this gate is bypassed.
|
|
10690
10901
|
*/
|
|
10691
10902
|
onboardMotionDrivesAnalyzer: boolean().default(true),
|
|
10903
|
+
/**
|
|
10904
|
+
* Master toggle for the occupancy re-check. When `false` (DEFAULT) the runner
|
|
10905
|
+
* never arms the periodic recheck timer, regardless of `occupancyRecheckSec` —
|
|
10906
|
+
* this is off by default because the recheck re-subscribes a detection session
|
|
10907
|
+
* every N seconds while `watching`, a major source of pull-decoder re-dial
|
|
10908
|
+
* churn (each cycle creates+tears a session → RTSP re-dial → latency). The
|
|
10909
|
+
* `occupancyRecheckSec` / `occupancyRecheckFrames` sliders only take effect
|
|
10910
|
+
* (and only render) when this is enabled.
|
|
10911
|
+
*/
|
|
10912
|
+
occupancyRecheckEnabled: boolean().default(false),
|
|
10692
10913
|
occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
|
|
10693
10914
|
occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
|
|
10694
10915
|
/**
|
|
@@ -12534,42 +12755,79 @@ var SessionInventoryEntrySchema = object({
|
|
|
12534
12755
|
framesIn: number(),
|
|
12535
12756
|
framesOut: number()
|
|
12536
12757
|
});
|
|
12537
|
-
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12541
|
-
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
12553
|
-
|
|
12554
|
-
|
|
12555
|
-
|
|
12556
|
-
|
|
12557
|
-
|
|
12558
|
-
|
|
12559
|
-
|
|
12560
|
-
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
12565
|
-
}),
|
|
12566
|
-
|
|
12567
|
-
|
|
12568
|
-
|
|
12569
|
-
}),
|
|
12570
|
-
|
|
12571
|
-
|
|
12572
|
-
|
|
12758
|
+
/**
|
|
12759
|
+
* audio-codec — bidirectional PCM ↔ encoded audio I/O box.
|
|
12760
|
+
*
|
|
12761
|
+
* Independent per-consumer sessions. The provider runs decode + resample
|
|
12762
|
+
* (or resample + encode) inside the session so a 16kHz mono ASA
|
|
12763
|
+
* subscriber and a 48kHz stereo WebRTC subscriber on the same source
|
|
12764
|
+
* stream don't share resamplers.
|
|
12765
|
+
*
|
|
12766
|
+
* Singleton on each node. Decoder and encoder live in the same provider
|
|
12767
|
+
* because they share the underlying libav contexts (node-av today,
|
|
12768
|
+
* pluggable later) — operators always install one or the other together.
|
|
12769
|
+
*/
|
|
12770
|
+
var audioCodecCapability = {
|
|
12771
|
+
name: "audio-codec",
|
|
12772
|
+
scope: "system",
|
|
12773
|
+
mode: "singleton",
|
|
12774
|
+
preferredProvider: "decoder-nodeav",
|
|
12775
|
+
methods: {
|
|
12776
|
+
/** Probe the local runtime and return the supported codec matrix. */
|
|
12777
|
+
listSupportedCodecs: method(_void(), array(AudioCodecInfoSchema).readonly()),
|
|
12778
|
+
/** Cheap predicate — does the runtime support `(codec, kind)`? */
|
|
12779
|
+
canHandle: method(object({
|
|
12780
|
+
codec: string(),
|
|
12781
|
+
kind: _enum(["decode", "encode"])
|
|
12782
|
+
}), boolean()),
|
|
12783
|
+
createDecodeSession: method(AudioDecodeSessionConfigSchema, object({
|
|
12784
|
+
sessionId: string(),
|
|
12785
|
+
nodeId: string()
|
|
12786
|
+
}), { kind: "mutation" }),
|
|
12787
|
+
createEncodeSession: method(AudioEncodeSessionConfigSchema, object({
|
|
12788
|
+
sessionId: string(),
|
|
12789
|
+
nodeId: string()
|
|
12790
|
+
}), { kind: "mutation" }),
|
|
12791
|
+
closeSession: method(object({
|
|
12792
|
+
sessionId: string(),
|
|
12793
|
+
nodeId: string().optional()
|
|
12794
|
+
}), _void(), { kind: "mutation" }),
|
|
12795
|
+
/** Push one encoded audio frame into a decode session. */
|
|
12796
|
+
pushEncodedFrame: method(object({
|
|
12797
|
+
sessionId: string(),
|
|
12798
|
+
nodeId: string().optional(),
|
|
12799
|
+
data: _instanceof(Uint8Array),
|
|
12800
|
+
/** Source PTS in milliseconds. Synthesised when omitted. */
|
|
12801
|
+
pts: number().optional()
|
|
12802
|
+
}), _void(), { kind: "mutation" }),
|
|
12803
|
+
/** Pull up to `maxCount` PCM chunks from a decode session. */
|
|
12804
|
+
pullPcm: method(object({
|
|
12805
|
+
sessionId: string(),
|
|
12806
|
+
nodeId: string().optional(),
|
|
12807
|
+
maxCount: number().int().positive().default(8)
|
|
12808
|
+
}), array(AudioPcmChunkSchema)),
|
|
12809
|
+
/** Push one PCM chunk into an encode session. */
|
|
12810
|
+
pushPcm: method(object({
|
|
12811
|
+
sessionId: string(),
|
|
12812
|
+
nodeId: string().optional(),
|
|
12813
|
+
data: _instanceof(Uint8Array),
|
|
12814
|
+
/** Source PTS in milliseconds. */
|
|
12815
|
+
pts: number().optional()
|
|
12816
|
+
}), _void(), { kind: "mutation" }),
|
|
12817
|
+
/** Pull up to `maxCount` encoded chunks from an encode session. */
|
|
12818
|
+
pullEncoded: method(object({
|
|
12819
|
+
sessionId: string(),
|
|
12820
|
+
nodeId: string().optional(),
|
|
12821
|
+
maxCount: number().int().positive().default(8)
|
|
12822
|
+
}), array(AudioEncodedChunkSchema)),
|
|
12823
|
+
/** Flush any pending encoded output (call before close on graceful tear). */
|
|
12824
|
+
flushEncode: method(object({
|
|
12825
|
+
sessionId: string(),
|
|
12826
|
+
nodeId: string().optional()
|
|
12827
|
+
}), array(AudioEncodedChunkSchema), { kind: "mutation" }),
|
|
12828
|
+
listActiveSessions: method(_void(), array(SessionInventoryEntrySchema).readonly())
|
|
12829
|
+
}
|
|
12830
|
+
};
|
|
12573
12831
|
var AuthResultSchema = object({
|
|
12574
12832
|
userId: string(),
|
|
12575
12833
|
username: string(),
|
|
@@ -19072,6 +19330,18 @@ Object.freeze({
|
|
|
19072
19330
|
addonId: null,
|
|
19073
19331
|
access: "view"
|
|
19074
19332
|
},
|
|
19333
|
+
"dayNight.getOptions": {
|
|
19334
|
+
capName: "day-night",
|
|
19335
|
+
capScope: "device",
|
|
19336
|
+
addonId: null,
|
|
19337
|
+
access: "view"
|
|
19338
|
+
},
|
|
19339
|
+
"dayNight.setSettings": {
|
|
19340
|
+
capName: "day-night",
|
|
19341
|
+
capScope: "device",
|
|
19342
|
+
addonId: null,
|
|
19343
|
+
access: "create"
|
|
19344
|
+
},
|
|
19075
19345
|
"decoder.createSession": {
|
|
19076
19346
|
capName: "decoder",
|
|
19077
19347
|
capScope: "system",
|
|
@@ -20002,6 +20272,18 @@ Object.freeze({
|
|
|
20002
20272
|
addonId: null,
|
|
20003
20273
|
access: "create"
|
|
20004
20274
|
},
|
|
20275
|
+
"imageSettings.getOptions": {
|
|
20276
|
+
capName: "image-settings",
|
|
20277
|
+
capScope: "device",
|
|
20278
|
+
addonId: null,
|
|
20279
|
+
access: "view"
|
|
20280
|
+
},
|
|
20281
|
+
"imageSettings.setSettings": {
|
|
20282
|
+
capName: "image-settings",
|
|
20283
|
+
capScope: "device",
|
|
20284
|
+
addonId: null,
|
|
20285
|
+
access: "create"
|
|
20286
|
+
},
|
|
20005
20287
|
"integrations.create": {
|
|
20006
20288
|
capName: "integrations",
|
|
20007
20289
|
capScope: "system",
|
|
@@ -23292,10 +23574,16 @@ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
|
|
|
23292
23574
|
* segment (resolution change) a distinct name so a stale consumer mapping is
|
|
23293
23575
|
* never silently reused.
|
|
23294
23576
|
*/
|
|
23577
|
+
/**
|
|
23578
|
+
* Shared prefix for every decoder shm segment name. Startup orphan reclamation
|
|
23579
|
+
* (`purgeOrphanSegments`) keys off this to find segments left behind by a
|
|
23580
|
+
* crashed prior instance.
|
|
23581
|
+
*/
|
|
23582
|
+
var SEGMENT_NAME_PREFIX = "csf.";
|
|
23295
23583
|
function makeSegmentName(seed, generation) {
|
|
23296
23584
|
let hash = 5381;
|
|
23297
23585
|
for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
|
|
23298
|
-
return
|
|
23586
|
+
return `${SEGMENT_NAME_PREFIX}${(hash >>> 0).toString(36)}.${generation}`;
|
|
23299
23587
|
}
|
|
23300
23588
|
/**
|
|
23301
23589
|
* The decoder-side owner of one stream's shared-memory frame ring.
|
|
@@ -24138,6 +24426,1225 @@ function probeGpuScaleFilters(ffmpegPath, logger) {
|
|
|
24138
24426
|
});
|
|
24139
24427
|
}
|
|
24140
24428
|
//#endregion
|
|
24429
|
+
//#region src/shm-orphan-purge.ts
|
|
24430
|
+
/**
|
|
24431
|
+
* Startup reclamation of orphaned shared-memory segments.
|
|
24432
|
+
*
|
|
24433
|
+
* A decoder writes frames into named `/dev/shm` segments and unlinks each one
|
|
24434
|
+
* on graceful session teardown (`DecoderFrameRingSink.destroy`). When the
|
|
24435
|
+
* decoder process dies *ungracefully* — SIGBUS, OOM-kill, or a SIGKILL during
|
|
24436
|
+
* redeploy — that teardown never runs and the segment is orphaned: it stays in
|
|
24437
|
+
* the tmpfs forever, since nothing else knows its name. Across many
|
|
24438
|
+
* crashes/redeploys these accumulate until `/dev/shm` fills, at which point the
|
|
24439
|
+
* next `mmap` write faults with an uncatchable SIGBUS and the decoder
|
|
24440
|
+
* crash-loops into its circuit breaker (which is exactly the incident this
|
|
24441
|
+
* guards against).
|
|
24442
|
+
*
|
|
24443
|
+
* The reclamation is safe *at process startup*: a freshly-booting decoder owns
|
|
24444
|
+
* no live sessions, so every pre-existing segment with its prefix is by
|
|
24445
|
+
* definition an orphan from a dead instance. `shm_unlink` only removes the
|
|
24446
|
+
* name — any consumer still holding a mapping keeps reading valid memory until
|
|
24447
|
+
* it closes (POSIX deferred reclaim + the ring seqlock), so unlinking is safe
|
|
24448
|
+
* even if a stale reader is momentarily still attached.
|
|
24449
|
+
*
|
|
24450
|
+
* POSIX-only: segments surface as files under `/dev/shm` on Linux. On platforms
|
|
24451
|
+
* without that directory (Windows, macOS) the scan finds nothing — no-op.
|
|
24452
|
+
*
|
|
24453
|
+
* NOTE: this lives inside the decoder addon (not `@camstack/shm-ring`) so it
|
|
24454
|
+
* ships in the self-contained addon bundle via `camstack deploy`, reusing the
|
|
24455
|
+
* already-deployed `unlinkSegment`; no host base-image rebuild required.
|
|
24456
|
+
*/
|
|
24457
|
+
/** Default tmpfs directory where POSIX shared-memory segments appear on Linux. */
|
|
24458
|
+
var DEFAULT_SHM_DIR = "/dev/shm";
|
|
24459
|
+
/**
|
|
24460
|
+
* Unlink every shared-memory segment whose name starts with `prefix`.
|
|
24461
|
+
*
|
|
24462
|
+
* Intended to run ONCE at decoder startup, before any session is created, to
|
|
24463
|
+
* reclaim segments orphaned by a previously-crashed instance. A per-file unlink
|
|
24464
|
+
* failure is swallowed so one stuck segment cannot block reclaiming the rest.
|
|
24465
|
+
*/
|
|
24466
|
+
function purgeOrphanSegments(prefix, options = {}) {
|
|
24467
|
+
const dir = options.dir ?? DEFAULT_SHM_DIR;
|
|
24468
|
+
const unlink = options.unlink ?? _camstack_shm_ring.unlinkSegment;
|
|
24469
|
+
let entries;
|
|
24470
|
+
try {
|
|
24471
|
+
entries = (0, node_fs.readdirSync)(dir);
|
|
24472
|
+
} catch {
|
|
24473
|
+
return {
|
|
24474
|
+
scanned: 0,
|
|
24475
|
+
removed: 0,
|
|
24476
|
+
names: []
|
|
24477
|
+
};
|
|
24478
|
+
}
|
|
24479
|
+
const names = [];
|
|
24480
|
+
for (const name of entries) {
|
|
24481
|
+
if (!name.startsWith(prefix)) continue;
|
|
24482
|
+
try {
|
|
24483
|
+
unlink(name);
|
|
24484
|
+
names.push(name);
|
|
24485
|
+
} catch {}
|
|
24486
|
+
}
|
|
24487
|
+
return {
|
|
24488
|
+
scanned: entries.length,
|
|
24489
|
+
removed: names.length,
|
|
24490
|
+
names
|
|
24491
|
+
};
|
|
24492
|
+
}
|
|
24493
|
+
//#endregion
|
|
24494
|
+
//#region src/audio-codec/ffmpeg-audio-process.ts
|
|
24495
|
+
/**
|
|
24496
|
+
* Minimal ffmpeg subprocess wrapper shared by the audio decode + encode
|
|
24497
|
+
* sessions of `audio-codec-ffmpeg`.
|
|
24498
|
+
*
|
|
24499
|
+
* Owns exactly the process concerns both directions need: spawn, stdin write
|
|
24500
|
+
* with tolerated backpressure, stdout chunk delivery, and the kill sequence.
|
|
24501
|
+
*
|
|
24502
|
+
* The kill logic copies the CORRECTED pattern from `decoder-ffmpeg`'s
|
|
24503
|
+
* `killFfmpeg`: SIGTERM, then SIGKILL after a grace — gated on
|
|
24504
|
+
* `exitCode === null && signalCode === null`, NOT on `child.killed` (which Node
|
|
24505
|
+
* sets true after ANY `kill()`, so `!child.killed` never fires and the SIGKILL
|
|
24506
|
+
* would leak the process).
|
|
24507
|
+
*/
|
|
24508
|
+
/** Grace period between SIGTERM and the escalated SIGKILL. */
|
|
24509
|
+
var KILL_GRACE_MS = 500;
|
|
24510
|
+
var FfmpegAudioProcess = class {
|
|
24511
|
+
child = null;
|
|
24512
|
+
logger;
|
|
24513
|
+
opts;
|
|
24514
|
+
killed = false;
|
|
24515
|
+
constructor(opts) {
|
|
24516
|
+
this.opts = opts;
|
|
24517
|
+
this.logger = opts.logger;
|
|
24518
|
+
this.spawn();
|
|
24519
|
+
}
|
|
24520
|
+
spawn() {
|
|
24521
|
+
let child;
|
|
24522
|
+
try {
|
|
24523
|
+
child = (0, node_child_process.spawn)(this.opts.ffmpegPath, [...this.opts.args]);
|
|
24524
|
+
} catch (err) {
|
|
24525
|
+
this.logger.error("audio-codec-ffmpeg: spawn threw", { meta: { error: errMsg(err) } });
|
|
24526
|
+
return;
|
|
24527
|
+
}
|
|
24528
|
+
this.child = child;
|
|
24529
|
+
child.stdin?.on("error", () => {});
|
|
24530
|
+
child.stdout?.on("data", (chunk) => this.opts.onStdout(chunk));
|
|
24531
|
+
child.stderr?.on("data", (data) => {
|
|
24532
|
+
const line = data.toString().trim();
|
|
24533
|
+
if (line) this.logger.debug("audio-codec-ffmpeg stderr", { meta: { line } });
|
|
24534
|
+
});
|
|
24535
|
+
child.on("error", (err) => {
|
|
24536
|
+
this.logger.error("audio-codec-ffmpeg: process error", { meta: { error: err.message } });
|
|
24537
|
+
});
|
|
24538
|
+
child.on("exit", (code, signal) => {
|
|
24539
|
+
if (this.killed) return;
|
|
24540
|
+
this.opts.onExit(code, signal);
|
|
24541
|
+
});
|
|
24542
|
+
}
|
|
24543
|
+
/** Write encoded frames / PCM into ffmpeg stdin. Backpressure is tolerated. */
|
|
24544
|
+
write(data) {
|
|
24545
|
+
const stdin = this.child?.stdin;
|
|
24546
|
+
if (!stdin) return;
|
|
24547
|
+
try {
|
|
24548
|
+
stdin.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
|
|
24549
|
+
} catch {}
|
|
24550
|
+
}
|
|
24551
|
+
/** Close stdin so ffmpeg flushes and drains its remaining output. */
|
|
24552
|
+
endStdin() {
|
|
24553
|
+
try {
|
|
24554
|
+
this.child?.stdin?.end();
|
|
24555
|
+
} catch {}
|
|
24556
|
+
}
|
|
24557
|
+
/** SIGTERM then escalated SIGKILL — see class doc for the gating rationale. */
|
|
24558
|
+
kill() {
|
|
24559
|
+
const child = this.child;
|
|
24560
|
+
if (!child) return;
|
|
24561
|
+
this.killed = true;
|
|
24562
|
+
this.child = null;
|
|
24563
|
+
try {
|
|
24564
|
+
child.stdin?.end();
|
|
24565
|
+
} catch {}
|
|
24566
|
+
try {
|
|
24567
|
+
child.kill("SIGTERM");
|
|
24568
|
+
} catch {}
|
|
24569
|
+
const { pid } = child;
|
|
24570
|
+
setTimeout(() => {
|
|
24571
|
+
if (pid !== void 0 && child.exitCode === null && child.signalCode === null) try {
|
|
24572
|
+
child.kill("SIGKILL");
|
|
24573
|
+
} catch {}
|
|
24574
|
+
}, KILL_GRACE_MS).unref?.();
|
|
24575
|
+
}
|
|
24576
|
+
};
|
|
24577
|
+
//#endregion
|
|
24578
|
+
//#region src/audio-codec/audio-codec-args.ts
|
|
24579
|
+
/**
|
|
24580
|
+
* Low-latency flag set shared by both the decode and encode invocations.
|
|
24581
|
+
*
|
|
24582
|
+
* - `-hide_banner -nostats -loglevel error`: quiet stderr (no periodic counter).
|
|
24583
|
+
* - `-fflags +nobuffer+flush_packets`: don't buffer input; flush output packets
|
|
24584
|
+
* the moment they are ready (critical for the intercom encode path).
|
|
24585
|
+
* - `-flags low_delay`: request the lowest-latency codec behaviour.
|
|
24586
|
+
* - `-probesize 32 -analyzeduration 0`: skip stream probing — the input format
|
|
24587
|
+
* is fully specified by `-f`/`-ar`/`-ac`, so probing only adds startup delay.
|
|
24588
|
+
* - `-threads 1`: single-threaded — audio frames are tiny, and extra worker
|
|
24589
|
+
* threads only add scheduling jitter to a real-time path.
|
|
24590
|
+
*/
|
|
24591
|
+
var AUDIO_LOW_LATENCY_ARGS = [
|
|
24592
|
+
"-hide_banner",
|
|
24593
|
+
"-nostats",
|
|
24594
|
+
"-loglevel",
|
|
24595
|
+
"error",
|
|
24596
|
+
"-fflags",
|
|
24597
|
+
"+nobuffer+flush_packets",
|
|
24598
|
+
"-flags",
|
|
24599
|
+
"low_delay",
|
|
24600
|
+
"-probesize",
|
|
24601
|
+
"32",
|
|
24602
|
+
"-analyzeduration",
|
|
24603
|
+
"0",
|
|
24604
|
+
"-threads",
|
|
24605
|
+
"1"
|
|
24606
|
+
];
|
|
24607
|
+
/**
|
|
24608
|
+
* Normalise an SDP-reported codec name to the libav/ffmpeg name. Mirrors the
|
|
24609
|
+
* `resolveCodecAlias` in `audio-codec-nodeav` so callers can pass the
|
|
24610
|
+
* SDP-reported value verbatim.
|
|
24611
|
+
*/
|
|
24612
|
+
function resolveAudioCodecAlias(codec) {
|
|
24613
|
+
const c = codec.toLowerCase();
|
|
24614
|
+
if (c === "mpeg4-generic") return "aac";
|
|
24615
|
+
if (c === "l16") return "pcm_s16be";
|
|
24616
|
+
return c;
|
|
24617
|
+
}
|
|
24618
|
+
/**
|
|
24619
|
+
* codec → ffmpeg DECODE input format.
|
|
24620
|
+
*
|
|
24621
|
+
* | codec | -f | raw? | note |
|
|
24622
|
+
* | ----------------------------- | ------ | ---- | ----------------------------- |
|
|
24623
|
+
* | pcm_mulaw | mulaw | yes | G.711 µ-law |
|
|
24624
|
+
* | pcm_alaw | alaw | yes | G.711 A-law |
|
|
24625
|
+
* | pcm_s16be (l16) | s16be | yes | RTP L16 |
|
|
24626
|
+
* | pcm_s16le | s16le | yes | |
|
|
24627
|
+
* | g722 | g722 | yes | 16 kHz wideband |
|
|
24628
|
+
* | aac / aac_latm / mpeg4-generic| aac | no | ADTS demuxer |
|
|
24629
|
+
* | opus | ogg | no | ⚠ needs Ogg framing (see NOTE)|
|
|
24630
|
+
*
|
|
24631
|
+
* NOTE (opus decode): ffmpeg has no raw-packet Opus demuxer. `audio-codec-nodeav`
|
|
24632
|
+
* feeds raw RTP Opus packets straight to the `AV_CODEC_ID_OPUS` decoder with no
|
|
24633
|
+
* demuxer; a subprocess cannot do that from a pipe. We map to `ogg`, which is
|
|
24634
|
+
* correct only if the pushed bytes are Ogg-Opus. Raw RTP Opus decode via the
|
|
24635
|
+
* subprocess is a KNOWN limitation — see the addon report.
|
|
24636
|
+
*/
|
|
24637
|
+
var DECODE_FORMAT_BY_CODEC = {
|
|
24638
|
+
pcm_mulaw: {
|
|
24639
|
+
inputFormat: "mulaw",
|
|
24640
|
+
rawInput: true
|
|
24641
|
+
},
|
|
24642
|
+
pcm_alaw: {
|
|
24643
|
+
inputFormat: "alaw",
|
|
24644
|
+
rawInput: true
|
|
24645
|
+
},
|
|
24646
|
+
pcm_s16be: {
|
|
24647
|
+
inputFormat: "s16be",
|
|
24648
|
+
rawInput: true
|
|
24649
|
+
},
|
|
24650
|
+
pcm_s16le: {
|
|
24651
|
+
inputFormat: "s16le",
|
|
24652
|
+
rawInput: true
|
|
24653
|
+
},
|
|
24654
|
+
g722: {
|
|
24655
|
+
inputFormat: "g722",
|
|
24656
|
+
rawInput: true
|
|
24657
|
+
},
|
|
24658
|
+
aac: {
|
|
24659
|
+
inputFormat: "aac",
|
|
24660
|
+
rawInput: false
|
|
24661
|
+
},
|
|
24662
|
+
aac_latm: {
|
|
24663
|
+
inputFormat: "aac",
|
|
24664
|
+
rawInput: false
|
|
24665
|
+
},
|
|
24666
|
+
opus: {
|
|
24667
|
+
inputFormat: "ogg",
|
|
24668
|
+
rawInput: false
|
|
24669
|
+
}
|
|
24670
|
+
};
|
|
24671
|
+
/**
|
|
24672
|
+
* codec → ffmpeg ENCODE codec + container.
|
|
24673
|
+
*
|
|
24674
|
+
* | codec | -c:a | -f | bitrate? |
|
|
24675
|
+
* | --------- | ---------- | ----- | -------- |
|
|
24676
|
+
* | pcm_mulaw | pcm_mulaw | mulaw | no |
|
|
24677
|
+
* | pcm_alaw | pcm_alaw | alaw | no |
|
|
24678
|
+
* | g722 | g722 | g722 | no |
|
|
24679
|
+
* | aac | aac | adts | yes |
|
|
24680
|
+
* | opus | libopus | ogg | yes |
|
|
24681
|
+
*/
|
|
24682
|
+
var ENCODE_FORMAT_BY_CODEC = {
|
|
24683
|
+
pcm_mulaw: {
|
|
24684
|
+
encoder: "pcm_mulaw",
|
|
24685
|
+
outputFormat: "mulaw",
|
|
24686
|
+
acceptsBitrate: false
|
|
24687
|
+
},
|
|
24688
|
+
pcm_alaw: {
|
|
24689
|
+
encoder: "pcm_alaw",
|
|
24690
|
+
outputFormat: "alaw",
|
|
24691
|
+
acceptsBitrate: false
|
|
24692
|
+
},
|
|
24693
|
+
g722: {
|
|
24694
|
+
encoder: "g722",
|
|
24695
|
+
outputFormat: "g722",
|
|
24696
|
+
acceptsBitrate: false
|
|
24697
|
+
},
|
|
24698
|
+
aac: {
|
|
24699
|
+
encoder: "aac",
|
|
24700
|
+
outputFormat: "adts",
|
|
24701
|
+
acceptsBitrate: true
|
|
24702
|
+
},
|
|
24703
|
+
opus: {
|
|
24704
|
+
encoder: "libopus",
|
|
24705
|
+
outputFormat: "ogg",
|
|
24706
|
+
acceptsBitrate: true
|
|
24707
|
+
}
|
|
24708
|
+
};
|
|
24709
|
+
/** Resolve the DECODE input-format descriptor for a codec, or `null` if unknown. */
|
|
24710
|
+
function decodeFormatForCodec(codec) {
|
|
24711
|
+
return DECODE_FORMAT_BY_CODEC[resolveAudioCodecAlias(codec)] ?? null;
|
|
24712
|
+
}
|
|
24713
|
+
/** Resolve the ENCODE codec+container descriptor for a codec, or `null` if unknown. */
|
|
24714
|
+
function encodeFormatForCodec(codec) {
|
|
24715
|
+
return ENCODE_FORMAT_BY_CODEC[resolveAudioCodecAlias(codec)] ?? null;
|
|
24716
|
+
}
|
|
24717
|
+
/** Packed bytes-per-sample for a PCM format (s16le = 2, f32le = 4). */
|
|
24718
|
+
function audioBytesPerSample(format) {
|
|
24719
|
+
return format === "f32le" ? 4 : 2;
|
|
24720
|
+
}
|
|
24721
|
+
/**
|
|
24722
|
+
* Build the ffmpeg argv for a DECODE session — encoded frames on `pipe:0`, raw
|
|
24723
|
+
* PCM on `pipe:1`. PURE: no spawn, no env access.
|
|
24724
|
+
*
|
|
24725
|
+
* ffmpeg <low-latency> -f <inputFmt> [-ar <src> -ac <srcCh>] -i pipe:0 \
|
|
24726
|
+
* -ar <target> -ac <targetCh> -f <s16le|f32le> pipe:1
|
|
24727
|
+
*
|
|
24728
|
+
* @throws if the codec has no known decode mapping.
|
|
24729
|
+
*/
|
|
24730
|
+
function buildAudioDecodeArgs(config) {
|
|
24731
|
+
const entry = decodeFormatForCodec(config.codec);
|
|
24732
|
+
if (!entry) throw new Error(`audio-codec-ffmpeg: no decode format for codec '${config.codec}'`);
|
|
24733
|
+
const outFormat = config.targetFormat ?? "s16le";
|
|
24734
|
+
const inputRateChannel = entry.rawInput ? [
|
|
24735
|
+
"-ar",
|
|
24736
|
+
String(config.sourceSampleRate),
|
|
24737
|
+
"-ac",
|
|
24738
|
+
String(config.sourceChannels)
|
|
24739
|
+
] : [];
|
|
24740
|
+
return [
|
|
24741
|
+
...AUDIO_LOW_LATENCY_ARGS,
|
|
24742
|
+
"-f",
|
|
24743
|
+
entry.inputFormat,
|
|
24744
|
+
...inputRateChannel,
|
|
24745
|
+
"-i",
|
|
24746
|
+
"pipe:0",
|
|
24747
|
+
"-ar",
|
|
24748
|
+
String(config.targetSampleRate),
|
|
24749
|
+
"-ac",
|
|
24750
|
+
String(config.targetChannels),
|
|
24751
|
+
"-f",
|
|
24752
|
+
outFormat,
|
|
24753
|
+
"pipe:1"
|
|
24754
|
+
];
|
|
24755
|
+
}
|
|
24756
|
+
/**
|
|
24757
|
+
* Build the ffmpeg argv for an ENCODE session — raw PCM on `pipe:0`, encoded
|
|
24758
|
+
* bytes on `pipe:1`. PURE: no spawn, no env access.
|
|
24759
|
+
*
|
|
24760
|
+
* ffmpeg <low-latency> -f <s16le|f32le> -ar <src> -ac <srcCh> -i pipe:0 \
|
|
24761
|
+
* -c:a <encoder> -ar <target> -ac <targetCh> [-b:a <k>k] \
|
|
24762
|
+
* [-application lowdelay] -f <containerFmt> pipe:1
|
|
24763
|
+
*
|
|
24764
|
+
* For `libopus` the intercom-oriented `-application lowdelay` mode is added,
|
|
24765
|
+
* trading a little quality for the lowest algorithmic latency.
|
|
24766
|
+
*
|
|
24767
|
+
* @throws if the codec has no known encode mapping.
|
|
24768
|
+
*/
|
|
24769
|
+
function buildAudioEncodeArgs(config) {
|
|
24770
|
+
const entry = encodeFormatForCodec(config.codec);
|
|
24771
|
+
if (!entry) throw new Error(`audio-codec-ffmpeg: no encode format for codec '${config.codec}'`);
|
|
24772
|
+
const inFormat = config.sourceFormat ?? "s16le";
|
|
24773
|
+
const bitrateArgs = entry.acceptsBitrate && config.bitrateKbps !== void 0 ? ["-b:a", `${config.bitrateKbps}k`] : [];
|
|
24774
|
+
const opusLowDelay = entry.encoder === "libopus" ? ["-application", "lowdelay"] : [];
|
|
24775
|
+
return [
|
|
24776
|
+
...AUDIO_LOW_LATENCY_ARGS,
|
|
24777
|
+
"-f",
|
|
24778
|
+
inFormat,
|
|
24779
|
+
"-ar",
|
|
24780
|
+
String(config.sourceSampleRate),
|
|
24781
|
+
"-ac",
|
|
24782
|
+
String(config.sourceChannels),
|
|
24783
|
+
"-i",
|
|
24784
|
+
"pipe:0",
|
|
24785
|
+
"-c:a",
|
|
24786
|
+
entry.encoder,
|
|
24787
|
+
"-ar",
|
|
24788
|
+
String(config.targetSampleRate),
|
|
24789
|
+
"-ac",
|
|
24790
|
+
String(config.targetChannels),
|
|
24791
|
+
...bitrateArgs,
|
|
24792
|
+
...opusLowDelay,
|
|
24793
|
+
"-f",
|
|
24794
|
+
entry.outputFormat,
|
|
24795
|
+
"pipe:1"
|
|
24796
|
+
];
|
|
24797
|
+
}
|
|
24798
|
+
//#endregion
|
|
24799
|
+
//#region src/audio-codec/ogg-opus-framer.ts
|
|
24800
|
+
/**
|
|
24801
|
+
* Ogg-Opus encapsulator for the `audio-codec-ffmpeg` decode path.
|
|
24802
|
+
*
|
|
24803
|
+
* Cameras / RTP deliver RAW Opus packets — one self-delimited Opus packet per
|
|
24804
|
+
* `pushEncodedFrame`. ffmpeg has NO raw-Opus demuxer, so the subprocess cannot
|
|
24805
|
+
* decode a bare packet stream from a pipe (unlike `audio-codec-nodeav`, which
|
|
24806
|
+
* feeds raw packets straight to `AV_CODEC_ID_OPUS` in-process). To decode Opus
|
|
24807
|
+
* out-of-process we must wrap the raw packets in a valid **Ogg-Opus** container
|
|
24808
|
+
* so `ffmpeg -f ogg -i pipe:0` can demux + decode them.
|
|
24809
|
+
*
|
|
24810
|
+
* This framer turns a stream of raw Opus packets into a byte stream shaped per
|
|
24811
|
+
* RFC 7845 (Ogg Encapsulation for Opus) + RFC 3533 (the Ogg container):
|
|
24812
|
+
*
|
|
24813
|
+
* Page 0 (BOS) : one "OpusHead" identification packet
|
|
24814
|
+
* Page 1 : one "OpusTags" comment packet
|
|
24815
|
+
* Page 2..n : one Opus audio packet each (one-packet-per-page = lowest
|
|
24816
|
+
* latency, simplest lacing), granule = running 48 kHz sample
|
|
24817
|
+
* total.
|
|
24818
|
+
* flush() : optional final EOS page.
|
|
24819
|
+
*
|
|
24820
|
+
* The Ogg page CRC is NOT the common zlib CRC-32: it uses polynomial
|
|
24821
|
+
* 0x04C11DB7 with init 0, NO input/output bit reflection, and NO final XOR,
|
|
24822
|
+
* computed over the whole page with the CRC field zeroed (see `oggCrc32`).
|
|
24823
|
+
*/
|
|
24824
|
+
/** Ogg capture pattern, "OggS". */
|
|
24825
|
+
var OGG_CAPTURE_PATTERN = Buffer.from("OggS", "ascii");
|
|
24826
|
+
/** Opus identification header magic, "OpusHead". */
|
|
24827
|
+
var OPUS_HEAD_MAGIC = Buffer.from("OpusHead", "ascii");
|
|
24828
|
+
/** Opus comment header magic, "OpusTags". */
|
|
24829
|
+
var OPUS_TAGS_MAGIC = Buffer.from("OpusTags", "ascii");
|
|
24830
|
+
/** Ogg page header byte offsets (before the segment table). */
|
|
24831
|
+
var OGG_HEADER_FIXED_BYTES = 27;
|
|
24832
|
+
/** Byte offset of the 4-byte CRC field within the fixed header. */
|
|
24833
|
+
var OGG_CRC_OFFSET = 22;
|
|
24834
|
+
var HEADER_TYPE_BOS = 2;
|
|
24835
|
+
var HEADER_TYPE_EOS = 4;
|
|
24836
|
+
/** OpusHead `input sample rate` — informational per RFC 7845 (§5.1). */
|
|
24837
|
+
var OPUS_INPUT_SAMPLE_RATE = 48e3;
|
|
24838
|
+
/** Opus granule positions are ALWAYS counted at 48 kHz (RFC 7845 §4). */
|
|
24839
|
+
var OPUS_GRANULE_SAMPLE_RATE = 48e3;
|
|
24840
|
+
/**
|
|
24841
|
+
* Opus frame size, in 48 kHz samples, indexed by the 5-bit TOC `config`
|
|
24842
|
+
* (0..31). Derived from the frame-duration table in RFC 6716 §3.1:
|
|
24843
|
+
*
|
|
24844
|
+
* config 0-11 SILK NB/MB/WB → 10 / 20 / 40 / 60 ms
|
|
24845
|
+
* config 12-15 Hybrid SWB/FB → 10 / 20 ms
|
|
24846
|
+
* config 16-31 CELT NB/…/FB → 2.5 / 5 / 10 / 20 ms
|
|
24847
|
+
*
|
|
24848
|
+
* samples@48k = ms * 48 (2.5→120, 5→240, 10→480, 20→960, 40→1920, 60→2880).
|
|
24849
|
+
*/
|
|
24850
|
+
var OPUS_FRAME_SAMPLES_48K = [
|
|
24851
|
+
480,
|
|
24852
|
+
960,
|
|
24853
|
+
1920,
|
|
24854
|
+
2880,
|
|
24855
|
+
480,
|
|
24856
|
+
960,
|
|
24857
|
+
1920,
|
|
24858
|
+
2880,
|
|
24859
|
+
480,
|
|
24860
|
+
960,
|
|
24861
|
+
1920,
|
|
24862
|
+
2880,
|
|
24863
|
+
480,
|
|
24864
|
+
960,
|
|
24865
|
+
480,
|
|
24866
|
+
960,
|
|
24867
|
+
120,
|
|
24868
|
+
240,
|
|
24869
|
+
480,
|
|
24870
|
+
960,
|
|
24871
|
+
120,
|
|
24872
|
+
240,
|
|
24873
|
+
480,
|
|
24874
|
+
960,
|
|
24875
|
+
120,
|
|
24876
|
+
240,
|
|
24877
|
+
480,
|
|
24878
|
+
960,
|
|
24879
|
+
120,
|
|
24880
|
+
240,
|
|
24881
|
+
480,
|
|
24882
|
+
960
|
|
24883
|
+
];
|
|
24884
|
+
/**
|
|
24885
|
+
* Precomputed Ogg CRC-32 lookup table (MSB-first, polynomial 0x04C11DB7).
|
|
24886
|
+
*
|
|
24887
|
+
* Each entry is the CRC of the single byte `i` placed in the top position of a
|
|
24888
|
+
* 32-bit register, shifted out MSB-first with no reflection. This is the table
|
|
24889
|
+
* form of the bit-serial algorithm used by `oggCrc32`.
|
|
24890
|
+
*/
|
|
24891
|
+
var OGG_CRC_TABLE = (() => {
|
|
24892
|
+
const table = new Uint32Array(256);
|
|
24893
|
+
for (let i = 0; i < 256; i++) {
|
|
24894
|
+
let r = i << 24 >>> 0;
|
|
24895
|
+
for (let bit = 0; bit < 8; bit++) r = (r & 2147483648) !== 0 ? (r << 1 ^ 79764919) >>> 0 : r << 1 >>> 0;
|
|
24896
|
+
table[i] = r >>> 0;
|
|
24897
|
+
}
|
|
24898
|
+
return table;
|
|
24899
|
+
})();
|
|
24900
|
+
/**
|
|
24901
|
+
* Ogg page CRC-32 (RFC 3533 §4). Polynomial 0x04C11DB7, init 0, NO input or
|
|
24902
|
+
* output reflection, NO final XOR — deliberately different from the reflected
|
|
24903
|
+
* zlib/IEEE CRC-32. Computed over the ENTIRE page bytes with the 4-byte CRC
|
|
24904
|
+
* field already zeroed.
|
|
24905
|
+
*/
|
|
24906
|
+
function oggCrc32(data) {
|
|
24907
|
+
let crc = 0;
|
|
24908
|
+
for (let i = 0; i < data.length; i++) {
|
|
24909
|
+
const idx = (crc >>> 24 ^ (data[i] ?? 0)) & 255;
|
|
24910
|
+
crc = (crc << 8 >>> 0 ^ (OGG_CRC_TABLE[idx] ?? 0)) >>> 0;
|
|
24911
|
+
}
|
|
24912
|
+
return crc >>> 0;
|
|
24913
|
+
}
|
|
24914
|
+
/**
|
|
24915
|
+
* Decode an Opus packet's total duration in samples from its TOC byte
|
|
24916
|
+
* (RFC 6716 §3). Returns samples at `sampleRate` (default 48 kHz — the rate Ogg
|
|
24917
|
+
* granule positions are counted in).
|
|
24918
|
+
*
|
|
24919
|
+
* TOC byte layout: `config` (bits 3-7), `s` stereo (bit 2), `c` frame-count
|
|
24920
|
+
* code (bits 0-1):
|
|
24921
|
+
* - code 0 → 1 frame
|
|
24922
|
+
* - code 1 / 2 → 2 frames
|
|
24923
|
+
* - code 3 → arbitrary; frame count = low 6 bits of the byte after the TOC.
|
|
24924
|
+
*
|
|
24925
|
+
* Returns 0 for an empty or malformed (code-3 with no count byte) packet.
|
|
24926
|
+
*/
|
|
24927
|
+
function opusPacketSampleCount(packet, sampleRate = 48e3) {
|
|
24928
|
+
if (packet.length < 1) return 0;
|
|
24929
|
+
const toc = packet[0] ?? 0;
|
|
24930
|
+
const config = toc >> 3;
|
|
24931
|
+
const code = toc & 3;
|
|
24932
|
+
const samplesPerFrame48k = OPUS_FRAME_SAMPLES_48K[config] ?? 0;
|
|
24933
|
+
if (samplesPerFrame48k === 0) return 0;
|
|
24934
|
+
let frameCount;
|
|
24935
|
+
if (code === 0) frameCount = 1;
|
|
24936
|
+
else if (code === 1 || code === 2) frameCount = 2;
|
|
24937
|
+
else {
|
|
24938
|
+
if (packet.length < 2) return 0;
|
|
24939
|
+
frameCount = (packet[1] ?? 0) & 63;
|
|
24940
|
+
}
|
|
24941
|
+
const samples48k = samplesPerFrame48k * frameCount;
|
|
24942
|
+
if (sampleRate === OPUS_GRANULE_SAMPLE_RATE) return samples48k;
|
|
24943
|
+
return Math.round(samples48k * sampleRate / OPUS_GRANULE_SAMPLE_RATE);
|
|
24944
|
+
}
|
|
24945
|
+
/**
|
|
24946
|
+
* Encode an Ogg lacing segment table for a single packet of length `len`
|
|
24947
|
+
* (RFC 3533 §6): `floor(len/255)` bytes of 0xFF then one final byte `len%255`.
|
|
24948
|
+
* A length that is an exact multiple of 255 therefore ends in a `0x00` lacing
|
|
24949
|
+
* value — required so the demuxer knows the packet terminates on this page.
|
|
24950
|
+
*/
|
|
24951
|
+
function buildLacing(len) {
|
|
24952
|
+
const full = Math.floor(len / 255);
|
|
24953
|
+
const table = Buffer.alloc(full + 1);
|
|
24954
|
+
table.fill(255, 0, full);
|
|
24955
|
+
table[full] = len % 255;
|
|
24956
|
+
return table;
|
|
24957
|
+
}
|
|
24958
|
+
/**
|
|
24959
|
+
* Turn a hash-derivable seed into a stable 32-bit Ogg bitstream serial. FNV-1a
|
|
24960
|
+
* over the seed string keeps distinct sessions on distinct serials without
|
|
24961
|
+
* requiring the caller to allocate one.
|
|
24962
|
+
*/
|
|
24963
|
+
function serialFromSeed(seed) {
|
|
24964
|
+
let hash = 2166136261;
|
|
24965
|
+
for (let i = 0; i < seed.length; i++) {
|
|
24966
|
+
hash ^= seed.charCodeAt(i) & 255;
|
|
24967
|
+
hash = Math.imul(hash, 16777619);
|
|
24968
|
+
}
|
|
24969
|
+
return hash >>> 0;
|
|
24970
|
+
}
|
|
24971
|
+
/**
|
|
24972
|
+
* Streaming Ogg-Opus encapsulator. Stateful: tracks page sequence numbers and
|
|
24973
|
+
* the running 48 kHz granule position across calls. One instance per decode
|
|
24974
|
+
* session; call {@link reset} to reuse it for a fresh stream.
|
|
24975
|
+
*/
|
|
24976
|
+
var OggOpusFramer = class {
|
|
24977
|
+
channelCount;
|
|
24978
|
+
serial;
|
|
24979
|
+
preSkip;
|
|
24980
|
+
vendor;
|
|
24981
|
+
pageSeq = 0;
|
|
24982
|
+
granule = 0;
|
|
24983
|
+
headersEmitted = false;
|
|
24984
|
+
audioEmitted = false;
|
|
24985
|
+
constructor(config) {
|
|
24986
|
+
this.channelCount = config.channelCount;
|
|
24987
|
+
this.serial = config.serial >>> 0;
|
|
24988
|
+
this.preSkip = config.preSkip ?? 3840;
|
|
24989
|
+
this.vendor = config.vendor ?? "camstack";
|
|
24990
|
+
}
|
|
24991
|
+
/**
|
|
24992
|
+
* Build the two header pages (OpusHead BOS + OpusTags). Advances the page
|
|
24993
|
+
* sequence to 2 so audio pages follow. Returns an empty buffer if the headers
|
|
24994
|
+
* were already emitted (idempotent within a stream).
|
|
24995
|
+
*/
|
|
24996
|
+
headerPages() {
|
|
24997
|
+
if (this.headersEmitted) return Buffer.alloc(0);
|
|
24998
|
+
this.headersEmitted = true;
|
|
24999
|
+
const head = this.buildOpusHead();
|
|
25000
|
+
const tags = this.buildOpusTags();
|
|
25001
|
+
const headPage = this.buildPage(head, HEADER_TYPE_BOS, 0);
|
|
25002
|
+
const tagsPage = this.buildPage(tags, 0, 0);
|
|
25003
|
+
return Buffer.concat([headPage, tagsPage]);
|
|
25004
|
+
}
|
|
25005
|
+
/**
|
|
25006
|
+
* Encapsulate one raw Opus packet as a single audio page, advancing the
|
|
25007
|
+
* granule by the packet's decoded sample count. Header pages are emitted
|
|
25008
|
+
* (and prepended) automatically on the first call, so the caller can simply
|
|
25009
|
+
* write the returned bytes to ffmpeg stdin.
|
|
25010
|
+
*/
|
|
25011
|
+
framePacket(packet) {
|
|
25012
|
+
const prefix = this.headersEmitted ? Buffer.alloc(0) : this.headerPages();
|
|
25013
|
+
this.granule += opusPacketSampleCount(packet, OPUS_GRANULE_SAMPLE_RATE);
|
|
25014
|
+
this.audioEmitted = true;
|
|
25015
|
+
const page = this.buildPage(packet, 0, this.granule);
|
|
25016
|
+
return prefix.length > 0 ? Buffer.concat([prefix, page]) : page;
|
|
25017
|
+
}
|
|
25018
|
+
/**
|
|
25019
|
+
* Emit a final EOS page terminating the logical bitstream. Returns `null`
|
|
25020
|
+
* when no audio was written (nothing to terminate). The EOS page carries no
|
|
25021
|
+
* packet data (0 segments) and the final granule position.
|
|
25022
|
+
*/
|
|
25023
|
+
flush() {
|
|
25024
|
+
if (!this.audioEmitted) return null;
|
|
25025
|
+
return this.buildPage(null, HEADER_TYPE_EOS, this.granule);
|
|
25026
|
+
}
|
|
25027
|
+
/** Reset all page/granule state so the instance can frame a fresh stream. */
|
|
25028
|
+
reset() {
|
|
25029
|
+
this.pageSeq = 0;
|
|
25030
|
+
this.granule = 0;
|
|
25031
|
+
this.headersEmitted = false;
|
|
25032
|
+
this.audioEmitted = false;
|
|
25033
|
+
}
|
|
25034
|
+
/** OpusHead identification packet (RFC 7845 §5.1) — 19 bytes for family 0. */
|
|
25035
|
+
buildOpusHead() {
|
|
25036
|
+
const buf = Buffer.alloc(19);
|
|
25037
|
+
OPUS_HEAD_MAGIC.copy(buf, 0);
|
|
25038
|
+
buf.writeUInt8(1, 8);
|
|
25039
|
+
buf.writeUInt8(this.channelCount, 9);
|
|
25040
|
+
buf.writeUInt16LE(this.preSkip & 65535, 10);
|
|
25041
|
+
buf.writeUInt32LE(OPUS_INPUT_SAMPLE_RATE, 12);
|
|
25042
|
+
buf.writeInt16LE(0, 16);
|
|
25043
|
+
buf.writeUInt8(0, 18);
|
|
25044
|
+
return buf;
|
|
25045
|
+
}
|
|
25046
|
+
/** OpusTags comment packet (RFC 7845 §5.2) — magic, vendor, 0 comments. */
|
|
25047
|
+
buildOpusTags() {
|
|
25048
|
+
const vendor = Buffer.from(this.vendor, "utf8");
|
|
25049
|
+
const buf = Buffer.alloc(12 + vendor.length + 4);
|
|
25050
|
+
OPUS_TAGS_MAGIC.copy(buf, 0);
|
|
25051
|
+
buf.writeUInt32LE(vendor.length, 8);
|
|
25052
|
+
vendor.copy(buf, 12);
|
|
25053
|
+
buf.writeUInt32LE(0, 12 + vendor.length);
|
|
25054
|
+
return buf;
|
|
25055
|
+
}
|
|
25056
|
+
/**
|
|
25057
|
+
* Assemble one Ogg page around a single packet (or none, for an EOS
|
|
25058
|
+
* terminator), compute its CRC, and return the framed bytes. Keeps to a
|
|
25059
|
+
* single packet per page so the segment count never approaches the 255 limit
|
|
25060
|
+
* (an Opus packet is ≤ ~1275 bytes → ≤ 6 lacing segments).
|
|
25061
|
+
*/
|
|
25062
|
+
buildPage(packet, headerType, granule) {
|
|
25063
|
+
const lacing = packet !== null ? buildLacing(packet.length) : Buffer.alloc(0);
|
|
25064
|
+
const segmentCount = lacing.length;
|
|
25065
|
+
const bodyLen = packet !== null ? packet.length : 0;
|
|
25066
|
+
const page = Buffer.alloc(OGG_HEADER_FIXED_BYTES + segmentCount + bodyLen);
|
|
25067
|
+
OGG_CAPTURE_PATTERN.copy(page, 0);
|
|
25068
|
+
page.writeUInt8(0, 4);
|
|
25069
|
+
page.writeUInt8(headerType & 7, 5);
|
|
25070
|
+
this.writeGranule(page, granule, 6);
|
|
25071
|
+
page.writeUInt32LE(this.serial, 14);
|
|
25072
|
+
page.writeUInt32LE(this.pageSeq >>> 0, 18);
|
|
25073
|
+
page.writeUInt32LE(0, OGG_CRC_OFFSET);
|
|
25074
|
+
page.writeUInt8(segmentCount, 26);
|
|
25075
|
+
lacing.copy(page, OGG_HEADER_FIXED_BYTES);
|
|
25076
|
+
if (packet !== null) packet.copy(page, OGG_HEADER_FIXED_BYTES + segmentCount);
|
|
25077
|
+
const crc = oggCrc32(page);
|
|
25078
|
+
page.writeUInt32LE(crc, OGG_CRC_OFFSET);
|
|
25079
|
+
this.pageSeq = this.pageSeq + 1 >>> 0;
|
|
25080
|
+
return page;
|
|
25081
|
+
}
|
|
25082
|
+
/**
|
|
25083
|
+
* Write a 64-bit little-endian granule position. Values fit comfortably in a
|
|
25084
|
+
* JS safe integer for any realistic stream duration (2^53 samples @48k ≈ 5940
|
|
25085
|
+
* years), so a split 32-bit lo/hi write is exact and avoids BigInt on the
|
|
25086
|
+
* hot path.
|
|
25087
|
+
*/
|
|
25088
|
+
writeGranule(page, granule, offset) {
|
|
25089
|
+
const lo = granule >>> 0;
|
|
25090
|
+
const hi = Math.floor(granule / 4294967296) >>> 0;
|
|
25091
|
+
page.writeUInt32LE(lo, offset);
|
|
25092
|
+
page.writeUInt32LE(hi, offset + 4);
|
|
25093
|
+
}
|
|
25094
|
+
};
|
|
25095
|
+
//#endregion
|
|
25096
|
+
//#region src/audio-codec/adts.ts
|
|
25097
|
+
/**
|
|
25098
|
+
* ADTS framing for raw AAC access units.
|
|
25099
|
+
*
|
|
25100
|
+
* The audio decode path runs `ffmpeg -f aac -i pipe:0` — the **ADTS** demuxer,
|
|
25101
|
+
* which requires each frame to begin with a 7-byte ADTS header (0xFFF sync).
|
|
25102
|
+
* But every AAC source that reaches the decoder delivers **raw** AAC access
|
|
25103
|
+
* units with no ADTS header: push sources (Reolink Baichuan) emit one bare
|
|
25104
|
+
* codec frame per packet, and rfc3640 (RTP AAC) depacketization yields bare
|
|
25105
|
+
* AUs too. Piping those straight into `-f aac` fails with
|
|
25106
|
+
* `Invalid data found when processing input` → no PCM → a silent WebRTC audio
|
|
25107
|
+
* track. Wrapping each raw AU in an ADTS header (built from the source
|
|
25108
|
+
* sample-rate / channel-count / AAC object type) makes the demuxer accept it.
|
|
25109
|
+
*
|
|
25110
|
+
* Opus is handled separately (Ogg encapsulation); this module is AAC-only.
|
|
25111
|
+
*/
|
|
25112
|
+
/** MPEG-4 AAC sampling-frequency index table (ISO/IEC 14496-3). */
|
|
25113
|
+
var AAC_SAMPLE_RATES = [
|
|
25114
|
+
96e3,
|
|
25115
|
+
88200,
|
|
25116
|
+
64e3,
|
|
25117
|
+
48e3,
|
|
25118
|
+
44100,
|
|
25119
|
+
32e3,
|
|
25120
|
+
24e3,
|
|
25121
|
+
22050,
|
|
25122
|
+
16e3,
|
|
25123
|
+
12e3,
|
|
25124
|
+
11025,
|
|
25125
|
+
8e3,
|
|
25126
|
+
7350
|
|
25127
|
+
];
|
|
25128
|
+
/** AAC-LC is object type 2; the ADTS `profile` field is objectType - 1. */
|
|
25129
|
+
var DEFAULT_AAC_OBJECT_TYPE = 2;
|
|
25130
|
+
/** Resolve the ADTS sampling-frequency index for a sample rate (default 16 kHz → 8). */
|
|
25131
|
+
function aacSampleRateIndex(sampleRate) {
|
|
25132
|
+
const idx = AAC_SAMPLE_RATES.indexOf(sampleRate);
|
|
25133
|
+
return idx >= 0 ? idx : AAC_SAMPLE_RATES.indexOf(16e3);
|
|
25134
|
+
}
|
|
25135
|
+
/**
|
|
25136
|
+
* True when `buf` already begins with an ADTS syncword (0xFFF) + MPEG layer 0.
|
|
25137
|
+
* Raw AAC AUs never do; ADTS-framed frames always do. Lets the wrapper pass
|
|
25138
|
+
* already-framed input through untouched.
|
|
25139
|
+
*/
|
|
25140
|
+
function hasAdtsSync(buf) {
|
|
25141
|
+
return buf.length >= 2 && buf[0] === 255 && (buf[1] & 246) === 240;
|
|
25142
|
+
}
|
|
25143
|
+
/**
|
|
25144
|
+
* Build the 7-byte ADTS header (no CRC) for a payload of `payloadLength` bytes.
|
|
25145
|
+
*/
|
|
25146
|
+
function buildAdtsHeader(config, payloadLength) {
|
|
25147
|
+
const objectType = config.aacObjectType ?? DEFAULT_AAC_OBJECT_TYPE;
|
|
25148
|
+
const profile = Math.max(0, objectType - 1) & 3;
|
|
25149
|
+
const freqIdx = aacSampleRateIndex(config.sampleRate) & 15;
|
|
25150
|
+
const chanCfg = Math.max(1, config.channels) & 7;
|
|
25151
|
+
const frameLength = payloadLength + 7;
|
|
25152
|
+
const h = Buffer.alloc(7);
|
|
25153
|
+
h[0] = 255;
|
|
25154
|
+
h[1] = 241;
|
|
25155
|
+
h[2] = profile << 6 | freqIdx << 2 | chanCfg >> 2 & 1;
|
|
25156
|
+
h[3] = (chanCfg & 3) << 6 | frameLength >> 11 & 3;
|
|
25157
|
+
h[4] = frameLength >> 3 & 255;
|
|
25158
|
+
h[5] = (frameLength & 7) << 5 | 31;
|
|
25159
|
+
h[6] = 252;
|
|
25160
|
+
return h;
|
|
25161
|
+
}
|
|
25162
|
+
/**
|
|
25163
|
+
* Return `frame` framed as ADTS: unchanged when it already carries an ADTS
|
|
25164
|
+
* sync, otherwise the 7-byte header prepended. Pure; the input is never mutated.
|
|
25165
|
+
*/
|
|
25166
|
+
function wrapAacAsAdts(frame, config) {
|
|
25167
|
+
if (hasAdtsSync(frame)) return Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
25168
|
+
const payload = Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
25169
|
+
return Buffer.concat([buildAdtsHeader(config, payload.length), payload]);
|
|
25170
|
+
}
|
|
25171
|
+
//#endregion
|
|
25172
|
+
//#region src/audio-codec/ffmpeg-audio-decode-session.ts
|
|
25173
|
+
var FfmpegAudioDecodeSession = class {
|
|
25174
|
+
logger;
|
|
25175
|
+
process;
|
|
25176
|
+
outFormat;
|
|
25177
|
+
bytesPerFrame;
|
|
25178
|
+
targetSampleRate;
|
|
25179
|
+
targetChannels;
|
|
25180
|
+
onChunk;
|
|
25181
|
+
/**
|
|
25182
|
+
* Ogg-Opus encapsulator, present ONLY when the codec is Opus. Raw Opus
|
|
25183
|
+
* packets have no ffmpeg demuxer, so each pushed packet is wrapped in an
|
|
25184
|
+
* Ogg page (with OpusHead/OpusTags emitted before the first) so
|
|
25185
|
+
* `ffmpeg -f ogg -i pipe:0` can demux + decode. `null` for all other codecs,
|
|
25186
|
+
* which pipe their encoded frames straight through.
|
|
25187
|
+
*/
|
|
25188
|
+
oggFramer;
|
|
25189
|
+
/**
|
|
25190
|
+
* ADTS framing config, present ONLY when the codec is AAC. `ffmpeg -f aac`
|
|
25191
|
+
* is the ADTS demuxer, but every AAC source delivers raw AAC access units
|
|
25192
|
+
* (push sources emit bare frames; rfc3640 depacketization yields bare AUs) —
|
|
25193
|
+
* piping those in raw fails `Invalid data found when processing input` → no
|
|
25194
|
+
* PCM → silent WebRTC audio. Each frame is ADTS-wrapped before ffmpeg.
|
|
25195
|
+
*/
|
|
25196
|
+
adtsConfig;
|
|
25197
|
+
residual = Buffer.alloc(0);
|
|
25198
|
+
nextPts = 0;
|
|
25199
|
+
destroyed = false;
|
|
25200
|
+
constructor(params, logger, onChunk) {
|
|
25201
|
+
this.logger = logger;
|
|
25202
|
+
this.onChunk = onChunk;
|
|
25203
|
+
this.outFormat = params.targetFormat ?? "s16le";
|
|
25204
|
+
this.targetSampleRate = params.targetSampleRate;
|
|
25205
|
+
this.targetChannels = params.targetChannels;
|
|
25206
|
+
this.bytesPerFrame = Math.max(1, params.targetChannels * audioBytesPerSample(this.outFormat));
|
|
25207
|
+
this.oggFramer = resolveAudioCodecAlias(params.codec) === "opus" ? new OggOpusFramer({
|
|
25208
|
+
channelCount: Math.max(1, params.sourceChannels),
|
|
25209
|
+
serial: serialFromSeed(params.oggSerialSeed ?? "audio-codec-ffmpeg-opus")
|
|
25210
|
+
}) : null;
|
|
25211
|
+
this.adtsConfig = resolveAudioCodecAlias(params.codec) === "aac" ? {
|
|
25212
|
+
sampleRate: params.sourceSampleRate,
|
|
25213
|
+
channels: Math.max(1, params.sourceChannels)
|
|
25214
|
+
} : null;
|
|
25215
|
+
const args = buildAudioDecodeArgs(params);
|
|
25216
|
+
this.process = new FfmpegAudioProcess({
|
|
25217
|
+
ffmpegPath: params.ffmpegPath,
|
|
25218
|
+
args,
|
|
25219
|
+
logger,
|
|
25220
|
+
onStdout: (chunk) => this.handleStdout(chunk),
|
|
25221
|
+
onExit: (code, signal) => {
|
|
25222
|
+
if (this.destroyed) return;
|
|
25223
|
+
this.logger.warn("audio-codec-ffmpeg: decode child exited", { meta: {
|
|
25224
|
+
code,
|
|
25225
|
+
signal
|
|
25226
|
+
} });
|
|
25227
|
+
}
|
|
25228
|
+
});
|
|
25229
|
+
}
|
|
25230
|
+
/** Push one encoded audio frame into ffmpeg stdin. */
|
|
25231
|
+
pushEncoded(data) {
|
|
25232
|
+
if (this.destroyed) return;
|
|
25233
|
+
if (this.oggFramer) {
|
|
25234
|
+
const packet = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
25235
|
+
this.process.write(this.oggFramer.framePacket(packet));
|
|
25236
|
+
return;
|
|
25237
|
+
}
|
|
25238
|
+
if (this.adtsConfig) {
|
|
25239
|
+
this.process.write(wrapAacAsAdts(data, this.adtsConfig));
|
|
25240
|
+
return;
|
|
25241
|
+
}
|
|
25242
|
+
this.process.write(data);
|
|
25243
|
+
}
|
|
25244
|
+
handleStdout(chunk) {
|
|
25245
|
+
if (this.destroyed) return;
|
|
25246
|
+
const buf = this.residual.length === 0 ? chunk : Buffer.concat([this.residual, chunk]);
|
|
25247
|
+
const frameBytes = this.bytesPerFrame;
|
|
25248
|
+
const usable = buf.length - buf.length % frameBytes;
|
|
25249
|
+
if (usable <= 0) {
|
|
25250
|
+
this.residual = buf;
|
|
25251
|
+
return;
|
|
25252
|
+
}
|
|
25253
|
+
const emit = buf.subarray(0, usable);
|
|
25254
|
+
this.residual = usable < buf.length ? Buffer.from(buf.subarray(usable)) : Buffer.alloc(0);
|
|
25255
|
+
const out = new Uint8Array(new ArrayBuffer(usable));
|
|
25256
|
+
out.set(emit);
|
|
25257
|
+
const samples = usable / frameBytes;
|
|
25258
|
+
const pts = this.nextPts;
|
|
25259
|
+
this.nextPts = pts + Math.round(samples * 1e3 / this.targetSampleRate);
|
|
25260
|
+
this.onChunk({
|
|
25261
|
+
data: out,
|
|
25262
|
+
sampleRate: this.targetSampleRate,
|
|
25263
|
+
channels: this.targetChannels,
|
|
25264
|
+
format: this.outFormat,
|
|
25265
|
+
pts
|
|
25266
|
+
});
|
|
25267
|
+
}
|
|
25268
|
+
destroy() {
|
|
25269
|
+
if (this.destroyed) return;
|
|
25270
|
+
this.destroyed = true;
|
|
25271
|
+
if (this.oggFramer) {
|
|
25272
|
+
const eos = this.oggFramer.flush();
|
|
25273
|
+
if (eos) this.process.write(eos);
|
|
25274
|
+
}
|
|
25275
|
+
this.process.kill();
|
|
25276
|
+
this.residual = Buffer.alloc(0);
|
|
25277
|
+
}
|
|
25278
|
+
};
|
|
25279
|
+
//#endregion
|
|
25280
|
+
//#region src/audio-codec/ffmpeg-audio-encode-session.ts
|
|
25281
|
+
var FfmpegAudioEncodeSession = class {
|
|
25282
|
+
logger;
|
|
25283
|
+
process;
|
|
25284
|
+
codec;
|
|
25285
|
+
onChunk;
|
|
25286
|
+
nextPts = 0;
|
|
25287
|
+
destroyed = false;
|
|
25288
|
+
constructor(params, logger, onChunk) {
|
|
25289
|
+
this.logger = logger;
|
|
25290
|
+
this.onChunk = onChunk;
|
|
25291
|
+
this.codec = resolveAudioCodecAlias(params.codec);
|
|
25292
|
+
const args = buildAudioEncodeArgs(params);
|
|
25293
|
+
this.process = new FfmpegAudioProcess({
|
|
25294
|
+
ffmpegPath: params.ffmpegPath,
|
|
25295
|
+
args,
|
|
25296
|
+
logger,
|
|
25297
|
+
onStdout: (chunk) => this.handleStdout(chunk),
|
|
25298
|
+
onExit: (code, signal) => {
|
|
25299
|
+
if (this.destroyed) return;
|
|
25300
|
+
this.logger.warn("audio-codec-ffmpeg: encode child exited", { meta: {
|
|
25301
|
+
code,
|
|
25302
|
+
signal
|
|
25303
|
+
} });
|
|
25304
|
+
}
|
|
25305
|
+
});
|
|
25306
|
+
}
|
|
25307
|
+
/** Push one PCM chunk into ffmpeg stdin. */
|
|
25308
|
+
pushPcm(data) {
|
|
25309
|
+
if (this.destroyed) return;
|
|
25310
|
+
this.process.write(data);
|
|
25311
|
+
}
|
|
25312
|
+
/** Close stdin so ffmpeg flushes and drains the remaining encoded output. */
|
|
25313
|
+
flush() {
|
|
25314
|
+
if (this.destroyed) return;
|
|
25315
|
+
this.process.endStdin();
|
|
25316
|
+
}
|
|
25317
|
+
handleStdout(chunk) {
|
|
25318
|
+
if (this.destroyed || chunk.length === 0) return;
|
|
25319
|
+
const out = new Uint8Array(new ArrayBuffer(chunk.length));
|
|
25320
|
+
out.set(chunk);
|
|
25321
|
+
const pts = this.nextPts;
|
|
25322
|
+
this.nextPts = pts + 1;
|
|
25323
|
+
this.onChunk({
|
|
25324
|
+
data: out,
|
|
25325
|
+
codec: this.codec,
|
|
25326
|
+
pts,
|
|
25327
|
+
frameComplete: true
|
|
25328
|
+
});
|
|
25329
|
+
}
|
|
25330
|
+
destroy() {
|
|
25331
|
+
if (this.destroyed) return;
|
|
25332
|
+
this.destroyed = true;
|
|
25333
|
+
this.process.kill();
|
|
25334
|
+
}
|
|
25335
|
+
};
|
|
25336
|
+
//#endregion
|
|
25337
|
+
//#region src/audio-codec/provider.ts
|
|
25338
|
+
var CODEC_CATALOG = [
|
|
25339
|
+
{
|
|
25340
|
+
codec: "pcm_mulaw",
|
|
25341
|
+
canDecode: true,
|
|
25342
|
+
canEncode: true,
|
|
25343
|
+
label: "PCM µ-law (G.711)"
|
|
25344
|
+
},
|
|
25345
|
+
{
|
|
25346
|
+
codec: "pcm_alaw",
|
|
25347
|
+
canDecode: true,
|
|
25348
|
+
canEncode: true,
|
|
25349
|
+
label: "PCM A-law (G.711)"
|
|
25350
|
+
},
|
|
25351
|
+
{
|
|
25352
|
+
codec: "g722",
|
|
25353
|
+
canDecode: true,
|
|
25354
|
+
canEncode: true,
|
|
25355
|
+
label: "G.722"
|
|
25356
|
+
},
|
|
25357
|
+
{
|
|
25358
|
+
codec: "aac",
|
|
25359
|
+
canDecode: true,
|
|
25360
|
+
canEncode: true,
|
|
25361
|
+
label: "AAC"
|
|
25362
|
+
},
|
|
25363
|
+
{
|
|
25364
|
+
codec: "aac_latm",
|
|
25365
|
+
canDecode: true,
|
|
25366
|
+
canEncode: false,
|
|
25367
|
+
label: "AAC LATM"
|
|
25368
|
+
},
|
|
25369
|
+
{
|
|
25370
|
+
codec: "mpeg4-generic",
|
|
25371
|
+
canDecode: true,
|
|
25372
|
+
canEncode: false,
|
|
25373
|
+
label: "AAC (MPEG4-GENERIC)"
|
|
25374
|
+
},
|
|
25375
|
+
{
|
|
25376
|
+
codec: "opus",
|
|
25377
|
+
canDecode: true,
|
|
25378
|
+
canEncode: true,
|
|
25379
|
+
label: "Opus"
|
|
25380
|
+
}
|
|
25381
|
+
];
|
|
25382
|
+
var DEFAULT_IDLE_MS = 3e4;
|
|
25383
|
+
var MAX_PCM_QUEUE_CHUNKS = 500;
|
|
25384
|
+
var REAPER_INTERVAL_MS = 5e3;
|
|
25385
|
+
/**
|
|
25386
|
+
* Grace wait in `flushEncode` for ffmpeg to drain its encoder after stdin is
|
|
25387
|
+
* closed. This is a graceful-teardown path (not the real-time push path), so a
|
|
25388
|
+
* short wait is acceptable to capture the encoder's tail output.
|
|
25389
|
+
*/
|
|
25390
|
+
var FLUSH_DRAIN_MS = 60;
|
|
25391
|
+
/**
|
|
25392
|
+
* Audio codec I/O box backed by an **ffmpeg subprocess** (Phase B replacement
|
|
25393
|
+
* for `audio-codec-nodeav`, which runs libavcodec + libswresample in-process).
|
|
25394
|
+
*
|
|
25395
|
+
* Each `createDecodeSession` / `createEncodeSession` spawns its own ffmpeg
|
|
25396
|
+
* child, so consumers never share a resampler and a codec crash is isolated to
|
|
25397
|
+
* the child — the addon runner survives. Cap surface + session bookkeeping
|
|
25398
|
+
* mirror the node-av addon exactly; only the codec backend changed.
|
|
25399
|
+
*
|
|
25400
|
+
* This is a PLAIN provider class merged into the `decoder-ffmpeg` addon (which
|
|
25401
|
+
* provides both the `decoder` video cap and this `audio-codec` cap). The parent
|
|
25402
|
+
* addon owns lifecycle: it resolves the ffmpeg path once, calls {@link start} in
|
|
25403
|
+
* `onInitialize`, and {@link stop} in `onShutdown`.
|
|
25404
|
+
*/
|
|
25405
|
+
var FfmpegAudioCodecProvider = class {
|
|
25406
|
+
deps;
|
|
25407
|
+
sessions = /* @__PURE__ */ new Map();
|
|
25408
|
+
reaperTimer = null;
|
|
25409
|
+
constructor(deps) {
|
|
25410
|
+
this.deps = deps;
|
|
25411
|
+
}
|
|
25412
|
+
/** Start the idle-session reaper. Called by the parent addon at init. */
|
|
25413
|
+
start() {
|
|
25414
|
+
this.reaperTimer = setInterval(() => this.reapIdleSessions(), REAPER_INTERVAL_MS);
|
|
25415
|
+
if (typeof this.reaperTimer.unref === "function") this.reaperTimer.unref();
|
|
25416
|
+
}
|
|
25417
|
+
/** Clear the reaper and dispose every session. Called by the parent at shutdown. */
|
|
25418
|
+
stop() {
|
|
25419
|
+
if (this.reaperTimer) {
|
|
25420
|
+
clearInterval(this.reaperTimer);
|
|
25421
|
+
this.reaperTimer = null;
|
|
25422
|
+
}
|
|
25423
|
+
for (const s of this.sessions.values()) this.disposeSession(s);
|
|
25424
|
+
this.sessions.clear();
|
|
25425
|
+
}
|
|
25426
|
+
async listSupportedCodecs() {
|
|
25427
|
+
return CODEC_CATALOG.map((e) => ({
|
|
25428
|
+
codec: e.codec,
|
|
25429
|
+
canDecode: e.canDecode,
|
|
25430
|
+
canEncode: e.canEncode,
|
|
25431
|
+
...e.label ? { label: e.label } : {}
|
|
25432
|
+
}));
|
|
25433
|
+
}
|
|
25434
|
+
async canHandle(input) {
|
|
25435
|
+
const resolved = resolveAudioCodecAlias(input.codec);
|
|
25436
|
+
const entry = CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === resolved);
|
|
25437
|
+
if (!entry) return false;
|
|
25438
|
+
return input.kind === "decode" ? entry.canDecode : entry.canEncode;
|
|
25439
|
+
}
|
|
25440
|
+
async createDecodeSession(input) {
|
|
25441
|
+
const codec = resolveAudioCodecAlias(input.codec);
|
|
25442
|
+
const entry = CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === codec);
|
|
25443
|
+
if (!entry || !entry.canDecode) throw new Error(`audio-codec-ffmpeg: decode unsupported for codec '${input.codec}'`);
|
|
25444
|
+
const sessionId = `dec-${(0, node_crypto.randomUUID)()}`;
|
|
25445
|
+
const state = {
|
|
25446
|
+
sessionId,
|
|
25447
|
+
kind: "decode",
|
|
25448
|
+
config: {
|
|
25449
|
+
...input,
|
|
25450
|
+
codec
|
|
25451
|
+
},
|
|
25452
|
+
...input.tag ? { tag: input.tag } : {},
|
|
25453
|
+
createdAtMs: Date.now(),
|
|
25454
|
+
lastActivityMs: Date.now(),
|
|
25455
|
+
framesIn: 0,
|
|
25456
|
+
framesOut: 0,
|
|
25457
|
+
pcmQueue: [],
|
|
25458
|
+
session: null
|
|
25459
|
+
};
|
|
25460
|
+
state.session = this.spawnDecodeSession(state);
|
|
25461
|
+
this.sessions.set(sessionId, state);
|
|
25462
|
+
this.deps.logger.info("audio-codec-ffmpeg: decode session created", {
|
|
25463
|
+
tags: { sessionId },
|
|
25464
|
+
meta: {
|
|
25465
|
+
codec,
|
|
25466
|
+
target: `${input.targetSampleRate}Hz×${input.targetChannels}`
|
|
25467
|
+
}
|
|
25468
|
+
});
|
|
25469
|
+
return {
|
|
25470
|
+
sessionId,
|
|
25471
|
+
nodeId: this.deps.resolveLocalNodeId()
|
|
25472
|
+
};
|
|
25473
|
+
}
|
|
25474
|
+
async createEncodeSession(input) {
|
|
25475
|
+
const codec = resolveAudioCodecAlias(input.codec);
|
|
25476
|
+
const entry = CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === codec);
|
|
25477
|
+
if (!entry || !entry.canEncode) throw new Error(`audio-codec-ffmpeg: encode unsupported for codec '${input.codec}'`);
|
|
25478
|
+
const sessionId = `enc-${(0, node_crypto.randomUUID)()}`;
|
|
25479
|
+
const state = {
|
|
25480
|
+
sessionId,
|
|
25481
|
+
kind: "encode",
|
|
25482
|
+
config: {
|
|
25483
|
+
...input,
|
|
25484
|
+
codec
|
|
25485
|
+
},
|
|
25486
|
+
...input.tag ? { tag: input.tag } : {},
|
|
25487
|
+
createdAtMs: Date.now(),
|
|
25488
|
+
lastActivityMs: Date.now(),
|
|
25489
|
+
framesIn: 0,
|
|
25490
|
+
framesOut: 0,
|
|
25491
|
+
encodedQueue: [],
|
|
25492
|
+
session: null
|
|
25493
|
+
};
|
|
25494
|
+
state.session = this.spawnEncodeSession(state);
|
|
25495
|
+
this.sessions.set(sessionId, state);
|
|
25496
|
+
this.deps.logger.info("audio-codec-ffmpeg: encode session created", {
|
|
25497
|
+
tags: { sessionId },
|
|
25498
|
+
meta: {
|
|
25499
|
+
codec,
|
|
25500
|
+
target: `${input.targetSampleRate}Hz×${input.targetChannels}`
|
|
25501
|
+
}
|
|
25502
|
+
});
|
|
25503
|
+
return {
|
|
25504
|
+
sessionId,
|
|
25505
|
+
nodeId: this.deps.resolveLocalNodeId()
|
|
25506
|
+
};
|
|
25507
|
+
}
|
|
25508
|
+
async closeSession(input) {
|
|
25509
|
+
const s = this.sessions.get(input.sessionId);
|
|
25510
|
+
if (!s) return;
|
|
25511
|
+
this.disposeSession(s);
|
|
25512
|
+
this.sessions.delete(input.sessionId);
|
|
25513
|
+
}
|
|
25514
|
+
async pushEncodedFrame(input) {
|
|
25515
|
+
const s = this.sessions.get(input.sessionId);
|
|
25516
|
+
if (!s || s.kind !== "decode") throw new Error(`audio-codec-ffmpeg: decode session '${input.sessionId}' not found`);
|
|
25517
|
+
s.lastActivityMs = Date.now();
|
|
25518
|
+
s.framesIn++;
|
|
25519
|
+
if (!s.session) s.session = this.spawnDecodeSession(s);
|
|
25520
|
+
s.session.pushEncoded(input.data);
|
|
25521
|
+
}
|
|
25522
|
+
async pullPcm(input) {
|
|
25523
|
+
const s = this.sessions.get(input.sessionId);
|
|
25524
|
+
if (!s || s.kind !== "decode") throw new Error(`audio-codec-ffmpeg: decode session '${input.sessionId}' not found`);
|
|
25525
|
+
s.lastActivityMs = Date.now();
|
|
25526
|
+
const out = s.pcmQueue.splice(0, input.maxCount);
|
|
25527
|
+
s.framesOut += out.length;
|
|
25528
|
+
return out;
|
|
25529
|
+
}
|
|
25530
|
+
async pushPcm(input) {
|
|
25531
|
+
const s = this.sessions.get(input.sessionId);
|
|
25532
|
+
if (!s || s.kind !== "encode") throw new Error(`audio-codec-ffmpeg: encode session '${input.sessionId}' not found`);
|
|
25533
|
+
s.lastActivityMs = Date.now();
|
|
25534
|
+
s.framesIn++;
|
|
25535
|
+
if (!s.session) s.session = this.spawnEncodeSession(s);
|
|
25536
|
+
s.session.pushPcm(input.data);
|
|
25537
|
+
}
|
|
25538
|
+
async pullEncoded(input) {
|
|
25539
|
+
const s = this.sessions.get(input.sessionId);
|
|
25540
|
+
if (!s || s.kind !== "encode") throw new Error(`audio-codec-ffmpeg: encode session '${input.sessionId}' not found`);
|
|
25541
|
+
s.lastActivityMs = Date.now();
|
|
25542
|
+
const out = s.encodedQueue.splice(0, input.maxCount);
|
|
25543
|
+
s.framesOut += out.length;
|
|
25544
|
+
return out;
|
|
25545
|
+
}
|
|
25546
|
+
async flushEncode(input) {
|
|
25547
|
+
const s = this.sessions.get(input.sessionId);
|
|
25548
|
+
if (!s || s.kind !== "encode") throw new Error(`audio-codec-ffmpeg: encode session '${input.sessionId}' not found`);
|
|
25549
|
+
s.lastActivityMs = Date.now();
|
|
25550
|
+
s.session?.flush();
|
|
25551
|
+
await new Promise((resolve) => setTimeout(resolve, FLUSH_DRAIN_MS));
|
|
25552
|
+
const out = s.encodedQueue.splice(0);
|
|
25553
|
+
s.framesOut += out.length;
|
|
25554
|
+
return out;
|
|
25555
|
+
}
|
|
25556
|
+
async listActiveSessions() {
|
|
25557
|
+
return [...this.sessions.values()].map((s) => ({
|
|
25558
|
+
sessionId: s.sessionId,
|
|
25559
|
+
kind: s.kind,
|
|
25560
|
+
codec: s.config.codec,
|
|
25561
|
+
sourceSampleRate: s.config.sourceSampleRate,
|
|
25562
|
+
sourceChannels: s.config.sourceChannels,
|
|
25563
|
+
targetSampleRate: s.config.targetSampleRate,
|
|
25564
|
+
targetChannels: s.config.targetChannels,
|
|
25565
|
+
format: this.resolveFormat(s),
|
|
25566
|
+
...s.tag ? { tag: s.tag } : {},
|
|
25567
|
+
createdAtMs: s.createdAtMs,
|
|
25568
|
+
lastActivityMs: s.lastActivityMs,
|
|
25569
|
+
framesIn: s.framesIn,
|
|
25570
|
+
framesOut: s.framesOut
|
|
25571
|
+
}));
|
|
25572
|
+
}
|
|
25573
|
+
spawnDecodeSession(s) {
|
|
25574
|
+
return new FfmpegAudioDecodeSession({
|
|
25575
|
+
codec: s.config.codec,
|
|
25576
|
+
sourceSampleRate: s.config.sourceSampleRate,
|
|
25577
|
+
sourceChannels: s.config.sourceChannels,
|
|
25578
|
+
targetSampleRate: s.config.targetSampleRate,
|
|
25579
|
+
targetChannels: s.config.targetChannels,
|
|
25580
|
+
targetFormat: this.pcmFormat(s.config.targetFormat),
|
|
25581
|
+
ffmpegPath: this.deps.ffmpegPath,
|
|
25582
|
+
oggSerialSeed: s.sessionId
|
|
25583
|
+
}, this.deps.logger, (chunk) => {
|
|
25584
|
+
s.pcmQueue.push(chunk);
|
|
25585
|
+
if (s.pcmQueue.length > MAX_PCM_QUEUE_CHUNKS) s.pcmQueue.splice(0, s.pcmQueue.length - MAX_PCM_QUEUE_CHUNKS);
|
|
25586
|
+
});
|
|
25587
|
+
}
|
|
25588
|
+
spawnEncodeSession(s) {
|
|
25589
|
+
return new FfmpegAudioEncodeSession({
|
|
25590
|
+
codec: s.config.codec,
|
|
25591
|
+
sourceSampleRate: s.config.sourceSampleRate,
|
|
25592
|
+
sourceChannels: s.config.sourceChannels,
|
|
25593
|
+
sourceFormat: this.pcmFormat(s.config.sourceFormat),
|
|
25594
|
+
targetSampleRate: s.config.targetSampleRate,
|
|
25595
|
+
targetChannels: s.config.targetChannels,
|
|
25596
|
+
...s.config.bitrateKbps !== void 0 ? { bitrateKbps: s.config.bitrateKbps } : {},
|
|
25597
|
+
ffmpegPath: this.deps.ffmpegPath
|
|
25598
|
+
}, this.deps.logger, (chunk) => {
|
|
25599
|
+
s.encodedQueue.push(chunk);
|
|
25600
|
+
});
|
|
25601
|
+
}
|
|
25602
|
+
/** Narrow the cap's PCM format enum to the two ffmpeg sessions produce/read. */
|
|
25603
|
+
pcmFormat(format) {
|
|
25604
|
+
return format === "f32le" ? "f32le" : "s16le";
|
|
25605
|
+
}
|
|
25606
|
+
resolveFormat(s) {
|
|
25607
|
+
if (s.kind === "decode") return this.pcmFormat(s.config.targetFormat);
|
|
25608
|
+
return this.pcmFormat(s.config.sourceFormat);
|
|
25609
|
+
}
|
|
25610
|
+
reapIdleSessions() {
|
|
25611
|
+
const now = Date.now();
|
|
25612
|
+
for (const [id, s] of this.sessions) {
|
|
25613
|
+
const limit = s.config.idleMs ?? this.deps.defaultIdleMs ?? DEFAULT_IDLE_MS;
|
|
25614
|
+
if (now - s.lastActivityMs > limit) {
|
|
25615
|
+
this.deps.logger.info("audio-codec-ffmpeg: reaping idle session", {
|
|
25616
|
+
tags: { sessionId: id },
|
|
25617
|
+
meta: {
|
|
25618
|
+
kind: s.kind,
|
|
25619
|
+
idleMs: now - s.lastActivityMs,
|
|
25620
|
+
limit
|
|
25621
|
+
}
|
|
25622
|
+
});
|
|
25623
|
+
try {
|
|
25624
|
+
this.disposeSession(s);
|
|
25625
|
+
} catch (err) {
|
|
25626
|
+
this.deps.logger.warn("audio-codec-ffmpeg: dispose failed during reap", {
|
|
25627
|
+
tags: { sessionId: id },
|
|
25628
|
+
meta: { error: errMsg(err) }
|
|
25629
|
+
});
|
|
25630
|
+
}
|
|
25631
|
+
this.sessions.delete(id);
|
|
25632
|
+
}
|
|
25633
|
+
}
|
|
25634
|
+
}
|
|
25635
|
+
disposeSession(s) {
|
|
25636
|
+
try {
|
|
25637
|
+
s.session?.destroy();
|
|
25638
|
+
} catch (err) {
|
|
25639
|
+
this.deps.logger.warn("audio-codec-ffmpeg: session destroy failed", {
|
|
25640
|
+
tags: { sessionId: s.sessionId },
|
|
25641
|
+
meta: { error: errMsg(err) }
|
|
25642
|
+
});
|
|
25643
|
+
}
|
|
25644
|
+
s.session = null;
|
|
25645
|
+
}
|
|
25646
|
+
};
|
|
25647
|
+
//#endregion
|
|
24141
25648
|
//#region src/addon/index.ts
|
|
24142
25649
|
var FRAME_BUFFER_CAPACITY = 32;
|
|
24143
25650
|
/**
|
|
@@ -24177,6 +25684,14 @@ var DecoderFfmpegAddon = class extends BaseAddon {
|
|
|
24177
25684
|
* process — e.g. a videotoolbox build whose decode returns software frames.
|
|
24178
25685
|
*/
|
|
24179
25686
|
unsupportedGpuScaleFilters = /* @__PURE__ */ new Set();
|
|
25687
|
+
/**
|
|
25688
|
+
* The merged-in ffmpeg AUDIO codec provider — registers the `audio-codec` cap
|
|
25689
|
+
* ALWAYS (independent of the per-node video decoder backend), so this addon
|
|
25690
|
+
* serves both video decode and bidirectional audio transcode. Owns its own
|
|
25691
|
+
* ffmpeg subprocess sessions + idle reaper; started at init, stopped at
|
|
25692
|
+
* shutdown. Shares the single node-level ffmpeg binary this addon resolves.
|
|
25693
|
+
*/
|
|
25694
|
+
audioProvider = null;
|
|
24180
25695
|
constructor() {
|
|
24181
25696
|
super(DEFAULT_DECODER_FFMPEG_ADDON_CONFIG);
|
|
24182
25697
|
}
|
|
@@ -24214,23 +25729,40 @@ var DecoderFfmpegAddon = class extends BaseAddon {
|
|
|
24214
25729
|
if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
|
|
24215
25730
|
this.ctx.logger.warn("ffmpeg: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
24216
25731
|
});
|
|
25732
|
+
const registrations = [];
|
|
25733
|
+
this.ffmpegPath = await this.resolveFfmpegBinaryPath();
|
|
25734
|
+
this.audioProvider = new FfmpegAudioCodecProvider({
|
|
25735
|
+
logger: this.ctx.logger,
|
|
25736
|
+
ffmpegPath: this.ffmpegPath,
|
|
25737
|
+
resolveLocalNodeId: () => this.resolveLocalNodeId()
|
|
25738
|
+
});
|
|
25739
|
+
this.audioProvider.start();
|
|
25740
|
+
registrations.push({
|
|
25741
|
+
capability: audioCodecCapability,
|
|
25742
|
+
provider: this.audioProvider
|
|
25743
|
+
});
|
|
24217
25744
|
const backend = await resolveDecoderBackend(this.ctx.api, this.resolveLocalNodeId(), this.ctx.logger);
|
|
24218
25745
|
if (backend !== "ffmpeg") {
|
|
24219
|
-
this.ctx.logger.info("ffmpeg decoder: this node selects a different decoder backend — standing down (no decoder provider registered)", { meta: { selectedBackend: backend } });
|
|
24220
|
-
return
|
|
25746
|
+
this.ctx.logger.info("ffmpeg decoder: this node selects a different decoder backend — standing down (no decoder provider registered; audio-codec stays)", { meta: { selectedBackend: backend } });
|
|
25747
|
+
return registrations;
|
|
24221
25748
|
}
|
|
24222
25749
|
this.ctx.logger.info("ffmpeg decoder addon initialized", { meta: { selectedBackend: backend } });
|
|
25750
|
+
const purged = purgeOrphanSegments(SEGMENT_NAME_PREFIX);
|
|
25751
|
+
if (purged.removed > 0) this.ctx.logger.warn("ffmpeg decoder: reclaimed orphaned shm segments at startup", { meta: {
|
|
25752
|
+
removed: purged.removed,
|
|
25753
|
+
scanned: purged.scanned
|
|
25754
|
+
} });
|
|
24223
25755
|
this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(this.ctx.logger);
|
|
24224
|
-
this.ffmpegPath = await this.resolveFfmpegBinaryPath();
|
|
24225
25756
|
this.probedGpuScaleFilters = await probeGpuScaleFilters(this.ffmpegPath, this.ctx.logger);
|
|
24226
25757
|
this.ctx.logger.info("decoder-ffmpeg: probed GPU scale filters", { meta: {
|
|
24227
25758
|
filters: [...this.probedGpuScaleFilters],
|
|
24228
25759
|
ffmpeg: this.ffmpegPath
|
|
24229
25760
|
} });
|
|
24230
|
-
|
|
25761
|
+
registrations.push({
|
|
24231
25762
|
capability: decoderCapability,
|
|
24232
25763
|
provider: this
|
|
24233
|
-
}
|
|
25764
|
+
});
|
|
25765
|
+
return registrations;
|
|
24234
25766
|
}
|
|
24235
25767
|
/**
|
|
24236
25768
|
* Resolve the ffmpeg binary the sessions spawn — node-level only.
|
|
@@ -24518,6 +26050,8 @@ var DecoderFfmpegAddon = class extends BaseAddon {
|
|
|
24518
26050
|
}
|
|
24519
26051
|
async onShutdown() {
|
|
24520
26052
|
this.ctx.logger.info("ffmpeg decoder addon shutdown — destroying all sessions");
|
|
26053
|
+
this.audioProvider?.stop();
|
|
26054
|
+
this.audioProvider = null;
|
|
24521
26055
|
const destroyPromises = [];
|
|
24522
26056
|
for (const [sessionId, session] of this.sessions) {
|
|
24523
26057
|
const unsub = this.unsubscribers.get(sessionId);
|