4c086251d9
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).
253 lines
8.5 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|