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).
This commit is contained in:
Grant Gilliam
2026-06-06 15:12:23 -05:00
parent 85bfdf2b56
commit 4c086251d9
11 changed files with 569 additions and 16 deletions
+32
View File
@@ -0,0 +1,32 @@
import AppKit
import SwiftUI
/// Hosts the speaker-correction editor in a standalone resizable window. A
/// menu-bar (LSUIElement) app has no normal window scene, so we open one via
/// AppKit and activate the app so it comes to the front.
@MainActor
final class EditorWindow {
static let shared = EditorWindow()
private var window: NSWindow?
func show(model: RecapEditModel) {
if let window {
window.contentViewController = NSHostingController(rootView: TranscriptEditorView(model: model))
window.title = model.title
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return
}
let w = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 560),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered, defer: false)
w.title = model.title
w.isReleasedWhenClosed = false
w.center()
w.contentViewController = NSHostingController(rootView: TranscriptEditorView(model: model))
window = w
NSApp.activate(ignoringOtherApps: true)
w.makeKeyAndOrderFront(nil)
}
}
+3
View File
@@ -94,6 +94,9 @@ struct MenuBarView: View {
if let recap = session.recapURL {
Button("Open recap") { NSWorkspace.shared.open(recap) }
}
if session.canEditSpeakers {
Button("Edit speakers") { session.editLastSession() }
}
Spacer()
}
if !transcriptText.isEmpty {
@@ -0,0 +1,152 @@
import SwiftUI
/// Speaker-correction editor: rename a speaker, merge two speakers, or reassign an
/// individual transcript line to a different speaker. Saving rewrites speakers.json,
/// re-renders the recap, and updates the voiceprint memory.
struct TranscriptEditorView: View {
@ObservedObject var model: RecapEditModel
@State private var renameTarget: String?
@State private var renameText = ""
@State private var reassignNewIndex: Int?
@State private var newSpeakerText = ""
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
transcript
Divider()
footer
}
.frame(minWidth: 560, minHeight: 480)
.sheet(item: Binding(get: { renameTarget.map { IdentifiableString($0) } },
set: { renameTarget = $0?.value })) { item in
renameSheet(for: item.value)
}
.sheet(item: Binding(get: { reassignNewIndex.map { IdentifiableInt($0) } },
set: { reassignNewIndex = $0?.value })) { item in
newSpeakerSheet(for: item.value)
}
}
// MARK: - Header / legend
private var header: some View {
VStack(alignment: .leading, spacing: 8) {
Text(model.title).font(.headline)
Text("Fix speaker names, merge duplicates, or reassign a line. Saving updates the recap and remembers any names you set.")
.font(.caption).foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(model.speakers, id: \.self) { name in
Menu {
Button("Rename…") { renameText = name; renameTarget = name }
if model.speakers.count > 1 {
Menu("Merge into") {
ForEach(model.speakers.filter { $0 != name }, id: \.self) { other in
Button(other) { model.merge(name, into: other) }
}
}
}
} label: {
Text(name).font(.caption).padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.accentColor.opacity(0.18)).clipShape(Capsule())
}
.menuStyle(.borderlessButton).fixedSize()
}
}
.padding(.vertical, 2)
}
}
.padding(12)
}
// MARK: - Transcript
private var transcript: some View {
List {
ForEach(Array(model.segments.enumerated()), id: \.offset) { idx, seg in
HStack(alignment: .top, spacing: 10) {
Text(RecapAnalyzer.mmss(seg.start))
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
.frame(width: 52, alignment: .trailing)
Menu {
ForEach(model.speakers, id: \.self) { name in
Button(name) { model.reassign(idx, to: name) }
}
Divider()
Button("New name…") { newSpeakerText = ""; reassignNewIndex = idx }
} label: {
Text(seg.speaker).font(.caption.weight(.semibold))
.frame(width: 96, alignment: .leading)
}
.menuStyle(.borderlessButton).fixedSize()
Text(seg.text ?? "").font(.callout).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 2)
}
}
.listStyle(.inset)
}
// MARK: - Footer
private var footer: some View {
HStack {
Button("Save corrections") { model.save() }
.keyboardShortcut("s", modifiers: .command)
.disabled(!model.dirty)
if let s = model.status { Text(s).font(.caption).foregroundStyle(.green) }
Spacer()
if let recap = recapURL {
Button("Open recap") { NSWorkspace.shared.open(recap) }
}
}
.padding(12)
}
private var recapURL: URL? {
let u = model.folder.appendingPathComponent("recap.html")
return FileManager.default.fileExists(atPath: u.path) ? u : nil
}
// MARK: - Sheets
private func renameSheet(for name: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rename “\(name)").font(.headline)
TextField("New name", text: $renameText).textFieldStyle(.roundedBorder).frame(width: 260)
HStack {
Spacer()
Button("Cancel") { renameTarget = nil }
Button("Rename") {
model.rename(name, to: renameText)
renameTarget = nil
}.keyboardShortcut(.defaultAction).disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(16)
}
private func newSpeakerSheet(for index: Int) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Assign this line to a new speaker").font(.headline)
TextField("Speaker name", text: $newSpeakerText).textFieldStyle(.roundedBorder).frame(width: 260)
HStack {
Spacer()
Button("Cancel") { reassignNewIndex = nil }
Button("Assign") {
let n = newSpeakerText.trimmingCharacters(in: .whitespaces)
if !n.isEmpty { model.reassign(index, to: n) }
reassignNewIndex = nil
}.keyboardShortcut(.defaultAction).disabled(newSpeakerText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(16)
}
}
private struct IdentifiableString: Identifiable { let value: String; var id: String { value }; init(_ v: String) { value = v } }
private struct IdentifiableInt: Identifiable { let value: Int; var id: Int { value }; init(_ v: Int) { value = v } }