From b76429eb460fab9356946bd2f98ed747b8090b10 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 11:39:39 +0100 Subject: [PATCH 1/2] fix(audio): keep a sluggish queue from holding a dead source inside the grace Each pass still counts the 100 ms threshold as running time, so passes that are always a little over it apart can no longer keep a silent source alive. Also cover the ingest-first order of the blocked-queue test. --- .../AudioTrackMixer.swift | 8 +++- .../AudioTrackMixerTests.swift | 38 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift index 85331e7c7..71bba9457 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift @@ -216,15 +216,19 @@ public final class AudioTrackMixer { /// whole time; it is the mixer that was not listening. So a pass that comes much later than /// the ticker allows moves each source's last delivery forward by the gap, which measures /// the grace against the time the queue was actually running. + /// + /// Every pass still counts `queueStallFrames` as running time, so a queue that is merely + /// sluggish — passes always a little over the threshold apart — cannot keep a dead source + /// inside the grace forever: it stalls after a few passes and the track keeps moving. private func discountQueueStall(until now: CMTime) { guard let anchor, now.isValid, now.isNumeric else { return } let nowFrame = frameIndex(of: now, from: anchor) if let lastPassFrame, nowFrame - lastPassFrame > Int64(MixFormat.queueStallFrames) { - let gap = nowFrame - lastPassFrame + let stall = nowFrame - lastPassFrame - Int64(MixFormat.queueStallFrames) for index in sources.indices { - sources[index].lastDeliveryFrame += gap + sources[index].lastDeliveryFrame += stall } } lastPassFrame = nowFrame diff --git a/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift index 9a2a686ad..1df1d8d88 100644 --- a/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift +++ b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift @@ -399,6 +399,40 @@ final class AudioTrackMixerTests: XCTestCase { /// clock alone, both sources would look a second quiet, the cursor would run to now in /// silence, and the queued second would then arrive behind it and be dropped. func testABlockedQueueDoesNotTurnQueuedAudioIntoSilence() { + assertBlockedQueueKeepsQueuedAudio(tickFirst: true) + } + + /// The same stall with a queued buffer first in line instead of the ticker. + func testABlockedQueueDoesNotTurnQueuedAudioIntoSilenceWhenABufferIsFirst() { + assertBlockedQueueKeepsQueuedAudio(tickFirst: false) + } + + /// A sluggish queue must not keep a dead source alive: passes 150 ms apart, just over the + /// stall threshold, still let a microphone that stopped be written off mid-take. + func testASluggishQueueStillWritesOffADeadSource() { + let sink = RecordingSink() + let clock = TestClock(.zero) + let mixer = makeMixer(sink: sink, clock: clock, includesMicrophone: true, microphoneGain: 1) + mixer.beginTimeline(at: clock.now) + + mixer.ingest( + makeSourceBuffer(burst(seconds: 0.15, left: 0, right: 0.5), at: clock.now, nonInterleaved: false), + from: .microphone + ) + for _ in 0..<20 { + mixer.ingest( + makeSourceBuffer(burst(seconds: 0.15, left: 0.5, right: 0), at: clock.now, nonInterleaved: true), + from: .system + ) + clock.advance(seconds: 0.15) + mixer.tick() + } + + // Before `finish`: the track has to have kept moving without the microphone. + XCTAssertGreaterThan(seconds(ofFrames: mixedFrames(sink)), 2.0) + } + + private func assertBlockedQueueKeepsQueuedAudio(tickFirst: Bool) { let sink = RecordingSink() let clock = TestClock(.zero) let mixer = makeMixer(sink: sink, clock: clock, includesMicrophone: true, microphoneGain: 1) @@ -424,7 +458,9 @@ final class AudioTrackMixerTests: XCTestCase { // The queue is blocked for a second: no tick, no buffer, while the clock runs on. clock.advance(seconds: 1) - mixer.tick() + if tickFirst { + mixer.tick() + } for _ in 0..<100 { deliverBoth(seconds: 0.01) } From 6a802dc8b0387c672d8379b17eb176264045a288 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 12:50:21 +0100 Subject: [PATCH 2/2] fix(audio): credit all but one ticker period of a queue stall Withholding the whole 100 ms threshold took that much grace from a live source on every block, so one 150-250 ms behind could be written off and lose its queued audio. One 10 ms period is enough to bound a sluggish queue. --- .../AudioTrackMixer.swift | 9 +++-- .../AudioTrackMixerTests.swift | 36 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift index 71bba9457..e0f3b1785 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/AudioTrackMixer.swift @@ -217,16 +217,19 @@ public final class AudioTrackMixer { /// the ticker allows moves each source's last delivery forward by the gap, which measures /// the grace against the time the queue was actually running. /// - /// Every pass still counts `queueStallFrames` as running time, so a queue that is merely + /// Every pass still counts one ticker period as running time, so a queue that is merely /// sluggish — passes always a little over the threshold apart — cannot keep a dead source - /// inside the grace forever: it stalls after a few passes and the track keeps moving. + /// inside the grace forever: it stalls after at most `emissionGraceFrames / chunkFrames` + /// passes and the track keeps moving. One period and not the whole threshold, because + /// whatever is not credited is grace taken from a live source on every block. private func discountQueueStall(until now: CMTime) { guard let anchor, now.isValid, now.isNumeric else { return } let nowFrame = frameIndex(of: now, from: anchor) if let lastPassFrame, nowFrame - lastPassFrame > Int64(MixFormat.queueStallFrames) { - let stall = nowFrame - lastPassFrame - Int64(MixFormat.queueStallFrames) + // The ticker period is the chunk size: 10 ms. + let stall = nowFrame - lastPassFrame - Int64(MixFormat.chunkFrames) for index in sources.indices { sources[index].lastDeliveryFrame += stall } diff --git a/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift index 1df1d8d88..69da3d841 100644 --- a/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift +++ b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/AudioTrackMixerTests.swift @@ -419,7 +419,9 @@ final class AudioTrackMixerTests: XCTestCase { makeSourceBuffer(burst(seconds: 0.15, left: 0, right: 0.5), at: clock.now, nonInterleaved: false), from: .microphone ) - for _ in 0..<20 { + // 40 passes, 6 s: each credits 10 ms of running time, so the 250 ms grace runs out by + // the 26th and the microphone is written off with seconds of take still to go. + for _ in 0..<40 { mixer.ingest( makeSourceBuffer(burst(seconds: 0.15, left: 0.5, right: 0), at: clock.now, nonInterleaved: true), from: .system @@ -429,7 +431,37 @@ final class AudioTrackMixerTests: XCTestCase { } // Before `finish`: the track has to have kept moving without the microphone. - XCTAssertGreaterThan(seconds(ofFrames: mixedFrames(sink)), 2.0) + XCTAssertGreaterThan(seconds(ofFrames: mixedFrames(sink)), 5.0) + } + + /// A block must not eat into the grace of a source that is late but alive: here 200 ms + /// behind, within the 250 ms grace, when the queue blocks for a second. + func testABlockedQueueKeepsTheGraceOfALateSource() { + let sink = RecordingSink() + let clock = TestClock(.zero) + let mixer = makeMixer(sink: sink, clock: clock, includesMicrophone: false) + mixer.beginTimeline(at: clock.now) + + mixer.ingest( + makeSourceBuffer(burst(seconds: 0.2, left: 0.5, right: 0.5), at: .zero, nonInterleaved: true), + from: .system + ) + runTicker(mixer, clock, seconds: 0.2) + clock.advance(seconds: 1) + mixer.tick() + mixer.ingest( + makeSourceBuffer( + burst(seconds: 1, left: 0.5, right: 0.5), + at: CMTime(seconds: 0.2, preferredTimescale: 48_000), + nonInterleaved: true + ), + from: .system + ) + runTicker(mixer, clock, seconds: 0.1) + mixer.finish(atSourceTime: clock.now) + + XCTAssertEqual(mixer.deliveryReport(for: .system).droppedSeconds, 0, accuracy: 0.0001) + XCTAssertGreaterThan(peak(mixedFrames(sink), from: 0.2, to: 1.2), audibleThreshold) } private func assertBlockedQueueKeepsQueuedAudio(tickFirst: Bool) {