Skip to content

Commit 36db44d

Browse files
perf(core): a paused preview stops running its audio output (#4534)
* perf(core): a paused preview stops running its audio output The preview runtime opened its AudioContext at load and left it running for the whole session, so a paused Studio kept an audio output stream rendering silence in Chrome's audio service and output threads, and on Chrome's fake sink once a context has been silent for 30 s. The transport now suspends the context once it has been paused for a second with no captured track playing, and resumes it for Play and for a captured track that plays while paused (the scrub route). The delay keeps a stop-and-reschedule inside one play (a rate change, a hidden track, a seek while playing) from bouncing the output. A Play that lands while a suspend is pending resumes anyway, and a Pause that lands while Play waits on resume() lets the context go back to sleep. * fix(core): decoded audio after an idle pause starts where the picture is Review follow-ups to the idle suspend: - A decoded track read its start time before waiting for the context to wake, so every Play after an idle pause anchored the clock that far back and the picture stepped back. The transport now reads the time after the wake. - The transport kept every element it ever captured. It now holds only captured tracks that are playing, so a replaced track is free once it pauses. - A failed suspend and destroy() both clear the pending-suspend flag, so the next pause, or a fresh init(), can suspend again. - A test pins the one-second idle delay; the redundant "ended" listener is gone, since a track that ends fires "pause" first. * fix(core): a preview whose browser starts audio late still goes quiet when idle The idle timer was armed only if the context was already running when the transport opened it or resumed it, so a browser that starts the context a moment after construction left it running until the first pause. The transport now arms the timer from the context's own statechange event, which Chrome fires after construction and on every resume, so the separate arming in init() and after a stale Play are gone. * fix(core): a reloaded track no longer keeps a paused preview's audio awake Reloading a playing captured track (el.load(), a src swap) stops it without a "pause" event, so the transport kept counting it as playing and a paused preview never went quiet. The idle check now drops tracks that are already paused before it counts, and "emptied" is handled like "pause" so the idle timer is armed when a reload stops a track. * perf(core): a paused preview waits 10 s before its audio output goes to sleep * docs(core): say why a paused preview's audio waits 10 s before it sleeps * fix(core): a browser-side audio suspend drops the preview's idle timer
1 parent 473c862 commit 36db44d

6 files changed

Lines changed: 423 additions & 34 deletions

File tree

‎packages/core/src/runtime/capturedMediaIdleRoute.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ class GraphAudioContext {
5050
resume() {
5151
return pendingResume ?? Promise.resolve();
5252
}
53+
suspend() {
54+
return Promise.resolve();
55+
}
5356
close() {
5457
return Promise.resolve();
5558
}

‎packages/core/src/runtime/init.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4311,6 +4311,9 @@ describe("initSandboxRuntimeModular", () => {
43114311
resume() {
43124312
return Promise.resolve();
43134313
}
4314+
suspend() {
4315+
return Promise.resolve();
4316+
}
43144317
createGain() {
43154318
return { gain: { value: 1 }, connect() {}, disconnect() {} };
43164319
}

‎packages/core/src/runtime/init.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4366,7 +4366,7 @@ export function initSandboxRuntimeModular(): void {
43664366
buffer,
43674367
compStart,
43684368
mediaStart,
4369-
clock.now(),
4369+
() => clock.now(),
43704370
vol,
43714371
gen,
43724372
state.playbackRate,

‎packages/core/src/runtime/webAudioTransport.test.ts‎

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function createMockAudioContext(currentTime = 100) {
3939
currentTime,
4040
state: "running",
4141
resume: vi.fn(),
42+
suspend: vi.fn(() => Promise.resolve()),
4243
createBufferSource: vi.fn(() => sourceNode),
4344
createMediaElementSource: vi.fn(() => mediaElementSourceNode),
4445
createGain: vi.fn(() => gainNode),
@@ -62,6 +63,8 @@ const mockBuffer = {} as AudioBuffer;
6263
const mockEl = {
6364
muted: false,
6465
volume: 0.4,
66+
paused: true,
67+
addEventListener: () => {},
6568
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
6669
} as unknown as HTMLMediaElement;
6770

@@ -435,7 +438,7 @@ describe("WebAudioTransport", () => {
435438
it("keeps author boost above unity on the per-element gain node", async () => {
436439
const { transport, mock, gen } = setupTransport(100);
437440

438-
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
441+
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, () => 0, 1, gen);
439442
transport.setElementVolume(mockEl, 3.98);
440443

441444
expect(mock.gainNode.gain.value).toBeCloseTo(3.98, 5);
@@ -444,39 +447,39 @@ describe("WebAudioTransport", () => {
444447
it("starts in-progress clips immediately with correct buffer offset", async () => {
445448
const { transport, mock, gen } = setupTransport(100);
446449

447-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
450+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen);
448451

449452
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
450453
});
451454

452455
it("starts in-progress clips with mediaStart offset", async () => {
453456
const { transport, mock, gen } = setupTransport(100);
454457

455-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 2, 8, 1, gen);
458+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 2, () => 8, 1, gen);
456459

457460
expect(mock.startFn).toHaveBeenCalledWith(0, 5);
458461
});
459462

460463
it("schedules future clips with delay instead of playing immediately", async () => {
461464
const { transport, mock, gen } = setupTransport(100);
462465

463-
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen);
466+
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, () => 2, 1, gen);
464467

465468
expect(mock.startFn).toHaveBeenCalledWith(108, 0);
466469
});
467470

468471
it("schedules future clips with correct mediaStart", async () => {
469472
const { transport, mock, gen } = setupTransport(100);
470473

471-
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, 2, 1, gen);
474+
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, () => 2, 1, gen);
472475

473476
expect(mock.startFn).toHaveBeenCalledWith(108, 1.5);
474477
});
475478

476479
it("starts clips at exact composition start time immediately", async () => {
477480
const { transport, mock, gen } = setupTransport(100);
478481

479-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 5, 1, gen);
482+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 5, 1, gen);
480483

481484
expect(mock.startFn).toHaveBeenCalledWith(0, 0);
482485
});
@@ -486,35 +489,45 @@ describe("WebAudioTransport", () => {
486489
it("bounds an in-progress clip to its remaining authored window", async () => {
487490
const { transport, mock, gen } = setupTransport(100);
488491
// compStart=5, mediaStart=0, compTime=8 → elapsed=3; clipDuration=10 → 7 left
489-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1, 10);
492+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 1, 10);
490493
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 7);
491494
});
492495

493496
it("bounds a future clip to its full authored window", async () => {
494497
const { transport, mock, gen } = setupTransport(100);
495498
// compStart=10, mediaStart=1.5, compTime=2 → elapsed=-8 → delay 8; clipDuration=4
496-
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, 2, 1, gen, 1, 4);
499+
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, () => 2, 1, gen, 1, 4);
497500
expect(mock.startFn).toHaveBeenCalledWith(108, 1.5, 4);
498501
});
499502

500503
it("does not schedule a clip whose window has already elapsed", async () => {
501504
const { transport, mock, gen } = setupTransport(100);
502505
// elapsed=15 > clipDuration=10 → nothing to play
503-
const result = await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 20, 1, gen, 1, 10);
506+
const result = await transport.schedulePlayback(
507+
mockEl,
508+
mockBuffer,
509+
5,
510+
0,
511+
() => 20,
512+
1,
513+
gen,
514+
1,
515+
10,
516+
);
504517
expect(result).toBeNull();
505518
expect(mock.startFn).not.toHaveBeenCalled();
506519
});
507520

508521
it("keeps source bounds in authored media time when global rate changes", async () => {
509522
const { transport, mock, gen } = setupTransport(100);
510523
// Global rate=2 changes wallclock speed, not source-time span.
511-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2, 10);
524+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2, 10);
512525
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 7);
513526
});
514527

515528
it("plays unbounded when clipDuration is omitted (legacy behavior)", async () => {
516529
const { transport, mock, gen } = setupTransport(100);
517-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
530+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen);
518531
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
519532
});
520533
});
@@ -530,7 +543,7 @@ describe("WebAudioTransport", () => {
530543
// At composition t=0.5 with mediaStart=1, authored 2x has consumed one
531544
// source second. Global 0.5x makes the source node's wallclock rate 1x,
532545
// but the composition clock must still advance at global 0.5x.
533-
await transport.schedulePlayback(el, mockBuffer, 0, 1, 0.5, 1, gen, 0.5, 2);
546+
await transport.schedulePlayback(el, mockBuffer, 0, 1, () => 0.5, 1, gen, 0.5, 2);
534547

535548
expect(mock.sourceNode.playbackRate.value).toBe(1);
536549
expect(mock.startFn).toHaveBeenCalledWith(0, 2, 3);
@@ -544,15 +557,15 @@ describe("WebAudioTransport", () => {
544557
it("sets sourceNode.playbackRate.value when rate is provided", async () => {
545558
const { transport, mock, gen } = setupTransport(100);
546559

547-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
560+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
548561

549562
expect(mock.sourceNode.playbackRate.value).toBe(2);
550563
});
551564

552565
it("defaults rate to 1 when not provided", async () => {
553566
const { transport, mock, gen } = setupTransport(100);
554567

555-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
568+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen);
556569

557570
expect(mock.sourceNode.playbackRate.value).toBe(1);
558571
});
@@ -561,23 +574,23 @@ describe("WebAudioTransport", () => {
561574
const { transport, mock, gen } = setupTransport(100);
562575

563576
// compStart=10, compositionTime=2, rate=2 → 8s of comp time = 4s wallclock
564-
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen, 2);
577+
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, () => 2, 1, gen, 2);
565578

566579
expect(mock.startFn).toHaveBeenCalledWith(104, 0);
567580
});
568581

569582
it("keeps in-progress buffer offset at elapsed + mediaStart regardless of rate", async () => {
570583
const { transport, mock, gen } = setupTransport(100);
571584

572-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
585+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
573586

574587
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
575588
});
576589

577590
it("setRate updates active sources in place", async () => {
578591
const { transport, mock, gen } = setupTransport(100);
579592

580-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
593+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 1);
581594
expect(mock.sourceNode.playbackRate.value).toBe(1);
582595

583596
transport.setRate(2);
@@ -590,7 +603,7 @@ describe("WebAudioTransport", () => {
590603
// scheduled, so bumping playbackRate alone left every automated parameter
591604
// running its original plan over audio moving at a different speed.
592605
const { transport, mock, gen } = setupTransport(100);
593-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
606+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 1);
594607
const active = (transport as unknown as { _activeSources: { fx?: unknown }[] })
595608
._activeSources;
596609
const setRate = vi.fn();
@@ -609,7 +622,7 @@ describe("WebAudioTransport", () => {
609622

610623
it("setRate is a no-op when the rate is unchanged", async () => {
611624
const { transport, mock, gen } = setupTransport(100);
612-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
625+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
613626

614627
mock.ctx.currentTime = 100.5;
615628
const timeBefore = transport.getTime();
@@ -624,7 +637,7 @@ describe("WebAudioTransport", () => {
624637

625638
it("setRate clamps non-finite or non-positive values to 1", async () => {
626639
const { transport, mock, gen } = setupTransport(100);
627-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
640+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
628641
expect(mock.sourceNode.playbackRate.value).toBe(2);
629642

630643
transport.setRate(Number.NaN);
@@ -642,7 +655,7 @@ describe("WebAudioTransport", () => {
642655
it("getTime advances at the configured rate", async () => {
643656
const { transport, mock, gen } = setupTransport(100);
644657

645-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
658+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 2);
646659

647660
// At schedule time, ctx.currentTime=100, compositionTime=8.
648661
expect(transport.getTime()).toBeCloseTo(8, 10);
@@ -656,7 +669,7 @@ describe("WebAudioTransport", () => {
656669
it("getTime tracks composition time after a mid-playback setRate", async () => {
657670
const { transport, mock, gen } = setupTransport(100);
658671

659-
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1);
672+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, () => 8, 1, gen, 1);
660673
expect(transport.getTime()).toBeCloseTo(8, 10);
661674

662675
// 0.5s passes at rate=1 → composition time = 8.5
@@ -681,7 +694,7 @@ describe("WebAudioTransport", () => {
681694
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
682695
} as unknown as HTMLMediaElement;
683696

684-
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
697+
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
685698
expect(transport.ownsClock()).toBe(true);
686699
expect(el.muted).toBe(true);
687700

@@ -698,7 +711,7 @@ describe("WebAudioTransport", () => {
698711
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
699712
} as unknown as HTMLMediaElement;
700713

701-
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
714+
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
702715
expect(el.muted).toBe(true);
703716

704717
mock.sourceNode._fireEnded();
@@ -712,7 +725,7 @@ describe("WebAudioTransport", () => {
712725
// already removed this entry — so the handle, its MutationObserver and any
713726
// running LFO survived the clip for the rest of the session.
714727
const { transport, mock, gen } = setupTransport(100);
715-
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
728+
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, () => 0, 1, gen);
716729

717730
mock.sourceNode._fireEnded();
718731

@@ -723,7 +736,7 @@ describe("WebAudioTransport", () => {
723736
it("registers onended listener on the sourceNode", async () => {
724737
const { transport, mock, gen } = setupTransport(100);
725738

726-
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen);
739+
await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, () => 0, 1, gen);
727740

728741
expect(mock.sourceNode.addEventListener).toHaveBeenCalledWith("ended", expect.any(Function));
729742
});
@@ -735,7 +748,7 @@ describe("WebAudioTransport", () => {
735748
getAttribute: (name: string) => (name === "data-playback-rate" ? "1" : null),
736749
} as unknown as HTMLMediaElement;
737750

738-
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
751+
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
739752
expect(el.muted).toBe(true);
740753

741754
transport.stopAll();
@@ -774,6 +787,7 @@ describe("WebAudioTransport", () => {
774787
currentTime,
775788
state: "running",
776789
resume: vi.fn(),
790+
suspend: vi.fn(() => Promise.resolve()),
777791
createBufferSource: vi.fn(() => ({
778792
buffer: null as AudioBuffer | null,
779793
playbackRate: { value: 1 },
@@ -853,7 +867,7 @@ describe("WebAudioTransport", () => {
853867
groupId?: string,
854868
): Promise<HTMLMediaElement> {
855869
const el = groupedAudioEl(id, groupId);
856-
await transport.schedulePlayback(el, mockBuffer, 0, 0, 0, 1, gen);
870+
await transport.schedulePlayback(el, mockBuffer, 0, 0, () => 0, 1, gen);
857871
return el;
858872
}
859873

0 commit comments

Comments
 (0)