@@ -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+
561631public 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 ] = [ ] ,
0 commit comments