Surface whether visual capture ran on the last session

Visual capture falls back to audio-only silently, so the user couldn't tell if
it attached on a real call. SessionInfo now carries visualSegmentCount (nil =
audio-only; a count = visual ran, with that many vision-detected speaker
segments), shown in the menu as '… · N visual segments' or '… · audio-only'.
Makes the pending live-call validation unambiguous.
This commit is contained in:
Grant Gilliam
2026-06-06 10:21:44 -05:00
parent 880b56e426
commit 3785f6bdd0
2 changed files with 20 additions and 11 deletions
@@ -8,6 +8,10 @@ struct SessionInfo: Equatable {
let mixedURL: URL let mixedURL: URL
let duration: Double let duration: Double
let selfSpanCount: Int let selfSpanCount: Int
/// Count of vision-detected speaker segments if visual capture attached, or nil
/// if the session was audio-only (no adapter / no window / capture failed). Lets
/// the user see at a glance whether the visual pipeline ran on a real call.
let visualSegmentCount: Int?
} }
/// Owns a single recording session: creates the session folder, drives /// Owns a single recording session: creates the session folder, drives
@@ -268,18 +272,21 @@ final class SessionController: ObservableObject {
/// Stop visual capture (if any), write `visual_timeline.json`, and return the /// Stop visual capture (if any), write `visual_timeline.json`, and return the
/// timeline for the backend: visual segments + merged self-spans when visual /// timeline for the backend: visual segments + merged self-spans when visual
/// ran, otherwise the mic-VAD self spans alone. /// ran, otherwise the mic-VAD self spans alone. `visualRan` reports whether the
private func stopVisualAndTimeline(_ result: RecordingResult, folder: URL?) async -> [VisualTimeline.Segment] { /// visual pipeline actually attached (for the after-session indicator).
private func stopVisualAndTimeline(_ result: RecordingResult, folder: URL?)
async -> (timeline: [VisualTimeline.Segment], visualRan: Bool) {
let selfName = settings.selfName let selfName = settings.selfName
if let vc = visualCapture, let folder { if let vc = visualCapture, let folder {
visualCapture = nil visualCapture = nil
return await vc.finish( let timeline = await vc.finish(
selfSpans: result.selfSpans, selfName: selfName, selfSpans: result.selfSpans, selfName: selfName,
sessionId: folder.lastPathComponent, t0Unix: result.t0Unix, sessionId: folder.lastPathComponent, t0Unix: result.t0Unix,
durationSec: result.duration, folder: folder) durationSec: result.duration, folder: folder)
return (timeline, true)
} }
if let vc = visualCapture { await vc.cancel(); visualCapture = nil } if let vc = visualCapture { await vc.cancel(); visualCapture = nil }
return TranscriptPipeline.timeline(fromSelfSpans: result.selfSpans, selfName: selfName) return (TranscriptPipeline.timeline(fromSelfSpans: result.selfSpans, selfName: selfName), false)
} }
private func stop() { private func stop() {
@@ -290,12 +297,12 @@ final class SessionController: ObservableObject {
lifecycleGeneration += 1 lifecycleGeneration += 1
lifecycleTask = Task { lifecycleTask = Task {
let result = await recorder.stop() let result = await recorder.stop()
let timeline = await self.stopVisualAndTimeline(result, folder: folder) let visual = await self.stopVisualAndTimeline(result, folder: folder)
self.finish(result, timeline: timeline) self.finish(result, timeline: visual.timeline, visualRan: visual.visualRan)
} }
} }
private func finish(_ result: RecordingResult, timeline: [VisualTimeline.Segment]) { private func finish(_ result: RecordingResult, timeline: [VisualTimeline.Segment], visualRan: Bool) {
recorder = nil recorder = nil
micLevel = 0 micLevel = 0
systemLevel = 0 systemLevel = 0
@@ -303,9 +310,11 @@ final class SessionController: ObservableObject {
transcriptStatus = .idle transcriptStatus = .idle
if let folder = currentFolder { if let folder = currentFolder {
writeSelfSpans(result, to: folder) writeSelfSpans(result, to: folder)
let visualCount = visualRan ? timeline.filter { $0.source == "vision" }.count : nil
lastSession = SessionInfo( lastSession = SessionInfo(
folder: folder, mixedURL: result.mixedURL, folder: folder, mixedURL: result.mixedURL,
duration: result.duration, selfSpanCount: result.selfSpans.count) duration: result.duration, selfSpanCount: result.selfSpans.count,
visualSegmentCount: visualCount)
lastProcess = ProcessInputs( lastProcess = ProcessInputs(
folder: folder, sessionId: folder.lastPathComponent, app: currentLabel, folder: folder, sessionId: folder.lastPathComponent, app: currentLabel,
mixedURL: result.mixedURL, timeline: timeline) mixedURL: result.mixedURL, timeline: timeline)
@@ -387,8 +396,8 @@ final class SessionController: ObservableObject {
stopTimer() stopTimer()
let folder = currentFolder let folder = currentFolder
let result = await recorder.stop() let result = await recorder.stop()
let timeline = await stopVisualAndTimeline(result, folder: folder) let visual = await stopVisualAndTimeline(result, folder: folder)
finish(result, timeline: timeline) finish(result, timeline: visual.timeline, visualRan: visual.visualRan)
} else if lifecycleGeneration == gen { } else if lifecycleGeneration == gen {
break // settled: no new transition was spawned break // settled: no new transition was spawned
} }
+1 -1
View File
@@ -83,7 +83,7 @@ struct MenuBarView: View {
Button { Button {
NSWorkspace.shared.activateFileViewerSelecting([last.mixedURL]) NSWorkspace.shared.activateFileViewerSelecting([last.mixedURL])
} label: { } label: {
Text("Last: \(Int(last.duration.rounded()))s · \(last.selfSpanCount) self-spans — reveal in Finder") Text("Last: \(Int(last.duration.rounded()))s · \(last.selfSpanCount) self-spans · \(last.visualSegmentCount.map { "\($0) visual segments" } ?? "audio-only") — reveal in Finder")
.font(.caption) .font(.caption)
} }
.buttonStyle(.link) .buttonStyle(.link)