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