@synapse-sdk/core 0.0.0-stage → 0.3.3

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/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @synapse-sdk/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of the Synapse SDK for JavaScript. Documentation: the repository.
@@ -0,0 +1,86 @@
1
+ /** NAL unit types this package cares about (H.264 Table 7-1). */
2
+ export declare const NAL_TYPE: {
3
+ readonly SLICE: 1;
4
+ readonly SLICE_DPA: 2;
5
+ readonly SLICE_DPB: 3;
6
+ readonly SLICE_DPC: 4;
7
+ readonly IDR: 5;
8
+ readonly SEI: 6;
9
+ readonly SPS: 7;
10
+ readonly PPS: 8;
11
+ readonly AUD: 9;
12
+ };
13
+ /** SEI payload type of a recovery point (H.264 D.1.8). */
14
+ export declare const SEI_RECOVERY_POINT = 6;
15
+ /** One NAL unit inside an Annex-B buffer. */
16
+ export interface Nal {
17
+ /** Offset of the start code in the buffer. */
18
+ offset: number;
19
+ /** 3 or 4 (bytes of `00 00 01` / `00 00 00 01`). */
20
+ startCodeLen: number;
21
+ /** The NAL unit bytes after the start code: header byte, then the payload (still escaped). A view, not a copy. */
22
+ bytes: Uint8Array;
23
+ }
24
+ /** `nal_unit_type` (low 5 bits of the header byte); 0 for an empty NAL. */
25
+ export declare function nalType(n: Nal): number;
26
+ /** Payload after the 1-byte NAL header (escaped RBSP). */
27
+ export declare function nalPayload(n: Nal): Uint8Array;
28
+ /** A VCL NAL type: a coded slice (types 1-5). */
29
+ export declare function isVclType(t: number): boolean;
30
+ export declare function nalIsVcl(n: Nal): boolean;
31
+ /** Start code plus NAL unit, as it sits in the stream (a view). */
32
+ export declare function nalWithStartCode(buf: Uint8Array, n: Nal): Uint8Array;
33
+ /** Next Annex-B start code (`00 00 01` or `00 00 00 01`) at or after `from`: `[offset, len]`. */
34
+ export declare function findStartCode(buf: Uint8Array, from: number): [offset: number, len: number] | undefined;
35
+ /**
36
+ * The NAL units of an Annex-B buffer, in order. Bytes before the first start code are skipped;
37
+ * trailing zero bytes (`trailing_zero_8bits`) stay attached to the NAL before them, which is
38
+ * harmless for type/flag decisions and keeps the bytes of a walked AU identical to the input.
39
+ */
40
+ export declare function nalUnits(buf: Uint8Array): Nal[];
41
+ /** The SPS + PPS NAL units of an access unit (with their start codes, in stream order), if it carries any. Defined ⇒ the AU deserves the `CONFIG` flag. */
42
+ export declare function auParamSets(au: Uint8Array): Uint8Array | undefined;
43
+ /**
44
+ * The sync points an Annex-B access unit offers.
45
+ * - `idr`: an IDR slice (NAL type 5) is present → instantaneous start point (`KEY`).
46
+ * - `recovery`: a recovery point SEI (NAL 6, payload type 6) is present → gradual start point,
47
+ * clean one refresh period later (`RECOVERY`; x264 and nvenc both emit it at each intra-refresh sweep).
48
+ * Decided from the bitstream rather than the encoder's keyframe flag, which libx264 raises for
49
+ * both kinds and nvenc only for IDRs.
50
+ */
51
+ export declare function auSyncPoints(au: Uint8Array): {
52
+ idr: boolean;
53
+ recovery: boolean;
54
+ };
55
+ /** An IDR is present: the AU is a keyframe in the `KEY` sense. */
56
+ export declare function isKeyframe(au: Uint8Array): boolean;
57
+ /** The AU carries at least one coded slice (otherwise it is parameter sets / SEI only). */
58
+ export declare function hasVcl(au: Uint8Array): boolean;
59
+ /** Whether an SEI NAL payload (after the NAL header byte) contains a recovery point message (payload type 6). */
60
+ export declare function seiHasRecoveryPoint(payload: Uint8Array): boolean;
61
+ /** The payload types of the SEI messages in an SEI NAL payload (D.1.1), in order. Emulation-prevention bytes are removed first. */
62
+ export declare function seiPayloadTypes(payload: Uint8Array): number[];
63
+ /** Strip H.264 emulation-prevention bytes (the `03` in `00 00 03`), giving the RBSP. */
64
+ export declare function rbspUnescape(nal: Uint8Array): Uint8Array;
65
+ /** The SPS of an AU (NAL bytes without start code), if present. */
66
+ export declare function findSps(au: Uint8Array): Uint8Array | undefined;
67
+ /** The WebCodecs / RFC 6381 codec string for an AU that carries an SPS: `avc1.` + profile_idc + constraint flags + level_idc as hex (e.g. `avc1.42c01e`). */
68
+ export declare function codecStringFromSps(au: Uint8Array): string | undefined;
69
+ /** `parts` joined into one new buffer (a single part is copied too, so the result never aliases the input). */
70
+ export declare function concatBytes(parts: readonly Uint8Array[]): Uint8Array;
71
+ /**
72
+ * Splits a continuous Annex-B byte stream (a file, an encoder pipe) into access units. Rule
73
+ * (dylithium's reader loop): an AU ends with its coded slice; the next NAL of any kind starts a
74
+ * new AU, so non-VCL NALs (SPS, PPS, SEI, AUD) are attached to the picture that follows them.
75
+ * Sufficient for one-slice-per-picture streams (x264 `slices=1`, nvenc, WebCodecs), which is what
76
+ * the encoder options enforce.
77
+ */
78
+ export declare class AuSplitter {
79
+ #private;
80
+ /** Feeds bytes; returns every access unit completed by them (in order). */
81
+ push(bytes: Uint8Array): Uint8Array[];
82
+ /** End of stream: the last NAL (whose end no start code marks) closes the last AU, or starts one more if the current AU already has its slice. */
83
+ finish(): Uint8Array[];
84
+ }
85
+ /** Splits a whole Annex-B buffer into access units (see {@link AuSplitter}). */
86
+ export declare function splitAccessUnits(stream: Uint8Array): Uint8Array[];
package/dist/annexb.js ADDED
@@ -0,0 +1,226 @@
1
+ // H.264 Annex-B byte-stream walkers (ITU-T H.264 Annex B, §7.3.1, §7.4.1, D.2.8).
2
+ //
3
+ // A line-for-line port of `crates/synapse-media/src/annexb.rs` (itself from dylithium-core and
4
+ // dylithium-web-2 `wire.ts`), so the browser, Rust and Python SDKs flag the same access units the
5
+ // same way (`vectors/media-00.json` is the shared fixture). Lives in core because the client reads
6
+ // the flags of a `video/h264` body off the bitstream when it publishes. Pure functions over `Uint8Array`;
7
+ // nothing here throws on malformed input: a truncated or garbage buffer yields no NAL units or
8
+ // `false`, never an error, because these run on wire input (lessons register).
9
+ /** NAL unit types this package cares about (H.264 Table 7-1). */
10
+ export const NAL_TYPE = { SLICE: 1, SLICE_DPA: 2, SLICE_DPB: 3, SLICE_DPC: 4, IDR: 5, SEI: 6, SPS: 7, PPS: 8, AUD: 9 };
11
+ /** SEI payload type of a recovery point (H.264 D.1.8). */
12
+ export const SEI_RECOVERY_POINT = 6;
13
+ /** `nal_unit_type` (low 5 bits of the header byte); 0 for an empty NAL. */
14
+ export function nalType(n) { return n.bytes.length ? n.bytes[0] & 0x1f : 0; }
15
+ /** Payload after the 1-byte NAL header (escaped RBSP). */
16
+ export function nalPayload(n) { return n.bytes.subarray(1); }
17
+ /** A VCL NAL type: a coded slice (types 1-5). */
18
+ export function isVclType(t) { return t >= NAL_TYPE.SLICE && t <= NAL_TYPE.IDR; }
19
+ export function nalIsVcl(n) { return isVclType(nalType(n)); }
20
+ /** Start code plus NAL unit, as it sits in the stream (a view). */
21
+ export function nalWithStartCode(buf, n) { return buf.subarray(n.offset, n.offset + n.startCodeLen + n.bytes.length); }
22
+ /** Next Annex-B start code (`00 00 01` or `00 00 00 01`) at or after `from`: `[offset, len]`. */
23
+ export function findStartCode(buf, from) {
24
+ const n = buf.length;
25
+ for (let i = from; i + 3 <= n; i++) {
26
+ if (buf[i] === 0 && buf[i + 1] === 0) {
27
+ if (buf[i + 2] === 1)
28
+ return [i, 3];
29
+ if (i + 4 <= n && buf[i + 2] === 0 && buf[i + 3] === 1)
30
+ return [i, 4];
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * The NAL units of an Annex-B buffer, in order. Bytes before the first start code are skipped;
37
+ * trailing zero bytes (`trailing_zero_8bits`) stay attached to the NAL before them, which is
38
+ * harmless for type/flag decisions and keeps the bytes of a walked AU identical to the input.
39
+ */
40
+ export function nalUnits(buf) {
41
+ const out = [];
42
+ let sc = findStartCode(buf, 0);
43
+ while (sc) {
44
+ const [s, l] = sc;
45
+ const start = s + l;
46
+ const next = findStartCode(buf, start);
47
+ out.push({ offset: s, startCodeLen: l, bytes: buf.subarray(start, next ? next[0] : buf.length) });
48
+ sc = next;
49
+ }
50
+ return out;
51
+ }
52
+ /** The SPS + PPS NAL units of an access unit (with their start codes, in stream order), if it carries any. Defined ⇒ the AU deserves the `CONFIG` flag. */
53
+ export function auParamSets(au) {
54
+ const parts = [];
55
+ for (const n of nalUnits(au)) {
56
+ const t = nalType(n);
57
+ if (t === NAL_TYPE.SPS || t === NAL_TYPE.PPS)
58
+ parts.push(nalWithStartCode(au, n));
59
+ }
60
+ return parts.length ? concatBytes(parts) : undefined;
61
+ }
62
+ /**
63
+ * The sync points an Annex-B access unit offers.
64
+ * - `idr`: an IDR slice (NAL type 5) is present → instantaneous start point (`KEY`).
65
+ * - `recovery`: a recovery point SEI (NAL 6, payload type 6) is present → gradual start point,
66
+ * clean one refresh period later (`RECOVERY`; x264 and nvenc both emit it at each intra-refresh sweep).
67
+ * Decided from the bitstream rather than the encoder's keyframe flag, which libx264 raises for
68
+ * both kinds and nvenc only for IDRs.
69
+ */
70
+ export function auSyncPoints(au) {
71
+ let idr = false, recovery = false;
72
+ for (const n of nalUnits(au)) {
73
+ const t = nalType(n);
74
+ if (t === NAL_TYPE.IDR)
75
+ idr = true;
76
+ else if (t === NAL_TYPE.SEI)
77
+ recovery ||= seiHasRecoveryPoint(nalPayload(n));
78
+ }
79
+ return { idr, recovery };
80
+ }
81
+ /** An IDR is present: the AU is a keyframe in the `KEY` sense. */
82
+ export function isKeyframe(au) { return auSyncPoints(au).idr; }
83
+ /** The AU carries at least one coded slice (otherwise it is parameter sets / SEI only). */
84
+ export function hasVcl(au) { return nalUnits(au).some(nalIsVcl); }
85
+ /** Whether an SEI NAL payload (after the NAL header byte) contains a recovery point message (payload type 6). */
86
+ export function seiHasRecoveryPoint(payload) { return seiPayloadTypes(payload).includes(SEI_RECOVERY_POINT); }
87
+ /** The payload types of the SEI messages in an SEI NAL payload (D.1.1), in order. Emulation-prevention bytes are removed first. */
88
+ export function seiPayloadTypes(payload) {
89
+ const rbsp = rbspUnescape(payload);
90
+ const out = [];
91
+ let i = 0;
92
+ // 0x80 is the rbsp_trailing_bits byte that ends the SEI NAL.
93
+ while (i < rbsp.length && rbsp[i] !== 0x80) {
94
+ // payloadType and payloadSize: runs of 0xFF each adding 255, then a final byte.
95
+ let ty = 0;
96
+ while (i < rbsp.length && rbsp[i] === 0xff) {
97
+ ty += 255;
98
+ i++;
99
+ }
100
+ if (i >= rbsp.length)
101
+ break;
102
+ ty += rbsp[i];
103
+ i++;
104
+ let sz = 0;
105
+ while (i < rbsp.length && rbsp[i] === 0xff) {
106
+ sz += 255;
107
+ i++;
108
+ }
109
+ if (i >= rbsp.length)
110
+ break;
111
+ sz += rbsp[i];
112
+ i++;
113
+ out.push(ty);
114
+ i += sz;
115
+ }
116
+ return out;
117
+ }
118
+ /** Strip H.264 emulation-prevention bytes (the `03` in `00 00 03`), giving the RBSP. */
119
+ export function rbspUnescape(nal) {
120
+ const out = new Uint8Array(nal.length);
121
+ let o = 0, zeros = 0;
122
+ for (const b of nal) {
123
+ if (zeros >= 2 && b === 3) {
124
+ zeros = 0;
125
+ continue;
126
+ }
127
+ out[o++] = b;
128
+ zeros = b === 0 ? zeros + 1 : 0;
129
+ }
130
+ return out.subarray(0, o);
131
+ }
132
+ /** The SPS of an AU (NAL bytes without start code), if present. */
133
+ export function findSps(au) {
134
+ for (const n of nalUnits(au))
135
+ if (nalType(n) === NAL_TYPE.SPS)
136
+ return n.bytes;
137
+ return undefined;
138
+ }
139
+ /** The WebCodecs / RFC 6381 codec string for an AU that carries an SPS: `avc1.` + profile_idc + constraint flags + level_idc as hex (e.g. `avc1.42c01e`). */
140
+ export function codecStringFromSps(au) {
141
+ const sps = findSps(au);
142
+ if (!sps || sps.length < 4)
143
+ return undefined;
144
+ const hex = (b) => b.toString(16).padStart(2, "0");
145
+ return `avc1.${hex(sps[1])}${hex(sps[2])}${hex(sps[3])}`;
146
+ }
147
+ /** `parts` joined into one new buffer (a single part is copied too, so the result never aliases the input). */
148
+ export function concatBytes(parts) {
149
+ let n = 0;
150
+ for (const p of parts)
151
+ n += p.length;
152
+ const out = new Uint8Array(n);
153
+ let o = 0;
154
+ for (const p of parts) {
155
+ out.set(p, o);
156
+ o += p.length;
157
+ }
158
+ return out;
159
+ }
160
+ /**
161
+ * Splits a continuous Annex-B byte stream (a file, an encoder pipe) into access units. Rule
162
+ * (dylithium's reader loop): an AU ends with its coded slice; the next NAL of any kind starts a
163
+ * new AU, so non-VCL NALs (SPS, PPS, SEI, AUD) are attached to the picture that follows them.
164
+ * Sufficient for one-slice-per-picture streams (x264 `slices=1`, nvenc, WebCodecs), which is what
165
+ * the encoder options enforce.
166
+ */
167
+ export class AuSplitter {
168
+ #pending = new Uint8Array(0);
169
+ #au = [];
170
+ #auHasVcl = false;
171
+ /** Feeds bytes; returns every access unit completed by them (in order). */
172
+ push(bytes) {
173
+ this.#pending = this.#pending.length ? concatBytes([this.#pending, bytes]) : bytes.slice();
174
+ const out = [];
175
+ const p = this.#pending;
176
+ let pos = 0;
177
+ for (;;) {
178
+ const sc0 = findStartCode(p, pos);
179
+ if (!sc0)
180
+ break; // no start code yet (maybe a partial one): keep the bytes
181
+ const [s0, l0] = sc0;
182
+ // The NAL is complete only once the next start code is visible.
183
+ const sc1 = findStartCode(p, s0 + l0);
184
+ if (!sc1) {
185
+ pos = s0;
186
+ break;
187
+ }
188
+ const isVcl = s0 + l0 < p.length && isVclType(p[s0 + l0] & 0x1f);
189
+ if (this.#auHasVcl)
190
+ out.push(this.#take()); // the previous AU already has its slice: this NAL belongs to the next one
191
+ this.#au.push(p.slice(s0, sc1[0]));
192
+ if (isVcl)
193
+ this.#auHasVcl = true;
194
+ pos = sc1[0];
195
+ }
196
+ this.#pending = pos > 0 ? p.slice(pos) : p;
197
+ return out;
198
+ }
199
+ /** End of stream: the last NAL (whose end no start code marks) closes the last AU, or starts one more if the current AU already has its slice. */
200
+ finish() {
201
+ const out = [];
202
+ const sc0 = findStartCode(this.#pending, 0);
203
+ if (sc0) {
204
+ if (this.#auHasVcl)
205
+ out.push(this.#take());
206
+ this.#au.push(this.#pending.slice(sc0[0]));
207
+ }
208
+ if (this.#au.length)
209
+ out.push(this.#take());
210
+ this.#pending = new Uint8Array(0);
211
+ return out;
212
+ }
213
+ #take() {
214
+ const au = this.#au.length === 1 ? this.#au[0] : concatBytes(this.#au);
215
+ this.#au = [];
216
+ this.#auHasVcl = false;
217
+ return au;
218
+ }
219
+ }
220
+ /** Splits a whole Annex-B buffer into access units (see {@link AuSplitter}). */
221
+ export function splitAccessUnits(stream) {
222
+ const sp = new AuSplitter();
223
+ const out = sp.push(stream);
224
+ out.push(...sp.finish());
225
+ return out;
226
+ }
@@ -0,0 +1,105 @@
1
+ import type { TimeOffsets, TimeRequest, TimeResponse } from "./types.ts";
2
+ /** Probes sent back to back at connect; the lowest-RTT one is the first estimate. */
3
+ export declare const CLOCK_BURST = 7;
4
+ /** One probe every 2 s after the burst. */
5
+ export declare const CLOCK_INTERVAL_US = 2000000;
6
+ /** The sliding window the candidate (lowest RTT) is picked from. */
7
+ export declare const CLOCK_WINDOW = 15;
8
+ /** Largest change to the estimate per probe after the burst, microseconds. */
9
+ export declare const CLOCK_MAX_SLEW_US = 500;
10
+ /** The four timestamps of one probe (Cristian / SNTP): `t1` local send, `t2` remote receive, `t3` remote send, `t4` local receive. */
11
+ export interface Probe {
12
+ t1: number;
13
+ t2: number;
14
+ t3: number;
15
+ t4: number;
16
+ }
17
+ /** Local clock minus remote clock: `((t1 − t2) + (t4 − t3)) / 2` (integer division toward zero, as Rust's i64 `/`). Off by at most `probeRttUs / 2` on an uneven path. */
18
+ export declare function probeOffsetUs(p: Probe): number;
19
+ /** Network round trip `(t4 − t1) − (t3 − t2)`: the remote's own processing time is excluded. */
20
+ export declare function probeRttUs(p: Probe): number;
21
+ /** One answered probe as the window keeps it. */
22
+ export interface ClockSample {
23
+ /** Local minus remote, from this probe alone. */
24
+ offsetUs: number;
25
+ rttUs: number;
26
+ /** Local clock when the answer arrived (`t4`). */
27
+ at: number;
28
+ }
29
+ /** What a node reports about its clock (SDK §4.4 `clock()`); the camelCase spelling of Rust's `ClockInfo`. */
30
+ export interface ClockInfo {
31
+ /** This node's time-domain id. */
32
+ dom: string;
33
+ /** The broker's time-domain id (from the hello). */
34
+ brokerDom: string;
35
+ /** `dom − brokerDom`, microseconds; 0 until the first sample and when `sameDom`. */
36
+ offsetUs: number;
37
+ /** Error bound on `offsetUs`; 0 when `sameDom`. */
38
+ epsUs: number;
39
+ /** Round trip of the sample the estimate rests on. */
40
+ rttUs: number;
41
+ /** `offsetUs` now minus the offset at the end of the connect burst: the drift tracked so far. */
42
+ driftUs: number;
43
+ /** Probes answered on this session. */
44
+ samples: number;
45
+ /** `dom === brokerDom`: the broker's clock is ours, nothing to estimate. */
46
+ sameDom: boolean;
47
+ /** Local clock when the estimate last changed (`undefined` before the first sample). */
48
+ updatedAt: number | undefined;
49
+ }
50
+ /** Whether `offsetUs`/`epsUs` mean anything yet: same domain, or at least one sample. */
51
+ export declare function hasEstimate(c: ClockInfo): boolean;
52
+ /**
53
+ * The estimator for one session. Drive it with `nextProbeAt()`, `request()` and `response()`;
54
+ * read `info()` (a new object on every change, so it can sit in a cell).
55
+ */
56
+ export declare class ClockSync {
57
+ #private;
58
+ /** A fresh estimator for a session whose hello named `brokerDom`. When it equals `dom` the estimator is done at once: 0 ± 0, `nextProbeAt()` undefined. */
59
+ constructor(dom: string, brokerDom: string, nowUs: number);
60
+ info(): ClockInfo;
61
+ get sameDom(): boolean;
62
+ /** The window's samples, oldest first. */
63
+ samples(): readonly ClockSample[];
64
+ /**
65
+ * When the next probe is due in the local clock: `undefined` when there is nothing to probe (same
66
+ * domain), the time of the previous probe (i.e. right away) while the burst is not spent, then
67
+ * `CLOCK_INTERVAL_US` after the previous probe. A probe that got no answer still counts as sent,
68
+ * so a dead link is probed at the interval, not hammered.
69
+ */
70
+ nextProbeAt(): number | undefined;
71
+ /** Builds the next `synapse/time` request with `t1 = nowUs` and the current estimate, if any. Marks the probe sent; the answer goes to `response()`. */
72
+ request(nowUs: number): TimeRequest;
73
+ /** The probe got no answer (timeout, session lost): forget it; the schedule goes on. */
74
+ probeLost(): void;
75
+ /**
76
+ * Records the answer to the outstanding probe, read at local time `t4`, and updates the estimate.
77
+ * Returns the sample, or `undefined` when the answer is not to the outstanding probe (`t1`
78
+ * differs: late or duplicate) or the broker turned out to share our domain (then the estimator
79
+ * switches to the same-domain shortcut).
80
+ */
81
+ response(resp: TimeResponse, t4: number): ClockSample | undefined;
82
+ }
83
+ /** A publisher's stamp converted into our clock, with the error bound to fold into any freshness judgment. */
84
+ export interface LocalTime {
85
+ localUs: number;
86
+ epsUs: number;
87
+ }
88
+ /**
89
+ * Converts `tUs`, stamped by a publisher in time domain `dom`, into the clock of the node `me`
90
+ * describes (SDK §4.4, the three judgment modes):
91
+ * - same domain (`dom === me.dom`): exact, `epsUs` 0;
92
+ * - different domain with a known offset: through the broker's clock,
93
+ * `local = tUs − offset(dom) + offset(me)`, `eps = eps(dom) + eps(me)`; the broker's own domain
94
+ * has offset 0 and eps 0, the publisher's comes from the `runtime/time` table, ours from `me`;
95
+ * - no entry for `dom` (or no estimate of our own yet): `undefined`, no judgment: never drop on age.
96
+ */
97
+ export declare function toLocalUs(me: ClockInfo, table: TimeOffsets, tUs: number, dom: string): LocalTime | undefined;
98
+ /** How old a frame is on our clock (`nowUs − local`), with the conversion's error bound; `undefined` when no judgment is possible. */
99
+ export interface Age {
100
+ ageUs: number;
101
+ epsUs: number;
102
+ }
103
+ export declare function ageOf(me: ClockInfo, table: TimeOffsets, tUs: number, dom: string, nowUs: number): Age | undefined;
104
+ /** The erring-stale rule (SDK §4.4): a frame that *might* be older than `thresholdUs` counts as stale; an unknown age is never judged. */
105
+ export declare function isStale(age: Age | undefined, thresholdUs: number): boolean;
package/dist/clock.js ADDED
@@ -0,0 +1,136 @@
1
+ /** Probes sent back to back at connect; the lowest-RTT one is the first estimate. */
2
+ export const CLOCK_BURST = 7;
3
+ /** One probe every 2 s after the burst. */
4
+ export const CLOCK_INTERVAL_US = 2_000_000;
5
+ /** The sliding window the candidate (lowest RTT) is picked from. */
6
+ export const CLOCK_WINDOW = 15;
7
+ /** Largest change to the estimate per probe after the burst, microseconds. */
8
+ export const CLOCK_MAX_SLEW_US = 500;
9
+ const U32_MAX = 0xffff_ffff;
10
+ /** Local clock minus remote clock: `((t1 − t2) + (t4 − t3)) / 2` (integer division toward zero, as Rust's i64 `/`). Off by at most `probeRttUs / 2` on an uneven path. */
11
+ export function probeOffsetUs(p) { return Math.trunc((p.t1 - p.t2 + (p.t4 - p.t3)) / 2); }
12
+ /** Network round trip `(t4 − t1) − (t3 − t2)`: the remote's own processing time is excluded. */
13
+ export function probeRttUs(p) { return p.t4 - p.t1 - (p.t3 - p.t2); }
14
+ function freshClockInfo(dom, brokerDom, updatedAt) {
15
+ const sameDom = dom === brokerDom;
16
+ return { dom, brokerDom, offsetUs: 0, epsUs: 0, rttUs: 0, driftUs: 0, samples: 0, sameDom, updatedAt: sameDom ? updatedAt : undefined };
17
+ }
18
+ /** Whether `offsetUs`/`epsUs` mean anything yet: same domain, or at least one sample. */
19
+ export function hasEstimate(c) { return c.sameDom || c.samples > 0; }
20
+ /**
21
+ * The estimator for one session. Drive it with `nextProbeAt()`, `request()` and `response()`;
22
+ * read `info()` (a new object on every change, so it can sit in a cell).
23
+ */
24
+ export class ClockSync {
25
+ #info;
26
+ #window = [];
27
+ #estimate;
28
+ #sent = 0;
29
+ #outstanding;
30
+ #lastProbeAt;
31
+ #startedAt;
32
+ /** A fresh estimator for a session whose hello named `brokerDom`. When it equals `dom` the estimator is done at once: 0 ± 0, `nextProbeAt()` undefined. */
33
+ constructor(dom, brokerDom, nowUs) {
34
+ this.#info = freshClockInfo(dom, brokerDom, nowUs);
35
+ this.#startedAt = nowUs;
36
+ }
37
+ info() { return this.#info; }
38
+ get sameDom() { return this.#info.sameDom; }
39
+ /** The window's samples, oldest first. */
40
+ samples() { return this.#window; }
41
+ /**
42
+ * When the next probe is due in the local clock: `undefined` when there is nothing to probe (same
43
+ * domain), the time of the previous probe (i.e. right away) while the burst is not spent, then
44
+ * `CLOCK_INTERVAL_US` after the previous probe. A probe that got no answer still counts as sent,
45
+ * so a dead link is probed at the interval, not hammered.
46
+ */
47
+ nextProbeAt() {
48
+ if (this.#info.sameDom)
49
+ return undefined;
50
+ if (this.#lastProbeAt === undefined)
51
+ return this.#startedAt;
52
+ return this.#sent < CLOCK_BURST ? this.#lastProbeAt : this.#lastProbeAt + CLOCK_INTERVAL_US;
53
+ }
54
+ /** Builds the next `synapse/time` request with `t1 = nowUs` and the current estimate, if any. Marks the probe sent; the answer goes to `response()`. */
55
+ request(nowUs) {
56
+ this.#sent++;
57
+ this.#lastProbeAt = nowUs;
58
+ this.#outstanding = nowUs;
59
+ const req = { t1: nowUs, dom: this.#info.dom };
60
+ if (this.#estimate && !this.#info.sameDom) {
61
+ req.offset_us = this.#estimate.offsetUs;
62
+ req.rtt_us = this.#info.rttUs;
63
+ req.eps_us = this.#info.epsUs;
64
+ }
65
+ return req;
66
+ }
67
+ /** The probe got no answer (timeout, session lost): forget it; the schedule goes on. */
68
+ probeLost() { this.#outstanding = undefined; }
69
+ /**
70
+ * Records the answer to the outstanding probe, read at local time `t4`, and updates the estimate.
71
+ * Returns the sample, or `undefined` when the answer is not to the outstanding probe (`t1`
72
+ * differs: late or duplicate) or the broker turned out to share our domain (then the estimator
73
+ * switches to the same-domain shortcut).
74
+ */
75
+ response(resp, t4) {
76
+ if (this.#outstanding !== resp.t1)
77
+ return undefined;
78
+ this.#outstanding = undefined;
79
+ if (resp.dom === this.#info.dom) {
80
+ this.#info = freshClockInfo(this.#info.dom, resp.dom, t4);
81
+ this.#estimate = undefined;
82
+ this.#window.length = 0;
83
+ return undefined;
84
+ }
85
+ const probe = { t1: resp.t1, t2: resp.t2, t3: resp.t3, t4 };
86
+ const sample = { offsetUs: probeOffsetUs(probe), rttUs: Math.min(Math.max(probeRttUs(probe), 0), U32_MAX), at: t4 };
87
+ this.#window.push(sample);
88
+ while (this.#window.length > CLOCK_WINDOW)
89
+ this.#window.shift();
90
+ // The candidate: lowest RTT in the window, the newest on a tie (it carries the least drift).
91
+ let candidate = this.#window[0];
92
+ for (const s of this.#window)
93
+ if (s.rttUs <= candidate.rttUs)
94
+ candidate = s;
95
+ const inBurst = this.#sent <= CLOCK_BURST;
96
+ const prev = this.#estimate;
97
+ // No estimate yet, or still in the burst: the lowest-RTT sample *is* the estimate; afterwards slew toward it.
98
+ const offsetUs = !prev || inBurst ? candidate.offsetUs : prev.offsetUs + Math.min(Math.max(candidate.offsetUs - prev.offsetUs, -CLOCK_MAX_SLEW_US), CLOCK_MAX_SLEW_US);
99
+ const initialUs = prev && !inBurst ? prev.initialUs : offsetUs;
100
+ this.#estimate = { offsetUs, initialUs };
101
+ const residual = Math.min(Math.abs(candidate.offsetUs - offsetUs), U32_MAX);
102
+ const i = this.#info;
103
+ this.#info = { ...i, offsetUs, rttUs: candidate.rttUs, epsUs: Math.min(Math.trunc(candidate.rttUs / 2) + residual, U32_MAX), driftUs: offsetUs - initialUs, samples: i.samples + 1, updatedAt: t4 };
104
+ return sample;
105
+ }
106
+ }
107
+ /**
108
+ * Converts `tUs`, stamped by a publisher in time domain `dom`, into the clock of the node `me`
109
+ * describes (SDK §4.4, the three judgment modes):
110
+ * - same domain (`dom === me.dom`): exact, `epsUs` 0;
111
+ * - different domain with a known offset: through the broker's clock,
112
+ * `local = tUs − offset(dom) + offset(me)`, `eps = eps(dom) + eps(me)`; the broker's own domain
113
+ * has offset 0 and eps 0, the publisher's comes from the `runtime/time` table, ours from `me`;
114
+ * - no entry for `dom` (or no estimate of our own yet): `undefined`, no judgment: never drop on age.
115
+ */
116
+ export function toLocalUs(me, table, tUs, dom) {
117
+ if (dom === me.dom)
118
+ return { localUs: tUs, epsUs: 0 };
119
+ if (!hasEstimate(me))
120
+ return undefined;
121
+ let offPub = 0, epsPub = 0;
122
+ if (dom !== me.brokerDom) {
123
+ const e = table[dom];
124
+ if (!e)
125
+ return undefined;
126
+ offPub = e.offset_us;
127
+ epsPub = e.eps_us;
128
+ }
129
+ return { localUs: tUs - offPub + me.offsetUs, epsUs: Math.min(epsPub + me.epsUs, U32_MAX) };
130
+ }
131
+ export function ageOf(me, table, tUs, dom, nowUs) {
132
+ const l = toLocalUs(me, table, tUs, dom);
133
+ return l && { ageUs: nowUs - l.localUs, epsUs: l.epsUs };
134
+ }
135
+ /** The erring-stale rule (SDK §4.4): a frame that *might* be older than `thresholdUs` counts as stale; an unknown age is never judged. */
136
+ export function isStale(age, thresholdUs) { return age !== undefined && age.ageUs + age.epsUs > thresholdUs; }