/
/
/
1/**
2 * Audio scheduler for synchronized playback.
3 *
4 * Handles Web Audio API scheduling, sync correction, AudioContext management,
5 * volume control, and output routing. Receives pre-decoded audio chunks
6 * (DecodedAudioChunk) from SendspinCore and schedules them for playback.
7 */
8import { ClockSource } from "./clock-source.js";
9import { RecorrectionMonitor, RECORRECTION_CUTOVER_GUARD_SEC, } from "./recorrection-monitor.js";
10import { OutputLatencyTracker } from "./output-latency-tracker.js";
11import { clampSyncDelayMs } from "../sync-delay.js";
12// Sync correction constants
13const SAMPLE_CORRECTION_FADE_LEN = 8;
14const SAMPLE_CORRECTION_TARGET_BLEND_SUM = 1.0;
15const SAMPLE_CORRECTION_FADE_STRENGTH = Math.min(1, (2 * SAMPLE_CORRECTION_TARGET_BLEND_SUM) / SAMPLE_CORRECTION_FADE_LEN);
16const SAMPLE_CORRECTION_FADE_ALPHAS = new Float32Array(SAMPLE_CORRECTION_FADE_LEN);
17for (let f = 0; f < SAMPLE_CORRECTION_FADE_LEN; f++) {
18 SAMPLE_CORRECTION_FADE_ALPHAS[f] =
19 ((SAMPLE_CORRECTION_FADE_LEN - f) / (SAMPLE_CORRECTION_FADE_LEN + 1)) *
20 SAMPLE_CORRECTION_FADE_STRENGTH;
21}
22const SYNC_ERROR_ALPHA = 0.1;
23const SCHEDULE_HEADROOM_SEC = 0.2;
24const SCHEDULE_HORIZON_PRECISE_SEC = 20;
25const SCHEDULE_HORIZON_GOOD_SEC = 8;
26const SCHEDULE_HORIZON_POOR_SEC = 4;
27const CAST_SCHEDULE_HORIZON_SEC = 1.5;
28const SCHEDULE_HORIZON_PRECISE_ERROR_MS = 2;
29const SCHEDULE_HORIZON_GOOD_ERROR_MS = 8;
30const SCHEDULE_REFILL_THRESHOLD_FRACTION = 0.5;
31const SCHEDULE_REFILL_MIN_THRESHOLD_SEC = 0.1;
32const SCHEDULE_REFILL_MAX_THRESHOLD_SEC = 5;
33const VOLUME_RAMP_TIME_CONSTANT_SEC = 0.015;
34export function perceptualGain(volume) {
35 return Math.pow(volume / 100, 1.5);
36}
37const DEFAULT_CORRECTION_THRESHOLDS = {
38 sync: {
39 resyncAboveMs: 200,
40 rate2AboveMs: 35,
41 rate1AboveMs: 8,
42 samplesBelowMs: 8,
43 deadbandBelowMs: 1,
44 enableRecorrectionMonitor: true,
45 immediateDelayCutover: true,
46 },
47 quality: {
48 resyncAboveMs: 35,
49 rate2AboveMs: Infinity,
50 rate1AboveMs: Infinity,
51 samplesBelowMs: 35,
52 deadbandBelowMs: 1,
53 enableRecorrectionMonitor: false,
54 immediateDelayCutover: false,
55 },
56 "quality-local": {
57 resyncAboveMs: 600,
58 rate2AboveMs: Infinity,
59 rate1AboveMs: Infinity,
60 samplesBelowMs: 0,
61 deadbandBelowMs: 5,
62 enableRecorrectionMonitor: false,
63 immediateDelayCutover: false,
64 },
65};
66export class AudioScheduler {
67 constructor(options) {
68 this.audioContext = null;
69 this.gainNode = null;
70 this.streamDestination = null;
71 this.audioBufferQueue = [];
72 this.scheduledSources = [];
73 this.nextPlaybackTime = 0;
74 this.nextScheduleTime = 0;
75 this.lastScheduledServerTime = 0;
76 this.currentSyncErrorMs = 0;
77 this.smoothedSyncErrorMs = 0;
78 this.resyncCount = 0;
79 this.currentPlaybackRate = 1.0;
80 this.currentCorrectionMethod = "none";
81 this.lastSamplesAdjusted = 0;
82 this._correctionMode = "sync";
83 this._lastStatusLogMs = 0;
84 this._intervalResyncCount = 0;
85 this.scheduleTimeout = null;
86 this.refillTimeout = null;
87 this.queueProcessScheduled = false;
88 // Sub-modules
89 this.clockSource = new ClockSource();
90 this.stateManager = options.stateManager;
91 this.timeFilter = options.timeFilter;
92 this.outputMode = options.outputMode ?? "direct";
93 this.audioElement = options.audioElement;
94 this.isAndroid = options.isAndroid ?? false;
95 this.isCastRuntime = options.isCastRuntime ?? false;
96 this.ownsAudioElement = options.ownsAudioElement ?? false;
97 this.silentAudioSrc = options.silentAudioSrc;
98 this.syncDelayMs = clampSyncDelayMs(options.syncDelayMs ?? 0);
99 this.useHardwareVolume = options.useHardwareVolume ?? false;
100 this._correctionMode = options.correctionMode ?? "sync";
101 this.useOutputLatencyCompensation =
102 options.useOutputLatencyCompensation ?? true;
103 // Merge user-provided threshold overrides with defaults
104 this.correctionThresholds = { ...DEFAULT_CORRECTION_THRESHOLDS };
105 const thresholdOverrides = options.correctionThresholds;
106 if (thresholdOverrides) {
107 for (const mode of Object.keys(thresholdOverrides)) {
108 const overrides = thresholdOverrides[mode];
109 if (overrides) {
110 this.correctionThresholds[mode] = {
111 ...DEFAULT_CORRECTION_THRESHOLDS[mode],
112 ...overrides,
113 };
114 }
115 }
116 }
117 this.latencyTracker = new OutputLatencyTracker(options.storage ?? null);
118 if (this.isCastRuntime) {
119 this.clockSource.disableTimestampPromotion();
120 }
121 this.clockSource.onPromotion(() => {
122 if (this.audioBufferQueue.length > 0 ||
123 this.scheduledSources.length > 0) {
124 this.scheduleQueueProcessing();
125 }
126 });
127 this.recorrectionMonitor = new RecorrectionMonitor(() => this.checkRecorrection());
128 }
129 get correctionMode() {
130 return this._correctionMode;
131 }
132 setCorrectionMode(mode) {
133 this._correctionMode = mode;
134 if (!this.correctionThresholds[mode].enableRecorrectionMonitor) {
135 this.recorrectionMonitor.stop();
136 }
137 else {
138 this.recorrectionMonitor.start();
139 }
140 }
141 get usesRecorrectionMonitor() {
142 return this.correctionThresholds[this._correctionMode]
143 .enableRecorrectionMonitor;
144 }
145 get usesImmediateDelayCutover() {
146 return this.correctionThresholds[this._correctionMode]
147 .immediateDelayCutover;
148 }
149 getTargetScheduledHorizonSec() {
150 if (this.isCastRuntime) {
151 return CAST_SCHEDULE_HORIZON_SEC;
152 }
153 const errorMs = this.timeFilter.error / 1000;
154 if (errorMs < SCHEDULE_HORIZON_PRECISE_ERROR_MS)
155 return SCHEDULE_HORIZON_PRECISE_SEC;
156 if (errorMs <= SCHEDULE_HORIZON_GOOD_ERROR_MS)
157 return SCHEDULE_HORIZON_GOOD_SEC;
158 return SCHEDULE_HORIZON_POOR_SEC;
159 }
160 getScheduledAheadSec(currentTimeSec) {
161 let farthest = this.nextScheduleTime;
162 for (const entry of this.scheduledSources) {
163 if (entry.endTime > farthest)
164 farthest = entry.endTime;
165 }
166 return farthest <= 0 ? 0 : Math.max(0, farthest - currentTimeSec);
167 }
168 resetScheduledPlaybackState(_reason) {
169 this.nextPlaybackTime = 0;
170 this.nextScheduleTime = 0;
171 this.lastScheduledServerTime = 0;
172 this.recorrectionMonitor.clearMinScheduleTime();
173 this.recorrectionMonitor.clearHardResyncCooldown();
174 this.clockSource.pendingCutover = false;
175 this.recorrectionMonitor.resetCheckState();
176 this.resetSyncErrorEma();
177 this.currentSyncErrorMs = 0;
178 this.currentPlaybackRate = 1.0;
179 this.currentCorrectionMethod = "none";
180 this.lastSamplesAdjusted = 0;
181 this._lastStatusLogMs = 0;
182 this._intervalResyncCount = 0;
183 }
184 pruneExpiredScheduledSources(currentTimeSec) {
185 if (this.scheduledSources.length === 0)
186 return;
187 this.scheduledSources = this.scheduledSources.filter((entry) => entry.endTime > currentTimeSec);
188 if (this.scheduledSources.length === 0) {
189 this.resetScheduledPlaybackState("no scheduled audio ahead");
190 }
191 }
192 performGuardedCutover(_reason, options = {}) {
193 if (!this.audioContext)
194 return;
195 const incrementResyncCount = options.incrementResyncCount ?? false;
196 const markCooldown = options.markCooldown ?? true;
197 const nowMs = performance.now();
198 const cutoffTime = this.audioContext.currentTime + RECORRECTION_CUTOVER_GUARD_SEC;
199 if (incrementResyncCount) {
200 this.resyncCount++;
201 this._intervalResyncCount++;
202 }
203 this.resetSyncErrorEma();
204 this.currentCorrectionMethod = "resync";
205 this.lastSamplesAdjusted = 0;
206 this.currentPlaybackRate = 1.0;
207 const cutResult = this.cutScheduledSources(cutoffTime);
208 this.recorrectionMonitor.setMinScheduleTime(Math.max(cutoffTime, cutResult.keptTailEndTimeSec));
209 this.nextPlaybackTime = 0;
210 this.nextScheduleTime = 0;
211 this.lastScheduledServerTime = 0;
212 this.recorrectionMonitor.resetCheckState();
213 if (markCooldown)
214 this.recorrectionMonitor.markRecorrection(nowMs);
215 this.recorrectionMonitor.noteHardResync(nowMs);
216 this.processAudioQueue();
217 }
218 checkRecorrection() {
219 if (!this.usesRecorrectionMonitor) {
220 this.recorrectionMonitor.resetCheckState();
221 return;
222 }
223 if (!this.audioContext || this.audioContext.state !== "running") {
224 this.recorrectionMonitor.resetCheckState();
225 return;
226 }
227 if (!this.stateManager.isPlaying ||
228 this.nextPlaybackTime === 0 ||
229 this.lastScheduledServerTime === 0) {
230 this.recorrectionMonitor.resetCheckState();
231 return;
232 }
233 const { audioContextTimeSec, audioContextRawTimeSec, nowMs, nowUs } = this.clockSource.getTimingSnapshot(this.audioContext);
234 this.pruneExpiredScheduledSources(audioContextRawTimeSec);
235 if (this.getScheduledAheadSec(audioContextRawTimeSec) <= 0) {
236 this.recorrectionMonitor.resetCheckState();
237 if (this.audioBufferQueue.length > 0)
238 this.processAudioQueue();
239 return;
240 }
241 const outputLatencySec = this.useOutputLatencyCompensation
242 ? this.latencyTracker.getSmoothedUs(this.audioContext) / 1000000
243 : 0;
244 const targetPlaybackTime = this.computeTargetPlaybackTime(this.lastScheduledServerTime, audioContextTimeSec, nowUs, outputLatencySec);
245 const syncErrorMs = (this.nextPlaybackTime - targetPlaybackTime) * 1000;
246 const smoothedSyncErrorMs = this.applySyncErrorEma(syncErrorMs);
247 if (this.recorrectionMonitor.shouldRecorrect(Math.abs(smoothedSyncErrorMs), syncErrorMs, nowMs)) {
248 this.performGuardedCutover("recorrection", {
249 incrementResyncCount: true,
250 markCooldown: true,
251 });
252 }
253 }
254 getSyncDelayMs() {
255 return this.syncDelayMs;
256 }
257 setSyncDelay(delayMs) {
258 const sanitized = clampSyncDelayMs(delayMs);
259 const delta = sanitized - this.syncDelayMs;
260 this.syncDelayMs = sanitized;
261 if (delta === 0 || !this.usesImmediateDelayCutover)
262 return;
263 if (!this.audioContext || this.audioContext.state !== "running")
264 return;
265 if (!this.stateManager.isPlaying)
266 return;
267 if (this.scheduledSources.length === 0 &&
268 this.audioBufferQueue.length === 0 &&
269 this.nextPlaybackTime === 0)
270 return;
271 this.performGuardedCutover("delay-change", {
272 incrementResyncCount: false,
273 markCooldown: true,
274 });
275 }
276 get syncInfo() {
277 return {
278 clockDriftPercent: this.timeFilter.drift * 100,
279 syncErrorMs: this.currentSyncErrorMs,
280 resyncCount: this.resyncCount,
281 outputLatencyMs: this.latencyTracker.getRawUs(this.audioContext) / 1000,
282 playbackRate: this.currentPlaybackRate,
283 correctionMethod: this.currentCorrectionMethod,
284 samplesAdjusted: this.lastSamplesAdjusted,
285 correctionMode: this._correctionMode,
286 };
287 }
288 emitStatusLog(nowMs) {
289 if (this._lastStatusLogMs !== 0 && nowMs - this._lastStatusLogMs < 10000)
290 return;
291 this._lastStatusLogMs = nowMs;
292 let corr;
293 switch (this.currentCorrectionMethod) {
294 case "rate":
295 corr = `rate@${this.currentPlaybackRate}`;
296 break;
297 case "samples":
298 corr = `samples:${this.lastSamplesAdjusted}`;
299 break;
300 default:
301 corr = this.currentCorrectionMethod;
302 }
303 const queueDepth = this.audioBufferQueue.length + this.scheduledSources.length;
304 const aheadSec = this.audioContext
305 ? this.getScheduledAheadSec(this.audioContext.currentTime)
306 : 0;
307 let clock;
308 if (this.clockSource.timestampPromotionDisabled) {
309 clock = "estimated(cast-disabled)";
310 }
311 else if (this.clockSource.active === "timestamp") {
312 clock = `timestamp(good:${this.clockSource.timestampGoodSamples})`;
313 }
314 else if (this.clockSource.lastRejectReason) {
315 clock = `estimated(reject:"${this.clockSource.lastRejectReason}")`;
316 }
317 else {
318 clock = "estimated";
319 }
320 const tf = this.timeFilter.is_synchronized
321 ? `synced(err=${(this.timeFilter.error / 1000).toFixed(1)}ms,drift=${this.timeFilter.drift.toFixed(3)},n=${this.timeFilter.count})`
322 : `pending(n=${this.timeFilter.count})`;
323 const smoothedLatUs = this.latencyTracker.getSmoothedUs(this.audioContext);
324 const latMs = Math.round(smoothedLatUs / 1000);
325 console.log(`Sendspin: sync=${this.smoothedSyncErrorMs >= 0 ? "+" : ""}${this.smoothedSyncErrorMs.toFixed(1)}ms` +
326 ` corr=${corr} q=${queueDepth}/${aheadSec.toFixed(1)}s resyncs=${this._intervalResyncCount}` +
327 ` clock=${clock} tf=${tf} lat=${latMs}ms mode=${this._correctionMode}` +
328 ` ctx=${this.audioContext?.state ?? "null"} gen=${this.stateManager.streamGeneration}`);
329 this._intervalResyncCount = 0;
330 }
331 applySyncErrorEma(inputMs) {
332 this.currentSyncErrorMs = inputMs;
333 this.smoothedSyncErrorMs =
334 SYNC_ERROR_ALPHA * inputMs +
335 (1 - SYNC_ERROR_ALPHA) * this.smoothedSyncErrorMs;
336 return this.smoothedSyncErrorMs;
337 }
338 resetSyncErrorEma() {
339 this.smoothedSyncErrorMs = 0;
340 }
341 copyBuffer(buffer) {
342 if (!this.audioContext)
343 return buffer;
344 const newBuffer = this.audioContext.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
345 for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
346 newBuffer.getChannelData(ch).set(buffer.getChannelData(ch));
347 }
348 return newBuffer;
349 }
350 adjustBufferSamples(buffer, samplesToAdjust) {
351 if (!this.audioContext || samplesToAdjust === 0 || buffer.length < 2)
352 return this.copyBuffer(buffer);
353 const channels = buffer.numberOfChannels;
354 const len = buffer.length;
355 const sampleRate = buffer.sampleRate;
356 try {
357 if (samplesToAdjust > 0) {
358 const newBuffer = this.audioContext.createBuffer(channels, len + 1, sampleRate);
359 for (let ch = 0; ch < channels; ch++) {
360 const oldData = buffer.getChannelData(ch);
361 const newData = newBuffer.getChannelData(ch);
362 newData[0] = oldData[0];
363 const insertedSample = (oldData[0] + oldData[1]) / 2;
364 newData[1] = insertedSample;
365 newData.set(oldData.subarray(1), 2);
366 for (let f = 0; f < SAMPLE_CORRECTION_FADE_LEN; f++) {
367 const pos = 2 + f;
368 if (pos >= newData.length)
369 break;
370 const alpha = SAMPLE_CORRECTION_FADE_ALPHAS[f];
371 newData[pos] = newData[pos] * (1 - alpha) + insertedSample * alpha;
372 }
373 }
374 return newBuffer;
375 }
376 else {
377 const newBuffer = this.audioContext.createBuffer(channels, len - 1, sampleRate);
378 for (let ch = 0; ch < channels; ch++) {
379 const oldData = buffer.getChannelData(ch);
380 const newData = newBuffer.getChannelData(ch);
381 newData.set(oldData.subarray(0, len - 2));
382 const replacementSample = (oldData[len - 2] + oldData[len - 1]) / 2;
383 newData[len - 2] = replacementSample;
384 for (let f = 0; f < SAMPLE_CORRECTION_FADE_LEN; f++) {
385 const pos = len - 3 - f;
386 if (pos < 0)
387 break;
388 const alpha = SAMPLE_CORRECTION_FADE_ALPHAS[f];
389 newData[pos] =
390 newData[pos] * (1 - alpha) + replacementSample * alpha;
391 }
392 }
393 return newBuffer;
394 }
395 }
396 catch (e) {
397 console.error("Sendspin: adjustBufferSamples error:", e);
398 return buffer;
399 }
400 }
401 initAudioContext() {
402 if (this.audioContext)
403 return;
404 if (this.outputMode === "media-element" && this.ownsAudioElement) {
405 this.audioElement = document.createElement("audio");
406 this.audioElement.style.display = "none";
407 document.body.appendChild(this.audioElement);
408 }
409 if (navigator.audioSession) {
410 navigator.audioSession.type = "playback";
411 }
412 const streamSampleRate = this.stateManager.currentStreamFormat?.sample_rate || 48000;
413 this.audioContext = new AudioContext({ sampleRate: streamSampleRate });
414 this.gainNode = this.audioContext.createGain();
415 const audioElement = this.audioElement;
416 if (this.outputMode === "direct") {
417 this.gainNode.connect(this.audioContext.destination);
418 }
419 else {
420 if (!audioElement)
421 throw new Error("Media-element output requires an audio element.");
422 if (this.isAndroid && this.silentAudioSrc) {
423 this.gainNode.connect(this.audioContext.destination);
424 audioElement.src = this.silentAudioSrc;
425 audioElement.loop = true;
426 audioElement.muted = false;
427 audioElement.volume = 1.0;
428 audioElement.play().catch((e) => {
429 console.warn("Sendspin: Audio autoplay blocked:", e);
430 });
431 }
432 else {
433 this.streamDestination =
434 this.audioContext.createMediaStreamDestination();
435 this.gainNode.connect(this.streamDestination);
436 audioElement.srcObject = this.streamDestination.stream;
437 audioElement.volume = 1.0;
438 audioElement.play().catch((e) => {
439 console.warn("Sendspin: Audio autoplay blocked:", e);
440 });
441 }
442 }
443 this.updateVolume();
444 if (this.usesRecorrectionMonitor)
445 this.recorrectionMonitor.start();
446 }
447 async resumeAudioContext() {
448 if (this.audioContext && this.audioContext.state === "suspended") {
449 try {
450 await this.audioContext.resume();
451 console.log("Sendspin: AudioContext resumed");
452 }
453 catch (e) {
454 console.warn("Sendspin: Failed to resume AudioContext:", e);
455 return;
456 }
457 if (this.audioBufferQueue.length > 0)
458 this.scheduleQueueProcessing();
459 if (this.usesRecorrectionMonitor)
460 this.recorrectionMonitor.start();
461 }
462 }
463 cutScheduledSources(cutoffTime) {
464 if (!this.audioContext)
465 return { requeuedCount: 0, cutCount: 0, keptTailEndTimeSec: 0 };
466 const stopTime = Math.max(cutoffTime, this.audioContext.currentTime);
467 let requeued = 0, cutCount = 0, keptTailEndTimeSec = 0;
468 this.scheduledSources = this.scheduledSources.filter((entry) => {
469 if (entry.startTime < stopTime) {
470 keptTailEndTimeSec = Math.max(keptTailEndTimeSec, entry.endTime);
471 return true;
472 }
473 try {
474 entry.source.onended = null;
475 entry.source.stop(stopTime);
476 }
477 catch {
478 /* ignore */
479 }
480 this.audioBufferQueue.push({
481 buffer: entry.buffer,
482 serverTime: entry.serverTime,
483 generation: entry.generation,
484 });
485 requeued++;
486 cutCount++;
487 return false;
488 });
489 return { requeuedCount: requeued, cutCount, keptTailEndTimeSec };
490 }
491 updateVolume() {
492 if (!this.gainNode)
493 return;
494 if (this.useHardwareVolume) {
495 this.gainNode.gain.value = 1.0;
496 return;
497 }
498 const target = this.stateManager.muted
499 ? 0
500 : perceptualGain(this.stateManager.volume);
501 if (this.audioContext) {
502 this.gainNode.gain.setTargetAtTime(target, this.audioContext.currentTime, VOLUME_RAMP_TIME_CONSTANT_SEC);
503 }
504 else {
505 this.gainNode.gain.value = target;
506 }
507 }
508 measureBufferedPlaybackRunwaySec() {
509 if (!this.audioContext)
510 return 0;
511 const currentTimeSec = this.audioContext.currentTime;
512 this.pruneExpiredScheduledSources(currentTimeSec);
513 const scheduledAheadSec = this.getScheduledAheadSec(currentTimeSec);
514 const queuedAheadSec = this.audioBufferQueue.reduce((totalSec, chunk) => totalSec + chunk.buffer.duration, 0);
515 return Math.max(0, scheduledAheadSec + queuedAheadSec);
516 }
517 cancelScheduledRefill() {
518 if (this.refillTimeout !== null) {
519 clearTimeout(this.refillTimeout);
520 this.refillTimeout = null;
521 }
522 }
523 getScheduledRefillThresholdSec(targetScheduledHorizonSec) {
524 return Math.max(SCHEDULE_REFILL_MIN_THRESHOLD_SEC, Math.min(SCHEDULE_REFILL_MAX_THRESHOLD_SEC, targetScheduledHorizonSec * SCHEDULE_REFILL_THRESHOLD_FRACTION));
525 }
526 scheduleQueueRefill(targetScheduledHorizonSec) {
527 this.cancelScheduledRefill();
528 if (!this.audioContext ||
529 this.audioContext.state !== "running" ||
530 !this.stateManager.isPlaying ||
531 this.audioBufferQueue.length === 0)
532 return;
533 const currentTimeSec = this.audioContext.currentTime;
534 this.pruneExpiredScheduledSources(currentTimeSec);
535 const scheduledAheadSec = this.getScheduledAheadSec(currentTimeSec);
536 const refillThresholdSec = this.getScheduledRefillThresholdSec(targetScheduledHorizonSec);
537 if (scheduledAheadSec <= refillThresholdSec) {
538 this.scheduleQueueProcessing();
539 return;
540 }
541 const delayMs = (scheduledAheadSec - refillThresholdSec) * 1000;
542 const runRefill = () => {
543 this.refillTimeout = null;
544 if (!this.audioContext ||
545 this.audioContext.state !== "running" ||
546 !this.stateManager.isPlaying ||
547 this.audioBufferQueue.length === 0)
548 return;
549 this.scheduleQueueProcessing();
550 };
551 if (typeof globalThis.setTimeout === "function") {
552 this.refillTimeout = globalThis.setTimeout(runRefill, delayMs);
553 return;
554 }
555 this.refillTimeout = null;
556 if (typeof globalThis
557 .queueMicrotask === "function") {
558 globalThis.queueMicrotask(runRefill);
559 return;
560 }
561 void Promise.resolve().then(runRefill);
562 }
563 scheduleQueueProcessing() {
564 this.cancelScheduledRefill();
565 if (this.queueProcessScheduled)
566 return;
567 this.queueProcessScheduled = true;
568 if (typeof globalThis.setTimeout === "function") {
569 this.scheduleTimeout = globalThis.setTimeout(() => {
570 this.scheduleTimeout = null;
571 this.queueProcessScheduled = false;
572 this.processAudioQueue();
573 }, 15);
574 return;
575 }
576 const run = () => {
577 this.queueProcessScheduled = false;
578 this.processAudioQueue();
579 };
580 if (typeof globalThis
581 .queueMicrotask === "function") {
582 globalThis.queueMicrotask(run);
583 }
584 else {
585 Promise.resolve().then(run);
586 }
587 }
588 handleDecodedChunk(chunk) {
589 if (!this.audioContext || !this.gainNode) {
590 console.warn("Sendspin: Received audio chunk but no audio context");
591 return;
592 }
593 if (chunk.generation !== this.stateManager.streamGeneration)
594 return;
595 const numChannels = chunk.samples.length;
596 const numFrames = chunk.samples[0].length;
597 const audioBuffer = this.audioContext.createBuffer(numChannels, numFrames, chunk.sampleRate);
598 for (let ch = 0; ch < numChannels; ch++)
599 audioBuffer.getChannelData(ch).set(chunk.samples[ch]);
600 this.audioBufferQueue.push({
601 buffer: audioBuffer,
602 serverTime: chunk.serverTimeUs,
603 generation: chunk.generation,
604 });
605 this.scheduleQueueProcessing();
606 }
607 processAudioQueue() {
608 this.cancelScheduledRefill();
609 if (!this.audioContext || !this.gainNode)
610 return;
611 if (this.audioContext.state !== "running")
612 return;
613 const currentGeneration = this.stateManager.streamGeneration;
614 this.audioBufferQueue = this.audioBufferQueue.filter((chunk) => chunk.generation === currentGeneration);
615 this.audioBufferQueue.sort((a, b) => a.serverTime - b.serverTime);
616 if (!this.timeFilter.is_synchronized)
617 return;
618 const { audioContextTimeSec: audioContextTime, audioContextRawTimeSec, nowMs, nowUs, } = this.clockSource.getTimingSnapshot(this.audioContext);
619 this.pruneExpiredScheduledSources(audioContextRawTimeSec);
620 const outputLatencySec = this.useOutputLatencyCompensation
621 ? this.latencyTracker.getSmoothedUs(this.audioContext) / 1000000
622 : 0;
623 const syncDelaySec = this.syncDelayMs / 1000;
624 const targetScheduledHorizonSec = this.getTargetScheduledHorizonSec();
625 if (this.usesRecorrectionMonitor)
626 this.recorrectionMonitor.start();
627 if (this.clockSource.pendingCutover) {
628 this.clockSource.pendingCutover = false;
629 if (this.scheduledSources.length > 0 ||
630 this.nextPlaybackTime !== 0 ||
631 this.lastScheduledServerTime !== 0) {
632 this.performGuardedCutover("delay-change", {
633 incrementResyncCount: false,
634 markCooldown: false,
635 });
636 return;
637 }
638 }
639 while (this.audioBufferQueue.length > 0) {
640 const scheduledAheadSec = this.getScheduledAheadSec(audioContextRawTimeSec);
641 if (this.nextPlaybackTime > 0 &&
642 scheduledAheadSec >= targetScheduledHorizonSec)
643 break;
644 const chunk = this.audioBufferQueue.shift();
645 let playbackTime;
646 let scheduleTime;
647 let playbackRate;
648 const targetPlaybackTime = this.computeTargetPlaybackTime(chunk.serverTime, audioContextTime, nowUs, outputLatencySec);
649 const isTimestamp = this.clockSource.active === "timestamp";
650 if (this.nextPlaybackTime === 0 || this.lastScheduledServerTime === 0) {
651 this.recorrectionMonitor.armStartupGrace(nowMs, isTimestamp);
652 playbackTime = targetPlaybackTime;
653 scheduleTime = playbackTime - syncDelaySec;
654 const minScheduleTimeSec = this.recorrectionMonitor.minScheduleTimeSec;
655 if (minScheduleTimeSec !== null) {
656 scheduleTime = Math.max(scheduleTime, minScheduleTimeSec);
657 playbackTime = scheduleTime + syncDelaySec;
658 }
659 this.recorrectionMonitor.clearMinScheduleTime();
660 playbackRate = 1.0;
661 chunk.buffer = this.copyBuffer(chunk.buffer);
662 }
663 else {
664 const serverGapUs = chunk.serverTime - this.lastScheduledServerTime;
665 const serverGapSec = serverGapUs / 1000000;
666 if (Math.abs(serverGapSec) < 0.1) {
667 const syncErrorSec = this.nextPlaybackTime - targetPlaybackTime;
668 const syncErrorMs = syncErrorSec * 1000;
669 const correctionErrorMs = this.applySyncErrorEma(syncErrorMs);
670 const thresholds = this.correctionThresholds[this._correctionMode];
671 const canHardResync = this.recorrectionMonitor.canUseHardResync(nowMs, isTimestamp);
672 if (Math.abs(correctionErrorMs) > thresholds.resyncAboveMs &&
673 canHardResync) {
674 this.recorrectionMonitor.noteHardResync(nowMs);
675 this.resyncCount++;
676 this._intervalResyncCount++;
677 this.resetSyncErrorEma();
678 this.cutScheduledSources(targetPlaybackTime - syncDelaySec);
679 playbackTime = targetPlaybackTime;
680 scheduleTime = playbackTime - syncDelaySec;
681 playbackRate = 1.0;
682 this.currentCorrectionMethod = "resync";
683 this.lastSamplesAdjusted = 0;
684 chunk.buffer = this.copyBuffer(chunk.buffer);
685 }
686 else if (Math.abs(correctionErrorMs) > thresholds.resyncAboveMs) {
687 playbackTime = this.nextPlaybackTime;
688 scheduleTime = this.nextScheduleTime;
689 playbackRate = Number.isFinite(thresholds.rate2AboveMs)
690 ? correctionErrorMs > 0
691 ? 1.02
692 : 0.98
693 : 1.0;
694 this.currentCorrectionMethod =
695 playbackRate === 1.0 ? "none" : "rate";
696 this.lastSamplesAdjusted = 0;
697 chunk.buffer = this.copyBuffer(chunk.buffer);
698 }
699 else if (Math.abs(correctionErrorMs) < thresholds.deadbandBelowMs) {
700 playbackTime = this.nextPlaybackTime;
701 scheduleTime = this.nextScheduleTime;
702 playbackRate = 1.0;
703 this.currentCorrectionMethod = "none";
704 this.lastSamplesAdjusted = 0;
705 chunk.buffer = this.copyBuffer(chunk.buffer);
706 }
707 else if (Math.abs(correctionErrorMs) <= thresholds.samplesBelowMs) {
708 playbackTime = this.nextPlaybackTime;
709 scheduleTime = this.nextScheduleTime;
710 playbackRate = 1.0;
711 const samplesToAdjust = correctionErrorMs > 0 ? -1 : 1;
712 chunk.buffer = this.adjustBufferSamples(chunk.buffer, samplesToAdjust);
713 this.currentCorrectionMethod = "samples";
714 this.lastSamplesAdjusted = samplesToAdjust;
715 }
716 else {
717 playbackTime = this.nextPlaybackTime;
718 scheduleTime = this.nextScheduleTime;
719 const absErrorMs = Math.abs(correctionErrorMs);
720 if (correctionErrorMs > 0) {
721 playbackRate =
722 absErrorMs >= thresholds.rate2AboveMs
723 ? 1.02
724 : absErrorMs >= thresholds.rate1AboveMs
725 ? 1.01
726 : 1.0;
727 }
728 else {
729 playbackRate =
730 absErrorMs >= thresholds.rate2AboveMs
731 ? 0.98
732 : absErrorMs >= thresholds.rate1AboveMs
733 ? 0.99
734 : 1.0;
735 }
736 this.currentCorrectionMethod =
737 playbackRate === 1.0 ? "none" : "rate";
738 this.lastSamplesAdjusted = 0;
739 chunk.buffer = this.copyBuffer(chunk.buffer);
740 }
741 }
742 else {
743 // Gap detected in server timestamps - hard resync (gated on cooldown)
744 if (this.recorrectionMonitor.canUseHardResync(nowMs, isTimestamp)) {
745 this.recorrectionMonitor.noteHardResync(nowMs);
746 this.resyncCount++;
747 this._intervalResyncCount++;
748 this.cutScheduledSources(targetPlaybackTime - syncDelaySec);
749 }
750 playbackTime = targetPlaybackTime;
751 scheduleTime = playbackTime - syncDelaySec;
752 playbackRate = 1.0;
753 this.currentCorrectionMethod = "resync";
754 this.lastSamplesAdjusted = 0;
755 chunk.buffer = this.copyBuffer(chunk.buffer);
756 }
757 }
758 this.currentPlaybackRate = playbackRate;
759 if (playbackTime < audioContextRawTimeSec) {
760 this.nextPlaybackTime = 0;
761 this.nextScheduleTime = 0;
762 this.lastScheduledServerTime = 0;
763 continue;
764 }
765 const effectiveScheduleTime = Math.max(scheduleTime, audioContextRawTimeSec);
766 const effectivePlaybackTime = effectiveScheduleTime + (playbackTime - scheduleTime);
767 const source = this.audioContext.createBufferSource();
768 source.buffer = chunk.buffer;
769 source.playbackRate.value = playbackRate;
770 source.connect(this.gainNode);
771 source.start(effectiveScheduleTime);
772 const actualDuration = chunk.buffer.duration / playbackRate;
773 this.nextPlaybackTime = effectivePlaybackTime + actualDuration;
774 this.nextScheduleTime = effectiveScheduleTime + actualDuration;
775 this.lastScheduledServerTime =
776 chunk.serverTime + chunk.buffer.duration * 1000000;
777 const scheduledEntry = {
778 source,
779 startTime: effectiveScheduleTime,
780 endTime: effectiveScheduleTime + actualDuration,
781 buffer: chunk.buffer,
782 serverTime: chunk.serverTime,
783 generation: chunk.generation,
784 };
785 this.scheduledSources.push(scheduledEntry);
786 source.onended = () => {
787 const idx = this.scheduledSources.indexOf(scheduledEntry);
788 if (idx > -1)
789 this.scheduledSources.splice(idx, 1);
790 if (this.scheduledSources.length === 0) {
791 this.resetScheduledPlaybackState("all scheduled audio ended");
792 if (this.audioBufferQueue.length > 0)
793 this.processAudioQueue();
794 }
795 };
796 }
797 this.scheduleQueueRefill(targetScheduledHorizonSec);
798 this.emitStatusLog(nowMs);
799 }
800 computeTargetPlaybackTime(serverTimeUs, audioContextTime, nowUs, outputLatencySec) {
801 const chunkClientTimeUs = this.timeFilter.computeClientTime(serverTimeUs);
802 const deltaSec = (chunkClientTimeUs - nowUs) / 1000000;
803 return (audioContextTime + deltaSec + SCHEDULE_HEADROOM_SEC - outputLatencySec);
804 }
805 startAudioElement() {
806 if (this.outputMode === "media-element" && this.audioElement?.paused) {
807 this.audioElement.play().catch((e) => {
808 console.warn("Sendspin: Failed to start audio element:", e);
809 });
810 }
811 }
812 stopAudioElement() {
813 if (this.outputMode === "media-element" &&
814 this.audioElement &&
815 !this.audioElement.paused) {
816 this.audioElement.pause();
817 }
818 }
819 clearBuffers() {
820 this.recorrectionMonitor.fullReset();
821 this.cancelScheduledRefill();
822 this.scheduledSources.forEach((entry) => {
823 try {
824 entry.source.stop();
825 }
826 catch {
827 /* ignore */
828 }
829 });
830 this.scheduledSources = [];
831 this.audioBufferQueue = [];
832 if (this.scheduleTimeout !== null) {
833 clearTimeout(this.scheduleTimeout);
834 this.scheduleTimeout = null;
835 }
836 this.queueProcessScheduled = false;
837 this.stateManager.resetStreamAnchors();
838 this.resetScheduledPlaybackState();
839 this.resyncCount = 0;
840 this.latencyTracker.reset();
841 this.clockSource.reset();
842 }
843 close() {
844 this.clearBuffers();
845 if (this.audioContext) {
846 this.audioContext.close();
847 this.audioContext = null;
848 }
849 this.gainNode = null;
850 this.streamDestination = null;
851 if (this.outputMode === "media-element" && this.audioElement) {
852 this.audioElement.pause();
853 this.audioElement.srcObject = null;
854 this.audioElement.loop = false;
855 this.audioElement.removeAttribute("src");
856 this.audioElement.load();
857 if (this.ownsAudioElement) {
858 this.audioElement.remove();
859 this.audioElement = undefined;
860 }
861 }
862 }
863 getAudioContext() {
864 return this.audioContext;
865 }
866}
867//# sourceMappingURL=scheduler.js.map
868