Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 56 additions & 13 deletions Sources/TranscriptsMac/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
/// 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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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
}

Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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) }
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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.
Expand Down
11 changes: 7 additions & 4 deletions Sources/TranscriptsMac/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down
16 changes: 16 additions & 0 deletions Tests/TranscriptsCoreTests/AudioTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down