@camstack/addon-provider-hikvision 1.2.46 → 1.2.47
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/addon.js +515 -20
- package/dist/addon.mjs +515 -20
- package/package.json +4 -1
package/dist/addon.js
CHANGED
|
@@ -8501,6 +8501,112 @@ var TIMEZONES = [
|
|
|
8501
8501
|
function findTimezone(id) {
|
|
8502
8502
|
return TIMEZONES.find((tz) => tz.id === id);
|
|
8503
8503
|
}
|
|
8504
|
+
/**
|
|
8505
|
+
* Distinct (device, family, variant) counters one instance will hold.
|
|
8506
|
+
*
|
|
8507
|
+
* A large fleet x the handful of families any single addon reports, with
|
|
8508
|
+
* slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
|
|
8509
|
+
* already declares an RSS budget in the gigabytes.
|
|
8510
|
+
*/
|
|
8511
|
+
var MAX_KEYS = 1024;
|
|
8512
|
+
/**
|
|
8513
|
+
* Where reasons past {@link MAX_REASONS_PER_KEY} go.
|
|
8514
|
+
*
|
|
8515
|
+
* They are FOLDED, never dropped: `attempts - succeeded` must always equal the
|
|
8516
|
+
* sum of the reason counts, or the ratio stops adding up.
|
|
8517
|
+
*/
|
|
8518
|
+
var OVERFLOW_REASON = "other";
|
|
8519
|
+
/** `deviceId` + `family` + optional `variant`, flattened into the map key. */
|
|
8520
|
+
function counterKey(deviceId, family, variant) {
|
|
8521
|
+
return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
|
|
8522
|
+
}
|
|
8523
|
+
/**
|
|
8524
|
+
* A bounded set of per-camera, cumulative failure counters.
|
|
8525
|
+
*
|
|
8526
|
+
* One instance per contributing subsystem. `note` is O(1) and allocation-free
|
|
8527
|
+
* on the steady path; `snapshot` reads without mutating anything.
|
|
8528
|
+
*/
|
|
8529
|
+
var FailureCounters = class {
|
|
8530
|
+
maxKeys;
|
|
8531
|
+
maxReasons;
|
|
8532
|
+
counters = /* @__PURE__ */ new Map();
|
|
8533
|
+
refused = 0;
|
|
8534
|
+
constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
|
|
8535
|
+
this.maxKeys = maxKeys;
|
|
8536
|
+
this.maxReasons = maxReasons;
|
|
8537
|
+
}
|
|
8538
|
+
/**
|
|
8539
|
+
* Counters refused because {@link MAX_KEYS} was already held.
|
|
8540
|
+
*
|
|
8541
|
+
* Cumulative for the life of the instance: a bound that bit is a fact about
|
|
8542
|
+
* the deployment, and a surface that hid it would under-report a fleet
|
|
8543
|
+
* precisely when the fleet got large enough to matter.
|
|
8544
|
+
*/
|
|
8545
|
+
get keysRefused() {
|
|
8546
|
+
return this.refused;
|
|
8547
|
+
}
|
|
8548
|
+
/** Counters currently held. */
|
|
8549
|
+
get size() {
|
|
8550
|
+
return this.counters.size;
|
|
8551
|
+
}
|
|
8552
|
+
/**
|
|
8553
|
+
* Fold one observation in.
|
|
8554
|
+
*
|
|
8555
|
+
* A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
|
|
8556
|
+
* see the module docblock — an entry that cannot name its camera is worse
|
|
8557
|
+
* than no entry.
|
|
8558
|
+
*/
|
|
8559
|
+
note(observation, nowMs) {
|
|
8560
|
+
if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
|
|
8561
|
+
const key = counterKey(observation.deviceId, observation.family, observation.variant);
|
|
8562
|
+
let counter = this.counters.get(key);
|
|
8563
|
+
if (counter === void 0) {
|
|
8564
|
+
if (this.counters.size >= this.maxKeys) {
|
|
8565
|
+
this.refused += 1;
|
|
8566
|
+
return;
|
|
8567
|
+
}
|
|
8568
|
+
counter = {
|
|
8569
|
+
deviceId: observation.deviceId,
|
|
8570
|
+
family: observation.family,
|
|
8571
|
+
variant: observation.variant,
|
|
8572
|
+
sinceMs: nowMs,
|
|
8573
|
+
attempts: 0,
|
|
8574
|
+
succeeded: 0,
|
|
8575
|
+
reasons: /* @__PURE__ */ new Map()
|
|
8576
|
+
};
|
|
8577
|
+
this.counters.set(key, counter);
|
|
8578
|
+
}
|
|
8579
|
+
counter.attempts += 1;
|
|
8580
|
+
if (observation.reason === void 0) {
|
|
8581
|
+
counter.succeeded += 1;
|
|
8582
|
+
return;
|
|
8583
|
+
}
|
|
8584
|
+
const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
|
|
8585
|
+
counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
|
|
8586
|
+
}
|
|
8587
|
+
/** Read every counter. Never mutates — see the module docblock. */
|
|
8588
|
+
snapshot(nowMs) {
|
|
8589
|
+
const out = [];
|
|
8590
|
+
for (const counter of this.counters.values()) out.push({
|
|
8591
|
+
deviceId: counter.deviceId,
|
|
8592
|
+
family: counter.family,
|
|
8593
|
+
...counter.variant !== void 0 ? { variant: counter.variant } : {},
|
|
8594
|
+
sinceMs: counter.sinceMs,
|
|
8595
|
+
atMs: nowMs,
|
|
8596
|
+
attempts: counter.attempts,
|
|
8597
|
+
succeeded: counter.succeeded,
|
|
8598
|
+
reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
|
|
8599
|
+
reason,
|
|
8600
|
+
count
|
|
8601
|
+
})).toSorted((a, b) => b.count - a.count)
|
|
8602
|
+
});
|
|
8603
|
+
return out;
|
|
8604
|
+
}
|
|
8605
|
+
/** Drop everything (host disposal). */
|
|
8606
|
+
clear() {
|
|
8607
|
+
this.counters.clear();
|
|
8608
|
+
}
|
|
8609
|
+
};
|
|
8504
8610
|
var MODEL_FORMATS = [
|
|
8505
8611
|
"onnx",
|
|
8506
8612
|
"coreml",
|
|
@@ -13756,7 +13862,26 @@ var FailureContributionSchema = object({
|
|
|
13756
13862
|
/** The loss, partitioned. Sums to `attempts - succeeded`. */
|
|
13757
13863
|
reasons: array(FailureReasonCountSchema).readonly()
|
|
13758
13864
|
});
|
|
13759
|
-
|
|
13865
|
+
var failureContributionCapability = {
|
|
13866
|
+
name: "failure-contribution",
|
|
13867
|
+
scope: "system",
|
|
13868
|
+
mode: "collection",
|
|
13869
|
+
internal: true,
|
|
13870
|
+
methods: {
|
|
13871
|
+
/**
|
|
13872
|
+
* This addon's per-camera failure counters, read live from bounded in-RAM
|
|
13873
|
+
* state it already keeps. Inert: no persistence, no sampling, no timer.
|
|
13874
|
+
*
|
|
13875
|
+
* READING NEVER RESETS. The counters are CUMULATIVE since `sinceMs`, and a
|
|
13876
|
+
* consumer that wants a rate differences two reads. A draining read would
|
|
13877
|
+
* make two operators with the page open each destroy half of the other's
|
|
13878
|
+
* numbers, and `load-contribution` already settled the same question the
|
|
13879
|
+
* same way for `cpuSeconds`.
|
|
13880
|
+
*/
|
|
13881
|
+
list: method(_void(), array(FailureContributionSchema).readonly()) },
|
|
13882
|
+
/** In-process only — enumerated through `addons.listCapabilityProviders`. */
|
|
13883
|
+
mount: { kind: "skip" }
|
|
13884
|
+
};
|
|
13760
13885
|
var LoadContributionSchema = object({
|
|
13761
13886
|
role: _enum([
|
|
13762
13887
|
"decode",
|
|
@@ -43589,6 +43714,15 @@ var DEFAULT_BACKLOG_MS = 200;
|
|
|
43589
43714
|
var MAX_BACKLOG_MS = 5e3;
|
|
43590
43715
|
var MIN_BACKLOG_MS = 20;
|
|
43591
43716
|
/**
|
|
43717
|
+
* The ONE place the operator's backlog request becomes the enforced bound.
|
|
43718
|
+
* `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
|
|
43719
|
+
* it — a second clamp would let the number a caller reads drift from the
|
|
43720
|
+
* number the buffer honours.
|
|
43721
|
+
*/
|
|
43722
|
+
function clampBacklogMs(requested) {
|
|
43723
|
+
return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
|
|
43724
|
+
}
|
|
43725
|
+
/**
|
|
43592
43726
|
* Fixed audioData PUT-body chunk size, in bytes. Encoded µ-law bytes are
|
|
43593
43727
|
* accumulated and flushed to the sticky PUT in FIXED chunks of this size
|
|
43594
43728
|
* (the sub-chunk remainder is held until the next flush, and the trailing
|
|
@@ -43657,6 +43791,36 @@ var HikvisionIntercomSession = class {
|
|
|
43657
43791
|
get audioCodec() {
|
|
43658
43792
|
return this.codec;
|
|
43659
43793
|
}
|
|
43794
|
+
/**
|
|
43795
|
+
* Effective PCM backlog bound, in ms — the operator's value clamped to
|
|
43796
|
+
* [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session is
|
|
43797
|
+
* actually enforcing rather than what it was asked for. Readable before
|
|
43798
|
+
* `start()` because the clamp is pure.
|
|
43799
|
+
*/
|
|
43800
|
+
get backlogMs() {
|
|
43801
|
+
return clampBacklogMs(this.opts.maxBacklogMs);
|
|
43802
|
+
}
|
|
43803
|
+
/**
|
|
43804
|
+
* The firmware's talk-back format — `IntercomStatus.ability`.
|
|
43805
|
+
*
|
|
43806
|
+
* Every field is a value this session already resolved against the camera;
|
|
43807
|
+
* nothing here is a default standing in for a probe. It was declared,
|
|
43808
|
+
* mirrored into runtime state and written by NOBODY while these exact
|
|
43809
|
+
* values were in hand and only reaching a log line (D281).
|
|
43810
|
+
*
|
|
43811
|
+
* `duplex` is the one judgement call: ISAPI two-way audio is a single
|
|
43812
|
+
* `audioData` channel and the provider enforces one active session per
|
|
43813
|
+
* camera, so `half` is reported. `full` would be the dangerous direction —
|
|
43814
|
+
* a consumer that believes it may listen while speaking takes no lock.
|
|
43815
|
+
*/
|
|
43816
|
+
get ability() {
|
|
43817
|
+
return {
|
|
43818
|
+
codecs: [this.codec],
|
|
43819
|
+
sampleRate: HIKVISION_INTERCOM_SAMPLE_RATE,
|
|
43820
|
+
duplex: "half",
|
|
43821
|
+
maxBacklogMs: this.backlogMs
|
|
43822
|
+
};
|
|
43823
|
+
}
|
|
43660
43824
|
async start() {
|
|
43661
43825
|
if (this.stream) return;
|
|
43662
43826
|
const desiredChannel = this.opts.channelId ?? "1";
|
|
@@ -43715,7 +43879,7 @@ var HikvisionIntercomSession = class {
|
|
|
43715
43879
|
});
|
|
43716
43880
|
this.stop(reason).catch(() => {});
|
|
43717
43881
|
});
|
|
43718
|
-
const wantedBacklogMs =
|
|
43882
|
+
const wantedBacklogMs = this.backlogMs;
|
|
43719
43883
|
this.bytesPerSecond = HIKVISION_INTERCOM_SAMPLE_RATE * 2;
|
|
43720
43884
|
this.maxBacklogBytes = Math.max(160, Math.floor(wantedBacklogMs / 1e3 * this.bytesPerSecond));
|
|
43721
43885
|
this.stream = stream;
|
|
@@ -43897,6 +44061,90 @@ var HikvisionIntercomSession = class {
|
|
|
43897
44061
|
}
|
|
43898
44062
|
};
|
|
43899
44063
|
//#endregion
|
|
44064
|
+
//#region src/intercom/intercom-failure-report.ts
|
|
44065
|
+
/**
|
|
44066
|
+
* Per-camera talk-back counters, published through `failure-contribution`.
|
|
44067
|
+
*
|
|
44068
|
+
* ## The number that was never divided
|
|
44069
|
+
*
|
|
44070
|
+
* The rate-mismatch drop had ONE warn line and no counter, so "how much
|
|
44071
|
+
* talk-back is this camera losing" was answerable only by grepping Loki and
|
|
44072
|
+
* hand-correlating timestamps — the exact cost `failure-contribution` exists to
|
|
44073
|
+
* remove. And a bare drop count could not have answered it either: 40 drops out
|
|
44074
|
+
* of 40 pushes and 40 out of 40 000 are opposite findings that produce
|
|
44075
|
+
* identical log volume.
|
|
44076
|
+
*
|
|
44077
|
+
* So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
|
|
44078
|
+
* place that decides it — the accepted ones too. {@link FailureCounters}
|
|
44079
|
+
* carries `attempts` as the denominator and `succeeded` as the numerator, and
|
|
44080
|
+
* the reasons partition the rest. A success counted somewhere else would drift
|
|
44081
|
+
* from the failures and turn the ratio into fiction.
|
|
44082
|
+
*
|
|
44083
|
+
* ## `variant` is the wire codec, and it is honest
|
|
44084
|
+
*
|
|
44085
|
+
* `failure-contribution` keeps `variant` for a second dimension WITHIN a
|
|
44086
|
+
* family, and here the useful one is the format the caller pushed: an operator
|
|
44087
|
+
* asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
|
|
44088
|
+
* raw PCM is the half that is failing. The provider is handed that value on
|
|
44089
|
+
* every call, so it is reported rather than guessed — absent, never invented.
|
|
44090
|
+
*
|
|
44091
|
+
* ## Process-wide, because a counter is
|
|
44092
|
+
*
|
|
44093
|
+
* One addon is one process (D2) and every camera this addon owns lives in it,
|
|
44094
|
+
* so the instance is module-scoped: the cameras note into it and the addon
|
|
44095
|
+
* registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
|
|
44096
|
+
* incarnation marker — a respawned runner restarts from zero and says so.
|
|
44097
|
+
* Reading NEVER drains.
|
|
44098
|
+
*/
|
|
44099
|
+
/** One `pushTalkAudio` call against an open talk session. */
|
|
44100
|
+
var FAMILY_INTERCOM_TALK = "intercom-talk";
|
|
44101
|
+
/** The push arrived with a sequence number at or below the last accepted one. */
|
|
44102
|
+
var REASON_TALK_OUT_OF_ORDER = "out-of-order";
|
|
44103
|
+
/** The payload decoded to zero bytes. */
|
|
44104
|
+
var REASON_TALK_EMPTY = "empty-frame";
|
|
44105
|
+
/** More than one channel — every camera here is mono-only. */
|
|
44106
|
+
var REASON_TALK_NOT_MONO = "not-mono";
|
|
44107
|
+
/** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
|
|
44108
|
+
var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
|
|
44109
|
+
/** The wire codec has no path onto this camera's talk channel. */
|
|
44110
|
+
var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
|
|
44111
|
+
/** The Opus decode path threw or could not open its session. */
|
|
44112
|
+
var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
|
|
44113
|
+
/**
|
|
44114
|
+
* The addon's talk-back counters. One instance per process; the export at the
|
|
44115
|
+
* bottom of this file IS that instance.
|
|
44116
|
+
*/
|
|
44117
|
+
var IntercomFailureReport = class {
|
|
44118
|
+
now;
|
|
44119
|
+
counters;
|
|
44120
|
+
constructor(now = Date.now, counters = new FailureCounters()) {
|
|
44121
|
+
this.now = now;
|
|
44122
|
+
this.counters = counters;
|
|
44123
|
+
}
|
|
44124
|
+
/**
|
|
44125
|
+
* Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
|
|
44126
|
+
* camera's talk channel.
|
|
44127
|
+
*/
|
|
44128
|
+
noteTalkFrame(deviceId, wireCodec, reason) {
|
|
44129
|
+
this.counters.note({
|
|
44130
|
+
deviceId,
|
|
44131
|
+
family: FAMILY_INTERCOM_TALK,
|
|
44132
|
+
variant: wireCodec,
|
|
44133
|
+
...reason !== void 0 ? { reason } : {}
|
|
44134
|
+
}, this.now());
|
|
44135
|
+
}
|
|
44136
|
+
/** The `failure-contribution` provider's payload. Reads, never resets. */
|
|
44137
|
+
list() {
|
|
44138
|
+
return this.counters.snapshot(this.now());
|
|
44139
|
+
}
|
|
44140
|
+
/** Addon disposal. */
|
|
44141
|
+
clear() {
|
|
44142
|
+
this.counters.clear();
|
|
44143
|
+
}
|
|
44144
|
+
};
|
|
44145
|
+
/** The process-wide instance every camera in this addon notes into. */
|
|
44146
|
+
var intercomFailureReport = new IntercomFailureReport();
|
|
44147
|
+
//#endregion
|
|
43900
44148
|
//#region src/intercom/intercom-orchestrator.ts
|
|
43901
44149
|
var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
|
|
43902
44150
|
var DEFAULT_OPUS_CHANNELS = 1;
|
|
@@ -43914,6 +44162,14 @@ var IntercomOrchestrator = class {
|
|
|
43914
44162
|
return this.session !== null && !this.session.closed;
|
|
43915
44163
|
}
|
|
43916
44164
|
/**
|
|
44165
|
+
* The live talk session's firmware ability, or `null` when no session is
|
|
44166
|
+
* open. Read by the camera at `startSession` so the WebRTC path writes
|
|
44167
|
+
* `IntercomStatus.ability` from the same source the raw-PCM path does.
|
|
44168
|
+
*/
|
|
44169
|
+
get ability() {
|
|
44170
|
+
return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
|
|
44171
|
+
}
|
|
44172
|
+
/**
|
|
43917
44173
|
* Subscribe to session-close notifications. The listener fires exactly
|
|
43918
44174
|
* once per session with the resolved `IntercomCloseReason` + stats, so
|
|
43919
44175
|
* the camera layer can surface WHY a talk session ended (the prime
|
|
@@ -44175,6 +44431,193 @@ function errMsg$1(err) {
|
|
|
44175
44431
|
return err instanceof Error ? err.message : String(err);
|
|
44176
44432
|
}
|
|
44177
44433
|
//#endregion
|
|
44434
|
+
//#region src/intercom/talk-pcm-transcoder.ts
|
|
44435
|
+
/** libav codec name of a linear little-endian 16-bit PCM decode session. */
|
|
44436
|
+
var TALK_PCM_CODEC = "pcm_s16le";
|
|
44437
|
+
/** The transcoder was already closed — the talk session ended under the push. */
|
|
44438
|
+
var REASON_PCM_CLOSED = "pcm-transcoder-closed";
|
|
44439
|
+
/** The caller's declared source rate is not a usable positive integer. */
|
|
44440
|
+
var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
|
|
44441
|
+
/** The frame is empty or holds half a sample — malformed, not convertible. */
|
|
44442
|
+
var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
|
|
44443
|
+
/** No `audio-codec` provider is mounted on this cluster. */
|
|
44444
|
+
var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
|
|
44445
|
+
/** The codec cap refused to open a linear-PCM decode session. */
|
|
44446
|
+
var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
|
|
44447
|
+
/** The push/pull round-trip through the codec cap threw. */
|
|
44448
|
+
var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
|
|
44449
|
+
function errMessage(err) {
|
|
44450
|
+
return err instanceof Error ? err.message : String(err);
|
|
44451
|
+
}
|
|
44452
|
+
var TalkPcmTranscoder = class {
|
|
44453
|
+
opts;
|
|
44454
|
+
active = null;
|
|
44455
|
+
closed = false;
|
|
44456
|
+
constructor(opts) {
|
|
44457
|
+
this.opts = opts;
|
|
44458
|
+
}
|
|
44459
|
+
/** The open codec session, or `null` before the first converted frame. */
|
|
44460
|
+
get sessionId() {
|
|
44461
|
+
return this.active?.sessionId ?? null;
|
|
44462
|
+
}
|
|
44463
|
+
/**
|
|
44464
|
+
* Convert one frame to the camera's rate and hand every produced chunk to
|
|
44465
|
+
* `feed`.
|
|
44466
|
+
*
|
|
44467
|
+
* Returns `null` when the frame was converted and fed, or the REASON string
|
|
44468
|
+
* it was refused for — already logged, with nothing fed.
|
|
44469
|
+
*/
|
|
44470
|
+
async feedResampled(frame) {
|
|
44471
|
+
if (this.closed) {
|
|
44472
|
+
this.refuse(REASON_PCM_CLOSED, {});
|
|
44473
|
+
return REASON_PCM_CLOSED;
|
|
44474
|
+
}
|
|
44475
|
+
const sourceSampleRate = frame.sourceSampleRate;
|
|
44476
|
+
if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
|
|
44477
|
+
this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
|
|
44478
|
+
return REASON_PCM_BAD_RATE;
|
|
44479
|
+
}
|
|
44480
|
+
if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
|
|
44481
|
+
this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
|
|
44482
|
+
return REASON_PCM_ODD_BYTES;
|
|
44483
|
+
}
|
|
44484
|
+
let api;
|
|
44485
|
+
try {
|
|
44486
|
+
api = this.opts.resolveAudioCodec();
|
|
44487
|
+
} catch (err) {
|
|
44488
|
+
this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
|
|
44489
|
+
return REASON_PCM_NO_CODEC_CAP;
|
|
44490
|
+
}
|
|
44491
|
+
if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
|
|
44492
|
+
const previous = this.active.sourceSampleRate;
|
|
44493
|
+
await this.disposeSession(api, "source-rate-changed");
|
|
44494
|
+
this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
|
|
44495
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44496
|
+
meta: {
|
|
44497
|
+
previousSourceSampleRate: previous,
|
|
44498
|
+
sourceSampleRate
|
|
44499
|
+
}
|
|
44500
|
+
});
|
|
44501
|
+
}
|
|
44502
|
+
if (this.active === null) try {
|
|
44503
|
+
const created = await api.createDecodeSession({
|
|
44504
|
+
codec: TALK_PCM_CODEC,
|
|
44505
|
+
sourceSampleRate,
|
|
44506
|
+
sourceChannels: 1,
|
|
44507
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44508
|
+
targetChannels: 1,
|
|
44509
|
+
targetFormat: "s16le",
|
|
44510
|
+
tag: this.opts.tag
|
|
44511
|
+
});
|
|
44512
|
+
this.active = {
|
|
44513
|
+
sessionId: created.sessionId,
|
|
44514
|
+
nodeId: created.nodeId,
|
|
44515
|
+
sourceSampleRate
|
|
44516
|
+
};
|
|
44517
|
+
this.opts.logger.info("intercom: pcm resample session opened", {
|
|
44518
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44519
|
+
meta: {
|
|
44520
|
+
codec: TALK_PCM_CODEC,
|
|
44521
|
+
codecSessionId: created.sessionId,
|
|
44522
|
+
codecNodeId: created.nodeId,
|
|
44523
|
+
sourceSampleRate,
|
|
44524
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44525
|
+
tag: this.opts.tag
|
|
44526
|
+
}
|
|
44527
|
+
});
|
|
44528
|
+
} catch (err) {
|
|
44529
|
+
this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
|
|
44530
|
+
sourceSampleRate,
|
|
44531
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44532
|
+
error: errMessage(err)
|
|
44533
|
+
});
|
|
44534
|
+
return REASON_PCM_SESSION_OPEN_FAILED;
|
|
44535
|
+
}
|
|
44536
|
+
const session = this.active;
|
|
44537
|
+
try {
|
|
44538
|
+
await api.pushEncodedFrame({
|
|
44539
|
+
sessionId: session.sessionId,
|
|
44540
|
+
nodeId: session.nodeId,
|
|
44541
|
+
data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
|
|
44542
|
+
});
|
|
44543
|
+
const chunks = await api.pullPcm({
|
|
44544
|
+
sessionId: session.sessionId,
|
|
44545
|
+
nodeId: session.nodeId,
|
|
44546
|
+
maxCount: 8
|
|
44547
|
+
});
|
|
44548
|
+
for (const chunk of chunks) {
|
|
44549
|
+
const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
|
|
44550
|
+
if (out.length > 0) this.opts.feed(out);
|
|
44551
|
+
}
|
|
44552
|
+
return null;
|
|
44553
|
+
} catch (err) {
|
|
44554
|
+
this.refuse(REASON_PCM_CONVERT_FAILED, {
|
|
44555
|
+
codecSessionId: session.sessionId,
|
|
44556
|
+
sourceSampleRate,
|
|
44557
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44558
|
+
error: errMessage(err)
|
|
44559
|
+
});
|
|
44560
|
+
await this.disposeSession(api, "convert-failed");
|
|
44561
|
+
return REASON_PCM_CONVERT_FAILED;
|
|
44562
|
+
}
|
|
44563
|
+
}
|
|
44564
|
+
/**
|
|
44565
|
+
* Close the codec session. Idempotent, and called from the provider's
|
|
44566
|
+
* `endTalkSession` so the session dies with the talk session it served.
|
|
44567
|
+
*/
|
|
44568
|
+
async close() {
|
|
44569
|
+
this.closed = true;
|
|
44570
|
+
if (this.active === null) return;
|
|
44571
|
+
let api;
|
|
44572
|
+
try {
|
|
44573
|
+
api = this.opts.resolveAudioCodec();
|
|
44574
|
+
} catch (err) {
|
|
44575
|
+
this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
|
|
44576
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44577
|
+
meta: {
|
|
44578
|
+
codecSessionId: this.active.sessionId,
|
|
44579
|
+
error: errMessage(err)
|
|
44580
|
+
}
|
|
44581
|
+
});
|
|
44582
|
+
this.active = null;
|
|
44583
|
+
return;
|
|
44584
|
+
}
|
|
44585
|
+
await this.disposeSession(api, "talk-session-ended");
|
|
44586
|
+
}
|
|
44587
|
+
/** Close + forget the current session. Never throws. */
|
|
44588
|
+
async disposeSession(api, why) {
|
|
44589
|
+
const session = this.active;
|
|
44590
|
+
this.active = null;
|
|
44591
|
+
if (session === null) return;
|
|
44592
|
+
try {
|
|
44593
|
+
await api.closeSession({
|
|
44594
|
+
sessionId: session.sessionId,
|
|
44595
|
+
nodeId: session.nodeId
|
|
44596
|
+
});
|
|
44597
|
+
} catch (err) {
|
|
44598
|
+
this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
|
|
44599
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44600
|
+
meta: {
|
|
44601
|
+
codecSessionId: session.sessionId,
|
|
44602
|
+
why,
|
|
44603
|
+
error: errMessage(err)
|
|
44604
|
+
}
|
|
44605
|
+
});
|
|
44606
|
+
}
|
|
44607
|
+
}
|
|
44608
|
+
/** One warn per refused frame. A branch that drops work says so. */
|
|
44609
|
+
refuse(reason, meta) {
|
|
44610
|
+
this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
|
|
44611
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44612
|
+
meta: {
|
|
44613
|
+
reason,
|
|
44614
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44615
|
+
...meta
|
|
44616
|
+
}
|
|
44617
|
+
});
|
|
44618
|
+
}
|
|
44619
|
+
};
|
|
44620
|
+
//#endregion
|
|
44178
44621
|
//#region src/intercom/werift-intercom-peer.ts
|
|
44179
44622
|
var _werift;
|
|
44180
44623
|
async function loadWerift() {
|
|
@@ -45222,13 +45665,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45222
45665
|
* Called at the four points that open or close a session — and seeded at
|
|
45223
45666
|
* registration, so the slice says `talking: false` from boot rather than
|
|
45224
45667
|
* only after the first session.
|
|
45668
|
+
*
|
|
45669
|
+
* `ability` is STICKY: it is the firmware's negotiated format, learned when a
|
|
45670
|
+
* session opens and still true after it closes, so a caller reading between
|
|
45671
|
+
* sessions gets the last probed value rather than `null`. Passing it is what
|
|
45672
|
+
* changed — it used to be copied forward from `previous` at every one of the
|
|
45673
|
+
* four call sites and written by nobody, while `session.sampleRate` and
|
|
45674
|
+
* `session.audioCodec` were in hand and only reaching a log line.
|
|
45225
45675
|
*/
|
|
45226
|
-
publishIntercomState(talking) {
|
|
45676
|
+
publishIntercomState(talking, ability) {
|
|
45227
45677
|
const previous = this.getCapSlice(intercomCapability);
|
|
45228
45678
|
this.setCapSlice(intercomCapability, {
|
|
45229
45679
|
talking,
|
|
45230
45680
|
lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
|
|
45231
|
-
ability: previous?.ability ?? null
|
|
45681
|
+
ability: ability ?? previous?.ability ?? null
|
|
45232
45682
|
});
|
|
45233
45683
|
}
|
|
45234
45684
|
registerIntercomIfSupported() {
|
|
@@ -45257,7 +45707,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45257
45707
|
});
|
|
45258
45708
|
try {
|
|
45259
45709
|
const opened = await this.intercomOrchestrator.start();
|
|
45260
|
-
this.publishIntercomState(true);
|
|
45710
|
+
this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
|
|
45261
45711
|
return opened;
|
|
45262
45712
|
} catch (err) {
|
|
45263
45713
|
this.publishIntercomState(false);
|
|
@@ -45279,8 +45729,10 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45279
45729
|
if (deviceId !== this.id) throw new Error(`HikvisionCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
45280
45730
|
if (this.disabled) throw new Error("Hikvision intercom: device is disabled — re-enable it before opening a talk session");
|
|
45281
45731
|
if (this.intercomRawSession) {
|
|
45282
|
-
|
|
45732
|
+
const previous = this.intercomRawSession;
|
|
45283
45733
|
this.intercomRawSession = null;
|
|
45734
|
+
await previous.pcmTranscode.close();
|
|
45735
|
+
await previous.session.stop().catch(() => {});
|
|
45284
45736
|
}
|
|
45285
45737
|
const session = new HikvisionIntercomSession({
|
|
45286
45738
|
client: this.ensureClient(),
|
|
@@ -45296,9 +45748,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45296
45748
|
id,
|
|
45297
45749
|
session,
|
|
45298
45750
|
lastSequenceNumber: -1,
|
|
45299
|
-
opusDecode: null
|
|
45751
|
+
opusDecode: null,
|
|
45752
|
+
pcmTranscode: new TalkPcmTranscoder({
|
|
45753
|
+
deviceId: this.id,
|
|
45754
|
+
logger: this.ctx.logger,
|
|
45755
|
+
resolveAudioCodec: () => this.resolveAudioCodecApi(),
|
|
45756
|
+
targetSampleRate: session.sampleRate,
|
|
45757
|
+
tag: `hikvision-intercom-pcm:${this.id}:${id}`,
|
|
45758
|
+
feed: (pcm) => session.feedPcm(pcm)
|
|
45759
|
+
})
|
|
45300
45760
|
};
|
|
45301
|
-
this.publishIntercomState(true);
|
|
45761
|
+
this.publishIntercomState(true, session.ability);
|
|
45302
45762
|
this.ctx.logger.info("intercom talk session opened", {
|
|
45303
45763
|
tags: { deviceId: this.id },
|
|
45304
45764
|
meta: {
|
|
@@ -45311,13 +45771,24 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45311
45771
|
},
|
|
45312
45772
|
pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
|
|
45313
45773
|
if (deviceId !== this.id) return { accepted: false };
|
|
45774
|
+
const wireCodec = codec ?? "s16le";
|
|
45775
|
+
const note = (reason) => {
|
|
45776
|
+
intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
|
|
45777
|
+
};
|
|
45314
45778
|
const active = this.intercomRawSession;
|
|
45315
45779
|
if (!active || !active.session.isOpen) return { accepted: false };
|
|
45316
|
-
if (sequenceNumber <= active.lastSequenceNumber)
|
|
45780
|
+
if (sequenceNumber <= active.lastSequenceNumber) {
|
|
45781
|
+
note(REASON_TALK_OUT_OF_ORDER);
|
|
45782
|
+
return { accepted: false };
|
|
45783
|
+
}
|
|
45317
45784
|
const buf = Buffer.from(audioBase64, "base64");
|
|
45318
|
-
if (buf.length === 0)
|
|
45785
|
+
if (buf.length === 0) {
|
|
45786
|
+
note(REASON_TALK_EMPTY);
|
|
45787
|
+
return { accepted: false };
|
|
45788
|
+
}
|
|
45319
45789
|
const ch = channels ?? 1;
|
|
45320
45790
|
if (ch !== 1) {
|
|
45791
|
+
note(REASON_TALK_NOT_MONO);
|
|
45321
45792
|
this.ctx.logger.warn("intercom: dropping non-mono talk frame (Hikvision is mono-only)", {
|
|
45322
45793
|
tags: { deviceId: this.id },
|
|
45323
45794
|
meta: {
|
|
@@ -45327,9 +45798,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45327
45798
|
});
|
|
45328
45799
|
return { accepted: false };
|
|
45329
45800
|
}
|
|
45330
|
-
const wireCodec = codec ?? "s16le";
|
|
45331
45801
|
if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
|
|
45332
45802
|
if (wireCodec !== active.session.audioCodec) {
|
|
45803
|
+
note(REASON_TALK_CODEC_UNSUPPORTED);
|
|
45333
45804
|
this.ctx.logger.warn("intercom: codec mismatch — wire codec is not what the camera negotiated, dropping frame", {
|
|
45334
45805
|
tags: { deviceId: this.id },
|
|
45335
45806
|
meta: {
|
|
@@ -45341,28 +45812,34 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45341
45812
|
}
|
|
45342
45813
|
active.lastSequenceNumber = sequenceNumber;
|
|
45343
45814
|
active.session.feedEncoded(buf);
|
|
45815
|
+
note();
|
|
45344
45816
|
return { accepted: true };
|
|
45345
45817
|
}
|
|
45346
45818
|
if (wireCodec === "s16le") {
|
|
45347
45819
|
if (!sampleRate) {
|
|
45820
|
+
note(REASON_TALK_NO_SAMPLE_RATE);
|
|
45348
45821
|
this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
|
|
45349
45822
|
return { accepted: false };
|
|
45350
45823
|
}
|
|
45351
45824
|
if (sampleRate !== active.session.sampleRate) {
|
|
45352
|
-
|
|
45353
|
-
|
|
45354
|
-
|
|
45355
|
-
wireRate: sampleRate,
|
|
45356
|
-
cameraRate: active.session.sampleRate
|
|
45357
|
-
}
|
|
45825
|
+
const refusal = await active.pcmTranscode.feedResampled({
|
|
45826
|
+
pcm: buf,
|
|
45827
|
+
sourceSampleRate: sampleRate
|
|
45358
45828
|
});
|
|
45359
|
-
|
|
45829
|
+
if (refusal !== null) {
|
|
45830
|
+
note(refusal);
|
|
45831
|
+
return { accepted: false };
|
|
45832
|
+
}
|
|
45833
|
+
active.lastSequenceNumber = sequenceNumber;
|
|
45834
|
+
note();
|
|
45835
|
+
return { accepted: true };
|
|
45360
45836
|
}
|
|
45361
45837
|
active.lastSequenceNumber = sequenceNumber;
|
|
45362
45838
|
active.session.feedPcm(buf);
|
|
45839
|
+
note();
|
|
45363
45840
|
return { accepted: true };
|
|
45364
45841
|
}
|
|
45365
|
-
if (wireCodec === "opus") {
|
|
45842
|
+
if (wireCodec === "opus") try {
|
|
45366
45843
|
if (!active.opusDecode) {
|
|
45367
45844
|
const created = await this.resolveAudioCodecApi().createDecodeSession({
|
|
45368
45845
|
codec: "opus",
|
|
@@ -45405,8 +45882,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45405
45882
|
const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
|
|
45406
45883
|
if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
|
|
45407
45884
|
}
|
|
45885
|
+
note();
|
|
45408
45886
|
return { accepted: true };
|
|
45887
|
+
} catch (err) {
|
|
45888
|
+
note(REASON_TALK_OPUS_FAILED);
|
|
45889
|
+
throw err;
|
|
45409
45890
|
}
|
|
45891
|
+
note(REASON_TALK_CODEC_UNSUPPORTED);
|
|
45892
|
+
this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
|
|
45893
|
+
tags: { deviceId: this.id },
|
|
45894
|
+
meta: {
|
|
45895
|
+
wireCodec,
|
|
45896
|
+
cameraCodec: active.session.audioCodec
|
|
45897
|
+
}
|
|
45898
|
+
});
|
|
45410
45899
|
return { accepted: false };
|
|
45411
45900
|
},
|
|
45412
45901
|
endTalkSession: async ({ deviceId }) => {
|
|
@@ -45414,6 +45903,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45414
45903
|
const active = this.intercomRawSession;
|
|
45415
45904
|
if (!active) return;
|
|
45416
45905
|
this.intercomRawSession = null;
|
|
45906
|
+
await active.pcmTranscode.close();
|
|
45417
45907
|
if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
|
|
45418
45908
|
sessionId: active.opusDecode.sessionId,
|
|
45419
45909
|
nodeId: active.opusDecode.nodeId
|
|
@@ -57616,7 +58106,12 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
|
|
|
57616
58106
|
throw new Error(`Hikvision: ${reason}`);
|
|
57617
58107
|
}
|
|
57618
58108
|
async onInitialize() {
|
|
57619
|
-
|
|
58109
|
+
const regs = await super.onInitialize();
|
|
58110
|
+
regs.push({
|
|
58111
|
+
capability: failureContributionCapability,
|
|
58112
|
+
provider: { list: () => intercomFailureReport.list() }
|
|
58113
|
+
});
|
|
58114
|
+
return regs;
|
|
57620
58115
|
}
|
|
57621
58116
|
async supportsDiscovery() {
|
|
57622
58117
|
return true;
|
package/dist/addon.mjs
CHANGED
|
@@ -8502,6 +8502,112 @@ var TIMEZONES = [
|
|
|
8502
8502
|
function findTimezone(id) {
|
|
8503
8503
|
return TIMEZONES.find((tz) => tz.id === id);
|
|
8504
8504
|
}
|
|
8505
|
+
/**
|
|
8506
|
+
* Distinct (device, family, variant) counters one instance will hold.
|
|
8507
|
+
*
|
|
8508
|
+
* A large fleet x the handful of families any single addon reports, with
|
|
8509
|
+
* slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
|
|
8510
|
+
* already declares an RSS budget in the gigabytes.
|
|
8511
|
+
*/
|
|
8512
|
+
var MAX_KEYS = 1024;
|
|
8513
|
+
/**
|
|
8514
|
+
* Where reasons past {@link MAX_REASONS_PER_KEY} go.
|
|
8515
|
+
*
|
|
8516
|
+
* They are FOLDED, never dropped: `attempts - succeeded` must always equal the
|
|
8517
|
+
* sum of the reason counts, or the ratio stops adding up.
|
|
8518
|
+
*/
|
|
8519
|
+
var OVERFLOW_REASON = "other";
|
|
8520
|
+
/** `deviceId` + `family` + optional `variant`, flattened into the map key. */
|
|
8521
|
+
function counterKey(deviceId, family, variant) {
|
|
8522
|
+
return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
|
|
8523
|
+
}
|
|
8524
|
+
/**
|
|
8525
|
+
* A bounded set of per-camera, cumulative failure counters.
|
|
8526
|
+
*
|
|
8527
|
+
* One instance per contributing subsystem. `note` is O(1) and allocation-free
|
|
8528
|
+
* on the steady path; `snapshot` reads without mutating anything.
|
|
8529
|
+
*/
|
|
8530
|
+
var FailureCounters = class {
|
|
8531
|
+
maxKeys;
|
|
8532
|
+
maxReasons;
|
|
8533
|
+
counters = /* @__PURE__ */ new Map();
|
|
8534
|
+
refused = 0;
|
|
8535
|
+
constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
|
|
8536
|
+
this.maxKeys = maxKeys;
|
|
8537
|
+
this.maxReasons = maxReasons;
|
|
8538
|
+
}
|
|
8539
|
+
/**
|
|
8540
|
+
* Counters refused because {@link MAX_KEYS} was already held.
|
|
8541
|
+
*
|
|
8542
|
+
* Cumulative for the life of the instance: a bound that bit is a fact about
|
|
8543
|
+
* the deployment, and a surface that hid it would under-report a fleet
|
|
8544
|
+
* precisely when the fleet got large enough to matter.
|
|
8545
|
+
*/
|
|
8546
|
+
get keysRefused() {
|
|
8547
|
+
return this.refused;
|
|
8548
|
+
}
|
|
8549
|
+
/** Counters currently held. */
|
|
8550
|
+
get size() {
|
|
8551
|
+
return this.counters.size;
|
|
8552
|
+
}
|
|
8553
|
+
/**
|
|
8554
|
+
* Fold one observation in.
|
|
8555
|
+
*
|
|
8556
|
+
* A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
|
|
8557
|
+
* see the module docblock — an entry that cannot name its camera is worse
|
|
8558
|
+
* than no entry.
|
|
8559
|
+
*/
|
|
8560
|
+
note(observation, nowMs) {
|
|
8561
|
+
if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
|
|
8562
|
+
const key = counterKey(observation.deviceId, observation.family, observation.variant);
|
|
8563
|
+
let counter = this.counters.get(key);
|
|
8564
|
+
if (counter === void 0) {
|
|
8565
|
+
if (this.counters.size >= this.maxKeys) {
|
|
8566
|
+
this.refused += 1;
|
|
8567
|
+
return;
|
|
8568
|
+
}
|
|
8569
|
+
counter = {
|
|
8570
|
+
deviceId: observation.deviceId,
|
|
8571
|
+
family: observation.family,
|
|
8572
|
+
variant: observation.variant,
|
|
8573
|
+
sinceMs: nowMs,
|
|
8574
|
+
attempts: 0,
|
|
8575
|
+
succeeded: 0,
|
|
8576
|
+
reasons: /* @__PURE__ */ new Map()
|
|
8577
|
+
};
|
|
8578
|
+
this.counters.set(key, counter);
|
|
8579
|
+
}
|
|
8580
|
+
counter.attempts += 1;
|
|
8581
|
+
if (observation.reason === void 0) {
|
|
8582
|
+
counter.succeeded += 1;
|
|
8583
|
+
return;
|
|
8584
|
+
}
|
|
8585
|
+
const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
|
|
8586
|
+
counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
|
|
8587
|
+
}
|
|
8588
|
+
/** Read every counter. Never mutates — see the module docblock. */
|
|
8589
|
+
snapshot(nowMs) {
|
|
8590
|
+
const out = [];
|
|
8591
|
+
for (const counter of this.counters.values()) out.push({
|
|
8592
|
+
deviceId: counter.deviceId,
|
|
8593
|
+
family: counter.family,
|
|
8594
|
+
...counter.variant !== void 0 ? { variant: counter.variant } : {},
|
|
8595
|
+
sinceMs: counter.sinceMs,
|
|
8596
|
+
atMs: nowMs,
|
|
8597
|
+
attempts: counter.attempts,
|
|
8598
|
+
succeeded: counter.succeeded,
|
|
8599
|
+
reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
|
|
8600
|
+
reason,
|
|
8601
|
+
count
|
|
8602
|
+
})).toSorted((a, b) => b.count - a.count)
|
|
8603
|
+
});
|
|
8604
|
+
return out;
|
|
8605
|
+
}
|
|
8606
|
+
/** Drop everything (host disposal). */
|
|
8607
|
+
clear() {
|
|
8608
|
+
this.counters.clear();
|
|
8609
|
+
}
|
|
8610
|
+
};
|
|
8505
8611
|
var MODEL_FORMATS = [
|
|
8506
8612
|
"onnx",
|
|
8507
8613
|
"coreml",
|
|
@@ -13757,7 +13863,26 @@ var FailureContributionSchema = object({
|
|
|
13757
13863
|
/** The loss, partitioned. Sums to `attempts - succeeded`. */
|
|
13758
13864
|
reasons: array(FailureReasonCountSchema).readonly()
|
|
13759
13865
|
});
|
|
13760
|
-
|
|
13866
|
+
var failureContributionCapability = {
|
|
13867
|
+
name: "failure-contribution",
|
|
13868
|
+
scope: "system",
|
|
13869
|
+
mode: "collection",
|
|
13870
|
+
internal: true,
|
|
13871
|
+
methods: {
|
|
13872
|
+
/**
|
|
13873
|
+
* This addon's per-camera failure counters, read live from bounded in-RAM
|
|
13874
|
+
* state it already keeps. Inert: no persistence, no sampling, no timer.
|
|
13875
|
+
*
|
|
13876
|
+
* READING NEVER RESETS. The counters are CUMULATIVE since `sinceMs`, and a
|
|
13877
|
+
* consumer that wants a rate differences two reads. A draining read would
|
|
13878
|
+
* make two operators with the page open each destroy half of the other's
|
|
13879
|
+
* numbers, and `load-contribution` already settled the same question the
|
|
13880
|
+
* same way for `cpuSeconds`.
|
|
13881
|
+
*/
|
|
13882
|
+
list: method(_void(), array(FailureContributionSchema).readonly()) },
|
|
13883
|
+
/** In-process only — enumerated through `addons.listCapabilityProviders`. */
|
|
13884
|
+
mount: { kind: "skip" }
|
|
13885
|
+
};
|
|
13761
13886
|
var LoadContributionSchema = object({
|
|
13762
13887
|
role: _enum([
|
|
13763
13888
|
"decode",
|
|
@@ -43590,6 +43715,15 @@ var DEFAULT_BACKLOG_MS = 200;
|
|
|
43590
43715
|
var MAX_BACKLOG_MS = 5e3;
|
|
43591
43716
|
var MIN_BACKLOG_MS = 20;
|
|
43592
43717
|
/**
|
|
43718
|
+
* The ONE place the operator's backlog request becomes the enforced bound.
|
|
43719
|
+
* `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
|
|
43720
|
+
* it — a second clamp would let the number a caller reads drift from the
|
|
43721
|
+
* number the buffer honours.
|
|
43722
|
+
*/
|
|
43723
|
+
function clampBacklogMs(requested) {
|
|
43724
|
+
return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
|
|
43725
|
+
}
|
|
43726
|
+
/**
|
|
43593
43727
|
* Fixed audioData PUT-body chunk size, in bytes. Encoded µ-law bytes are
|
|
43594
43728
|
* accumulated and flushed to the sticky PUT in FIXED chunks of this size
|
|
43595
43729
|
* (the sub-chunk remainder is held until the next flush, and the trailing
|
|
@@ -43658,6 +43792,36 @@ var HikvisionIntercomSession = class {
|
|
|
43658
43792
|
get audioCodec() {
|
|
43659
43793
|
return this.codec;
|
|
43660
43794
|
}
|
|
43795
|
+
/**
|
|
43796
|
+
* Effective PCM backlog bound, in ms — the operator's value clamped to
|
|
43797
|
+
* [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session is
|
|
43798
|
+
* actually enforcing rather than what it was asked for. Readable before
|
|
43799
|
+
* `start()` because the clamp is pure.
|
|
43800
|
+
*/
|
|
43801
|
+
get backlogMs() {
|
|
43802
|
+
return clampBacklogMs(this.opts.maxBacklogMs);
|
|
43803
|
+
}
|
|
43804
|
+
/**
|
|
43805
|
+
* The firmware's talk-back format — `IntercomStatus.ability`.
|
|
43806
|
+
*
|
|
43807
|
+
* Every field is a value this session already resolved against the camera;
|
|
43808
|
+
* nothing here is a default standing in for a probe. It was declared,
|
|
43809
|
+
* mirrored into runtime state and written by NOBODY while these exact
|
|
43810
|
+
* values were in hand and only reaching a log line (D281).
|
|
43811
|
+
*
|
|
43812
|
+
* `duplex` is the one judgement call: ISAPI two-way audio is a single
|
|
43813
|
+
* `audioData` channel and the provider enforces one active session per
|
|
43814
|
+
* camera, so `half` is reported. `full` would be the dangerous direction —
|
|
43815
|
+
* a consumer that believes it may listen while speaking takes no lock.
|
|
43816
|
+
*/
|
|
43817
|
+
get ability() {
|
|
43818
|
+
return {
|
|
43819
|
+
codecs: [this.codec],
|
|
43820
|
+
sampleRate: HIKVISION_INTERCOM_SAMPLE_RATE,
|
|
43821
|
+
duplex: "half",
|
|
43822
|
+
maxBacklogMs: this.backlogMs
|
|
43823
|
+
};
|
|
43824
|
+
}
|
|
43661
43825
|
async start() {
|
|
43662
43826
|
if (this.stream) return;
|
|
43663
43827
|
const desiredChannel = this.opts.channelId ?? "1";
|
|
@@ -43716,7 +43880,7 @@ var HikvisionIntercomSession = class {
|
|
|
43716
43880
|
});
|
|
43717
43881
|
this.stop(reason).catch(() => {});
|
|
43718
43882
|
});
|
|
43719
|
-
const wantedBacklogMs =
|
|
43883
|
+
const wantedBacklogMs = this.backlogMs;
|
|
43720
43884
|
this.bytesPerSecond = HIKVISION_INTERCOM_SAMPLE_RATE * 2;
|
|
43721
43885
|
this.maxBacklogBytes = Math.max(160, Math.floor(wantedBacklogMs / 1e3 * this.bytesPerSecond));
|
|
43722
43886
|
this.stream = stream;
|
|
@@ -43898,6 +44062,90 @@ var HikvisionIntercomSession = class {
|
|
|
43898
44062
|
}
|
|
43899
44063
|
};
|
|
43900
44064
|
//#endregion
|
|
44065
|
+
//#region src/intercom/intercom-failure-report.ts
|
|
44066
|
+
/**
|
|
44067
|
+
* Per-camera talk-back counters, published through `failure-contribution`.
|
|
44068
|
+
*
|
|
44069
|
+
* ## The number that was never divided
|
|
44070
|
+
*
|
|
44071
|
+
* The rate-mismatch drop had ONE warn line and no counter, so "how much
|
|
44072
|
+
* talk-back is this camera losing" was answerable only by grepping Loki and
|
|
44073
|
+
* hand-correlating timestamps — the exact cost `failure-contribution` exists to
|
|
44074
|
+
* remove. And a bare drop count could not have answered it either: 40 drops out
|
|
44075
|
+
* of 40 pushes and 40 out of 40 000 are opposite findings that produce
|
|
44076
|
+
* identical log volume.
|
|
44077
|
+
*
|
|
44078
|
+
* So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
|
|
44079
|
+
* place that decides it — the accepted ones too. {@link FailureCounters}
|
|
44080
|
+
* carries `attempts` as the denominator and `succeeded` as the numerator, and
|
|
44081
|
+
* the reasons partition the rest. A success counted somewhere else would drift
|
|
44082
|
+
* from the failures and turn the ratio into fiction.
|
|
44083
|
+
*
|
|
44084
|
+
* ## `variant` is the wire codec, and it is honest
|
|
44085
|
+
*
|
|
44086
|
+
* `failure-contribution` keeps `variant` for a second dimension WITHIN a
|
|
44087
|
+
* family, and here the useful one is the format the caller pushed: an operator
|
|
44088
|
+
* asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
|
|
44089
|
+
* raw PCM is the half that is failing. The provider is handed that value on
|
|
44090
|
+
* every call, so it is reported rather than guessed — absent, never invented.
|
|
44091
|
+
*
|
|
44092
|
+
* ## Process-wide, because a counter is
|
|
44093
|
+
*
|
|
44094
|
+
* One addon is one process (D2) and every camera this addon owns lives in it,
|
|
44095
|
+
* so the instance is module-scoped: the cameras note into it and the addon
|
|
44096
|
+
* registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
|
|
44097
|
+
* incarnation marker — a respawned runner restarts from zero and says so.
|
|
44098
|
+
* Reading NEVER drains.
|
|
44099
|
+
*/
|
|
44100
|
+
/** One `pushTalkAudio` call against an open talk session. */
|
|
44101
|
+
var FAMILY_INTERCOM_TALK = "intercom-talk";
|
|
44102
|
+
/** The push arrived with a sequence number at or below the last accepted one. */
|
|
44103
|
+
var REASON_TALK_OUT_OF_ORDER = "out-of-order";
|
|
44104
|
+
/** The payload decoded to zero bytes. */
|
|
44105
|
+
var REASON_TALK_EMPTY = "empty-frame";
|
|
44106
|
+
/** More than one channel — every camera here is mono-only. */
|
|
44107
|
+
var REASON_TALK_NOT_MONO = "not-mono";
|
|
44108
|
+
/** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
|
|
44109
|
+
var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
|
|
44110
|
+
/** The wire codec has no path onto this camera's talk channel. */
|
|
44111
|
+
var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
|
|
44112
|
+
/** The Opus decode path threw or could not open its session. */
|
|
44113
|
+
var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
|
|
44114
|
+
/**
|
|
44115
|
+
* The addon's talk-back counters. One instance per process; the export at the
|
|
44116
|
+
* bottom of this file IS that instance.
|
|
44117
|
+
*/
|
|
44118
|
+
var IntercomFailureReport = class {
|
|
44119
|
+
now;
|
|
44120
|
+
counters;
|
|
44121
|
+
constructor(now = Date.now, counters = new FailureCounters()) {
|
|
44122
|
+
this.now = now;
|
|
44123
|
+
this.counters = counters;
|
|
44124
|
+
}
|
|
44125
|
+
/**
|
|
44126
|
+
* Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
|
|
44127
|
+
* camera's talk channel.
|
|
44128
|
+
*/
|
|
44129
|
+
noteTalkFrame(deviceId, wireCodec, reason) {
|
|
44130
|
+
this.counters.note({
|
|
44131
|
+
deviceId,
|
|
44132
|
+
family: FAMILY_INTERCOM_TALK,
|
|
44133
|
+
variant: wireCodec,
|
|
44134
|
+
...reason !== void 0 ? { reason } : {}
|
|
44135
|
+
}, this.now());
|
|
44136
|
+
}
|
|
44137
|
+
/** The `failure-contribution` provider's payload. Reads, never resets. */
|
|
44138
|
+
list() {
|
|
44139
|
+
return this.counters.snapshot(this.now());
|
|
44140
|
+
}
|
|
44141
|
+
/** Addon disposal. */
|
|
44142
|
+
clear() {
|
|
44143
|
+
this.counters.clear();
|
|
44144
|
+
}
|
|
44145
|
+
};
|
|
44146
|
+
/** The process-wide instance every camera in this addon notes into. */
|
|
44147
|
+
var intercomFailureReport = new IntercomFailureReport();
|
|
44148
|
+
//#endregion
|
|
43901
44149
|
//#region src/intercom/intercom-orchestrator.ts
|
|
43902
44150
|
var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
|
|
43903
44151
|
var DEFAULT_OPUS_CHANNELS = 1;
|
|
@@ -43915,6 +44163,14 @@ var IntercomOrchestrator = class {
|
|
|
43915
44163
|
return this.session !== null && !this.session.closed;
|
|
43916
44164
|
}
|
|
43917
44165
|
/**
|
|
44166
|
+
* The live talk session's firmware ability, or `null` when no session is
|
|
44167
|
+
* open. Read by the camera at `startSession` so the WebRTC path writes
|
|
44168
|
+
* `IntercomStatus.ability` from the same source the raw-PCM path does.
|
|
44169
|
+
*/
|
|
44170
|
+
get ability() {
|
|
44171
|
+
return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
|
|
44172
|
+
}
|
|
44173
|
+
/**
|
|
43918
44174
|
* Subscribe to session-close notifications. The listener fires exactly
|
|
43919
44175
|
* once per session with the resolved `IntercomCloseReason` + stats, so
|
|
43920
44176
|
* the camera layer can surface WHY a talk session ended (the prime
|
|
@@ -44176,6 +44432,193 @@ function errMsg$1(err) {
|
|
|
44176
44432
|
return err instanceof Error ? err.message : String(err);
|
|
44177
44433
|
}
|
|
44178
44434
|
//#endregion
|
|
44435
|
+
//#region src/intercom/talk-pcm-transcoder.ts
|
|
44436
|
+
/** libav codec name of a linear little-endian 16-bit PCM decode session. */
|
|
44437
|
+
var TALK_PCM_CODEC = "pcm_s16le";
|
|
44438
|
+
/** The transcoder was already closed — the talk session ended under the push. */
|
|
44439
|
+
var REASON_PCM_CLOSED = "pcm-transcoder-closed";
|
|
44440
|
+
/** The caller's declared source rate is not a usable positive integer. */
|
|
44441
|
+
var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
|
|
44442
|
+
/** The frame is empty or holds half a sample — malformed, not convertible. */
|
|
44443
|
+
var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
|
|
44444
|
+
/** No `audio-codec` provider is mounted on this cluster. */
|
|
44445
|
+
var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
|
|
44446
|
+
/** The codec cap refused to open a linear-PCM decode session. */
|
|
44447
|
+
var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
|
|
44448
|
+
/** The push/pull round-trip through the codec cap threw. */
|
|
44449
|
+
var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
|
|
44450
|
+
function errMessage(err) {
|
|
44451
|
+
return err instanceof Error ? err.message : String(err);
|
|
44452
|
+
}
|
|
44453
|
+
var TalkPcmTranscoder = class {
|
|
44454
|
+
opts;
|
|
44455
|
+
active = null;
|
|
44456
|
+
closed = false;
|
|
44457
|
+
constructor(opts) {
|
|
44458
|
+
this.opts = opts;
|
|
44459
|
+
}
|
|
44460
|
+
/** The open codec session, or `null` before the first converted frame. */
|
|
44461
|
+
get sessionId() {
|
|
44462
|
+
return this.active?.sessionId ?? null;
|
|
44463
|
+
}
|
|
44464
|
+
/**
|
|
44465
|
+
* Convert one frame to the camera's rate and hand every produced chunk to
|
|
44466
|
+
* `feed`.
|
|
44467
|
+
*
|
|
44468
|
+
* Returns `null` when the frame was converted and fed, or the REASON string
|
|
44469
|
+
* it was refused for — already logged, with nothing fed.
|
|
44470
|
+
*/
|
|
44471
|
+
async feedResampled(frame) {
|
|
44472
|
+
if (this.closed) {
|
|
44473
|
+
this.refuse(REASON_PCM_CLOSED, {});
|
|
44474
|
+
return REASON_PCM_CLOSED;
|
|
44475
|
+
}
|
|
44476
|
+
const sourceSampleRate = frame.sourceSampleRate;
|
|
44477
|
+
if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
|
|
44478
|
+
this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
|
|
44479
|
+
return REASON_PCM_BAD_RATE;
|
|
44480
|
+
}
|
|
44481
|
+
if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
|
|
44482
|
+
this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
|
|
44483
|
+
return REASON_PCM_ODD_BYTES;
|
|
44484
|
+
}
|
|
44485
|
+
let api;
|
|
44486
|
+
try {
|
|
44487
|
+
api = this.opts.resolveAudioCodec();
|
|
44488
|
+
} catch (err) {
|
|
44489
|
+
this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
|
|
44490
|
+
return REASON_PCM_NO_CODEC_CAP;
|
|
44491
|
+
}
|
|
44492
|
+
if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
|
|
44493
|
+
const previous = this.active.sourceSampleRate;
|
|
44494
|
+
await this.disposeSession(api, "source-rate-changed");
|
|
44495
|
+
this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
|
|
44496
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44497
|
+
meta: {
|
|
44498
|
+
previousSourceSampleRate: previous,
|
|
44499
|
+
sourceSampleRate
|
|
44500
|
+
}
|
|
44501
|
+
});
|
|
44502
|
+
}
|
|
44503
|
+
if (this.active === null) try {
|
|
44504
|
+
const created = await api.createDecodeSession({
|
|
44505
|
+
codec: TALK_PCM_CODEC,
|
|
44506
|
+
sourceSampleRate,
|
|
44507
|
+
sourceChannels: 1,
|
|
44508
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44509
|
+
targetChannels: 1,
|
|
44510
|
+
targetFormat: "s16le",
|
|
44511
|
+
tag: this.opts.tag
|
|
44512
|
+
});
|
|
44513
|
+
this.active = {
|
|
44514
|
+
sessionId: created.sessionId,
|
|
44515
|
+
nodeId: created.nodeId,
|
|
44516
|
+
sourceSampleRate
|
|
44517
|
+
};
|
|
44518
|
+
this.opts.logger.info("intercom: pcm resample session opened", {
|
|
44519
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44520
|
+
meta: {
|
|
44521
|
+
codec: TALK_PCM_CODEC,
|
|
44522
|
+
codecSessionId: created.sessionId,
|
|
44523
|
+
codecNodeId: created.nodeId,
|
|
44524
|
+
sourceSampleRate,
|
|
44525
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44526
|
+
tag: this.opts.tag
|
|
44527
|
+
}
|
|
44528
|
+
});
|
|
44529
|
+
} catch (err) {
|
|
44530
|
+
this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
|
|
44531
|
+
sourceSampleRate,
|
|
44532
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44533
|
+
error: errMessage(err)
|
|
44534
|
+
});
|
|
44535
|
+
return REASON_PCM_SESSION_OPEN_FAILED;
|
|
44536
|
+
}
|
|
44537
|
+
const session = this.active;
|
|
44538
|
+
try {
|
|
44539
|
+
await api.pushEncodedFrame({
|
|
44540
|
+
sessionId: session.sessionId,
|
|
44541
|
+
nodeId: session.nodeId,
|
|
44542
|
+
data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
|
|
44543
|
+
});
|
|
44544
|
+
const chunks = await api.pullPcm({
|
|
44545
|
+
sessionId: session.sessionId,
|
|
44546
|
+
nodeId: session.nodeId,
|
|
44547
|
+
maxCount: 8
|
|
44548
|
+
});
|
|
44549
|
+
for (const chunk of chunks) {
|
|
44550
|
+
const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
|
|
44551
|
+
if (out.length > 0) this.opts.feed(out);
|
|
44552
|
+
}
|
|
44553
|
+
return null;
|
|
44554
|
+
} catch (err) {
|
|
44555
|
+
this.refuse(REASON_PCM_CONVERT_FAILED, {
|
|
44556
|
+
codecSessionId: session.sessionId,
|
|
44557
|
+
sourceSampleRate,
|
|
44558
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44559
|
+
error: errMessage(err)
|
|
44560
|
+
});
|
|
44561
|
+
await this.disposeSession(api, "convert-failed");
|
|
44562
|
+
return REASON_PCM_CONVERT_FAILED;
|
|
44563
|
+
}
|
|
44564
|
+
}
|
|
44565
|
+
/**
|
|
44566
|
+
* Close the codec session. Idempotent, and called from the provider's
|
|
44567
|
+
* `endTalkSession` so the session dies with the talk session it served.
|
|
44568
|
+
*/
|
|
44569
|
+
async close() {
|
|
44570
|
+
this.closed = true;
|
|
44571
|
+
if (this.active === null) return;
|
|
44572
|
+
let api;
|
|
44573
|
+
try {
|
|
44574
|
+
api = this.opts.resolveAudioCodec();
|
|
44575
|
+
} catch (err) {
|
|
44576
|
+
this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
|
|
44577
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44578
|
+
meta: {
|
|
44579
|
+
codecSessionId: this.active.sessionId,
|
|
44580
|
+
error: errMessage(err)
|
|
44581
|
+
}
|
|
44582
|
+
});
|
|
44583
|
+
this.active = null;
|
|
44584
|
+
return;
|
|
44585
|
+
}
|
|
44586
|
+
await this.disposeSession(api, "talk-session-ended");
|
|
44587
|
+
}
|
|
44588
|
+
/** Close + forget the current session. Never throws. */
|
|
44589
|
+
async disposeSession(api, why) {
|
|
44590
|
+
const session = this.active;
|
|
44591
|
+
this.active = null;
|
|
44592
|
+
if (session === null) return;
|
|
44593
|
+
try {
|
|
44594
|
+
await api.closeSession({
|
|
44595
|
+
sessionId: session.sessionId,
|
|
44596
|
+
nodeId: session.nodeId
|
|
44597
|
+
});
|
|
44598
|
+
} catch (err) {
|
|
44599
|
+
this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
|
|
44600
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44601
|
+
meta: {
|
|
44602
|
+
codecSessionId: session.sessionId,
|
|
44603
|
+
why,
|
|
44604
|
+
error: errMessage(err)
|
|
44605
|
+
}
|
|
44606
|
+
});
|
|
44607
|
+
}
|
|
44608
|
+
}
|
|
44609
|
+
/** One warn per refused frame. A branch that drops work says so. */
|
|
44610
|
+
refuse(reason, meta) {
|
|
44611
|
+
this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
|
|
44612
|
+
tags: { deviceId: this.opts.deviceId },
|
|
44613
|
+
meta: {
|
|
44614
|
+
reason,
|
|
44615
|
+
targetSampleRate: this.opts.targetSampleRate,
|
|
44616
|
+
...meta
|
|
44617
|
+
}
|
|
44618
|
+
});
|
|
44619
|
+
}
|
|
44620
|
+
};
|
|
44621
|
+
//#endregion
|
|
44179
44622
|
//#region src/intercom/werift-intercom-peer.ts
|
|
44180
44623
|
var _werift;
|
|
44181
44624
|
async function loadWerift() {
|
|
@@ -45223,13 +45666,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45223
45666
|
* Called at the four points that open or close a session — and seeded at
|
|
45224
45667
|
* registration, so the slice says `talking: false` from boot rather than
|
|
45225
45668
|
* only after the first session.
|
|
45669
|
+
*
|
|
45670
|
+
* `ability` is STICKY: it is the firmware's negotiated format, learned when a
|
|
45671
|
+
* session opens and still true after it closes, so a caller reading between
|
|
45672
|
+
* sessions gets the last probed value rather than `null`. Passing it is what
|
|
45673
|
+
* changed — it used to be copied forward from `previous` at every one of the
|
|
45674
|
+
* four call sites and written by nobody, while `session.sampleRate` and
|
|
45675
|
+
* `session.audioCodec` were in hand and only reaching a log line.
|
|
45226
45676
|
*/
|
|
45227
|
-
publishIntercomState(talking) {
|
|
45677
|
+
publishIntercomState(talking, ability) {
|
|
45228
45678
|
const previous = this.getCapSlice(intercomCapability);
|
|
45229
45679
|
this.setCapSlice(intercomCapability, {
|
|
45230
45680
|
talking,
|
|
45231
45681
|
lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
|
|
45232
|
-
ability: previous?.ability ?? null
|
|
45682
|
+
ability: ability ?? previous?.ability ?? null
|
|
45233
45683
|
});
|
|
45234
45684
|
}
|
|
45235
45685
|
registerIntercomIfSupported() {
|
|
@@ -45258,7 +45708,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45258
45708
|
});
|
|
45259
45709
|
try {
|
|
45260
45710
|
const opened = await this.intercomOrchestrator.start();
|
|
45261
|
-
this.publishIntercomState(true);
|
|
45711
|
+
this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
|
|
45262
45712
|
return opened;
|
|
45263
45713
|
} catch (err) {
|
|
45264
45714
|
this.publishIntercomState(false);
|
|
@@ -45280,8 +45730,10 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45280
45730
|
if (deviceId !== this.id) throw new Error(`HikvisionCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
45281
45731
|
if (this.disabled) throw new Error("Hikvision intercom: device is disabled — re-enable it before opening a talk session");
|
|
45282
45732
|
if (this.intercomRawSession) {
|
|
45283
|
-
|
|
45733
|
+
const previous = this.intercomRawSession;
|
|
45284
45734
|
this.intercomRawSession = null;
|
|
45735
|
+
await previous.pcmTranscode.close();
|
|
45736
|
+
await previous.session.stop().catch(() => {});
|
|
45285
45737
|
}
|
|
45286
45738
|
const session = new HikvisionIntercomSession({
|
|
45287
45739
|
client: this.ensureClient(),
|
|
@@ -45297,9 +45749,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45297
45749
|
id,
|
|
45298
45750
|
session,
|
|
45299
45751
|
lastSequenceNumber: -1,
|
|
45300
|
-
opusDecode: null
|
|
45752
|
+
opusDecode: null,
|
|
45753
|
+
pcmTranscode: new TalkPcmTranscoder({
|
|
45754
|
+
deviceId: this.id,
|
|
45755
|
+
logger: this.ctx.logger,
|
|
45756
|
+
resolveAudioCodec: () => this.resolveAudioCodecApi(),
|
|
45757
|
+
targetSampleRate: session.sampleRate,
|
|
45758
|
+
tag: `hikvision-intercom-pcm:${this.id}:${id}`,
|
|
45759
|
+
feed: (pcm) => session.feedPcm(pcm)
|
|
45760
|
+
})
|
|
45301
45761
|
};
|
|
45302
|
-
this.publishIntercomState(true);
|
|
45762
|
+
this.publishIntercomState(true, session.ability);
|
|
45303
45763
|
this.ctx.logger.info("intercom talk session opened", {
|
|
45304
45764
|
tags: { deviceId: this.id },
|
|
45305
45765
|
meta: {
|
|
@@ -45312,13 +45772,24 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45312
45772
|
},
|
|
45313
45773
|
pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
|
|
45314
45774
|
if (deviceId !== this.id) return { accepted: false };
|
|
45775
|
+
const wireCodec = codec ?? "s16le";
|
|
45776
|
+
const note = (reason) => {
|
|
45777
|
+
intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
|
|
45778
|
+
};
|
|
45315
45779
|
const active = this.intercomRawSession;
|
|
45316
45780
|
if (!active || !active.session.isOpen) return { accepted: false };
|
|
45317
|
-
if (sequenceNumber <= active.lastSequenceNumber)
|
|
45781
|
+
if (sequenceNumber <= active.lastSequenceNumber) {
|
|
45782
|
+
note(REASON_TALK_OUT_OF_ORDER);
|
|
45783
|
+
return { accepted: false };
|
|
45784
|
+
}
|
|
45318
45785
|
const buf = Buffer.from(audioBase64, "base64");
|
|
45319
|
-
if (buf.length === 0)
|
|
45786
|
+
if (buf.length === 0) {
|
|
45787
|
+
note(REASON_TALK_EMPTY);
|
|
45788
|
+
return { accepted: false };
|
|
45789
|
+
}
|
|
45320
45790
|
const ch = channels ?? 1;
|
|
45321
45791
|
if (ch !== 1) {
|
|
45792
|
+
note(REASON_TALK_NOT_MONO);
|
|
45322
45793
|
this.ctx.logger.warn("intercom: dropping non-mono talk frame (Hikvision is mono-only)", {
|
|
45323
45794
|
tags: { deviceId: this.id },
|
|
45324
45795
|
meta: {
|
|
@@ -45328,9 +45799,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45328
45799
|
});
|
|
45329
45800
|
return { accepted: false };
|
|
45330
45801
|
}
|
|
45331
|
-
const wireCodec = codec ?? "s16le";
|
|
45332
45802
|
if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
|
|
45333
45803
|
if (wireCodec !== active.session.audioCodec) {
|
|
45804
|
+
note(REASON_TALK_CODEC_UNSUPPORTED);
|
|
45334
45805
|
this.ctx.logger.warn("intercom: codec mismatch — wire codec is not what the camera negotiated, dropping frame", {
|
|
45335
45806
|
tags: { deviceId: this.id },
|
|
45336
45807
|
meta: {
|
|
@@ -45342,28 +45813,34 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45342
45813
|
}
|
|
45343
45814
|
active.lastSequenceNumber = sequenceNumber;
|
|
45344
45815
|
active.session.feedEncoded(buf);
|
|
45816
|
+
note();
|
|
45345
45817
|
return { accepted: true };
|
|
45346
45818
|
}
|
|
45347
45819
|
if (wireCodec === "s16le") {
|
|
45348
45820
|
if (!sampleRate) {
|
|
45821
|
+
note(REASON_TALK_NO_SAMPLE_RATE);
|
|
45349
45822
|
this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
|
|
45350
45823
|
return { accepted: false };
|
|
45351
45824
|
}
|
|
45352
45825
|
if (sampleRate !== active.session.sampleRate) {
|
|
45353
|
-
|
|
45354
|
-
|
|
45355
|
-
|
|
45356
|
-
wireRate: sampleRate,
|
|
45357
|
-
cameraRate: active.session.sampleRate
|
|
45358
|
-
}
|
|
45826
|
+
const refusal = await active.pcmTranscode.feedResampled({
|
|
45827
|
+
pcm: buf,
|
|
45828
|
+
sourceSampleRate: sampleRate
|
|
45359
45829
|
});
|
|
45360
|
-
|
|
45830
|
+
if (refusal !== null) {
|
|
45831
|
+
note(refusal);
|
|
45832
|
+
return { accepted: false };
|
|
45833
|
+
}
|
|
45834
|
+
active.lastSequenceNumber = sequenceNumber;
|
|
45835
|
+
note();
|
|
45836
|
+
return { accepted: true };
|
|
45361
45837
|
}
|
|
45362
45838
|
active.lastSequenceNumber = sequenceNumber;
|
|
45363
45839
|
active.session.feedPcm(buf);
|
|
45840
|
+
note();
|
|
45364
45841
|
return { accepted: true };
|
|
45365
45842
|
}
|
|
45366
|
-
if (wireCodec === "opus") {
|
|
45843
|
+
if (wireCodec === "opus") try {
|
|
45367
45844
|
if (!active.opusDecode) {
|
|
45368
45845
|
const created = await this.resolveAudioCodecApi().createDecodeSession({
|
|
45369
45846
|
codec: "opus",
|
|
@@ -45406,8 +45883,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45406
45883
|
const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
|
|
45407
45884
|
if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
|
|
45408
45885
|
}
|
|
45886
|
+
note();
|
|
45409
45887
|
return { accepted: true };
|
|
45888
|
+
} catch (err) {
|
|
45889
|
+
note(REASON_TALK_OPUS_FAILED);
|
|
45890
|
+
throw err;
|
|
45410
45891
|
}
|
|
45892
|
+
note(REASON_TALK_CODEC_UNSUPPORTED);
|
|
45893
|
+
this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
|
|
45894
|
+
tags: { deviceId: this.id },
|
|
45895
|
+
meta: {
|
|
45896
|
+
wireCodec,
|
|
45897
|
+
cameraCodec: active.session.audioCodec
|
|
45898
|
+
}
|
|
45899
|
+
});
|
|
45411
45900
|
return { accepted: false };
|
|
45412
45901
|
},
|
|
45413
45902
|
endTalkSession: async ({ deviceId }) => {
|
|
@@ -45415,6 +45904,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
|
|
|
45415
45904
|
const active = this.intercomRawSession;
|
|
45416
45905
|
if (!active) return;
|
|
45417
45906
|
this.intercomRawSession = null;
|
|
45907
|
+
await active.pcmTranscode.close();
|
|
45418
45908
|
if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
|
|
45419
45909
|
sessionId: active.opusDecode.sessionId,
|
|
45420
45910
|
nodeId: active.opusDecode.nodeId
|
|
@@ -57617,7 +58107,12 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
|
|
|
57617
58107
|
throw new Error(`Hikvision: ${reason}`);
|
|
57618
58108
|
}
|
|
57619
58109
|
async onInitialize() {
|
|
57620
|
-
|
|
58110
|
+
const regs = await super.onInitialize();
|
|
58111
|
+
regs.push({
|
|
58112
|
+
capability: failureContributionCapability,
|
|
58113
|
+
provider: { list: () => intercomFailureReport.list() }
|
|
58114
|
+
});
|
|
58115
|
+
return regs;
|
|
57621
58116
|
}
|
|
57622
58117
|
async supportsDiscovery() {
|
|
57623
58118
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-provider-hikvision",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.47",
|
|
4
4
|
"description": "Hikvision camera device provider addon for CamStack — ISAPI over HTTP(S) with digest auth (snapshot, alarm stream, RTSP discovery)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -63,6 +63,9 @@
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"name": "image-settings"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "failure-contribution"
|
|
66
69
|
}
|
|
67
70
|
]
|
|
68
71
|
}
|