diff --git a/Packages/ShowRecorderKit/Sources/Recording/TakeSequencer.swift b/Packages/ShowRecorderKit/Sources/Recording/TakeSequencer.swift index e50746bd..49c0c046 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/TakeSequencer.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/TakeSequencer.swift @@ -88,6 +88,8 @@ public final class TakeSequencer { public private(set) var takeStartedAt: Date? /// Whether the recorder was disarmed for sitting idle, so coming back should Arm it again. public private(set) var disarmedForIdle = false + /// A re-Arm for a changed setting is waiting for a Take to finish saving (#932). + private var settingsArmWaiting = false /// What to do about audio interruptions, and the banner they leave. public private(set) var interruptions = InterruptionPolicy() @ObservationIgnored private var toldInputStopped = false @@ -339,6 +341,18 @@ public final class TakeSequencer { /// The Pre-roll length or the sample rate choice changed: re-Arm so the input uses it. Only between Takes; a /// running Take keeps what it started with (Show-Safe Promise). public func settingsChanged() { + // A Take being saved is still the Take: re-Arm once it is on disk, or the Arm would fail with a false "Couldn't + // start" after a clean Take (#932). Several changes during one save make one Arm, as each Arm restarts the input. + if settingsArmWaiting { return } + if recorder.isFinishing { + settingsArmWaiting = true + Task { [self] in + await recorder.waitWhileFinishing() + settingsArmWaiting = false + settingsChanged() + } + return + } if recorder.isArmed, !recorder.isRecording { arm() } } diff --git a/Packages/ShowRecorderKit/Tests/RecordingTests/AsyncStopOffMainTests.swift b/Packages/ShowRecorderKit/Tests/RecordingTests/AsyncStopOffMainTests.swift index 16e2d056..2721e714 100644 --- a/Packages/ShowRecorderKit/Tests/RecordingTests/AsyncStopOffMainTests.swift +++ b/Packages/ShowRecorderKit/Tests/RecordingTests/AsyncStopOffMainTests.swift @@ -455,6 +455,32 @@ struct AsyncStopOffMainTests { #expect(sequencer.armError == nil) } + @Test("Pre-roll or sample rate changes while the Take is being saved show no error, and the input re-Arms once after the save (#932)") + func settingsChangedWhileFinishing() async throws { + let gate = TestGate() + nonisolated(unsafe) var gated: GatedStem? + let recorder = makeRecorder(in: root, asyncStop: true, gate: gate, stem: { gated = $0 }) + let source = FakeInputDevices([("xr18", 1)]) + let sequencer = TakeSequencer(recorder: recorder, devices: source, capsAt48k: { false }) + sequencer.arm() + let audio = try #require(source.opened.last) + let armsBefore = source.opened.count + let stop = try await startBlockedStop(recorder, audio: audio, gated: { gated }) + + sequencer.settingsChanged() + sequencer.settingsChanged() + sequencer.settingsChanged() + #expect(sequencer.armError == nil, "no false \"Couldn't start\" after a clean Take: \(sequencer.armError ?? "")") + gate.open() + try await stop.value + + try await waitUntil { source.opened.count > armsBefore } + try await Task.sleep(for: .milliseconds(200)) + #expect(sequencer.armError == nil) + #expect(source.opened.count == armsBefore + 1, "three changes, one Arm") + #expect(recorder.isArmed) + } + @Test("A gain change buffered during the save doesn't write the Show.json of a Drive Stop gave up on") func gainChangeSkipsAbandonedDrive() async throws { let drive = root.appending(path: "Drive")