@camstack/addon-decoder-nodeav 1.1.3 → 1.1.5
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 +1485 -56
- package/dist/index.mjs +1482 -58
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperties(exports, {
|
|
|
4
4
|
});
|
|
5
5
|
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
|
+
let node_fs = require("node:fs");
|
|
7
8
|
//#region src/frame-ring-sink.ts
|
|
8
9
|
/**
|
|
9
10
|
* `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
|
|
@@ -71,10 +72,16 @@ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
|
|
|
71
72
|
* segment (resolution change) a distinct name so a stale consumer mapping is
|
|
72
73
|
* never silently reused.
|
|
73
74
|
*/
|
|
75
|
+
/**
|
|
76
|
+
* Shared prefix for every decoder shm segment name. Startup orphan reclamation
|
|
77
|
+
* (`purgeOrphanSegments`) keys off this to find segments left behind by a
|
|
78
|
+
* crashed prior instance.
|
|
79
|
+
*/
|
|
80
|
+
var SEGMENT_NAME_PREFIX = "csf.";
|
|
74
81
|
function makeSegmentName(seed, generation) {
|
|
75
82
|
let hash = 5381;
|
|
76
83
|
for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
|
|
77
|
-
return
|
|
84
|
+
return `${SEGMENT_NAME_PREFIX}${(hash >>> 0).toString(36)}.${generation}`;
|
|
78
85
|
}
|
|
79
86
|
/**
|
|
80
87
|
* The decoder-side owner of one stream's shared-memory frame ring.
|
|
@@ -4897,7 +4904,7 @@ function _instanceof(cls, params = {}) {
|
|
|
4897
4904
|
return inst;
|
|
4898
4905
|
}
|
|
4899
4906
|
//#endregion
|
|
4900
|
-
//#region ../types/dist/sleep-
|
|
4907
|
+
//#region ../types/dist/sleep-CZDdRBua.mjs
|
|
4901
4908
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
4902
4909
|
EventCategory["SystemBoot"] = "system.boot";
|
|
4903
4910
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -7504,7 +7511,16 @@ var DecoderStatsSchema = object({
|
|
|
7504
7511
|
inputFps: number(),
|
|
7505
7512
|
outputFps: number(),
|
|
7506
7513
|
avgDecodeTimeMs: number(),
|
|
7507
|
-
droppedFrames: number()
|
|
7514
|
+
droppedFrames: number(),
|
|
7515
|
+
/**
|
|
7516
|
+
* Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
|
|
7517
|
+
* lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
|
|
7518
|
+
* the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
|
|
7519
|
+
* is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
|
|
7520
|
+
*/
|
|
7521
|
+
lagMs: number().optional(),
|
|
7522
|
+
effectiveFps: number().optional(),
|
|
7523
|
+
adaptiveFps: number().optional()
|
|
7508
7524
|
});
|
|
7509
7525
|
var DecoderSessionConfigSchema = object({
|
|
7510
7526
|
codec: string(),
|
|
@@ -7545,7 +7561,15 @@ var DecoderSessionConfigSchema = object({
|
|
|
7545
7561
|
* other — `pullFrames` returns nothing for an `'shm'` session and
|
|
7546
7562
|
* `pullHandles` returns nothing for a `'callback'` session.
|
|
7547
7563
|
*/
|
|
7548
|
-
frameSink: _enum(["callback", "shm"]).default("callback")
|
|
7564
|
+
frameSink: _enum(["callback", "shm"]).default("callback"),
|
|
7565
|
+
/**
|
|
7566
|
+
* Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
|
|
7567
|
+
* a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
|
|
7568
|
+
* real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
|
|
7569
|
+
* stream-broker's `streamingDebug` gate — off by default so production logs
|
|
7570
|
+
* stay quiet and the emit path pays zero per-frame cost when disabled.
|
|
7571
|
+
*/
|
|
7572
|
+
debug: boolean().optional()
|
|
7549
7573
|
});
|
|
7550
7574
|
var EncodeProfileSchema = object({
|
|
7551
7575
|
video: object({
|
|
@@ -9703,6 +9727,75 @@ DeviceType.Cover, method(object({ deviceId: number().int().nonnegative() }), _vo
|
|
|
9703
9727
|
auth: "admin"
|
|
9704
9728
|
});
|
|
9705
9729
|
/**
|
|
9730
|
+
* Vendor-neutral day/night (IR-cut) control — the per-camera config cap
|
|
9731
|
+
* shared by reolink / hikvision / amcrest. Models the common firmware
|
|
9732
|
+
* surface: the IR-cut switching MODE plus the two knobs that gate it
|
|
9733
|
+
* (photocell `sensitivity` + `switchDelaySec`). Each vendor maps these
|
|
9734
|
+
* onto its own ISAPI / Baichuan / Dahua-CGI fields; the cap standardises
|
|
9735
|
+
* the shape so ONE derived-form renders every camera.
|
|
9736
|
+
*
|
|
9737
|
+
* Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
|
|
9738
|
+
* `getOptions` advertises per-camera availability, `getStatus` (auto-
|
|
9739
|
+
* injected from `status`) reports the live values, and a single
|
|
9740
|
+
* `setSettings` mutation applies a partial change. No hand-written
|
|
9741
|
+
* settings-contribution methods — the framework derives the UI + save
|
|
9742
|
+
* routing from this surface.
|
|
9743
|
+
*/
|
|
9744
|
+
/** IR-cut switching mode. `schedule` = time-of-day table configured on the camera. */
|
|
9745
|
+
var DayNightModeSchema = _enum([
|
|
9746
|
+
"auto",
|
|
9747
|
+
"day",
|
|
9748
|
+
"night",
|
|
9749
|
+
"schedule"
|
|
9750
|
+
]);
|
|
9751
|
+
/** Normalized numeric range descriptor — `{ min, max, step }` per the
|
|
9752
|
+
* getOptions availability convention. Normalized values are 0–100. */
|
|
9753
|
+
var NormalizedRangeSchema$1 = object({
|
|
9754
|
+
min: number(),
|
|
9755
|
+
max: number(),
|
|
9756
|
+
step: number()
|
|
9757
|
+
});
|
|
9758
|
+
object({
|
|
9759
|
+
mode: DayNightModeSchema,
|
|
9760
|
+
/** IR-cut trigger sensitivity, NORMALIZED 0–100 (higher = switches to night sooner). */
|
|
9761
|
+
sensitivity: number().optional(),
|
|
9762
|
+
/** Delay before the IR-cut filter flips, in seconds. */
|
|
9763
|
+
switchDelaySec: number().optional(),
|
|
9764
|
+
lastFetchedAt: number()
|
|
9765
|
+
});
|
|
9766
|
+
/**
|
|
9767
|
+
* Per-camera availability descriptor — drives which controls the admin UI
|
|
9768
|
+
* renders. Booleans as `supportsX`; numeric ranges as `{ min, max, step }`
|
|
9769
|
+
* (normalized 0–100); the mode choice-set as an array. A provider returns
|
|
9770
|
+
* honest, camera-probed values — never hardcoded.
|
|
9771
|
+
*/
|
|
9772
|
+
var DayNightOptionsSchema = object({
|
|
9773
|
+
/** Modes this camera accepts. Empty → the camera has no configurable day/night mode. */
|
|
9774
|
+
modes: array(DayNightModeSchema),
|
|
9775
|
+
supportsSensitivity: boolean(),
|
|
9776
|
+
/** Present when `supportsSensitivity` — the normalized 0–100 range. */
|
|
9777
|
+
sensitivity: NormalizedRangeSchema$1.optional(),
|
|
9778
|
+
supportsSwitchDelay: boolean(),
|
|
9779
|
+
/** Present when `supportsSwitchDelay` — the allowed delay range in seconds. */
|
|
9780
|
+
switchDelaySec: NormalizedRangeSchema$1.optional()
|
|
9781
|
+
});
|
|
9782
|
+
/**
|
|
9783
|
+
* Partial change to the day/night config — every field optional. A
|
|
9784
|
+
* provider ignores fields it does not support.
|
|
9785
|
+
*/
|
|
9786
|
+
var DayNightSettingsPatchSchema = object({
|
|
9787
|
+
mode: DayNightModeSchema.optional(),
|
|
9788
|
+
sensitivity: number().optional(),
|
|
9789
|
+
switchDelaySec: number().optional()
|
|
9790
|
+
});
|
|
9791
|
+
DeviceType.Camera, method(object({ deviceId: number() }), DayNightOptionsSchema), method(object({
|
|
9792
|
+
deviceId: number(),
|
|
9793
|
+
settings: DayNightSettingsPatchSchema
|
|
9794
|
+
}), _void(), {
|
|
9795
|
+
kind: "mutation",
|
|
9796
|
+
auth: "admin"
|
|
9797
|
+
});
|
|
9798
|
+
/**
|
|
9706
9799
|
* Identity envelope for a device's upstream-system metadata.
|
|
9707
9800
|
*
|
|
9708
9801
|
* Two jobs:
|
|
@@ -10058,6 +10151,130 @@ object({
|
|
|
10058
10151
|
});
|
|
10059
10152
|
DeviceType.Image;
|
|
10060
10153
|
/**
|
|
10154
|
+
* Vendor-neutral image / picture-adjustment cap — the per-camera config
|
|
10155
|
+
* cap shared by reolink / hikvision / amcrest. Models the common ISP
|
|
10156
|
+
* surface: the four picture sliders (brightness / contrast / saturation /
|
|
10157
|
+
* sharpness), orientation (mirror / flip / rotate), white-balance,
|
|
10158
|
+
* exposure and backlight-compensation modes.
|
|
10159
|
+
*
|
|
10160
|
+
* NORMALIZATION: every slider is a normalized int 0–100. Vendors expose
|
|
10161
|
+
* these natively as 0–100 or 0–255 (or other ranges); each provider maps
|
|
10162
|
+
* its native range to/from this normalized 0–100 space so the cap surface
|
|
10163
|
+
* (and the derived form) is identical across cameras. `warmth` (manual
|
|
10164
|
+
* white-balance) is likewise normalized 0–100.
|
|
10165
|
+
*
|
|
10166
|
+
* Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
|
|
10167
|
+
* `getOptions` advertises per-camera availability, `getStatus` (auto-
|
|
10168
|
+
* injected from `status`) reports the live values, and a single
|
|
10169
|
+
* `setSettings` mutation applies a partial change. No hand-written
|
|
10170
|
+
* settings-contribution methods — the framework derives the UI + save
|
|
10171
|
+
* routing from this surface.
|
|
10172
|
+
*/
|
|
10173
|
+
/** Sensor/image rotation, degrees clockwise. */
|
|
10174
|
+
var ImageRotateSchema = _enum([
|
|
10175
|
+
"0",
|
|
10176
|
+
"90",
|
|
10177
|
+
"180",
|
|
10178
|
+
"270"
|
|
10179
|
+
]);
|
|
10180
|
+
/** White-balance mode. `manual` unlocks the normalized `warmth` knob. */
|
|
10181
|
+
var WhiteBalanceModeSchema = _enum(["auto", "manual"]);
|
|
10182
|
+
/** Exposure mode. */
|
|
10183
|
+
var ExposureModeSchema = _enum(["auto", "manual"]);
|
|
10184
|
+
/**
|
|
10185
|
+
* Backlight-compensation mode:
|
|
10186
|
+
* - `off` — disabled
|
|
10187
|
+
* - `blc` — backlight compensation
|
|
10188
|
+
* - `wdr` — wide dynamic range
|
|
10189
|
+
* - `hlc` — highlight compensation
|
|
10190
|
+
*/
|
|
10191
|
+
var BacklightModeSchema = _enum([
|
|
10192
|
+
"off",
|
|
10193
|
+
"blc",
|
|
10194
|
+
"wdr",
|
|
10195
|
+
"hlc"
|
|
10196
|
+
]);
|
|
10197
|
+
/** Normalized numeric range descriptor — `{ min, max, step }` per the
|
|
10198
|
+
* getOptions availability convention. Slider values are normalized 0–100. */
|
|
10199
|
+
var NormalizedRangeSchema = object({
|
|
10200
|
+
min: number(),
|
|
10201
|
+
max: number(),
|
|
10202
|
+
step: number()
|
|
10203
|
+
});
|
|
10204
|
+
object({
|
|
10205
|
+
/** Normalized 0–100. */
|
|
10206
|
+
brightness: number().optional(),
|
|
10207
|
+
/** Normalized 0–100. */
|
|
10208
|
+
contrast: number().optional(),
|
|
10209
|
+
/** Normalized 0–100. */
|
|
10210
|
+
saturation: number().optional(),
|
|
10211
|
+
/** Normalized 0–100. */
|
|
10212
|
+
sharpness: number().optional(),
|
|
10213
|
+
mirror: boolean().optional(),
|
|
10214
|
+
flip: boolean().optional(),
|
|
10215
|
+
rotate: ImageRotateSchema.optional(),
|
|
10216
|
+
whiteBalance: WhiteBalanceModeSchema.optional(),
|
|
10217
|
+
/** Manual white-balance warmth, NORMALIZED 0–100. Meaningful only when `whiteBalance === 'manual'`. */
|
|
10218
|
+
warmth: number().optional(),
|
|
10219
|
+
exposureMode: ExposureModeSchema.optional(),
|
|
10220
|
+
backlightMode: BacklightModeSchema.optional(),
|
|
10221
|
+
lastFetchedAt: number()
|
|
10222
|
+
});
|
|
10223
|
+
/**
|
|
10224
|
+
* Per-camera availability descriptor — drives which controls the admin UI
|
|
10225
|
+
* renders. Booleans as `supportsX`; numeric ranges as `{ min, max, step }`
|
|
10226
|
+
* (the normalized 0–100 range); enums as arrays of supported values (empty
|
|
10227
|
+
* array → control hidden). A provider returns honest, camera-probed values
|
|
10228
|
+
* — never hardcoded.
|
|
10229
|
+
*/
|
|
10230
|
+
var ImageSettingsOptionsSchema = object({
|
|
10231
|
+
supportsBrightness: boolean(),
|
|
10232
|
+
brightness: NormalizedRangeSchema.optional(),
|
|
10233
|
+
supportsContrast: boolean(),
|
|
10234
|
+
contrast: NormalizedRangeSchema.optional(),
|
|
10235
|
+
supportsSaturation: boolean(),
|
|
10236
|
+
saturation: NormalizedRangeSchema.optional(),
|
|
10237
|
+
supportsSharpness: boolean(),
|
|
10238
|
+
sharpness: NormalizedRangeSchema.optional(),
|
|
10239
|
+
supportsMirror: boolean(),
|
|
10240
|
+
supportsFlip: boolean(),
|
|
10241
|
+
/** Supported rotation values. Empty → rotation not configurable. */
|
|
10242
|
+
rotateOptions: array(ImageRotateSchema),
|
|
10243
|
+
/** Supported white-balance modes. Empty → white-balance not configurable. */
|
|
10244
|
+
whiteBalanceModes: array(WhiteBalanceModeSchema),
|
|
10245
|
+
supportsWarmth: boolean(),
|
|
10246
|
+
/** Present when `supportsWarmth` — the normalized 0–100 range. */
|
|
10247
|
+
warmth: NormalizedRangeSchema.optional(),
|
|
10248
|
+
/** Supported exposure modes. Empty → exposure not configurable. */
|
|
10249
|
+
exposureModes: array(ExposureModeSchema),
|
|
10250
|
+
/** Supported backlight modes. Empty → backlight-compensation not configurable. */
|
|
10251
|
+
backlightModes: array(BacklightModeSchema)
|
|
10252
|
+
});
|
|
10253
|
+
/**
|
|
10254
|
+
* Partial change to the image config — every field optional. Slider values
|
|
10255
|
+
* are normalized 0–100. A provider ignores fields it does not support.
|
|
10256
|
+
*/
|
|
10257
|
+
var ImageSettingsPatchSchema = object({
|
|
10258
|
+
brightness: number().optional(),
|
|
10259
|
+
contrast: number().optional(),
|
|
10260
|
+
saturation: number().optional(),
|
|
10261
|
+
sharpness: number().optional(),
|
|
10262
|
+
mirror: boolean().optional(),
|
|
10263
|
+
flip: boolean().optional(),
|
|
10264
|
+
rotate: ImageRotateSchema.optional(),
|
|
10265
|
+
whiteBalance: WhiteBalanceModeSchema.optional(),
|
|
10266
|
+
warmth: number().optional(),
|
|
10267
|
+
exposureMode: ExposureModeSchema.optional(),
|
|
10268
|
+
backlightMode: BacklightModeSchema.optional()
|
|
10269
|
+
});
|
|
10270
|
+
DeviceType.Camera, method(object({ deviceId: number() }), ImageSettingsOptionsSchema), method(object({
|
|
10271
|
+
deviceId: number(),
|
|
10272
|
+
settings: ImageSettingsPatchSchema
|
|
10273
|
+
}), _void(), {
|
|
10274
|
+
kind: "mutation",
|
|
10275
|
+
auth: "admin"
|
|
10276
|
+
});
|
|
10277
|
+
/**
|
|
10061
10278
|
* Robotic lawn-mower cap. Models HA `lawn_mower.*` entities — anything
|
|
10062
10279
|
* with a mowing lifecycle plus a dock action.
|
|
10063
10280
|
*
|
|
@@ -10952,6 +11169,16 @@ var RunnerCameraConfigSchema = object({
|
|
|
10952
11169
|
* this gate is bypassed.
|
|
10953
11170
|
*/
|
|
10954
11171
|
onboardMotionDrivesAnalyzer: boolean().default(true),
|
|
11172
|
+
/**
|
|
11173
|
+
* Master toggle for the occupancy re-check. When `false` (DEFAULT) the runner
|
|
11174
|
+
* never arms the periodic recheck timer, regardless of `occupancyRecheckSec` —
|
|
11175
|
+
* this is off by default because the recheck re-subscribes a detection session
|
|
11176
|
+
* every N seconds while `watching`, a major source of pull-decoder re-dial
|
|
11177
|
+
* churn (each cycle creates+tears a session → RTSP re-dial → latency). The
|
|
11178
|
+
* `occupancyRecheckSec` / `occupancyRecheckFrames` sliders only take effect
|
|
11179
|
+
* (and only render) when this is enabled.
|
|
11180
|
+
*/
|
|
11181
|
+
occupancyRecheckEnabled: boolean().default(false),
|
|
10955
11182
|
occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
|
|
10956
11183
|
occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
|
|
10957
11184
|
/**
|
|
@@ -12797,42 +13024,79 @@ var SessionInventoryEntrySchema = object({
|
|
|
12797
13024
|
framesIn: number(),
|
|
12798
13025
|
framesOut: number()
|
|
12799
13026
|
});
|
|
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
|
-
|
|
12832
|
-
}),
|
|
12833
|
-
|
|
12834
|
-
|
|
12835
|
-
|
|
13027
|
+
/**
|
|
13028
|
+
* audio-codec — bidirectional PCM ↔ encoded audio I/O box.
|
|
13029
|
+
*
|
|
13030
|
+
* Independent per-consumer sessions. The provider runs decode + resample
|
|
13031
|
+
* (or resample + encode) inside the session so a 16kHz mono ASA
|
|
13032
|
+
* subscriber and a 48kHz stereo WebRTC subscriber on the same source
|
|
13033
|
+
* stream don't share resamplers.
|
|
13034
|
+
*
|
|
13035
|
+
* Singleton on each node. Decoder and encoder live in the same provider
|
|
13036
|
+
* because they share the underlying libav contexts (node-av today,
|
|
13037
|
+
* pluggable later) — operators always install one or the other together.
|
|
13038
|
+
*/
|
|
13039
|
+
var audioCodecCapability = {
|
|
13040
|
+
name: "audio-codec",
|
|
13041
|
+
scope: "system",
|
|
13042
|
+
mode: "singleton",
|
|
13043
|
+
preferredProvider: "decoder-nodeav",
|
|
13044
|
+
methods: {
|
|
13045
|
+
/** Probe the local runtime and return the supported codec matrix. */
|
|
13046
|
+
listSupportedCodecs: method(_void(), array(AudioCodecInfoSchema).readonly()),
|
|
13047
|
+
/** Cheap predicate — does the runtime support `(codec, kind)`? */
|
|
13048
|
+
canHandle: method(object({
|
|
13049
|
+
codec: string(),
|
|
13050
|
+
kind: _enum(["decode", "encode"])
|
|
13051
|
+
}), boolean()),
|
|
13052
|
+
createDecodeSession: method(AudioDecodeSessionConfigSchema, object({
|
|
13053
|
+
sessionId: string(),
|
|
13054
|
+
nodeId: string()
|
|
13055
|
+
}), { kind: "mutation" }),
|
|
13056
|
+
createEncodeSession: method(AudioEncodeSessionConfigSchema, object({
|
|
13057
|
+
sessionId: string(),
|
|
13058
|
+
nodeId: string()
|
|
13059
|
+
}), { kind: "mutation" }),
|
|
13060
|
+
closeSession: method(object({
|
|
13061
|
+
sessionId: string(),
|
|
13062
|
+
nodeId: string().optional()
|
|
13063
|
+
}), _void(), { kind: "mutation" }),
|
|
13064
|
+
/** Push one encoded audio frame into a decode session. */
|
|
13065
|
+
pushEncodedFrame: method(object({
|
|
13066
|
+
sessionId: string(),
|
|
13067
|
+
nodeId: string().optional(),
|
|
13068
|
+
data: _instanceof(Uint8Array),
|
|
13069
|
+
/** Source PTS in milliseconds. Synthesised when omitted. */
|
|
13070
|
+
pts: number().optional()
|
|
13071
|
+
}), _void(), { kind: "mutation" }),
|
|
13072
|
+
/** Pull up to `maxCount` PCM chunks from a decode session. */
|
|
13073
|
+
pullPcm: method(object({
|
|
13074
|
+
sessionId: string(),
|
|
13075
|
+
nodeId: string().optional(),
|
|
13076
|
+
maxCount: number().int().positive().default(8)
|
|
13077
|
+
}), array(AudioPcmChunkSchema)),
|
|
13078
|
+
/** Push one PCM chunk into an encode session. */
|
|
13079
|
+
pushPcm: method(object({
|
|
13080
|
+
sessionId: string(),
|
|
13081
|
+
nodeId: string().optional(),
|
|
13082
|
+
data: _instanceof(Uint8Array),
|
|
13083
|
+
/** Source PTS in milliseconds. */
|
|
13084
|
+
pts: number().optional()
|
|
13085
|
+
}), _void(), { kind: "mutation" }),
|
|
13086
|
+
/** Pull up to `maxCount` encoded chunks from an encode session. */
|
|
13087
|
+
pullEncoded: method(object({
|
|
13088
|
+
sessionId: string(),
|
|
13089
|
+
nodeId: string().optional(),
|
|
13090
|
+
maxCount: number().int().positive().default(8)
|
|
13091
|
+
}), array(AudioEncodedChunkSchema)),
|
|
13092
|
+
/** Flush any pending encoded output (call before close on graceful tear). */
|
|
13093
|
+
flushEncode: method(object({
|
|
13094
|
+
sessionId: string(),
|
|
13095
|
+
nodeId: string().optional()
|
|
13096
|
+
}), array(AudioEncodedChunkSchema), { kind: "mutation" }),
|
|
13097
|
+
listActiveSessions: method(_void(), array(SessionInventoryEntrySchema).readonly())
|
|
13098
|
+
}
|
|
13099
|
+
};
|
|
12836
13100
|
var AuthResultSchema = object({
|
|
12837
13101
|
userId: string(),
|
|
12838
13102
|
username: string(),
|
|
@@ -19335,6 +19599,18 @@ Object.freeze({
|
|
|
19335
19599
|
addonId: null,
|
|
19336
19600
|
access: "view"
|
|
19337
19601
|
},
|
|
19602
|
+
"dayNight.getOptions": {
|
|
19603
|
+
capName: "day-night",
|
|
19604
|
+
capScope: "device",
|
|
19605
|
+
addonId: null,
|
|
19606
|
+
access: "view"
|
|
19607
|
+
},
|
|
19608
|
+
"dayNight.setSettings": {
|
|
19609
|
+
capName: "day-night",
|
|
19610
|
+
capScope: "device",
|
|
19611
|
+
addonId: null,
|
|
19612
|
+
access: "create"
|
|
19613
|
+
},
|
|
19338
19614
|
"decoder.createSession": {
|
|
19339
19615
|
capName: "decoder",
|
|
19340
19616
|
capScope: "system",
|
|
@@ -20265,6 +20541,18 @@ Object.freeze({
|
|
|
20265
20541
|
addonId: null,
|
|
20266
20542
|
access: "create"
|
|
20267
20543
|
},
|
|
20544
|
+
"imageSettings.getOptions": {
|
|
20545
|
+
capName: "image-settings",
|
|
20546
|
+
capScope: "device",
|
|
20547
|
+
addonId: null,
|
|
20548
|
+
access: "view"
|
|
20549
|
+
},
|
|
20550
|
+
"imageSettings.setSettings": {
|
|
20551
|
+
capName: "image-settings",
|
|
20552
|
+
capScope: "device",
|
|
20553
|
+
addonId: null,
|
|
20554
|
+
access: "create"
|
|
20555
|
+
},
|
|
20268
20556
|
"integrations.create": {
|
|
20269
20557
|
capName: "integrations",
|
|
20270
20558
|
capScope: "system",
|
|
@@ -23043,6 +23331,71 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
|
23043
23331
|
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
23044
23332
|
}
|
|
23045
23333
|
//#endregion
|
|
23334
|
+
//#region src/shm-orphan-purge.ts
|
|
23335
|
+
/**
|
|
23336
|
+
* Startup reclamation of orphaned shared-memory segments.
|
|
23337
|
+
*
|
|
23338
|
+
* A decoder writes frames into named `/dev/shm` segments and unlinks each one
|
|
23339
|
+
* on graceful session teardown (`DecoderFrameRingSink.destroy`). When the
|
|
23340
|
+
* decoder process dies *ungracefully* — SIGBUS, OOM-kill, or a SIGKILL during
|
|
23341
|
+
* redeploy — that teardown never runs and the segment is orphaned: it stays in
|
|
23342
|
+
* the tmpfs forever, since nothing else knows its name. Across many
|
|
23343
|
+
* crashes/redeploys these accumulate until `/dev/shm` fills, at which point the
|
|
23344
|
+
* next `mmap` write faults with an uncatchable SIGBUS and the decoder
|
|
23345
|
+
* crash-loops into its circuit breaker (which is exactly the incident this
|
|
23346
|
+
* guards against).
|
|
23347
|
+
*
|
|
23348
|
+
* The reclamation is safe *at process startup*: a freshly-booting decoder owns
|
|
23349
|
+
* no live sessions, so every pre-existing segment with its prefix is by
|
|
23350
|
+
* definition an orphan from a dead instance. `shm_unlink` only removes the
|
|
23351
|
+
* name — any consumer still holding a mapping keeps reading valid memory until
|
|
23352
|
+
* it closes (POSIX deferred reclaim + the ring seqlock), so unlinking is safe
|
|
23353
|
+
* even if a stale reader is momentarily still attached.
|
|
23354
|
+
*
|
|
23355
|
+
* POSIX-only: segments surface as files under `/dev/shm` on Linux. On platforms
|
|
23356
|
+
* without that directory (Windows, macOS) the scan finds nothing — no-op.
|
|
23357
|
+
*
|
|
23358
|
+
* NOTE: this lives inside the decoder addon (not `@camstack/shm-ring`) so it
|
|
23359
|
+
* ships in the self-contained addon bundle via `camstack deploy`, reusing the
|
|
23360
|
+
* already-deployed `unlinkSegment`; no host base-image rebuild required.
|
|
23361
|
+
*/
|
|
23362
|
+
/** Default tmpfs directory where POSIX shared-memory segments appear on Linux. */
|
|
23363
|
+
var DEFAULT_SHM_DIR = "/dev/shm";
|
|
23364
|
+
/**
|
|
23365
|
+
* Unlink every shared-memory segment whose name starts with `prefix`.
|
|
23366
|
+
*
|
|
23367
|
+
* Intended to run ONCE at decoder startup, before any session is created, to
|
|
23368
|
+
* reclaim segments orphaned by a previously-crashed instance. A per-file unlink
|
|
23369
|
+
* failure is swallowed so one stuck segment cannot block reclaiming the rest.
|
|
23370
|
+
*/
|
|
23371
|
+
function purgeOrphanSegments(prefix, options = {}) {
|
|
23372
|
+
const dir = options.dir ?? DEFAULT_SHM_DIR;
|
|
23373
|
+
const unlink = options.unlink ?? _camstack_shm_ring.unlinkSegment;
|
|
23374
|
+
let entries;
|
|
23375
|
+
try {
|
|
23376
|
+
entries = (0, node_fs.readdirSync)(dir);
|
|
23377
|
+
} catch {
|
|
23378
|
+
return {
|
|
23379
|
+
scanned: 0,
|
|
23380
|
+
removed: 0,
|
|
23381
|
+
names: []
|
|
23382
|
+
};
|
|
23383
|
+
}
|
|
23384
|
+
const names = [];
|
|
23385
|
+
for (const name of entries) {
|
|
23386
|
+
if (!name.startsWith(prefix)) continue;
|
|
23387
|
+
try {
|
|
23388
|
+
unlink(name);
|
|
23389
|
+
names.push(name);
|
|
23390
|
+
} catch {}
|
|
23391
|
+
}
|
|
23392
|
+
return {
|
|
23393
|
+
scanned: entries.length,
|
|
23394
|
+
removed: names.length,
|
|
23395
|
+
names
|
|
23396
|
+
};
|
|
23397
|
+
}
|
|
23398
|
+
//#endregion
|
|
23046
23399
|
//#region src/shared/notifying-ring-buffer.ts
|
|
23047
23400
|
/**
|
|
23048
23401
|
* A {@link RingBuffer} that can notify a single blocked consumer the moment an
|
|
@@ -23228,6 +23581,36 @@ async function resolveDecoderBackend(api, nodeId, logger) {
|
|
|
23228
23581
|
return DEFAULT_DECODER_BACKEND;
|
|
23229
23582
|
}
|
|
23230
23583
|
//#endregion
|
|
23584
|
+
//#region src/pull-demuxer-options.ts
|
|
23585
|
+
/**
|
|
23586
|
+
* libav demuxer options for the node-av PULL decode path — RTP sources
|
|
23587
|
+
* (`isRtpSource()`) dialing the broker's RTSP restream (`rtsp://…/muted`).
|
|
23588
|
+
*
|
|
23589
|
+
* Without low-latency flags libav buffers up to `analyzeduration` (default
|
|
23590
|
+
* **5s**) of the live stream during `avformat_find_stream_info`, and that buffer
|
|
23591
|
+
* becomes a fixed ~5s offset on the decoded-frame timeline — detection overlays
|
|
23592
|
+
* then lag the WebRTC raw-RTP passthrough (which never decodes) by ~5s. Adding
|
|
23593
|
+
* `fflags: nobuffer` + a short `analyzeduration` collapses that offset to
|
|
23594
|
+
* sub-second, mirroring the ffmpeg backend's `-fflags +nobuffer` on its pull
|
|
23595
|
+
* path (decoder-ffmpeg/ffmpeg-args.ts).
|
|
23596
|
+
*
|
|
23597
|
+
* ‼ `analyzeduration`/`probesize` stay SMALL BUT NON-ZERO. Probe-zeroing
|
|
23598
|
+
* (`analyzeduration 0`) on the pull RTSP path STARVES the Reolink rfc4571
|
|
23599
|
+
* restream demuxer (garbage / zero frames — looked like a ~3000fps churn
|
|
23600
|
+
* runaway); that optimisation is push-mode-only. Keep them > 0.
|
|
23601
|
+
*/
|
|
23602
|
+
function buildPullDemuxerOptions() {
|
|
23603
|
+
return {
|
|
23604
|
+
rtsp_transport: "tcp",
|
|
23605
|
+
fflags: "nobuffer",
|
|
23606
|
+
analyzeduration: "1000000",
|
|
23607
|
+
probesize: "1000000",
|
|
23608
|
+
max_delay: "0",
|
|
23609
|
+
reorder_queue_size: "0",
|
|
23610
|
+
user_agent: "decoder"
|
|
23611
|
+
};
|
|
23612
|
+
}
|
|
23613
|
+
//#endregion
|
|
23231
23614
|
//#region src/scaler-geometry.ts
|
|
23232
23615
|
/**
|
|
23233
23616
|
* Decide the scaler action for an incoming frame's source geometry.
|
|
@@ -23250,6 +23633,49 @@ function resolveScalerAction(current, incoming) {
|
|
|
23250
23633
|
* not hot-loop `Demuxer.open`.
|
|
23251
23634
|
*/
|
|
23252
23635
|
var PULL_REDIAL_MS = 3e3;
|
|
23636
|
+
/**
|
|
23637
|
+
* Short re-dial backoff for the adaptive "drop-to-live" path. When the decoder
|
|
23638
|
+
* has fallen hopelessly behind the live edge ({@link DROP_TO_LIVE_LAG_MS}) the
|
|
23639
|
+
* loop tears the input down and re-dials fast so the restream's burst-prime
|
|
23640
|
+
* re-seats it at the live edge, instead of waiting the full {@link PULL_REDIAL_MS}
|
|
23641
|
+
* (which is for transient stream blips, not a deliberate re-seat).
|
|
23642
|
+
*/
|
|
23643
|
+
var PULL_FAST_REDIAL_MS = 250;
|
|
23644
|
+
/** Adaptive controller cadence — recompute emit-fps from lag at most this often. */
|
|
23645
|
+
var ADAPTIVE_TICK_MS = 1e3;
|
|
23646
|
+
/** Lag EWMA above this (ms) → halve the adaptive emit-fps. */
|
|
23647
|
+
var ADAPTIVE_LAG_HIGH_MS = 750;
|
|
23648
|
+
/** Lag EWMA below this (ms), sustained {@link ADAPTIVE_RECOVER_SUSTAIN_MS} → grow emit-fps. */
|
|
23649
|
+
var ADAPTIVE_LAG_LOW_MS = 250;
|
|
23650
|
+
/** How long lag must stay below {@link ADAPTIVE_LAG_LOW_MS} before growing emit-fps. */
|
|
23651
|
+
var ADAPTIVE_RECOVER_SUSTAIN_MS = 5e3;
|
|
23652
|
+
/** Floor the adaptive controller never sheds below (fps). */
|
|
23653
|
+
var ADAPTIVE_MIN_FPS = 2;
|
|
23654
|
+
/**
|
|
23655
|
+
* Ceiling the adaptive controller recovers toward when the subscriber requested
|
|
23656
|
+
* an UNLIMITED rate (`maxFps <= 0`). Source cameras run ≤ this, so the clamp is
|
|
23657
|
+
* a no-op in practice while keeping the /2 shed + *1.5 recover math bounded.
|
|
23658
|
+
*/
|
|
23659
|
+
var ADAPTIVE_FALLBACK_CEILING_FPS = 30;
|
|
23660
|
+
/**
|
|
23661
|
+
* Lag EWMA above this (ms) = hopelessly behind → drop-to-live re-seat.
|
|
23662
|
+
*
|
|
23663
|
+
* This bounds the MAX glass-to-box media staleness: `lagMsEwma` is how far the
|
|
23664
|
+
* decoder's media clock trails live, i.e. how old the *content* of an emitted
|
|
23665
|
+
* frame is (distinct from `frameAge`, which only measures decode→pick pipeline
|
|
23666
|
+
* delay and does NOT capture this media lag). On a host that decodes marginally
|
|
23667
|
+
* below real-time the lag sawtooths up to this ceiling, then re-seats to live —
|
|
23668
|
+
* so this constant directly caps how stale the boxes can get. 1200 ms (was
|
|
23669
|
+
* 2000) trades slightly more frequent 250 ms re-seats for fresher overlays; the
|
|
23670
|
+
* re-seat cost is negligible (~1.4 drops/cam/min at the observed drift). The
|
|
23671
|
+
* real fix — keeping decode at real-time so the lag never builds — is the
|
|
23672
|
+
* co-located / subprocess-decode epic.
|
|
23673
|
+
*/
|
|
23674
|
+
var DROP_TO_LIVE_LAG_MS = 1200;
|
|
23675
|
+
/** Never trigger drop-to-live more than once per this window (ms). */
|
|
23676
|
+
var DROP_TO_LIVE_MIN_INTERVAL_MS = 3e3;
|
|
23677
|
+
/** Decoder DEBUG facility flush cadence (~1Hz). */
|
|
23678
|
+
var DEBUG_FLUSH_MS = 1e3;
|
|
23253
23679
|
/** Map our canonical backend name to the node-av `AV_HWDEVICE_TYPE_*` constant. */
|
|
23254
23680
|
function backendToHwDeviceConst(backend, consts) {
|
|
23255
23681
|
switch (backend) {
|
|
@@ -23411,6 +23837,34 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23411
23837
|
scalerSrcFmt = -1;
|
|
23412
23838
|
lastEmitTime = 0;
|
|
23413
23839
|
minIntervalMs;
|
|
23840
|
+
/** Gates {@link emitDebugFlash}; set from `config.debug === true`. Off = zero cost. */
|
|
23841
|
+
debugEnabled;
|
|
23842
|
+
/** Wall-clock of the last `decoder debug` flush — throttles the facility to ~1Hz. */
|
|
23843
|
+
lastDebugFlush = 0;
|
|
23844
|
+
/** `droppedFrames` snapshot at the last debug flush — window base for the drop delta. */
|
|
23845
|
+
debugWindowDropped = 0;
|
|
23846
|
+
/** Subscriber-requested emit ceiling (fps). `maxFps<=0` clamps to the fallback ceiling. */
|
|
23847
|
+
ceilingFps;
|
|
23848
|
+
/** Current lag-throttled emit rate (fps) — starts at the ceiling, floored at ADAPTIVE_MIN_FPS. */
|
|
23849
|
+
adaptiveFps;
|
|
23850
|
+
/** Frames actually emitted/sec over the last adaptive-tick window (feeds getStats + debug). */
|
|
23851
|
+
effectiveFps = 0;
|
|
23852
|
+
/** `outputFrames` snapshot at the last adaptive tick — window base for effective-fps. */
|
|
23853
|
+
adaptiveWindowFrames = 0;
|
|
23854
|
+
/** First sampled frame's pts (timebase units); `null` until the current dial seeds it. */
|
|
23855
|
+
basePts = null;
|
|
23856
|
+
/** Wall-clock at `basePts` — the real-time anchor for the drift computation. */
|
|
23857
|
+
baseWall = 0;
|
|
23858
|
+
/** EWMA of real-time drift (ms). Rising = decoder falling behind the live edge. */
|
|
23859
|
+
lagMsEwma = 0;
|
|
23860
|
+
/** Wall-clock the lag EWMA first dropped below ADAPTIVE_LAG_LOW_MS (0 = not low). */
|
|
23861
|
+
lowLagSince = 0;
|
|
23862
|
+
/** Wall-clock of the last adaptive-controller tick. */
|
|
23863
|
+
lastAdaptiveCheck = 0;
|
|
23864
|
+
/** Wall-clock of the last drop-to-live re-seat — rate-limits the re-dial. */
|
|
23865
|
+
lastDropToLive = 0;
|
|
23866
|
+
/** Set by drop-to-live so `runPullLoop` uses the SHORT re-dial backoff for one iteration. */
|
|
23867
|
+
pullFastRedial = false;
|
|
23414
23868
|
inputPackets = 0;
|
|
23415
23869
|
outputFrames = 0;
|
|
23416
23870
|
droppedFrames = 0;
|
|
@@ -23445,6 +23899,9 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23445
23899
|
if (typeof config.tag === "string" && config.tag.length > 0) sessionTags["tag"] = config.tag;
|
|
23446
23900
|
this.logger = Object.keys(sessionTags).length > 0 ? logger.withTags(sessionTags) : logger;
|
|
23447
23901
|
this.minIntervalMs = config.maxFps > 0 ? 1e3 / config.maxFps : 0;
|
|
23902
|
+
this.debugEnabled = config.debug === true;
|
|
23903
|
+
this.ceilingFps = config.maxFps > 0 ? config.maxFps : ADAPTIVE_FALLBACK_CEILING_FPS;
|
|
23904
|
+
this.adaptiveFps = this.ceilingFps;
|
|
23448
23905
|
this.outputMode = NodeAvDecoderSession.resolveOutputMode(config.outputFormat);
|
|
23449
23906
|
this.hwaccelPref = options?.hwaccel ?? "auto";
|
|
23450
23907
|
this.hwaccelResolver = options?.hwaccelResolver ?? null;
|
|
@@ -23759,7 +24216,9 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23759
24216
|
this.closePullInput();
|
|
23760
24217
|
}
|
|
23761
24218
|
if (this.destroyed || !this.pullActive) break;
|
|
23762
|
-
|
|
24219
|
+
const backoffMs = this.pullFastRedial ? PULL_FAST_REDIAL_MS : PULL_REDIAL_MS;
|
|
24220
|
+
this.pullFastRedial = false;
|
|
24221
|
+
await this.pullSleep(backoffMs);
|
|
23763
24222
|
}
|
|
23764
24223
|
}
|
|
23765
24224
|
/**
|
|
@@ -23770,7 +24229,7 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23770
24229
|
* stream ends or the session is torn down.
|
|
23771
24230
|
*/
|
|
23772
24231
|
async pullDialAndDecode(nav, C, url) {
|
|
23773
|
-
const demuxer = await nav.Demuxer.open(url, { options:
|
|
24232
|
+
const demuxer = await nav.Demuxer.open(url, { options: buildPullDemuxerOptions() });
|
|
23774
24233
|
if (this.destroyed || !this.pullActive) {
|
|
23775
24234
|
demuxer[Symbol.dispose]?.();
|
|
23776
24235
|
return;
|
|
@@ -23780,7 +24239,8 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23780
24239
|
if (!videoStream) throw new Error("node-av decoder: pull input has no video stream");
|
|
23781
24240
|
const decoder = await nav.Decoder.create(videoStream, {
|
|
23782
24241
|
...this.pullHwContext ? { hardware: this.pullHwContext } : {},
|
|
23783
|
-
rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P }
|
|
24242
|
+
rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P },
|
|
24243
|
+
exitOnError: false
|
|
23784
24244
|
});
|
|
23785
24245
|
if (this.destroyed || !this.pullActive) {
|
|
23786
24246
|
decoder[Symbol.dispose]?.();
|
|
@@ -23793,11 +24253,45 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23793
24253
|
hwAccel: this.activeHwAccel,
|
|
23794
24254
|
streamIndex: videoStream.index
|
|
23795
24255
|
} });
|
|
23796
|
-
|
|
23797
|
-
|
|
23798
|
-
|
|
23799
|
-
|
|
24256
|
+
const probeTb = videoStream.timeBase;
|
|
24257
|
+
const tbNum = probeTb?.num ?? 0;
|
|
24258
|
+
const tbDen = probeTb?.den ?? 0;
|
|
24259
|
+
this.resetLagTracker(Date.now());
|
|
24260
|
+
await this.consumePullFrames(decoder.frames(demuxer.packets(videoStream.index)), tbNum, tbDen);
|
|
24261
|
+
}
|
|
24262
|
+
/**
|
|
24263
|
+
* Consume decoded pull frames until the stream ends or the session is torn
|
|
24264
|
+
* down. Each yielded frame is a node-av `Frame` — for the HW path a `clone`
|
|
24265
|
+
* that refs a VAAPI surface + the decoder's whole `hw_frames_ctx`, so a SINGLE
|
|
24266
|
+
* unfreed frame pins the dial's entire GPU surface pool (i915 GEM/shmem) until
|
|
24267
|
+
* the process dies. Therefore EVERY exit path (teardown break, drop-to-live
|
|
24268
|
+
* re-seat, normal emit, or a throw from the lag/emit calls) MUST `frame.free()`
|
|
24269
|
+
* — the `try/finally` below is the single release point that guarantees it.
|
|
24270
|
+
*
|
|
24271
|
+
* Extracted from {@link pullDialAndDecode} so this free-on-all-paths contract
|
|
24272
|
+
* is unit-testable with a stub async iterable (no live libav / GPU needed).
|
|
24273
|
+
*/
|
|
24274
|
+
async consumePullFrames(frames, tbNum, tbDen) {
|
|
24275
|
+
for await (const frame of frames) {
|
|
24276
|
+
if (!frame) {
|
|
24277
|
+
if (this.destroyed || !this.pullActive) break;
|
|
24278
|
+
continue;
|
|
24279
|
+
}
|
|
23800
24280
|
try {
|
|
24281
|
+
if (this.destroyed || !this.pullActive) break;
|
|
24282
|
+
this.inputPackets++;
|
|
24283
|
+
const wallNow = Date.now();
|
|
24284
|
+
this.updateLagTracker(frame.pts, tbNum, tbDen, wallNow);
|
|
24285
|
+
this.runAdaptiveController(wallNow);
|
|
24286
|
+
this.emitDebugFlash(wallNow);
|
|
24287
|
+
if (this.shouldDropToLive(wallNow)) {
|
|
24288
|
+
this.logger.warn("node-av decoder: lag beyond recovery — dropping to live edge", { meta: {
|
|
24289
|
+
lagMs: Math.round(this.lagMsEwma),
|
|
24290
|
+
redialInMs: PULL_FAST_REDIAL_MS
|
|
24291
|
+
} });
|
|
24292
|
+
this.pullFastRedial = true;
|
|
24293
|
+
break;
|
|
24294
|
+
}
|
|
23801
24295
|
this.emitDecodedFrame(frame);
|
|
23802
24296
|
} finally {
|
|
23803
24297
|
frame.free();
|
|
@@ -23805,10 +24299,11 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23805
24299
|
}
|
|
23806
24300
|
}
|
|
23807
24301
|
/**
|
|
23808
|
-
* Resolve and build the HW context for pull mode ONCE
|
|
23809
|
-
* single try; `'auto'` → the
|
|
23810
|
-
*
|
|
23811
|
-
* failures fall through to
|
|
24302
|
+
* Resolve and build the HW context for pull mode ONCE (reused across every
|
|
24303
|
+
* re-dial, freed in `destroy`). Explicit backend → single try; `'auto'` → the
|
|
24304
|
+
* kernel resolver's ordered list; no resolver → software. The first
|
|
24305
|
+
* `HardwareContext.create` that succeeds wins; all failures fall through to
|
|
24306
|
+
* software decode (`hardware: null`).
|
|
23812
24307
|
*/
|
|
23813
24308
|
async ensurePullHwContext(nav, C) {
|
|
23814
24309
|
if (this.hwaccelPref === "none") {
|
|
@@ -23828,7 +24323,7 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23828
24323
|
if (!deviceType) continue;
|
|
23829
24324
|
const hw = nav.HardwareContext.create(deviceType);
|
|
23830
24325
|
if (!hw) {
|
|
23831
|
-
this.logger.
|
|
24326
|
+
this.logger.debug("node-av: pull hwaccel context create failed — trying next", { meta: { backend } });
|
|
23832
24327
|
continue;
|
|
23833
24328
|
}
|
|
23834
24329
|
this.pullHwContext = hw;
|
|
@@ -23863,6 +24358,107 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23863
24358
|
}, ms);
|
|
23864
24359
|
});
|
|
23865
24360
|
}
|
|
24361
|
+
/**
|
|
24362
|
+
* Re-anchor the real-time lag tracker + per-dial controllers at the start of
|
|
24363
|
+
* each dial. A fresh dial (including a drop-to-live re-seat) begins at the
|
|
24364
|
+
* live edge, so the drift EWMA resets to 0 — otherwise the just-torn dial's
|
|
24365
|
+
* accumulated lag would re-trigger drop-to-live immediately. The learned
|
|
24366
|
+
* `adaptiveFps` is intentionally NOT reset: a re-seat should keep the shed
|
|
24367
|
+
* emit-rate and recover from there once lag stays low.
|
|
24368
|
+
*/
|
|
24369
|
+
resetLagTracker(wallNow) {
|
|
24370
|
+
this.basePts = null;
|
|
24371
|
+
this.baseWall = 0;
|
|
24372
|
+
this.lagMsEwma = 0;
|
|
24373
|
+
this.lowLagSince = 0;
|
|
24374
|
+
this.lastAdaptiveCheck = wallNow;
|
|
24375
|
+
this.adaptiveWindowFrames = this.outputFrames;
|
|
24376
|
+
this.lastDebugFlush = wallNow;
|
|
24377
|
+
this.debugWindowDropped = this.droppedFrames;
|
|
24378
|
+
}
|
|
24379
|
+
/**
|
|
24380
|
+
* Update the EWMA of the decoder's real-time drift from a decoded frame's
|
|
24381
|
+
* pts. The first frame of a dial anchors `basePts`/`baseWall`; every later
|
|
24382
|
+
* frame compares elapsed wall-clock against elapsed media time —
|
|
24383
|
+
* `lagMs = wallElapsed - mediaElapsed`. A rising EWMA means the decoder is
|
|
24384
|
+
* falling behind the live edge (wall time outrunning the media timeline).
|
|
24385
|
+
*/
|
|
24386
|
+
updateLagTracker(pts, tbNum, tbDen, wallNow) {
|
|
24387
|
+
if (tbDen === 0) return;
|
|
24388
|
+
if (this.basePts === null) {
|
|
24389
|
+
this.basePts = pts;
|
|
24390
|
+
this.baseWall = wallNow;
|
|
24391
|
+
return;
|
|
24392
|
+
}
|
|
24393
|
+
const sample = wallNow - this.baseWall - Number(pts - this.basePts) * 1e3 * tbNum / tbDen;
|
|
24394
|
+
this.lagMsEwma = .9 * this.lagMsEwma + .1 * sample;
|
|
24395
|
+
}
|
|
24396
|
+
/**
|
|
24397
|
+
* Lag-driven adaptive emit-fps controller (P1), self-throttled to
|
|
24398
|
+
* {@link ADAPTIVE_TICK_MS}. Under load (lag EWMA high) it halves the emit-fps
|
|
24399
|
+
* — fewer frames transferred + GPU-scaled + emitted, so the decoder stays
|
|
24400
|
+
* real-time instead of accumulating lag; once lag stays low for a sustained
|
|
24401
|
+
* window it grows the emit-fps back toward the subscriber's ceiling.
|
|
24402
|
+
* `minIntervalMs` (the throttle both emit paths honour) is recomputed from
|
|
24403
|
+
* `adaptiveFps`, NOT the raw ceiling.
|
|
24404
|
+
*/
|
|
24405
|
+
runAdaptiveController(wallNow) {
|
|
24406
|
+
const windowMs = wallNow - this.lastAdaptiveCheck;
|
|
24407
|
+
if (windowMs < ADAPTIVE_TICK_MS) return;
|
|
24408
|
+
this.lastAdaptiveCheck = wallNow;
|
|
24409
|
+
const framesDelta = this.outputFrames - this.adaptiveWindowFrames;
|
|
24410
|
+
this.adaptiveWindowFrames = this.outputFrames;
|
|
24411
|
+
this.effectiveFps = windowMs > 0 ? framesDelta * 1e3 / windowMs : 0;
|
|
24412
|
+
const lag = this.lagMsEwma;
|
|
24413
|
+
if (lag > ADAPTIVE_LAG_HIGH_MS) {
|
|
24414
|
+
this.adaptiveFps = Math.max(ADAPTIVE_MIN_FPS, this.adaptiveFps / 2);
|
|
24415
|
+
this.lowLagSince = 0;
|
|
24416
|
+
} else if (lag < ADAPTIVE_LAG_LOW_MS) {
|
|
24417
|
+
if (this.lowLagSince === 0) this.lowLagSince = wallNow;
|
|
24418
|
+
else if (wallNow - this.lowLagSince >= ADAPTIVE_RECOVER_SUSTAIN_MS) {
|
|
24419
|
+
this.adaptiveFps = Math.min(this.ceilingFps, this.adaptiveFps * 1.5);
|
|
24420
|
+
this.lowLagSince = wallNow;
|
|
24421
|
+
}
|
|
24422
|
+
} else this.lowLagSince = 0;
|
|
24423
|
+
this.minIntervalMs = this.adaptiveFps > 0 ? 1e3 / this.adaptiveFps : 0;
|
|
24424
|
+
}
|
|
24425
|
+
/**
|
|
24426
|
+
* Whether the decoder is hopelessly behind the live edge and should tear the
|
|
24427
|
+
* input down for a fast re-seat. Rate-limited to at most once per
|
|
24428
|
+
* {@link DROP_TO_LIVE_MIN_INTERVAL_MS} so a bad stretch can't hot-loop the
|
|
24429
|
+
* dial. Records the trigger time as a side effect when it returns `true`.
|
|
24430
|
+
*/
|
|
24431
|
+
shouldDropToLive(wallNow) {
|
|
24432
|
+
if (this.lagMsEwma <= DROP_TO_LIVE_LAG_MS) return false;
|
|
24433
|
+
if (wallNow - this.lastDropToLive < DROP_TO_LIVE_MIN_INTERVAL_MS) return false;
|
|
24434
|
+
this.lastDropToLive = wallNow;
|
|
24435
|
+
return true;
|
|
24436
|
+
}
|
|
24437
|
+
/**
|
|
24438
|
+
* Per-camera DEBUG facility (mirrors the stream-broker's `streamingDebug`
|
|
24439
|
+
* gate). When `config.debug` is set, flush a single structured `decoder
|
|
24440
|
+
* debug` line at ~1Hz — effective/adaptive fps, real-time lag, dropped-frame
|
|
24441
|
+
* delta, avg decode time, hwaccel. `deviceId`/`tag` ride on the logger tags.
|
|
24442
|
+
* Cheap: all values come from running counters, and the whole method is a
|
|
24443
|
+
* no-op (one comparison) when debug is disabled or the window hasn't elapsed.
|
|
24444
|
+
*/
|
|
24445
|
+
emitDebugFlash(wallNow) {
|
|
24446
|
+
if (!this.debugEnabled) return;
|
|
24447
|
+
if (wallNow - this.lastDebugFlush < DEBUG_FLUSH_MS) return;
|
|
24448
|
+
this.lastDebugFlush = wallNow;
|
|
24449
|
+
const droppedDelta = this.droppedFrames - this.debugWindowDropped;
|
|
24450
|
+
this.debugWindowDropped = this.droppedFrames;
|
|
24451
|
+
const decodeMsAvg = this.outputFrames > 0 ? this.totalDecodeTimeMs / this.outputFrames : 0;
|
|
24452
|
+
this.logger.info("decoder debug", { meta: {
|
|
24453
|
+
effectiveFps: Number(this.effectiveFps.toFixed(1)),
|
|
24454
|
+
adaptiveFps: Number(this.adaptiveFps.toFixed(1)),
|
|
24455
|
+
ceilingFps: this.ceilingFps,
|
|
24456
|
+
lagMs: Math.round(this.lagMsEwma),
|
|
24457
|
+
droppedFrames: droppedDelta,
|
|
24458
|
+
decodeMs: Number(decodeMsAvg.toFixed(2)),
|
|
24459
|
+
hwAccel: this.activeHwAccel
|
|
24460
|
+
} });
|
|
24461
|
+
}
|
|
23866
24462
|
pushPacket(packet) {
|
|
23867
24463
|
if (this.destroyed) return;
|
|
23868
24464
|
if (this.pullActive) {
|
|
@@ -23919,6 +24515,10 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
23919
24515
|
}
|
|
23920
24516
|
emitDecodedFrame(frame) {
|
|
23921
24517
|
const now = performance.now();
|
|
24518
|
+
if (frame.isHwFrame()) {
|
|
24519
|
+
this.droppedFrames++;
|
|
24520
|
+
return;
|
|
24521
|
+
}
|
|
23922
24522
|
if (this.minIntervalMs > 0 && now - this.lastEmitTime < this.minIntervalMs) {
|
|
23923
24523
|
this.droppedFrames++;
|
|
23924
24524
|
return;
|
|
@@ -24288,18 +24888,809 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
|
|
|
24288
24888
|
}
|
|
24289
24889
|
getStats() {
|
|
24290
24890
|
const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
|
|
24291
|
-
|
|
24891
|
+
const base = {
|
|
24292
24892
|
inputFps: this.inputPackets / uptimeSec,
|
|
24293
24893
|
outputFps: this.outputFrames / uptimeSec,
|
|
24294
24894
|
avgDecodeTimeMs: this.outputFrames > 0 ? this.totalDecodeTimeMs / this.outputFrames : 0,
|
|
24295
24895
|
droppedFrames: this.droppedFrames
|
|
24296
24896
|
};
|
|
24897
|
+
if (!this.pullActive) return base;
|
|
24898
|
+
return {
|
|
24899
|
+
...base,
|
|
24900
|
+
lagMs: this.lagMsEwma,
|
|
24901
|
+
effectiveFps: this.effectiveFps,
|
|
24902
|
+
adaptiveFps: this.adaptiveFps
|
|
24903
|
+
};
|
|
24297
24904
|
}
|
|
24298
24905
|
get isPullMode() {
|
|
24299
24906
|
return this.pullActive;
|
|
24300
24907
|
}
|
|
24301
24908
|
};
|
|
24302
24909
|
//#endregion
|
|
24910
|
+
//#region src/audio-codec/codec-catalog.ts
|
|
24911
|
+
/**
|
|
24912
|
+
* Normalise an SDP-reported codec name to the libav/ffmpeg name. Mirrors
|
|
24913
|
+
* `audio-codec-ffmpeg`'s `resolveAudioCodecAlias` so callers can pass the
|
|
24914
|
+
* SDP-reported value verbatim.
|
|
24915
|
+
*/
|
|
24916
|
+
function resolveAudioCodecAlias(codec) {
|
|
24917
|
+
const c = codec.toLowerCase();
|
|
24918
|
+
if (c === "mpeg4-generic") return "aac";
|
|
24919
|
+
if (c === "l16") return "pcm_s16be";
|
|
24920
|
+
return c;
|
|
24921
|
+
}
|
|
24922
|
+
/**
|
|
24923
|
+
* Supported codec matrix. `aac_latm` / `mpeg4-generic` are decode-only (they
|
|
24924
|
+
* exist only as camera-side inbound streams; the intercom back-channel encodes
|
|
24925
|
+
* plain `aac`). Kept byte-for-byte in step with the ffmpeg addon's catalogue.
|
|
24926
|
+
*/
|
|
24927
|
+
var CODEC_CATALOG = [
|
|
24928
|
+
{
|
|
24929
|
+
codec: "pcm_mulaw",
|
|
24930
|
+
canDecode: true,
|
|
24931
|
+
canEncode: true,
|
|
24932
|
+
label: "PCM µ-law (G.711)"
|
|
24933
|
+
},
|
|
24934
|
+
{
|
|
24935
|
+
codec: "pcm_alaw",
|
|
24936
|
+
canDecode: true,
|
|
24937
|
+
canEncode: true,
|
|
24938
|
+
label: "PCM A-law (G.711)"
|
|
24939
|
+
},
|
|
24940
|
+
{
|
|
24941
|
+
codec: "g722",
|
|
24942
|
+
canDecode: true,
|
|
24943
|
+
canEncode: true,
|
|
24944
|
+
label: "G.722"
|
|
24945
|
+
},
|
|
24946
|
+
{
|
|
24947
|
+
codec: "aac",
|
|
24948
|
+
canDecode: true,
|
|
24949
|
+
canEncode: true,
|
|
24950
|
+
label: "AAC"
|
|
24951
|
+
},
|
|
24952
|
+
{
|
|
24953
|
+
codec: "aac_latm",
|
|
24954
|
+
canDecode: true,
|
|
24955
|
+
canEncode: false,
|
|
24956
|
+
label: "AAC LATM"
|
|
24957
|
+
},
|
|
24958
|
+
{
|
|
24959
|
+
codec: "mpeg4-generic",
|
|
24960
|
+
canDecode: true,
|
|
24961
|
+
canEncode: false,
|
|
24962
|
+
label: "AAC (MPEG4-GENERIC)"
|
|
24963
|
+
},
|
|
24964
|
+
{
|
|
24965
|
+
codec: "opus",
|
|
24966
|
+
canDecode: true,
|
|
24967
|
+
canEncode: true,
|
|
24968
|
+
label: "Opus"
|
|
24969
|
+
}
|
|
24970
|
+
];
|
|
24971
|
+
/** Resolve the catalogue entry for a codec name (alias-normalised). */
|
|
24972
|
+
function catalogEntryFor(codec) {
|
|
24973
|
+
const resolved = resolveAudioCodecAlias(codec);
|
|
24974
|
+
return CODEC_CATALOG.find((e) => resolveAudioCodecAlias(e.codec) === resolved) ?? null;
|
|
24975
|
+
}
|
|
24976
|
+
/** Cheap predicate — is `(codec, kind)` in the catalogue? */
|
|
24977
|
+
function codecSupports(codec, kind) {
|
|
24978
|
+
const entry = catalogEntryFor(codec);
|
|
24979
|
+
if (!entry) return false;
|
|
24980
|
+
return kind === "decode" ? entry.canDecode : entry.canEncode;
|
|
24981
|
+
}
|
|
24982
|
+
/**
|
|
24983
|
+
* Resolve an (alias-normalised) codec name to its canonical libav key, or
|
|
24984
|
+
* `null` for names outside this addon's SDP audio matrix. Kept pure (string
|
|
24985
|
+
* only) — the branded `AVCodecID` lookup lives in `nodeav-av-types.ts` where
|
|
24986
|
+
* node-av's constants are in scope. G.722 maps to `AV_CODEC_ID_ADPCM_G722`.
|
|
24987
|
+
*/
|
|
24988
|
+
function resolveCodecKey(codec) {
|
|
24989
|
+
switch (resolveAudioCodecAlias(codec)) {
|
|
24990
|
+
case "aac": return "aac";
|
|
24991
|
+
case "aac_latm": return "aac_latm";
|
|
24992
|
+
case "opus": return "opus";
|
|
24993
|
+
case "pcm_alaw": return "pcm_alaw";
|
|
24994
|
+
case "pcm_mulaw": return "pcm_mulaw";
|
|
24995
|
+
case "g722": return "g722";
|
|
24996
|
+
default: return null;
|
|
24997
|
+
}
|
|
24998
|
+
}
|
|
24999
|
+
//#endregion
|
|
25000
|
+
//#region src/audio-codec/nodeav-av-types.ts
|
|
25001
|
+
/**
|
|
25002
|
+
* Resolve a codec name to its branded libav `AVCodecID` using the real
|
|
25003
|
+
* constants. Returns `null` for names outside this addon's matrix. G.722 maps
|
|
25004
|
+
* to `AV_CODEC_ID_ADPCM_G722` (there is no plain `AV_CODEC_ID_G722`).
|
|
25005
|
+
*/
|
|
25006
|
+
function resolveAvCodecId(codec, consts) {
|
|
25007
|
+
switch (resolveCodecKey(codec)) {
|
|
25008
|
+
case "aac": return consts.AV_CODEC_ID_AAC;
|
|
25009
|
+
case "aac_latm": return consts.AV_CODEC_ID_AAC_LATM;
|
|
25010
|
+
case "opus": return consts.AV_CODEC_ID_OPUS;
|
|
25011
|
+
case "pcm_alaw": return consts.AV_CODEC_ID_PCM_ALAW;
|
|
25012
|
+
case "pcm_mulaw": return consts.AV_CODEC_ID_PCM_MULAW;
|
|
25013
|
+
case "g722": return consts.AV_CODEC_ID_ADPCM_G722;
|
|
25014
|
+
case null: return null;
|
|
25015
|
+
}
|
|
25016
|
+
}
|
|
25017
|
+
/** Build a canonical mono/stereo (or bare-mask) channel layout. */
|
|
25018
|
+
function buildChannelLayout(consts, channels) {
|
|
25019
|
+
if (channels === 1) return {
|
|
25020
|
+
nbChannels: 1,
|
|
25021
|
+
order: consts.AV_CHANNEL_ORDER_NATIVE,
|
|
25022
|
+
mask: consts.AV_CH_LAYOUT_MONO
|
|
25023
|
+
};
|
|
25024
|
+
if (channels === 2) return {
|
|
25025
|
+
nbChannels: 2,
|
|
25026
|
+
order: consts.AV_CHANNEL_ORDER_NATIVE,
|
|
25027
|
+
mask: consts.AV_CH_LAYOUT_STEREO
|
|
25028
|
+
};
|
|
25029
|
+
return {
|
|
25030
|
+
nbChannels: channels,
|
|
25031
|
+
order: consts.AV_CHANNEL_ORDER_NATIVE,
|
|
25032
|
+
mask: 0n
|
|
25033
|
+
};
|
|
25034
|
+
}
|
|
25035
|
+
//#endregion
|
|
25036
|
+
//#region src/audio-codec/nodeav-audio-decode-session.ts
|
|
25037
|
+
function targetSampleFmt(consts, format) {
|
|
25038
|
+
return format === "f32le" ? consts.AV_SAMPLE_FMT_FLT : consts.AV_SAMPLE_FMT_S16;
|
|
25039
|
+
}
|
|
25040
|
+
function bytesPerSample(format) {
|
|
25041
|
+
return format === "f32le" ? 4 : 2;
|
|
25042
|
+
}
|
|
25043
|
+
var NodeAvAudioDecodeSession = class {
|
|
25044
|
+
runtime;
|
|
25045
|
+
cfg;
|
|
25046
|
+
logger;
|
|
25047
|
+
onPcm;
|
|
25048
|
+
ctx;
|
|
25049
|
+
swr;
|
|
25050
|
+
packet;
|
|
25051
|
+
inFrame;
|
|
25052
|
+
outFrame;
|
|
25053
|
+
outLayout;
|
|
25054
|
+
outSampleFmt;
|
|
25055
|
+
outBytesPerSample;
|
|
25056
|
+
outFormat;
|
|
25057
|
+
nextPts = 0;
|
|
25058
|
+
closed = false;
|
|
25059
|
+
/**
|
|
25060
|
+
* Synchronously build a decode runtime from the already-loaded node-av
|
|
25061
|
+
* runtime. Throws (freeing anything already allocated) on any libav error so
|
|
25062
|
+
* the addon surfaces a clean "decode session failed" without leaking a
|
|
25063
|
+
* half-open context.
|
|
25064
|
+
*/
|
|
25065
|
+
constructor(runtime, cfg, logger, onPcm) {
|
|
25066
|
+
this.runtime = runtime;
|
|
25067
|
+
this.cfg = cfg;
|
|
25068
|
+
this.logger = logger;
|
|
25069
|
+
this.onPcm = onPcm;
|
|
25070
|
+
this.outFormat = cfg.targetFormat;
|
|
25071
|
+
this.outBytesPerSample = bytesPerSample(cfg.targetFormat);
|
|
25072
|
+
const { nav, consts } = runtime;
|
|
25073
|
+
const codecId = resolveAvCodecId(cfg.codec, consts);
|
|
25074
|
+
if (codecId === null) throw new Error(`audio-codec-nodeav: unknown codec '${cfg.codec}' for decode`);
|
|
25075
|
+
const codec = nav.Codec.findDecoder(codecId);
|
|
25076
|
+
if (!codec) throw new Error(`audio-codec-nodeav: decoder not registered for '${cfg.codec}'`);
|
|
25077
|
+
const ctx = new nav.CodecContext();
|
|
25078
|
+
ctx.allocContext3(codec);
|
|
25079
|
+
ctx.sampleRate = cfg.sourceSampleRate;
|
|
25080
|
+
const inLayout = buildChannelLayout(consts, cfg.sourceChannels);
|
|
25081
|
+
ctx.channelLayout = inLayout;
|
|
25082
|
+
if (cfg.extraData && cfg.extraData.byteLength > 0) ctx.extraData = Buffer.from(cfg.extraData);
|
|
25083
|
+
const openRet = ctx.open2Sync(codec, null);
|
|
25084
|
+
if (openRet < 0) {
|
|
25085
|
+
ctx.freeContext();
|
|
25086
|
+
throw new Error(`audio-codec-nodeav: open2 failed for '${cfg.codec}' (ret=${openRet})`);
|
|
25087
|
+
}
|
|
25088
|
+
const inSampleFmt = ctx.sampleFormat;
|
|
25089
|
+
this.outSampleFmt = targetSampleFmt(consts, cfg.targetFormat);
|
|
25090
|
+
this.outLayout = buildChannelLayout(consts, cfg.targetChannels);
|
|
25091
|
+
const swr = new nav.SoftwareResampleContext();
|
|
25092
|
+
const allocRet = swr.allocSetOpts2(this.outLayout, this.outSampleFmt, cfg.targetSampleRate, inLayout, inSampleFmt, cfg.sourceSampleRate);
|
|
25093
|
+
if (allocRet < 0) {
|
|
25094
|
+
ctx.freeContext();
|
|
25095
|
+
throw new Error(`audio-codec-nodeav: swr allocSetOpts2 failed (ret=${allocRet})`);
|
|
25096
|
+
}
|
|
25097
|
+
const initRet = swr.init();
|
|
25098
|
+
if (initRet < 0) {
|
|
25099
|
+
swr.free();
|
|
25100
|
+
ctx.freeContext();
|
|
25101
|
+
throw new Error(`audio-codec-nodeav: swr init failed (ret=${initRet})`);
|
|
25102
|
+
}
|
|
25103
|
+
const packet = new nav.Packet();
|
|
25104
|
+
packet.alloc();
|
|
25105
|
+
const inFrame = new nav.Frame();
|
|
25106
|
+
inFrame.alloc();
|
|
25107
|
+
const outFrame = new nav.Frame();
|
|
25108
|
+
outFrame.alloc();
|
|
25109
|
+
this.ctx = ctx;
|
|
25110
|
+
this.swr = swr;
|
|
25111
|
+
this.packet = packet;
|
|
25112
|
+
this.inFrame = inFrame;
|
|
25113
|
+
this.outFrame = outFrame;
|
|
25114
|
+
}
|
|
25115
|
+
/** Decode one encoded access unit, emitting resampled PCM via `onPcm`. */
|
|
25116
|
+
pushEncoded(data, pts) {
|
|
25117
|
+
if (this.closed) return;
|
|
25118
|
+
const { consts } = this.runtime;
|
|
25119
|
+
this.packet.data = Buffer.from(data);
|
|
25120
|
+
if (pts !== void 0) {
|
|
25121
|
+
const p = BigInt(Math.round(pts));
|
|
25122
|
+
this.packet.pts = p;
|
|
25123
|
+
this.packet.dts = p;
|
|
25124
|
+
}
|
|
25125
|
+
const sendRet = this.ctx.sendPacketSync(this.packet);
|
|
25126
|
+
if (sendRet < 0 && sendRet !== consts.AVERROR_EAGAIN) throw new Error(`audio-codec-nodeav: sendPacket failed (ret=${sendRet})`);
|
|
25127
|
+
for (;;) {
|
|
25128
|
+
const recvRet = this.ctx.receiveFrameSync(this.inFrame);
|
|
25129
|
+
if (recvRet === consts.AVERROR_EAGAIN || recvRet === consts.AVERROR_EOF) break;
|
|
25130
|
+
if (recvRet < 0) throw new Error(`audio-codec-nodeav: receiveFrame failed (ret=${recvRet})`);
|
|
25131
|
+
try {
|
|
25132
|
+
const chunk = this.resampleFrame(this.inFrame);
|
|
25133
|
+
if (chunk) this.onPcm(chunk);
|
|
25134
|
+
} finally {
|
|
25135
|
+
this.inFrame.unref();
|
|
25136
|
+
}
|
|
25137
|
+
}
|
|
25138
|
+
}
|
|
25139
|
+
resampleFrame(inFrame) {
|
|
25140
|
+
const outSamples = this.swr.getOutSamples(inFrame.nbSamples);
|
|
25141
|
+
if (outSamples <= 0) return null;
|
|
25142
|
+
this.outFrame.unref();
|
|
25143
|
+
this.outFrame.format = this.outSampleFmt;
|
|
25144
|
+
this.outFrame.sampleRate = this.cfg.targetSampleRate;
|
|
25145
|
+
this.outFrame.channelLayout = this.outLayout;
|
|
25146
|
+
this.outFrame.nbSamples = outSamples;
|
|
25147
|
+
const bufRet = this.outFrame.getBuffer(0);
|
|
25148
|
+
if (bufRet < 0) throw new Error(`audio-codec-nodeav: outFrame.getBuffer failed (ret=${bufRet})`);
|
|
25149
|
+
const convRet = this.swr.convertFrame(this.outFrame, inFrame);
|
|
25150
|
+
if (convRet < 0) throw new Error(`audio-codec-nodeav: swr.convertFrame failed (ret=${convRet})`);
|
|
25151
|
+
const produced = this.outFrame.nbSamples;
|
|
25152
|
+
if (produced <= 0) return null;
|
|
25153
|
+
const planes = this.outFrame.extendedData;
|
|
25154
|
+
const plane0 = planes && planes.length > 0 ? planes[0] : null;
|
|
25155
|
+
if (!plane0) return null;
|
|
25156
|
+
const bytes = produced * this.cfg.targetChannels * this.outBytesPerSample;
|
|
25157
|
+
const out = new Uint8Array(new ArrayBuffer(bytes));
|
|
25158
|
+
out.set(plane0.subarray(0, bytes));
|
|
25159
|
+
const ptsMs = this.nextPts;
|
|
25160
|
+
this.nextPts = ptsMs + Math.round(produced * 1e3 / this.cfg.targetSampleRate);
|
|
25161
|
+
return {
|
|
25162
|
+
data: out,
|
|
25163
|
+
sampleRate: this.cfg.targetSampleRate,
|
|
25164
|
+
channels: this.cfg.targetChannels,
|
|
25165
|
+
format: this.outFormat,
|
|
25166
|
+
pts: ptsMs
|
|
25167
|
+
};
|
|
25168
|
+
}
|
|
25169
|
+
/** Release every native handle. Idempotent; safe to call from a reaper. */
|
|
25170
|
+
destroy() {
|
|
25171
|
+
if (this.closed) return;
|
|
25172
|
+
this.closed = true;
|
|
25173
|
+
this.freeQuietly(() => this.outFrame.free(), "outFrame");
|
|
25174
|
+
this.freeQuietly(() => this.inFrame.free(), "inFrame");
|
|
25175
|
+
this.freeQuietly(() => this.packet.free(), "packet");
|
|
25176
|
+
this.freeQuietly(() => this.swr.free(), "swr");
|
|
25177
|
+
this.freeQuietly(() => this.ctx.freeContext(), "codecContext");
|
|
25178
|
+
}
|
|
25179
|
+
freeQuietly(fn, what) {
|
|
25180
|
+
try {
|
|
25181
|
+
fn();
|
|
25182
|
+
} catch (err) {
|
|
25183
|
+
this.logger.warn("audio-codec-nodeav: decode handle free failed", { meta: {
|
|
25184
|
+
what,
|
|
25185
|
+
error: err instanceof Error ? err.message : String(err)
|
|
25186
|
+
} });
|
|
25187
|
+
}
|
|
25188
|
+
}
|
|
25189
|
+
};
|
|
25190
|
+
//#endregion
|
|
25191
|
+
//#region src/audio-codec/nodeav-audio-encode-session.ts
|
|
25192
|
+
function sourceSampleFmt(consts, format) {
|
|
25193
|
+
return format === "f32le" ? consts.AV_SAMPLE_FMT_FLT : consts.AV_SAMPLE_FMT_S16;
|
|
25194
|
+
}
|
|
25195
|
+
function sourceBytesPerSample(format) {
|
|
25196
|
+
return format === "f32le" ? 4 : 2;
|
|
25197
|
+
}
|
|
25198
|
+
var NodeAvAudioEncodeSession = class {
|
|
25199
|
+
runtime;
|
|
25200
|
+
cfg;
|
|
25201
|
+
logger;
|
|
25202
|
+
onChunk;
|
|
25203
|
+
codecName;
|
|
25204
|
+
ctx;
|
|
25205
|
+
swr;
|
|
25206
|
+
fifo;
|
|
25207
|
+
packet;
|
|
25208
|
+
outLayout;
|
|
25209
|
+
encoderSampleFmt;
|
|
25210
|
+
srcSampleFmt;
|
|
25211
|
+
srcBytesPerSample;
|
|
25212
|
+
/** Encoder-required samples per frame; `<= 0` means variable (drain all). */
|
|
25213
|
+
frameSize;
|
|
25214
|
+
ptsSamples = 0;
|
|
25215
|
+
closed = false;
|
|
25216
|
+
constructor(runtime, cfg, logger, onChunk) {
|
|
25217
|
+
this.runtime = runtime;
|
|
25218
|
+
this.cfg = cfg;
|
|
25219
|
+
this.logger = logger;
|
|
25220
|
+
this.onChunk = onChunk;
|
|
25221
|
+
this.codecName = resolveAudioCodecAlias(cfg.codec);
|
|
25222
|
+
this.srcSampleFmt = sourceSampleFmt(runtime.consts, cfg.sourceFormat);
|
|
25223
|
+
this.srcBytesPerSample = sourceBytesPerSample(cfg.sourceFormat);
|
|
25224
|
+
const { nav, consts } = runtime;
|
|
25225
|
+
const codecId = resolveAvCodecId(cfg.codec, consts);
|
|
25226
|
+
if (codecId === null) throw new Error(`audio-codec-nodeav: unknown codec '${cfg.codec}' for encode`);
|
|
25227
|
+
const codec = nav.Codec.findEncoder(codecId);
|
|
25228
|
+
if (!codec) throw new Error(`audio-codec-nodeav: encoder not registered for '${cfg.codec}'`);
|
|
25229
|
+
const fmts = codec.sampleFormats;
|
|
25230
|
+
this.encoderSampleFmt = fmts && fmts.length > 0 ? fmts[0] : consts.AV_SAMPLE_FMT_S16;
|
|
25231
|
+
this.outLayout = buildChannelLayout(consts, cfg.targetChannels);
|
|
25232
|
+
const ctx = new nav.CodecContext();
|
|
25233
|
+
ctx.allocContext3(codec);
|
|
25234
|
+
ctx.sampleRate = cfg.targetSampleRate;
|
|
25235
|
+
ctx.channelLayout = this.outLayout;
|
|
25236
|
+
ctx.sampleFormat = this.encoderSampleFmt;
|
|
25237
|
+
if (cfg.bitrateKbps !== void 0) ctx.bitRate = BigInt(Math.round(cfg.bitrateKbps * 1e3));
|
|
25238
|
+
const openRet = ctx.open2Sync(codec, null);
|
|
25239
|
+
if (openRet < 0) {
|
|
25240
|
+
ctx.freeContext();
|
|
25241
|
+
throw new Error(`audio-codec-nodeav: encoder open2 failed for '${cfg.codec}' (ret=${openRet})`);
|
|
25242
|
+
}
|
|
25243
|
+
this.frameSize = ctx.frameSize > 0 ? ctx.frameSize : 0;
|
|
25244
|
+
const swr = new nav.SoftwareResampleContext();
|
|
25245
|
+
const inLayout = buildChannelLayout(consts, cfg.sourceChannels);
|
|
25246
|
+
const allocRet = swr.allocSetOpts2(this.outLayout, this.encoderSampleFmt, cfg.targetSampleRate, inLayout, this.srcSampleFmt, cfg.sourceSampleRate);
|
|
25247
|
+
if (allocRet < 0) {
|
|
25248
|
+
ctx.freeContext();
|
|
25249
|
+
throw new Error(`audio-codec-nodeav: encode swr allocSetOpts2 failed (ret=${allocRet})`);
|
|
25250
|
+
}
|
|
25251
|
+
if (swr.init() < 0) {
|
|
25252
|
+
swr.free();
|
|
25253
|
+
ctx.freeContext();
|
|
25254
|
+
throw new Error("audio-codec-nodeav: encode swr init failed");
|
|
25255
|
+
}
|
|
25256
|
+
const fifo = new nav.AudioFifo();
|
|
25257
|
+
fifo.alloc(this.encoderSampleFmt, cfg.targetChannels, this.frameSize > 0 ? this.frameSize : 1024);
|
|
25258
|
+
const packet = new nav.Packet();
|
|
25259
|
+
packet.alloc();
|
|
25260
|
+
this.ctx = ctx;
|
|
25261
|
+
this.swr = swr;
|
|
25262
|
+
this.fifo = fifo;
|
|
25263
|
+
this.packet = packet;
|
|
25264
|
+
}
|
|
25265
|
+
/** Push one interleaved PCM chunk; emits encoded packets via `onChunk`. */
|
|
25266
|
+
pushPcm(data) {
|
|
25267
|
+
if (this.closed || data.byteLength === 0) return;
|
|
25268
|
+
const frameBytes = this.cfg.sourceChannels * this.srcBytesPerSample;
|
|
25269
|
+
const inSamples = Math.floor(data.byteLength / frameBytes);
|
|
25270
|
+
if (inSamples <= 0) return;
|
|
25271
|
+
this.resampleIntoFifo(data, inSamples);
|
|
25272
|
+
this.drainFifo(false);
|
|
25273
|
+
}
|
|
25274
|
+
/**
|
|
25275
|
+
* Flush the encoder: drain any partial frame left in the FIFO, then signal
|
|
25276
|
+
* end-of-stream so the codec emits its tail packets. Called before a graceful
|
|
25277
|
+
* close.
|
|
25278
|
+
*/
|
|
25279
|
+
flush() {
|
|
25280
|
+
if (this.closed) return;
|
|
25281
|
+
this.drainFifo(true);
|
|
25282
|
+
try {
|
|
25283
|
+
if (this.ctx.sendFrameSync(null) >= 0) this.drainEncoder();
|
|
25284
|
+
} catch (err) {
|
|
25285
|
+
this.logger.warn("audio-codec-nodeav: encode flush failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
25286
|
+
}
|
|
25287
|
+
}
|
|
25288
|
+
resampleIntoFifo(data, inSamples) {
|
|
25289
|
+
const { nav } = this.runtime;
|
|
25290
|
+
const inFrame = new nav.Frame();
|
|
25291
|
+
inFrame.alloc();
|
|
25292
|
+
const outFrame = new nav.Frame();
|
|
25293
|
+
outFrame.alloc();
|
|
25294
|
+
try {
|
|
25295
|
+
inFrame.format = this.srcSampleFmt;
|
|
25296
|
+
inFrame.sampleRate = this.cfg.sourceSampleRate;
|
|
25297
|
+
inFrame.channelLayout = buildChannelLayout(this.runtime.consts, this.cfg.sourceChannels);
|
|
25298
|
+
inFrame.nbSamples = inSamples;
|
|
25299
|
+
const inBufRet = inFrame.getBuffer(0);
|
|
25300
|
+
if (inBufRet < 0) throw new Error(`inFrame.getBuffer failed (ret=${inBufRet})`);
|
|
25301
|
+
const planes = inFrame.extendedData;
|
|
25302
|
+
const plane0 = planes && planes.length > 0 ? planes[0] : null;
|
|
25303
|
+
if (!plane0) throw new Error("inFrame has no data plane");
|
|
25304
|
+
plane0.set(data.subarray(0, inSamples * this.cfg.sourceChannels * this.srcBytesPerSample));
|
|
25305
|
+
const outSamples = this.swr.getOutSamples(inSamples);
|
|
25306
|
+
if (outSamples <= 0) return;
|
|
25307
|
+
outFrame.format = this.encoderSampleFmt;
|
|
25308
|
+
outFrame.sampleRate = this.cfg.targetSampleRate;
|
|
25309
|
+
outFrame.channelLayout = this.outLayout;
|
|
25310
|
+
outFrame.nbSamples = outSamples;
|
|
25311
|
+
const outBufRet = outFrame.getBuffer(0);
|
|
25312
|
+
if (outBufRet < 0) throw new Error(`outFrame.getBuffer failed (ret=${outBufRet})`);
|
|
25313
|
+
const convRet = this.swr.convertFrame(outFrame, inFrame);
|
|
25314
|
+
if (convRet < 0) throw new Error(`swr.convertFrame failed (ret=${convRet})`);
|
|
25315
|
+
const produced = outFrame.nbSamples;
|
|
25316
|
+
if (produced <= 0) return;
|
|
25317
|
+
const outPlanes = outFrame.extendedData;
|
|
25318
|
+
if (!outPlanes || outPlanes.length === 0) return;
|
|
25319
|
+
this.fifo.writeSync(outPlanes, produced);
|
|
25320
|
+
} finally {
|
|
25321
|
+
inFrame.free();
|
|
25322
|
+
outFrame.free();
|
|
25323
|
+
}
|
|
25324
|
+
}
|
|
25325
|
+
/**
|
|
25326
|
+
* Pull `frameSize`-sized frames out of the FIFO and encode them. When
|
|
25327
|
+
* `flushTail` is set, also encode the final short frame (< frameSize) so no
|
|
25328
|
+
* tail samples are dropped on close.
|
|
25329
|
+
*/
|
|
25330
|
+
drainFifo(flushTail) {
|
|
25331
|
+
const chunk = this.frameSize > 0 ? this.frameSize : this.fifo.size;
|
|
25332
|
+
if (chunk <= 0) return;
|
|
25333
|
+
while (this.fifo.size >= chunk && chunk > 0) {
|
|
25334
|
+
this.encodeFromFifo(chunk);
|
|
25335
|
+
if (this.frameSize <= 0) break;
|
|
25336
|
+
}
|
|
25337
|
+
if (flushTail && this.fifo.size > 0) this.encodeFromFifo(this.fifo.size);
|
|
25338
|
+
}
|
|
25339
|
+
encodeFromFifo(nbSamples) {
|
|
25340
|
+
if (nbSamples <= 0) return;
|
|
25341
|
+
const { nav } = this.runtime;
|
|
25342
|
+
const frame = new nav.Frame();
|
|
25343
|
+
frame.alloc();
|
|
25344
|
+
try {
|
|
25345
|
+
frame.format = this.encoderSampleFmt;
|
|
25346
|
+
frame.sampleRate = this.cfg.targetSampleRate;
|
|
25347
|
+
frame.channelLayout = this.outLayout;
|
|
25348
|
+
frame.nbSamples = nbSamples;
|
|
25349
|
+
const bufRet = frame.getBuffer(0);
|
|
25350
|
+
if (bufRet < 0) throw new Error(`encodeFrame.getBuffer failed (ret=${bufRet})`);
|
|
25351
|
+
const planes = frame.extendedData;
|
|
25352
|
+
if (!planes || planes.length === 0) throw new Error("encodeFrame has no data plane");
|
|
25353
|
+
const read = this.fifo.readSync(planes, nbSamples);
|
|
25354
|
+
if (read <= 0) return;
|
|
25355
|
+
frame.nbSamples = read;
|
|
25356
|
+
frame.pts = BigInt(this.ptsSamples);
|
|
25357
|
+
this.ptsSamples += read;
|
|
25358
|
+
const sendRet = this.ctx.sendFrameSync(frame);
|
|
25359
|
+
if (sendRet < 0) throw new Error(`sendFrame failed (ret=${sendRet})`);
|
|
25360
|
+
this.drainEncoder();
|
|
25361
|
+
} finally {
|
|
25362
|
+
frame.free();
|
|
25363
|
+
}
|
|
25364
|
+
}
|
|
25365
|
+
drainEncoder() {
|
|
25366
|
+
const { consts } = this.runtime;
|
|
25367
|
+
for (;;) {
|
|
25368
|
+
const recvRet = this.ctx.receivePacketSync(this.packet);
|
|
25369
|
+
if (recvRet === consts.AVERROR_EAGAIN || recvRet === consts.AVERROR_EOF) break;
|
|
25370
|
+
if (recvRet < 0) throw new Error(`audio-codec-nodeav: receivePacket failed (ret=${recvRet})`);
|
|
25371
|
+
try {
|
|
25372
|
+
const payload = this.packet.data;
|
|
25373
|
+
if (payload && payload.byteLength > 0) {
|
|
25374
|
+
const out = new Uint8Array(new ArrayBuffer(payload.byteLength));
|
|
25375
|
+
out.set(payload);
|
|
25376
|
+
const pktPts = this.packet.pts;
|
|
25377
|
+
const ptsMs = pktPts >= 0n ? Math.round(Number(pktPts) * 1e3 / this.cfg.targetSampleRate) : 0;
|
|
25378
|
+
this.onChunk({
|
|
25379
|
+
data: out,
|
|
25380
|
+
codec: this.codecName,
|
|
25381
|
+
pts: ptsMs,
|
|
25382
|
+
frameComplete: true
|
|
25383
|
+
});
|
|
25384
|
+
}
|
|
25385
|
+
} finally {
|
|
25386
|
+
this.packet.unref();
|
|
25387
|
+
}
|
|
25388
|
+
}
|
|
25389
|
+
}
|
|
25390
|
+
destroy() {
|
|
25391
|
+
if (this.closed) return;
|
|
25392
|
+
this.closed = true;
|
|
25393
|
+
this.freeQuietly(() => this.packet.free(), "packet");
|
|
25394
|
+
this.freeQuietly(() => this.fifo.free(), "fifo");
|
|
25395
|
+
this.freeQuietly(() => this.swr.free(), "swr");
|
|
25396
|
+
this.freeQuietly(() => this.ctx.freeContext(), "codecContext");
|
|
25397
|
+
}
|
|
25398
|
+
freeQuietly(fn, what) {
|
|
25399
|
+
try {
|
|
25400
|
+
fn();
|
|
25401
|
+
} catch (err) {
|
|
25402
|
+
this.logger.warn("audio-codec-nodeav: encode handle free failed", { meta: {
|
|
25403
|
+
what,
|
|
25404
|
+
error: err instanceof Error ? err.message : String(err)
|
|
25405
|
+
} });
|
|
25406
|
+
}
|
|
25407
|
+
}
|
|
25408
|
+
};
|
|
25409
|
+
//#endregion
|
|
25410
|
+
//#region src/audio-codec/provider.ts
|
|
25411
|
+
var DEFAULT_IDLE_MS = 3e4;
|
|
25412
|
+
var MAX_PCM_QUEUE_CHUNKS = 500;
|
|
25413
|
+
var MAX_ENCODED_QUEUE_CHUNKS = 500;
|
|
25414
|
+
var REAPER_INTERVAL_MS = 5e3;
|
|
25415
|
+
/** Grace wait for the encoder to drain its tail after `flushEncode`. */
|
|
25416
|
+
var FLUSH_DRAIN_MS = 60;
|
|
25417
|
+
/**
|
|
25418
|
+
* Audio codec I/O box backed by **node-av's in-process libavcodec +
|
|
25419
|
+
* libswresample bindings** — the native counterpart to `audio-codec-ffmpeg`.
|
|
25420
|
+
* Decode/encode run in this addon's process (no subprocess), feeding raw
|
|
25421
|
+
* depacketized access units straight into a `CodecContext` (no ADTS/Ogg
|
|
25422
|
+
* container shim needed).
|
|
25423
|
+
*
|
|
25424
|
+
* This is a PLAIN provider (not a `BaseAddon`): it is owned and driven by the
|
|
25425
|
+
* `decoder-nodeav` addon, which loads the shared node-av runtime once and
|
|
25426
|
+
* hands it in. When node-av cannot be loaded on this node the owning addon
|
|
25427
|
+
* never constructs this provider, so the singleton slot falls back to
|
|
25428
|
+
* `audio-codec-ffmpeg`.
|
|
25429
|
+
*/
|
|
25430
|
+
var NodeAvAudioCodecProvider = class {
|
|
25431
|
+
deps;
|
|
25432
|
+
sessions = /* @__PURE__ */ new Map();
|
|
25433
|
+
reaperTimer = null;
|
|
25434
|
+
constructor(deps) {
|
|
25435
|
+
this.deps = deps;
|
|
25436
|
+
}
|
|
25437
|
+
/** Arm the idle-session reaper. Called by the owning addon after construction. */
|
|
25438
|
+
start() {
|
|
25439
|
+
this.reaperTimer = setInterval(() => this.reapIdleSessions(), REAPER_INTERVAL_MS);
|
|
25440
|
+
if (typeof this.reaperTimer.unref === "function") this.reaperTimer.unref();
|
|
25441
|
+
}
|
|
25442
|
+
/** Stop the reaper and dispose every live session. Called on addon shutdown. */
|
|
25443
|
+
stop() {
|
|
25444
|
+
if (this.reaperTimer) {
|
|
25445
|
+
clearInterval(this.reaperTimer);
|
|
25446
|
+
this.reaperTimer = null;
|
|
25447
|
+
}
|
|
25448
|
+
for (const s of this.sessions.values()) this.disposeSession(s);
|
|
25449
|
+
this.sessions.clear();
|
|
25450
|
+
}
|
|
25451
|
+
async listSupportedCodecs() {
|
|
25452
|
+
return CODEC_CATALOG.map((e) => ({
|
|
25453
|
+
codec: e.codec,
|
|
25454
|
+
canDecode: e.canDecode,
|
|
25455
|
+
canEncode: e.canEncode,
|
|
25456
|
+
...e.label ? { label: e.label } : {}
|
|
25457
|
+
}));
|
|
25458
|
+
}
|
|
25459
|
+
async canHandle(input) {
|
|
25460
|
+
return codecSupports(input.codec, input.kind);
|
|
25461
|
+
}
|
|
25462
|
+
async createDecodeSession(input) {
|
|
25463
|
+
const codec = resolveAudioCodecAlias(input.codec);
|
|
25464
|
+
const entry = catalogEntryFor(input.codec);
|
|
25465
|
+
if (!entry || !entry.canDecode) throw new Error(`audio-codec-nodeav: decode unsupported for codec '${input.codec}'`);
|
|
25466
|
+
const sessionId = `dec-${(0, node_crypto.randomUUID)()}`;
|
|
25467
|
+
const state = {
|
|
25468
|
+
sessionId,
|
|
25469
|
+
kind: "decode",
|
|
25470
|
+
config: {
|
|
25471
|
+
...input,
|
|
25472
|
+
codec
|
|
25473
|
+
},
|
|
25474
|
+
...input.tag ? { tag: input.tag } : {},
|
|
25475
|
+
createdAtMs: Date.now(),
|
|
25476
|
+
lastActivityMs: Date.now(),
|
|
25477
|
+
framesIn: 0,
|
|
25478
|
+
framesOut: 0,
|
|
25479
|
+
pcmQueue: [],
|
|
25480
|
+
session: null
|
|
25481
|
+
};
|
|
25482
|
+
state.session = this.spawnDecodeSession(state);
|
|
25483
|
+
this.sessions.set(sessionId, state);
|
|
25484
|
+
this.deps.logger.info("audio-codec-nodeav: decode session created", {
|
|
25485
|
+
tags: { sessionId },
|
|
25486
|
+
meta: {
|
|
25487
|
+
codec,
|
|
25488
|
+
target: `${input.targetSampleRate}Hz×${input.targetChannels}`
|
|
25489
|
+
}
|
|
25490
|
+
});
|
|
25491
|
+
return {
|
|
25492
|
+
sessionId,
|
|
25493
|
+
nodeId: this.deps.resolveLocalNodeId()
|
|
25494
|
+
};
|
|
25495
|
+
}
|
|
25496
|
+
async createEncodeSession(input) {
|
|
25497
|
+
const codec = resolveAudioCodecAlias(input.codec);
|
|
25498
|
+
const entry = catalogEntryFor(input.codec);
|
|
25499
|
+
if (!entry || !entry.canEncode) throw new Error(`audio-codec-nodeav: encode unsupported for codec '${input.codec}'`);
|
|
25500
|
+
const sessionId = `enc-${(0, node_crypto.randomUUID)()}`;
|
|
25501
|
+
const state = {
|
|
25502
|
+
sessionId,
|
|
25503
|
+
kind: "encode",
|
|
25504
|
+
config: {
|
|
25505
|
+
...input,
|
|
25506
|
+
codec
|
|
25507
|
+
},
|
|
25508
|
+
...input.tag ? { tag: input.tag } : {},
|
|
25509
|
+
createdAtMs: Date.now(),
|
|
25510
|
+
lastActivityMs: Date.now(),
|
|
25511
|
+
framesIn: 0,
|
|
25512
|
+
framesOut: 0,
|
|
25513
|
+
encodedQueue: [],
|
|
25514
|
+
session: null
|
|
25515
|
+
};
|
|
25516
|
+
state.session = this.spawnEncodeSession(state);
|
|
25517
|
+
this.sessions.set(sessionId, state);
|
|
25518
|
+
this.deps.logger.info("audio-codec-nodeav: encode session created", {
|
|
25519
|
+
tags: { sessionId },
|
|
25520
|
+
meta: {
|
|
25521
|
+
codec,
|
|
25522
|
+
target: `${input.targetSampleRate}Hz×${input.targetChannels}`
|
|
25523
|
+
}
|
|
25524
|
+
});
|
|
25525
|
+
return {
|
|
25526
|
+
sessionId,
|
|
25527
|
+
nodeId: this.deps.resolveLocalNodeId()
|
|
25528
|
+
};
|
|
25529
|
+
}
|
|
25530
|
+
async closeSession(input) {
|
|
25531
|
+
const s = this.sessions.get(input.sessionId);
|
|
25532
|
+
if (!s) return;
|
|
25533
|
+
this.disposeSession(s);
|
|
25534
|
+
this.sessions.delete(input.sessionId);
|
|
25535
|
+
}
|
|
25536
|
+
async pushEncodedFrame(input) {
|
|
25537
|
+
const s = this.sessions.get(input.sessionId);
|
|
25538
|
+
if (!s || s.kind !== "decode") throw new Error(`audio-codec-nodeav: decode session '${input.sessionId}' not found`);
|
|
25539
|
+
s.lastActivityMs = Date.now();
|
|
25540
|
+
s.framesIn++;
|
|
25541
|
+
if (!s.session) s.session = this.spawnDecodeSession(s);
|
|
25542
|
+
s.session.pushEncoded(input.data, input.pts);
|
|
25543
|
+
}
|
|
25544
|
+
async pullPcm(input) {
|
|
25545
|
+
const s = this.sessions.get(input.sessionId);
|
|
25546
|
+
if (!s || s.kind !== "decode") throw new Error(`audio-codec-nodeav: decode session '${input.sessionId}' not found`);
|
|
25547
|
+
s.lastActivityMs = Date.now();
|
|
25548
|
+
const out = s.pcmQueue.splice(0, input.maxCount);
|
|
25549
|
+
s.framesOut += out.length;
|
|
25550
|
+
return out;
|
|
25551
|
+
}
|
|
25552
|
+
async pushPcm(input) {
|
|
25553
|
+
const s = this.sessions.get(input.sessionId);
|
|
25554
|
+
if (!s || s.kind !== "encode") throw new Error(`audio-codec-nodeav: encode session '${input.sessionId}' not found`);
|
|
25555
|
+
s.lastActivityMs = Date.now();
|
|
25556
|
+
s.framesIn++;
|
|
25557
|
+
if (!s.session) s.session = this.spawnEncodeSession(s);
|
|
25558
|
+
s.session.pushPcm(input.data);
|
|
25559
|
+
}
|
|
25560
|
+
async pullEncoded(input) {
|
|
25561
|
+
const s = this.sessions.get(input.sessionId);
|
|
25562
|
+
if (!s || s.kind !== "encode") throw new Error(`audio-codec-nodeav: encode session '${input.sessionId}' not found`);
|
|
25563
|
+
s.lastActivityMs = Date.now();
|
|
25564
|
+
const out = s.encodedQueue.splice(0, input.maxCount);
|
|
25565
|
+
s.framesOut += out.length;
|
|
25566
|
+
return out;
|
|
25567
|
+
}
|
|
25568
|
+
async flushEncode(input) {
|
|
25569
|
+
const s = this.sessions.get(input.sessionId);
|
|
25570
|
+
if (!s || s.kind !== "encode") throw new Error(`audio-codec-nodeav: encode session '${input.sessionId}' not found`);
|
|
25571
|
+
s.lastActivityMs = Date.now();
|
|
25572
|
+
s.session?.flush();
|
|
25573
|
+
await new Promise((resolve) => setTimeout(resolve, FLUSH_DRAIN_MS));
|
|
25574
|
+
const out = s.encodedQueue.splice(0);
|
|
25575
|
+
s.framesOut += out.length;
|
|
25576
|
+
return out;
|
|
25577
|
+
}
|
|
25578
|
+
async listActiveSessions() {
|
|
25579
|
+
return [...this.sessions.values()].map((s) => ({
|
|
25580
|
+
sessionId: s.sessionId,
|
|
25581
|
+
kind: s.kind,
|
|
25582
|
+
codec: s.config.codec,
|
|
25583
|
+
sourceSampleRate: s.config.sourceSampleRate,
|
|
25584
|
+
sourceChannels: s.config.sourceChannels,
|
|
25585
|
+
targetSampleRate: s.config.targetSampleRate,
|
|
25586
|
+
targetChannels: s.config.targetChannels,
|
|
25587
|
+
format: this.resolveFormat(s),
|
|
25588
|
+
...s.tag ? { tag: s.tag } : {},
|
|
25589
|
+
createdAtMs: s.createdAtMs,
|
|
25590
|
+
lastActivityMs: s.lastActivityMs,
|
|
25591
|
+
framesIn: s.framesIn,
|
|
25592
|
+
framesOut: s.framesOut
|
|
25593
|
+
}));
|
|
25594
|
+
}
|
|
25595
|
+
spawnDecodeSession(s) {
|
|
25596
|
+
return new NodeAvAudioDecodeSession(this.deps.runtime, {
|
|
25597
|
+
codec: s.config.codec,
|
|
25598
|
+
sourceSampleRate: s.config.sourceSampleRate,
|
|
25599
|
+
sourceChannels: s.config.sourceChannels,
|
|
25600
|
+
...s.config.extraData ? { extraData: s.config.extraData } : {},
|
|
25601
|
+
targetSampleRate: s.config.targetSampleRate,
|
|
25602
|
+
targetChannels: s.config.targetChannels,
|
|
25603
|
+
targetFormat: this.pcmFormat(s.config.targetFormat)
|
|
25604
|
+
}, this.deps.logger, (chunk) => {
|
|
25605
|
+
s.pcmQueue.push(chunk);
|
|
25606
|
+
if (s.pcmQueue.length > MAX_PCM_QUEUE_CHUNKS) s.pcmQueue.splice(0, s.pcmQueue.length - MAX_PCM_QUEUE_CHUNKS);
|
|
25607
|
+
});
|
|
25608
|
+
}
|
|
25609
|
+
spawnEncodeSession(s) {
|
|
25610
|
+
return new NodeAvAudioEncodeSession(this.deps.runtime, {
|
|
25611
|
+
codec: s.config.codec,
|
|
25612
|
+
sourceSampleRate: s.config.sourceSampleRate,
|
|
25613
|
+
sourceChannels: s.config.sourceChannels,
|
|
25614
|
+
sourceFormat: this.pcmFormat(s.config.sourceFormat),
|
|
25615
|
+
targetSampleRate: s.config.targetSampleRate,
|
|
25616
|
+
targetChannels: s.config.targetChannels,
|
|
25617
|
+
...s.config.bitrateKbps !== void 0 ? { bitrateKbps: s.config.bitrateKbps } : {}
|
|
25618
|
+
}, this.deps.logger, (chunk) => {
|
|
25619
|
+
s.encodedQueue.push(chunk);
|
|
25620
|
+
if (s.encodedQueue.length > MAX_ENCODED_QUEUE_CHUNKS) s.encodedQueue.splice(0, s.encodedQueue.length - MAX_ENCODED_QUEUE_CHUNKS);
|
|
25621
|
+
});
|
|
25622
|
+
}
|
|
25623
|
+
pcmFormat(format) {
|
|
25624
|
+
return format === "f32le" ? "f32le" : "s16le";
|
|
25625
|
+
}
|
|
25626
|
+
resolveFormat(s) {
|
|
25627
|
+
if (s.kind === "decode") return this.pcmFormat(s.config.targetFormat);
|
|
25628
|
+
return this.pcmFormat(s.config.sourceFormat);
|
|
25629
|
+
}
|
|
25630
|
+
reapIdleSessions() {
|
|
25631
|
+
const now = Date.now();
|
|
25632
|
+
for (const [id, s] of this.sessions) {
|
|
25633
|
+
const limit = s.config.idleMs ?? DEFAULT_IDLE_MS;
|
|
25634
|
+
if (now - s.lastActivityMs > limit) {
|
|
25635
|
+
this.deps.logger.info("audio-codec-nodeav: reaping idle session", {
|
|
25636
|
+
tags: { sessionId: id },
|
|
25637
|
+
meta: {
|
|
25638
|
+
kind: s.kind,
|
|
25639
|
+
idleMs: now - s.lastActivityMs,
|
|
25640
|
+
limit
|
|
25641
|
+
}
|
|
25642
|
+
});
|
|
25643
|
+
try {
|
|
25644
|
+
this.disposeSession(s);
|
|
25645
|
+
} catch (err) {
|
|
25646
|
+
this.deps.logger.warn("audio-codec-nodeav: dispose failed during reap", {
|
|
25647
|
+
tags: { sessionId: id },
|
|
25648
|
+
meta: { error: errMsg(err) }
|
|
25649
|
+
});
|
|
25650
|
+
}
|
|
25651
|
+
this.sessions.delete(id);
|
|
25652
|
+
}
|
|
25653
|
+
}
|
|
25654
|
+
}
|
|
25655
|
+
disposeSession(s) {
|
|
25656
|
+
try {
|
|
25657
|
+
s.session?.destroy();
|
|
25658
|
+
} catch (err) {
|
|
25659
|
+
this.deps.logger.warn("audio-codec-nodeav: session destroy failed", {
|
|
25660
|
+
tags: { sessionId: s.sessionId },
|
|
25661
|
+
meta: { error: errMsg(err) }
|
|
25662
|
+
});
|
|
25663
|
+
}
|
|
25664
|
+
s.session = null;
|
|
25665
|
+
}
|
|
25666
|
+
};
|
|
25667
|
+
//#endregion
|
|
25668
|
+
//#region src/audio-codec/nodeav-runtime-loader.ts
|
|
25669
|
+
var _runtime = null;
|
|
25670
|
+
/**
|
|
25671
|
+
* Load (or return the cached) node-av runtime. Rejects if the native binding
|
|
25672
|
+
* cannot be loaded on this node — the caller treats that as "node-av
|
|
25673
|
+
* unavailable" and declines to register the cap so the singleton falls back to
|
|
25674
|
+
* `audio-codec-ffmpeg`.
|
|
25675
|
+
*/
|
|
25676
|
+
async function loadNodeAvRuntime() {
|
|
25677
|
+
if (_runtime) return _runtime;
|
|
25678
|
+
const [nav, consts] = await Promise.all([import("node-av"), import("node-av/constants")]);
|
|
25679
|
+
_runtime = {
|
|
25680
|
+
nav,
|
|
25681
|
+
consts
|
|
25682
|
+
};
|
|
25683
|
+
return _runtime;
|
|
25684
|
+
}
|
|
25685
|
+
/**
|
|
25686
|
+
* The already-loaded runtime, or `null` if `loadNodeAvRuntime` has not resolved
|
|
25687
|
+
* yet. Session runtimes read this synchronously — the addon guarantees it is
|
|
25688
|
+
* populated before any session is created.
|
|
25689
|
+
*/
|
|
25690
|
+
function peekNodeAvRuntime() {
|
|
25691
|
+
return _runtime;
|
|
25692
|
+
}
|
|
25693
|
+
//#endregion
|
|
24303
25694
|
//#region src/addon/index.ts
|
|
24304
25695
|
var FRAME_BUFFER_CAPACITY = 32;
|
|
24305
25696
|
var DecoderNodeAvAddon = class extends BaseAddon {
|
|
@@ -24325,6 +25716,12 @@ var DecoderNodeAvAddon = class extends BaseAddon {
|
|
|
24325
25716
|
/** Running `getFrame` hit/miss counters surfaced via `getShmStats`. */
|
|
24326
25717
|
getFrameHits = 0;
|
|
24327
25718
|
getFrameMisses = 0;
|
|
25719
|
+
/**
|
|
25720
|
+
* node-av audio-codec provider — registered independently of the video
|
|
25721
|
+
* decoder backend gate whenever node-av loads on this node, so this single
|
|
25722
|
+
* addon provides both the `decoder` and `audio-codec` caps.
|
|
25723
|
+
*/
|
|
25724
|
+
audioProvider = null;
|
|
24328
25725
|
constructor() {
|
|
24329
25726
|
super(DEFAULT_DECODER_HWACCEL_CONFIG);
|
|
24330
25727
|
}
|
|
@@ -24359,20 +25756,45 @@ var DecoderNodeAvAddon = class extends BaseAddon {
|
|
|
24359
25756
|
}] });
|
|
24360
25757
|
}
|
|
24361
25758
|
async onInitialize() {
|
|
25759
|
+
const registrations = [];
|
|
25760
|
+
let runtime = null;
|
|
25761
|
+
try {
|
|
25762
|
+
runtime = await loadNodeAvRuntime();
|
|
25763
|
+
} catch (err) {
|
|
25764
|
+
this.ctx.logger.error("decoder-nodeav: node-av failed to load — no audio-codec provider", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
25765
|
+
}
|
|
25766
|
+
if (runtime) {
|
|
25767
|
+
this.audioProvider = new NodeAvAudioCodecProvider({
|
|
25768
|
+
logger: this.ctx.logger,
|
|
25769
|
+
runtime,
|
|
25770
|
+
resolveLocalNodeId: () => this.resolveLocalNodeId()
|
|
25771
|
+
});
|
|
25772
|
+
this.audioProvider.start();
|
|
25773
|
+
registrations.push({
|
|
25774
|
+
capability: audioCodecCapability,
|
|
25775
|
+
provider: this.audioProvider
|
|
25776
|
+
});
|
|
25777
|
+
}
|
|
24362
25778
|
const backend = await resolveDecoderBackend(this.ctx.api, this.resolveLocalNodeId(), this.ctx.logger);
|
|
24363
25779
|
if (backend !== "nodeav") {
|
|
24364
25780
|
this.ctx.logger.info("node-av decoder: this node selects a different decoder backend — standing down (no decoder provider registered)", { meta: { selectedBackend: backend } });
|
|
24365
|
-
return
|
|
25781
|
+
return registrations;
|
|
24366
25782
|
}
|
|
24367
25783
|
this.ctx.logger.info("node-av decoder addon initialized", { meta: { selectedBackend: backend } });
|
|
25784
|
+
const purged = purgeOrphanSegments(SEGMENT_NAME_PREFIX);
|
|
25785
|
+
if (purged.removed > 0) this.ctx.logger.warn("node-av decoder: reclaimed orphaned shm segments at startup", { meta: {
|
|
25786
|
+
removed: purged.removed,
|
|
25787
|
+
scanned: purged.scanned
|
|
25788
|
+
} });
|
|
24368
25789
|
this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(this.ctx.logger);
|
|
24369
25790
|
if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
|
|
24370
25791
|
this.ctx.logger.warn("nodeav: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
24371
25792
|
});
|
|
24372
|
-
|
|
25793
|
+
registrations.push({
|
|
24373
25794
|
capability: decoderCapability,
|
|
24374
25795
|
provider: this
|
|
24375
|
-
}
|
|
25796
|
+
});
|
|
25797
|
+
return registrations;
|
|
24376
25798
|
}
|
|
24377
25799
|
/**
|
|
24378
25800
|
* Resolve the effective hwaccel backend for a new session — from THIS addon's
|
|
@@ -24618,6 +26040,8 @@ var DecoderNodeAvAddon = class extends BaseAddon {
|
|
|
24618
26040
|
};
|
|
24619
26041
|
}
|
|
24620
26042
|
async onShutdown() {
|
|
26043
|
+
this.audioProvider?.stop();
|
|
26044
|
+
this.audioProvider = null;
|
|
24621
26045
|
this.ctx.logger.info("node-av decoder addon shutdown — destroying all sessions");
|
|
24622
26046
|
const destroyPromises = [];
|
|
24623
26047
|
for (const [sessionId, session] of this.sessions) {
|
|
@@ -24638,6 +26062,11 @@ var DecoderNodeAvAddon = class extends BaseAddon {
|
|
|
24638
26062
|
//#endregion
|
|
24639
26063
|
exports.DecoderFrameRingSink = DecoderFrameRingSink;
|
|
24640
26064
|
exports.DecoderNodeAvAddon = DecoderNodeAvAddon;
|
|
26065
|
+
exports.NodeAvAudioCodecProvider = NodeAvAudioCodecProvider;
|
|
26066
|
+
exports.NodeAvAudioDecodeSession = NodeAvAudioDecodeSession;
|
|
26067
|
+
exports.NodeAvAudioEncodeSession = NodeAvAudioEncodeSession;
|
|
24641
26068
|
exports.NodeAvDecoderSession = NodeAvDecoderSession;
|
|
24642
26069
|
exports.default = DecoderNodeAvAddon;
|
|
26070
|
+
exports.loadNodeAvRuntime = loadNodeAvRuntime;
|
|
24643
26071
|
exports.makeSegmentName = makeSegmentName;
|
|
26072
|
+
exports.peekNodeAvRuntime = peekNodeAvRuntime;
|