Skip to content

Commit b531663

Browse files
authored
Merge pull request #33 from execsumo/claude/speaker-labeling-review-tgiikq
Speaker labeling overhaul: user-paced naming, split voices, speaking-time metric
2 parents 98dcdc8 + fb20ffb commit b531663

13 files changed

Lines changed: 846 additions & 242 deletions

Sources/Heard/MTApp.swift

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,10 @@ struct HeardApp: App {
108108
SpeakerNamingView(model: appModel)
109109
.heardAppearance(appModel.settingsStore.settings.appearance)
110110
.onAppear { WindowActivationCoordinator.begin("speaker-naming") }
111-
.onDisappear {
112-
WindowActivationCoordinator.end("speaker-naming")
113-
// If user closes window without naming, skip naming
114-
if !appModel.namingCandidates.isEmpty {
115-
appModel.skipNaming()
116-
}
117-
}
111+
// Closing the window means "later", not "skip" — candidates stay
112+
// pending and the window can be reopened from the menu bar. Skipping
113+
// is only ever the explicit "Skip All" button.
114+
.onDisappear { WindowActivationCoordinator.end("speaker-naming") }
118115
}
119116
.defaultSize(width: 560, height: 520)
120117
.windowResizability(.contentSize)

Sources/HeardCore/AppModel.swift

Lines changed: 147 additions & 59 deletions
Large diffs are not rendered by default.

Sources/HeardCore/AudioClipExtractor.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ public enum AudioClipExtractor {
210210
return clipped
211211
}
212212

213+
/// One extracted voice sample: the saved WAV plus the original-audio time region it
214+
/// was cut from, so callers can correlate the clip back to diarizer chunk data.
215+
public struct ExtractedClip {
216+
public let url: URL
217+
public let startTime: TimeInterval
218+
public let endTime: TimeInterval
219+
}
220+
213221
/// Extract clips for all unmatched speakers and return candidate info.
214222
/// Each speaker gets up to `clipsPerSpeaker` distinct samples saved to the recordings
215223
/// directory, ordered best-first.
@@ -218,15 +226,15 @@ public enum AudioClipExtractor {
218226
/// When provided, silence gaps within each extracted clip are skipped so every playback
219227
/// is continuous speech rather than a raw time slice that may include long pauses.
220228
public static func extractSpeakerClips(
221-
unmatchedSpeakers: [(speakerID: String, temporaryName: String, embedding: [Float], duration: TimeInterval, words: Int)],
229+
unmatchedSpeakers: [UnmatchedSpeaker],
222230
diarizationSegments: [(speakerID: String, startTime: TimeInterval, endTime: TimeInterval)],
223231
speechSegments: [(startTime: TimeInterval, endTime: TimeInterval)]? = nil,
224232
sourceAudioURL: URL,
225233
outputDirectory: URL,
226234
clipsPerSpeaker: Int = 3,
227235
vadSpeechSegments: [(startTime: TimeInterval, endTime: TimeInterval)] = []
228-
) -> [(temporaryName: String, clipURLs: [URL], embedding: [Float], duration: TimeInterval, words: Int)] {
229-
var results: [(temporaryName: String, clipURLs: [URL], embedding: [Float], duration: TimeInterval, words: Int)] = []
236+
) -> [(speaker: UnmatchedSpeaker, clips: [ExtractedClip])] {
237+
var results: [(speaker: UnmatchedSpeaker, clips: [ExtractedClip])] = []
230238

231239
for speaker in unmatchedSpeakers {
232240
let regions = bestClipRegions(
@@ -236,7 +244,7 @@ public enum AudioClipExtractor {
236244
maxCount: clipsPerSpeaker
237245
)
238246

239-
var savedURLs: [URL] = []
247+
var saved: [ExtractedClip] = []
240248
for region in regions {
241249
let clipFilename = "clip_\(UUID().uuidString.prefix(8)).wav"
242250
let clipURL = outputDirectory.appendingPathComponent(clipFilename)
@@ -248,11 +256,11 @@ public enum AudioClipExtractor {
248256
outputURL: clipURL,
249257
vadSpeechSegments: vadSpeechSegments
250258
) {
251-
savedURLs.append(savedURL)
259+
saved.append(ExtractedClip(url: savedURL, startTime: region.startTime, endTime: region.endTime))
252260
}
253261
}
254262

255-
results.append((speaker.temporaryName, savedURLs, speaker.embedding, speaker.duration, speaker.words))
263+
results.append((speaker, saved))
256264
}
257265

258266
return results

Sources/HeardCore/CoreModels.swift

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,13 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
151151
public var firstSeen: Date
152152
public var lastSeen: Date
153153
public var meetingCount: Int
154+
/// Total wall-clock duration of meetings this speaker attended.
154155
public var totalMeetingDuration: TimeInterval
155156
public var totalWordCount: Int
157+
/// Cumulative time this speaker actually spent talking (sum of their
158+
/// transcript segment durations), as opposed to `totalMeetingDuration`
159+
/// which counts the whole meeting for every attendee.
160+
public var totalSpeakingTime: TimeInterval
156161
/// Persisted voice samples for this speaker (used for replay in settings).
157162
/// Ordered best-first; multiple samples help the user disambiguate when one is silent.
158163
public var audioClipURLs: [URL]
@@ -166,6 +171,7 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
166171
meetingCount: Int,
167172
totalMeetingDuration: TimeInterval = 0,
168173
totalWordCount: Int = 0,
174+
totalSpeakingTime: TimeInterval = 0,
169175
audioClipURLs: [URL] = []
170176
) {
171177
self.id = id
@@ -176,12 +182,13 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
176182
self.meetingCount = meetingCount
177183
self.totalMeetingDuration = totalMeetingDuration
178184
self.totalWordCount = totalWordCount
185+
self.totalSpeakingTime = totalSpeakingTime
179186
self.audioClipURLs = audioClipURLs
180187
}
181188

182189
private enum CodingKeys: String, CodingKey {
183190
case id, name, embeddings, firstSeen, lastSeen, meetingCount
184-
case totalMeetingDuration, totalWordCount
191+
case totalMeetingDuration, totalWordCount, totalSpeakingTime
185192
case audioClipURLs
186193
case audioClipURL // legacy single-URL field
187194
}
@@ -196,6 +203,7 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
196203
meetingCount = try c.decode(Int.self, forKey: .meetingCount)
197204
totalMeetingDuration = try c.decodeIfPresent(TimeInterval.self, forKey: .totalMeetingDuration) ?? 0
198205
totalWordCount = try c.decodeIfPresent(Int.self, forKey: .totalWordCount) ?? 0
206+
totalSpeakingTime = try c.decodeIfPresent(TimeInterval.self, forKey: .totalSpeakingTime) ?? 0
199207
if let urls = try c.decodeIfPresent([URL].self, forKey: .audioClipURLs) {
200208
audioClipURLs = urls
201209
} else if let legacy = try c.decodeIfPresent(URL.self, forKey: .audioClipURL) {
@@ -215,42 +223,65 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
215223
try c.encode(meetingCount, forKey: .meetingCount)
216224
try c.encode(totalMeetingDuration, forKey: .totalMeetingDuration)
217225
try c.encode(totalWordCount, forKey: .totalWordCount)
226+
try c.encode(totalSpeakingTime, forKey: .totalSpeakingTime)
218227
try c.encode(audioClipURLs, forKey: .audioClipURLs)
219228
}
220229
}
221230

222-
public struct NamingCandidate: Identifiable, Equatable {
231+
/// Codable so pending candidates survive an app restart (`NamingCandidateStore`).
232+
public struct NamingCandidate: Identifiable, Equatable, Codable {
223233
public let id: UUID
224234
public var temporaryName: String
225-
public var suggestedName: String?
235+
/// Roster names not matched to any known speaker this meeting. All of them are
236+
/// offered as tappable suggestions on every candidate — with several unknown
237+
/// voices there is no reliable way to pre-pair a specific name to a specific
238+
/// voice, so the user picks by ear.
239+
public var suggestedNames: [String]
226240
/// Voice samples for this candidate, ordered best-first. The naming prompt lets the
227241
/// user play any of them so they can disambiguate when one sample is silent or has
228242
/// crosstalk.
229243
public var audioClipURLs: [URL]
230244
public var embedding: [Float]
245+
/// Per-clip embeddings parallel to `audioClipURLs` (an entry may be empty when the
246+
/// diarizer didn't expose chunk embeddings for that region). Powers "Split voices":
247+
/// when the clips of one cluster turn out to be different people, each split part is
248+
/// saved with its own clip-local embedding instead of the polluted cluster centroid.
249+
public var clipEmbeddings: [[Float]]
231250
/// Path to the transcript file that uses this temporary name; used to rewrite the file when the speaker is named.
232251
public var transcriptPath: URL?
233252
public var totalMeetingDuration: TimeInterval
234253
public var totalWordCount: Int
254+
/// Time this voice actually spent talking (sum of its segment durations).
255+
public var totalSpeakingTime: TimeInterval
256+
257+
/// The one name worth pre-filling: only when the roster leaves exactly one
258+
/// unmatched name is the pairing unambiguous enough to type it in for the user.
259+
public var suggestedName: String? {
260+
suggestedNames.count == 1 ? suggestedNames.first : nil
261+
}
235262

236263
public init(
237264
id: UUID,
238265
temporaryName: String,
239-
suggestedName: String? = nil,
266+
suggestedNames: [String] = [],
240267
audioClipURLs: [URL] = [],
241268
embedding: [Float] = [],
269+
clipEmbeddings: [[Float]] = [],
242270
transcriptPath: URL? = nil,
243271
totalMeetingDuration: TimeInterval = 0,
244-
totalWordCount: Int = 0
272+
totalWordCount: Int = 0,
273+
totalSpeakingTime: TimeInterval = 0
245274
) {
246275
self.id = id
247276
self.temporaryName = temporaryName
248-
self.suggestedName = suggestedName
277+
self.suggestedNames = suggestedNames
249278
self.audioClipURLs = audioClipURLs
250279
self.embedding = embedding
280+
self.clipEmbeddings = clipEmbeddings
251281
self.transcriptPath = transcriptPath
252282
self.totalMeetingDuration = totalMeetingDuration
253283
self.totalWordCount = totalWordCount
284+
self.totalSpeakingTime = totalSpeakingTime
254285
}
255286
}
256287

@@ -332,6 +363,13 @@ public struct AppSettings: Codable, Equatable {
332363
/// tab, which is easier than recovering from a merged-embedding poisoning a
333364
/// profile.
334365
public var diarizationClusteringSimilarity: Double
366+
/// Cosine *distance* below which a detected voice is considered the same person
367+
/// as a stored profile (cross-meeting recognition). Lower = stricter: fewer
368+
/// false matches against the wrong profile, but more "new speaker" naming
369+
/// prompts for people already in the database; higher = looser. Distinct from
370+
/// `diarizationClusteringSimilarity`, which separates voices *within* one
371+
/// meeting.
372+
public var speakerMatchThreshold: Double
335373
public var appearance: AppAppearance
336374
/// Controls whether preprocessing runs the app and mic tracks sequentially (low
337375
/// memory) or concurrently. `.auto` decides based on the machine's physical RAM;
@@ -382,6 +420,7 @@ public struct AppSettings: Codable, Equatable {
382420
enableZoomDetection: true,
383421
enableWebexDetection: true,
384422
diarizationClusteringSimilarity: 0.65,
423+
speakerMatchThreshold: 0.30,
385424
appearance: .system,
386425
memoryMode: .auto,
387426
speakerRetentionDays: 90,
@@ -409,6 +448,7 @@ public struct AppSettings: Codable, Equatable {
409448
enableZoomDetection: Bool = true,
410449
enableWebexDetection: Bool = true,
411450
diarizationClusteringSimilarity: Double = 0.65,
451+
speakerMatchThreshold: Double = 0.30,
412452
appearance: AppAppearance = .system,
413453
memoryMode: MemoryMode = .auto,
414454
speakerRetentionDays: Int = 90,
@@ -434,6 +474,7 @@ public struct AppSettings: Codable, Equatable {
434474
self.enableZoomDetection = enableZoomDetection
435475
self.enableWebexDetection = enableWebexDetection
436476
self.diarizationClusteringSimilarity = diarizationClusteringSimilarity
477+
self.speakerMatchThreshold = speakerMatchThreshold
437478
self.appearance = appearance
438479
self.memoryMode = memoryMode
439480
self.speakerRetentionDays = speakerRetentionDays
@@ -558,14 +599,43 @@ public struct TranscriptSegment: Identifiable, Equatable {
558599
}
559600
}
560601

602+
/// A diarized voice with no confident match in the speaker database, carrying the
603+
/// per-meeting stats that flow into the profile once the user names (or skips) it.
604+
public struct UnmatchedSpeaker {
605+
public let speakerID: String
606+
public let temporaryName: String
607+
public let embedding: [Float]
608+
/// Wall-clock duration of the meeting this voice appeared in.
609+
public var totalMeetingDuration: TimeInterval
610+
public var totalWordCount: Int
611+
/// Time this voice actually spent talking (sum of its segment durations).
612+
public var totalSpeakingTime: TimeInterval
613+
614+
public init(
615+
speakerID: String,
616+
temporaryName: String,
617+
embedding: [Float],
618+
totalMeetingDuration: TimeInterval = 0,
619+
totalWordCount: Int = 0,
620+
totalSpeakingTime: TimeInterval = 0
621+
) {
622+
self.speakerID = speakerID
623+
self.temporaryName = temporaryName
624+
self.embedding = embedding
625+
self.totalMeetingDuration = totalMeetingDuration
626+
self.totalWordCount = totalWordCount
627+
self.totalSpeakingTime = totalSpeakingTime
628+
}
629+
}
630+
561631
public struct TranscriptDocument {
562632
public var title: String
563633
public var startTime: Date
564634
public var endTime: Date
565635
public var participants: [String]
566636
public var segments: [TranscriptSegment]
567-
/// Unmatched speakers from diarization (speakerID, temporary name, embedding, totalMeetingDuration, totalWordCount).
568-
public var unmatchedSpeakers: [(speakerID: String, temporaryName: String, embedding: [Float], duration: TimeInterval, words: Int)]
637+
/// Unmatched speakers from diarization, pending the naming prompt.
638+
public var unmatchedSpeakers: [UnmatchedSpeaker]
569639
/// Diarization segments with original-time timestamps for clip extraction.
570640
public var diarizationSegments: [(speakerID: String, startTime: TimeInterval, endTime: TimeInterval)]
571641
/// Roster names not matched to known speakers (potential suggested names).
@@ -582,7 +652,7 @@ public struct TranscriptDocument {
582652
endTime: Date,
583653
participants: [String],
584654
segments: [TranscriptSegment],
585-
unmatchedSpeakers: [(speakerID: String, temporaryName: String, embedding: [Float], duration: TimeInterval, words: Int)] = [],
655+
unmatchedSpeakers: [UnmatchedSpeaker] = [],
586656
diarizationSegments: [(speakerID: String, startTime: TimeInterval, endTime: TimeInterval)] = [],
587657
unmatchedRosterNames: [String] = [],
588658
notes: [MeetingNote] = [],

Sources/HeardCore/MenuBarView.swift

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -168,20 +168,9 @@ public struct MenuBarView: View {
168168
}
169169
.frame(width: 268)
170170
.background(HeardTheme.Paper.bg)
171-
.onChange(of: model.showNamingPrompt) { _, show in
172-
NSLog("Heard: MenuBarView observed showNamingPrompt=\(show)")
173-
if show {
174-
openWindow(id: "speaker-naming")
175-
NSApp.activate(ignoringOtherApps: true)
176-
}
177-
}
178-
.onChange(of: model.namingCandidates.isEmpty) { wasEmpty, isEmpty in
179-
if wasEmpty && !isEmpty {
180-
NSLog("Heard: MenuBarView observed namingCandidates became non-empty (\(model.namingCandidates.count))")
181-
openWindow(id: "speaker-naming")
182-
NSApp.activate(ignoringOtherApps: true)
183-
}
184-
}
171+
// Naming is user-initiated: no auto-open. The orange badge, the
172+
// "Name Speakers…" row above, and the Speakers-tab banner are the cues;
173+
// candidates stay pending until the user acts.
185174
}
186175

187176
// MARK: Status Header

0 commit comments

Comments
 (0)