From ed6c739bf0cc066893ba1f43e6fc1008a29170ae Mon Sep 17 00:00:00 2001 From: Doug Hatcher Date: Wed, 11 Nov 2026 15:31:40 -0500 Subject: [PATCH] Apply microphone selections to active recordings and refresh Settings --- Sources/TranscriptsMac/AppController.swift | 69 +++++++++++++++---- .../Settings/SettingsView.swift | 11 +-- Tests/TranscriptsCoreTests/AudioTests.swift | 16 +++++ 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/Sources/TranscriptsMac/AppController.swift b/Sources/TranscriptsMac/AppController.swift index 1a608bb..2c711d6 100644 --- a/Sources/TranscriptsMac/AppController.swift +++ b/Sources/TranscriptsMac/AppController.swift @@ -114,6 +114,10 @@ final class AppController: ObservableObject { /// Inputs that produced pure silence while recording this session — excluded /// from device selection so a dead-mic recovery can't re-pick them. private var deadInputUIDs: Set = [] + /// The input actually bound to the running recorder, including recovery routes. + @Published private(set) var recordingInput: AudioInputDevice? + /// Invalidated by a deliberate stop, so a pending switch cannot resume it. + private var captureRestartID: UUID? /// A specific device to bind on the next recovery restart, bypassing the /// resolver. Set when we've identified the exact input carrying the signal — /// e.g. the Voice-Processing aggregate that swallowed the built-in mic — which @@ -393,7 +397,7 @@ final class AppController: ObservableObject { /// `isRecovery` it keeps the fragments already set aside, because the whole /// point is that this is the same meeting. func startRecording(isRecovery: Bool = false, continuing: Bool = false) { - guard recorder == nil else { return } + guard recorder == nil, captureRestartID == nil else { return } if !isRecovery && !continuing { deadInputUIDs.removeAll(); pendingRecoveryDevice = nil; carriedFragments.removeAll() } @@ -454,6 +458,7 @@ final class AppController: ObservableObject { do { try rec.start(into: dir) self.recorder = rec + self.recordingInput = device self.currentRecordID = recordID // Joins the running session, and keeps its idle clock alive: // a three-hour take must not age out mid-recording. @@ -504,7 +509,13 @@ final class AppController: ObservableObject { /// Ends the capture and files it. `carryForward` is the mic-recovery case: the /// meeting is still going, so instead of running the pipeline we set this slice /// aside and let the fragment that finally ends the meeting absorb it. - func stopRecordingAndProcess(carryForward: Bool = false) { + func stopRecordingAndProcess(carryForward: Bool = false, onCarriedForward: (() -> Void)? = nil) { + if !carryForward, captureRestartID != nil { + captureRestartID = nil + relaunch.clearMarker() + resumedFragmentCount = 0 + } + let restartID = captureRestartID guard let rec = recorder else { return } stopLevelTimer() micSilentWhileCallAudible = false @@ -521,6 +532,7 @@ final class AppController: ObservableObject { do { var recording = try rec.stop() recorder = nil + recordingInput = nil state = .processing // Near-zero peak = the mic captured nothing (dead/muted). Mark it so the UI // shows it as empty instead of a hallucinated title from a blank transcript. @@ -540,7 +552,10 @@ final class AppController: ObservableObject { capturer?.firstSampleAt?.timeIntervalSince(recording.startedAt) } - if carryForward { + // A deliberate stop while a switch was finalizing saves this + // slice normally instead of letting the queued switch resume it. + let keepRecording = carryForward && (restartID == nil || self.captureRestartID == restartID) + if keepRecording { // Hold this slice — no pipeline, no note, no history row. The // recording that ends the meeting stitches it back in. self.carriedFragments.append(CarriedFragment( @@ -552,6 +567,7 @@ final class AppController: ObservableObject { self.refreshRecents() let secs = Int(recording.endedAt?.timeIntervalSince(recording.startedAt) ?? 0) Log.write("recovery: holding \(secs)s fragment to stitch into this meeting (\(self.carriedFragments.count) held)") + onCarriedForward?() return } @@ -593,6 +609,8 @@ final class AppController: ObservableObject { } catch { lastError = "Recording could not be saved: \(Self.shortError(error))" recorder = nil + recordingInput = nil + captureRestartID = nil updateRecord(recordID) { $0.status = .failed; $0.errorText = "stop failed: \(error)" } refreshRecents() Task { _ = await capturer?.stop() } @@ -1242,14 +1260,17 @@ final class AppController: ObservableObject { /// pins the exact input to use (an aggregate the resolver would never pick); /// nil lets the resolver choose, minus everything already proved dead. private func restartCapture(on device: AudioInputDevice?) { + guard recorder != nil else { return } + let restartID = UUID() + let wasCall = recordingIsCall + captureRestartID = restartID pendingRecoveryDevice = device - stopRecordingAndProcess(carryForward: true) - Task { @MainActor in - // Give the engine a beat to release the old device before rebinding. - try? await Task.sleep(for: .milliseconds(400)) - // stopRecordingAndProcess cleared the call flag; re-derive it so the - // recovered fragment is still treated (titled, grouped) as a call. - self.recordingIsCall = CallDetector.meetingAppUsingInput() != nil + stopRecordingAndProcess(carryForward: true) { [weak self] in + // Finalize both tracks and hold the old slice BEFORE starting another. + // A fixed delay alone can race a slow system-audio shutdown. + guard let self, self.captureRestartID == restartID else { return } + self.captureRestartID = nil + self.recordingIsCall = wasCall || CallDetector.meetingAppUsingInput() != nil self.startRecording(isRecovery: true) } } @@ -3062,10 +3083,10 @@ final class AppController: ObservableObject { } } - /// The device that recording will actually use right now (override if connected, - /// else the top connected favorite, else the smart default). + /// The next recording's input, using the same call-aware policy as startRecording. var resolvedInput: AudioInputDevice? { - AudioInputDevices.resolve(favorites: config.favoriteInputUIDs, override: config.overrideInputUID) + AudioInputDevices.resolve(favorites: config.favoriteInputUIDs, override: config.overrideInputUID, + callDeviceUIDs: CallDetector.meetingAppDeviceUIDs()) } func isFavoriteInput(_ uid: String) -> Bool { config.favoriteInputUIDs.contains(uid) } @@ -3074,9 +3095,28 @@ final class AppController: ObservableObject { func setInputOverride(_ uid: String?) { config.overrideInputUID = uid Log.write("input override = \(uid ?? "(cleared — using favorites)")") + applyInputSelection() } var hasInputOverride: Bool { config.overrideInputUID != nil } + /// A choice in Settings applies to the current meeting as well as the next. + /// Resolve again at restart so rapid edits use the latest preference. + private func applyInputSelection() { + if captureRestartID != nil { + pendingRecoveryDevice = nil + deadInputUIDs.removeAll() + return + } + guard let recorder, + let selected = AudioInputDevices.resolve( + favorites: config.favoriteInputUIDs, override: config.overrideInputUID, + callDeviceUIDs: CallDetector.meetingAppDeviceUIDs()), + selected.uid != recorder.device.uid else { return } + deadInputUIDs.removeAll() + Log.write("input selection: switching '\(recorder.device.name)' → '\(selected.name)' within this recording") + restartCapture(on: nil) + } + func toggleFavoriteInput(_ uid: String) { if let i = config.favoriteInputUIDs.firstIndex(of: uid) { config.favoriteInputUIDs.remove(at: i) @@ -3087,10 +3127,12 @@ final class AppController: ObservableObject { config.favoriteInputNamesByUID[uid] = device.name } } + applyInputSelection() } func moveFavoriteInputs(from offsets: IndexSet, to destination: Int) { config.favoriteInputUIDs.move(fromOffsets: offsets, toOffset: destination) + applyInputSelection() } func moveFavoriteInput(uid: String, by delta: Int) { @@ -3099,6 +3141,7 @@ final class AppController: ObservableObject { guard destination >= 0, destination < config.favoriteInputUIDs.count else { return } let moved = config.favoriteInputUIDs.remove(at: index) config.favoriteInputUIDs.insert(moved, at: destination) + applyInputSelection() } /// One-time migration of the old docked/undocked single prefs into favorites. diff --git a/Sources/TranscriptsMac/Settings/SettingsView.swift b/Sources/TranscriptsMac/Settings/SettingsView.swift index b894245..07c689c 100644 --- a/Sources/TranscriptsMac/Settings/SettingsView.swift +++ b/Sources/TranscriptsMac/Settings/SettingsView.swift @@ -38,14 +38,15 @@ struct SettingsView: View { /// itself is not a view body; it settles only because the names stop /// changing. @State private var connectedInputs: [AudioInputDevice] = [] - @State private var resolvedInput: AudioInputDevice? + @State private var nextInput: AudioInputDevice? + private var resolvedInput: AudioInputDevice? { controller.recordingInput ?? nextInput } @State private var launchAtLogin = false @State private var importSummary: String? /// Off the render path deliberately — this is the call that may write config. private func refreshDeviceState() { connectedInputs = controller.availableInputs() - resolvedInput = controller.resolvedInput + nextInput = controller.resolvedInput launchAtLogin = controller.launchAtLoginEnabled } @@ -98,6 +99,8 @@ struct SettingsView: View { // list refreshes when the window comes forward rather than never. .onReceive(NotificationCenter.default.publisher( for: NSApplication.didBecomeActiveNotification)) { _ in refreshDeviceState() } + .onChange(of: controller.config.overrideInputUID) { _, _ in refreshDeviceState() } + .onChange(of: controller.config.favoriteInputUIDs) { _, _ in refreshDeviceState() } } // MARK: - Drawn tab bar (screenshots) @@ -600,7 +603,7 @@ struct SettingsView: View { HStack(spacing: 6) { Image(systemName: "mic") - Text("Recording from:") + Text(controller.recordingInput == nil ? "Next recording:" : "Recording from:") if let r = resolvedInput { Text(r.name).foregroundStyle(.secondary) } else { @@ -644,7 +647,7 @@ struct SettingsView: View { .font(.caption) } - Text("★ favorites are always listed here, even when unplugged. Transcripts records from the highest-priority favorite that's connected, top to bottom. Click a connected mic to force it for now; that beats favorites.") + Text("★ favorites are always listed here, even when unplugged. Transcripts records from the highest-priority favorite that's connected, top to bottom. Click a connected mic to force it; that beats favorites. Changes also apply to a recording in progress.") .font(.caption2).foregroundStyle(.secondary) } diff --git a/Tests/TranscriptsCoreTests/AudioTests.swift b/Tests/TranscriptsCoreTests/AudioTests.swift index 5c6c6be..d847789 100644 --- a/Tests/TranscriptsCoreTests/AudioTests.swift +++ b/Tests/TranscriptsCoreTests/AudioTests.swift @@ -34,6 +34,22 @@ import AVFoundation #expect(chosen?.uid == dock.uid) } + @Test func clearingForcedZoomRestoresBuiltInFavoriteDuringCall() { + // A real support case: the built-in mic is starred, but Zoom's loopback + // was forced. Clearing the override must not keep following that virtual + // input just because the call app still has it open. + let mic = AudioInputCandidate(uid: "built-in-air", name: "MacBook Air Microphone") + let zoom = AudioInputCandidate(uid: "zoom", name: "ZoomAudioDevice", isVirtual: true) + let teams = AudioInputCandidate(uid: "teams", name: "Microsoft Teams Audio", isVirtual: true) + let devices = [mic, teams, zoom] + let forced = InputSelection.resolve(devices: devices, favorites: [mic.uid], + override: zoom.uid, callDeviceUIDs: [zoom.uid, teams.uid]) + #expect(forced?.uid == zoom.uid) + let cleared = InputSelection.resolve(devices: devices, favorites: [mic.uid], + override: nil, callDeviceUIDs: [zoom.uid, teams.uid]) + #expect(cleared?.uid == mic.uid) + } + @Test func unpluggedOverrideIsIgnored() { // The override device was unplugged — fall back to favorites rather than // recording nothing.