Files
ten31-transcripts/Ten31Transcripts/UI/MenuBarView.swift
T
Grant Gilliam 4c086251d9 Speaker corrections: rename / merge / reassign + voice learning
Native editor to fix speaker-ID errors after transcription (modeled on recap-relay's
correction UX): rename a speaker in the legend, merge two speakers, or reassign an
individual transcript line. Saving rewrites speakers.json, re-renders transcript.md +
recap.html, and updates the voiceprint memory — so a correction compounds: naming an
"Unknown" speaker teaches that voice for future calls.

- SpeakerEditing (pure, tested): replaceSpeaker (rename = merge-onto-existing),
  reassign, netNameMap (compose ops), and remap (apply a name map to a recap's
  structured fields + whole-word free text, so summaries/extras update without re-LLM).
- RecapEditModel (@MainActor): loads speakers.json (+ optional recap.json +
  cluster_fingerprints.json); on save writes the resolved speakers.json, re-renders,
  and reconciles voiceprints — merge keeps the survivor's print; rename/name-an-Unknown
  enrolls the cluster's fingerprint under the new name.
- TranscriptEditorView (SwiftUI) + EditorWindow (AppKit window for the LSUIElement app);
  menu gains "Edit speakers".
- Pipeline now persists cluster_fingerprints.json (every cluster incl. Unknown) and
  recap.json (RecapFile) so the editor can learn voices + re-render offline.
- RecapModels made Codable; TranscriptAssembler exposes allFingerprints;
  VoiceprintStore gains enroll() + merge().

52/52 XCTest (6 new, incl. a full rename→artifacts→voiceprint round-trip on disk).
2026-06-06 15:12:23 -05:00

253 lines
8.5 KiB
Swift

import SwiftUI
import AppKit
/// The menu-bar panel: permission statuses, backend health, and a link to
/// Settings. Shown when the user clicks the status-bar item.
struct MenuBarView: View {
@EnvironmentObject private var settings: AppSettings
@EnvironmentObject private var permissions: PermissionsManager
@EnvironmentObject private var health: SparkControlHealth
@EnvironmentObject private var session: SessionController
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 12) {
header
Divider()
recordingSection
Divider()
permissionsSection
Divider()
backendSection
Divider()
footer
}
.padding(14)
.frame(width: 320)
}
.onAppear { permissions.refresh() }
.task { await refreshHealth() }
}
// MARK: Recording
private var canRecord: Bool {
permissions.microphone == .granted && permissions.screenRecording == .granted
}
private var recordingSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Recording").font(.subheadline).bold()
Spacer()
if session.state == .recording {
Text(timeString(session.elapsed))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
Text(detectionText)
.font(.caption)
.foregroundStyle(.secondary)
Button {
session.toggle()
} label: {
Label(recordButtonTitle, systemImage: recordButtonIcon)
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.tint(session.state == .recording ? .red : .accentColor)
.disabled(recordButtonDisabled)
if session.state == .recording {
LevelBar(label: "Mic", level: session.micLevel)
LevelBar(label: "System", level: session.systemLevel)
}
if !canRecord && !session.isBusy {
Text("Grant Microphone + Screen Recording above to record.")
.font(.caption)
.foregroundStyle(.secondary)
}
if case .error(let message) = session.state {
Text(message).font(.caption).foregroundStyle(.red)
}
if let warning = session.warning {
Text(warning).font(.caption).foregroundStyle(.orange)
}
if let last = session.lastSession {
Button {
NSWorkspace.shared.activateFileViewerSelecting([last.mixedURL])
} label: {
Text("Last: \(Int(last.duration.rounded()))s · \(last.selfSpanCount) self-spans · \(last.visualSegmentCount.map { "\($0) visual segments" } ?? "audio-only") — reveal in Finder")
.font(.caption)
}
.buttonStyle(.link)
HStack {
Button("Send to backend") { session.processLastSession() }
.disabled(transcriptProcessing)
if let recap = session.recapURL {
Button("Open recap") { NSWorkspace.shared.open(recap) }
}
if session.canEditSpeakers {
Button("Edit speakers") { session.editLastSession() }
}
Spacer()
}
if !transcriptText.isEmpty {
Text(transcriptText).font(.caption).foregroundStyle(transcriptColor)
}
}
}
}
private var recordButtonTitle: String {
switch session.state {
case .starting: return "Starting…"
case .recording: return "Stop Recording"
case .finishing: return "Finishing…"
case .idle, .error: return "Start Recording"
}
}
private var recordButtonIcon: String {
session.state == .recording ? "stop.circle.fill" : "record.circle"
}
private var recordButtonDisabled: Bool {
switch session.state {
case .starting, .finishing: return true
case .recording: return false
case .idle, .error: return !canRecord
}
}
private func timeString(_ t: TimeInterval) -> String {
let total = Int(t)
return String(format: "%02d:%02d", total / 60, total % 60)
}
private var detectionText: String {
switch session.detectionStatus {
case .disabled: return "Auto-detect off"
case .listening: return "Listening for calls…"
case .inCall(let app): return "In call: \(app.display)"
}
}
private var transcriptProcessing: Bool {
if case .processing = session.transcriptStatus { return true }
return false
}
private var transcriptText: String {
switch session.transcriptStatus {
case .idle: return ""
case .processing(let d, let t): return "Transcribing… chunk \(d)/\(t)"
case .done(let s, let seg): return "Transcript ready · \(s) speakers · \(seg) segments"
case .failed(let m): return "Transcript failed: \(m)"
}
}
private var transcriptColor: Color {
switch session.transcriptStatus {
case .failed: return .red
case .done: return .green
default: return .secondary
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Ten31 Transcripts").font(.headline)
Text("Phase 0 · setup & status")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Permissions").font(.subheadline).bold()
PermissionRow(
title: "Microphone",
state: permissions.microphone,
onGrant: permissions.requestMicrophone,
onOpenSettings: { permissions.openSettings(.microphone) }
)
PermissionRow(
title: "Screen Recording",
state: permissions.screenRecording,
onGrant: permissions.requestScreenRecording,
onOpenSettings: { permissions.openSettings(.screenRecording) }
)
PermissionRow(
title: "Accessibility",
state: permissions.accessibility,
onGrant: permissions.requestAccessibility,
onOpenSettings: { permissions.openSettings(.accessibility) }
)
}
}
private var backendSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Backend").font(.subheadline).bold()
Spacer()
Button("Check") { Task { await refreshHealth() } }
.disabled(health.status == .checking)
}
Text(settings.backendBaseURL)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: 8) {
StatusDot(color: healthColor)
Text(healthText).font(.caption)
}
}
}
private var footer: some View {
HStack {
NavigationLink("Settings…") {
SettingsView()
}
Spacer()
Button("Quit") { NSApplication.shared.terminate(nil) }
}
}
private func refreshHealth() async {
await health.check(
baseURL: settings.backendBaseURL,
skipTLS: settings.skipTLSVerification
)
}
private var healthColor: Color {
switch health.status {
case .online: return .green
case .offline: return .red
case .checking: return .orange
case .unknown: return .gray
}
}
private var healthText: String {
switch health.status {
case .unknown: return "Not checked yet"
case .checking: return "Checking…"
case .online(let detail): return "Online · \(detail)"
case .offline(let error): return "Offline · \(error)"
}
}
}