/
/
/
1/**
2 * Audio clock source selection and output timestamp validation.
3 *
4 * Manages two clock sources for AudioContext time:
5 * - "estimated": De-quantized AudioContext.currentTime using wall-clock slew
6 * - "timestamp": AudioContext.getOutputTimestamp() with extensive validation
7 *
8 * Promotes to "timestamp" after enough good samples, demotes on failures.
9 */
10const OUTPUT_TIMESTAMP_MAX_FRESHNESS_MS = 250;
11const OUTPUT_TIMESTAMP_MIN_SAMPLE_INTERVAL_MS = 40;
12const OUTPUT_TIMESTAMP_SLOPE_MIN = 0.95;
13const OUTPUT_TIMESTAMP_SLOPE_MAX = 1.05;
14const OUTPUT_TIMESTAMP_MAX_DIVERGENCE_SEC = 0.25;
15const OUTPUT_TIMESTAMP_MAX_DIVERGENCE_DELTA_SEC = 0.05;
16const OUTPUT_TIMESTAMP_MAX_BACKWARD_SEC = 0.005;
17const OUTPUT_TIMESTAMP_FUTURE_TOLERANCE_MS = 5;
18const OUTPUT_TIMESTAMP_PROMOTION_MIN_GOOD_SAMPLES = 6;
19const OUTPUT_TIMESTAMP_PROMOTION_MIN_SPAN_MS = 750;
20const OUTPUT_TIMESTAMP_MAX_CONSECUTIVE_BAD_SAMPLES = 2;
21// Timing estimate constants
22const TIMING_MAX_SLEW_SEC = 0.002;
23const TIMING_RESET_THRESHOLD_SEC = 0.5;
24const TIMING_MAX_LEAD_SEC = 0.1;
25export class ClockSource {
26 constructor() {
27 this.activeSource = "estimated";
28 this._pendingCutover = false;
29 this._lastRejectReason = null;
30 this._timestampPromotionDisabled = false;
31 // Output timestamp validation state
32 this.lastSample = null;
33 this.goodSamples = 0;
34 this.badSamples = 0;
35 this.goodSinceMs = null;
36 // Estimated time state
37 this.estimateAudioTimeSec = null;
38 this.estimateAtMs = null;
39 }
40 get active() {
41 return this.activeSource;
42 }
43 get pendingCutover() {
44 return this._pendingCutover;
45 }
46 set pendingCutover(value) {
47 this._pendingCutover = value;
48 }
49 get lastRejectReason() {
50 return this._lastRejectReason;
51 }
52 get timestampGoodSamples() {
53 return this.goodSamples;
54 }
55 get timestampPromotionDisabled() {
56 return this._timestampPromotionDisabled;
57 }
58 /** Disable timestamp promotion (e.g., on Cast receivers to avoid rate oscillations). */
59 disableTimestampPromotion() {
60 this._timestampPromotionDisabled = true;
61 }
62 setActive(source) {
63 if (this.activeSource === source)
64 return false;
65 this.activeSource = source;
66 this._pendingCutover = source === "timestamp";
67 if (this._pendingCutover) {
68 this._onPromotion?.();
69 }
70 return this._pendingCutover;
71 }
72 onPromotion(cb) {
73 this._onPromotion = cb;
74 }
75 reset() {
76 this.activeSource = "estimated";
77 this._pendingCutover = false;
78 this.lastSample = null;
79 this.goodSamples = 0;
80 this._lastRejectReason = null;
81 this.badSamples = 0;
82 this.goodSinceMs = null;
83 this.estimateAudioTimeSec = null;
84 this.estimateAtMs = null;
85 }
86 demote(reason) {
87 this.reset();
88 this._lastRejectReason = reason;
89 }
90 rejectSample(reason, catastrophic = false) {
91 this.lastSample = null;
92 this.goodSamples = 0;
93 this.goodSinceMs = null;
94 this._lastRejectReason = reason;
95 if (this.activeSource !== "timestamp") {
96 this.badSamples = 0;
97 return;
98 }
99 this.badSamples += 1;
100 if (catastrophic ||
101 this.badSamples >= OUTPUT_TIMESTAMP_MAX_CONSECUTIVE_BAD_SAMPLES) {
102 this.demote(reason);
103 }
104 }
105 getEstimatedTime(rawTimeSec, nowMs) {
106 if (this.estimateAudioTimeSec === null) {
107 this.estimateAudioTimeSec = rawTimeSec;
108 this.estimateAtMs = nowMs;
109 }
110 else if (this.estimateAtMs !== null) {
111 const wallDeltaSec = Math.max(0, (nowMs - this.estimateAtMs) / 1000);
112 const predicted = this.estimateAudioTimeSec + wallDeltaSec;
113 this.estimateAtMs = nowMs;
114 const errorSec = rawTimeSec - predicted;
115 if (Math.abs(errorSec) > TIMING_RESET_THRESHOLD_SEC) {
116 this.estimateAudioTimeSec = rawTimeSec;
117 }
118 else {
119 const slew = Math.max(-TIMING_MAX_SLEW_SEC, Math.min(TIMING_MAX_SLEW_SEC, errorSec));
120 const next = Math.max(this.estimateAudioTimeSec, predicted + slew);
121 this.estimateAudioTimeSec = Math.min(next, rawTimeSec + TIMING_MAX_LEAD_SEC);
122 }
123 }
124 return this.estimateAudioTimeSec ?? rawTimeSec;
125 }
126 getTimestampDerivedTime(rawTimeSec, audioContext) {
127 // On Cast receivers, stay on the estimated clock to avoid rate oscillations.
128 if (this._timestampPromotionDisabled) {
129 if (this.activeSource !== "estimated" ||
130 this.lastSample !== null ||
131 this.goodSamples !== 0 ||
132 this._lastRejectReason !== null) {
133 this.reset();
134 }
135 return null;
136 }
137 const getOutputTimestamp = audioContext.getOutputTimestamp;
138 if (typeof getOutputTimestamp !== "function") {
139 if (this.activeSource === "timestamp") {
140 this.demote("getOutputTimestamp unavailable");
141 }
142 return null;
143 }
144 try {
145 const ts = getOutputTimestamp.call(audioContext);
146 const nowMs = performance.now();
147 const rawFreshnessMs = nowMs - ts.performanceTime;
148 if (rawFreshnessMs < -OUTPUT_TIMESTAMP_FUTURE_TOLERANCE_MS) {
149 this.rejectSample(`performanceTime in future (${rawFreshnessMs.toFixed(1)}ms)`, true);
150 return null;
151 }
152 const freshnessMs = Math.max(0, rawFreshnessMs);
153 const predictedAudioTimeSec = ts.contextTime + freshnessMs / 1000;
154 const sample = {
155 contextTimeSec: ts.contextTime,
156 performanceTimeMs: ts.performanceTime,
157 nowMs,
158 predictedAudioTimeSec,
159 rawAudioTimeSec: rawTimeSec,
160 };
161 if (freshnessMs > OUTPUT_TIMESTAMP_MAX_FRESHNESS_MS) {
162 this.rejectSample(`stale timestamp (${freshnessMs.toFixed(1)}ms old)`, true);
163 return null;
164 }
165 const divergenceSec = predictedAudioTimeSec - rawTimeSec;
166 if (Math.abs(divergenceSec) > OUTPUT_TIMESTAMP_MAX_DIVERGENCE_SEC) {
167 this.rejectSample(`timestamp/raw divergence ${Math.abs(divergenceSec * 1000).toFixed(1)}ms`, true);
168 return null;
169 }
170 const prev = this.lastSample;
171 if (prev) {
172 const perfDeltaMs = ts.performanceTime - prev.performanceTimeMs;
173 if (perfDeltaMs < 0) {
174 this.rejectSample(`performanceTime moved backward (${perfDeltaMs.toFixed(1)}ms)`, true);
175 return null;
176 }
177 if (predictedAudioTimeSec <
178 prev.predictedAudioTimeSec - OUTPUT_TIMESTAMP_MAX_BACKWARD_SEC) {
179 this.rejectSample(`predicted audio time moved backward ${((prev.predictedAudioTimeSec - predictedAudioTimeSec) * 1000).toFixed(1)}ms`, true);
180 return null;
181 }
182 const prevDivergenceSec = prev.predictedAudioTimeSec - prev.rawAudioTimeSec;
183 if (Math.abs(divergenceSec - prevDivergenceSec) >
184 OUTPUT_TIMESTAMP_MAX_DIVERGENCE_DELTA_SEC) {
185 this.rejectSample(`timestamp/raw divergence drift ${Math.abs((divergenceSec - prevDivergenceSec) * 1000).toFixed(1)}ms`);
186 return null;
187 }
188 if (perfDeltaMs >= OUTPUT_TIMESTAMP_MIN_SAMPLE_INTERVAL_MS) {
189 const perfDeltaSec = perfDeltaMs / 1000;
190 const contextSlope = (ts.contextTime - prev.contextTimeSec) / perfDeltaSec;
191 const predictedSlope = (predictedAudioTimeSec - prev.predictedAudioTimeSec) / perfDeltaSec;
192 if (contextSlope < OUTPUT_TIMESTAMP_SLOPE_MIN ||
193 contextSlope > OUTPUT_TIMESTAMP_SLOPE_MAX) {
194 this.rejectSample(`context slope ${contextSlope.toFixed(3)} out of range`);
195 return null;
196 }
197 if (predictedSlope < OUTPUT_TIMESTAMP_SLOPE_MIN ||
198 predictedSlope > OUTPUT_TIMESTAMP_SLOPE_MAX) {
199 this.rejectSample(`predicted slope ${predictedSlope.toFixed(3)} out of range`);
200 return null;
201 }
202 }
203 }
204 this.lastSample = sample;
205 this.badSamples = 0;
206 if (this.goodSinceMs === null) {
207 this.goodSinceMs = nowMs;
208 }
209 this.goodSamples += 1;
210 if (this.activeSource !== "timestamp" &&
211 this.goodSamples >= OUTPUT_TIMESTAMP_PROMOTION_MIN_GOOD_SAMPLES &&
212 this.goodSinceMs !== null &&
213 nowMs - this.goodSinceMs >= OUTPUT_TIMESTAMP_PROMOTION_MIN_SPAN_MS) {
214 this.setActive("timestamp");
215 this._lastRejectReason = null;
216 }
217 return predictedAudioTimeSec;
218 }
219 catch (error) {
220 const reason = error instanceof Error
221 ? `getOutputTimestamp failed: ${error.message}`
222 : `getOutputTimestamp failed: ${String(error)}`;
223 this.rejectSample(reason, true);
224 return null;
225 }
226 }
227 /** Get a timing snapshot with both derived and raw AudioContext times. */
228 getTimingSnapshot(audioContext) {
229 const nowMs = performance.now();
230 const nowUs = nowMs * 1000;
231 if (!audioContext) {
232 return {
233 audioContextTimeSec: 0,
234 audioContextRawTimeSec: 0,
235 nowMs,
236 nowUs,
237 };
238 }
239 const rawTimeSec = audioContext.currentTime;
240 const estimatedTimeSec = this.getEstimatedTime(rawTimeSec, nowMs);
241 const timestampTimeSec = this.getTimestampDerivedTime(rawTimeSec, audioContext);
242 let derivedTimeSec = this.activeSource === "timestamp" && timestampTimeSec !== null
243 ? timestampTimeSec
244 : estimatedTimeSec;
245 if (!Number.isFinite(derivedTimeSec)) {
246 derivedTimeSec = rawTimeSec;
247 }
248 return {
249 audioContextTimeSec: derivedTimeSec,
250 audioContextRawTimeSec: rawTimeSec,
251 nowMs,
252 nowUs,
253 };
254 }
255}
256//# sourceMappingURL=clock-source.js.map
257