Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions Sources/Heard/MTApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,10 @@ struct HeardApp: App {
SpeakerNamingView(model: appModel)
.heardAppearance(appModel.settingsStore.settings.appearance)
.onAppear { WindowActivationCoordinator.begin("speaker-naming") }
.onDisappear {
WindowActivationCoordinator.end("speaker-naming")
// If user closes window without naming, skip naming
if !appModel.namingCandidates.isEmpty {
appModel.skipNaming()
}
}
// Closing the window means "later", not "skip" — candidates stay
// pending and the window can be reopened from the menu bar. Skipping
// is only ever the explicit "Skip All" button.
.onDisappear { WindowActivationCoordinator.end("speaker-naming") }
}
.defaultSize(width: 560, height: 520)
.windowResizability(.contentSize)
Expand Down
206 changes: 147 additions & 59 deletions Sources/HeardCore/AppModel.swift

Large diffs are not rendered by default.

20 changes: 14 additions & 6 deletions Sources/HeardCore/AudioClipExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ public enum AudioClipExtractor {
return clipped
}

/// One extracted voice sample: the saved WAV plus the original-audio time region it
/// was cut from, so callers can correlate the clip back to diarizer chunk data.
public struct ExtractedClip {
public let url: URL
public let startTime: TimeInterval
public let endTime: TimeInterval
}

/// Extract clips for all unmatched speakers and return candidate info.
/// Each speaker gets up to `clipsPerSpeaker` distinct samples saved to the recordings
/// directory, ordered best-first.
Expand All @@ -218,15 +226,15 @@ public enum AudioClipExtractor {
/// When provided, silence gaps within each extracted clip are skipped so every playback
/// is continuous speech rather than a raw time slice that may include long pauses.
public static func extractSpeakerClips(
unmatchedSpeakers: [(speakerID: String, temporaryName: String, embedding: [Float], duration: TimeInterval, words: Int)],
unmatchedSpeakers: [UnmatchedSpeaker],
diarizationSegments: [(speakerID: String, startTime: TimeInterval, endTime: TimeInterval)],
speechSegments: [(startTime: TimeInterval, endTime: TimeInterval)]? = nil,
sourceAudioURL: URL,
outputDirectory: URL,
clipsPerSpeaker: Int = 3,
vadSpeechSegments: [(startTime: TimeInterval, endTime: TimeInterval)] = []
) -> [(temporaryName: String, clipURLs: [URL], embedding: [Float], duration: TimeInterval, words: Int)] {
var results: [(temporaryName: String, clipURLs: [URL], embedding: [Float], duration: TimeInterval, words: Int)] = []
) -> [(speaker: UnmatchedSpeaker, clips: [ExtractedClip])] {
var results: [(speaker: UnmatchedSpeaker, clips: [ExtractedClip])] = []

for speaker in unmatchedSpeakers {
let regions = bestClipRegions(
Expand All @@ -236,7 +244,7 @@ public enum AudioClipExtractor {
maxCount: clipsPerSpeaker
)

var savedURLs: [URL] = []
var saved: [ExtractedClip] = []
for region in regions {
let clipFilename = "clip_\(UUID().uuidString.prefix(8)).wav"
let clipURL = outputDirectory.appendingPathComponent(clipFilename)
Expand All @@ -248,11 +256,11 @@ public enum AudioClipExtractor {
outputURL: clipURL,
vadSpeechSegments: vadSpeechSegments
) {
savedURLs.append(savedURL)
saved.append(ExtractedClip(url: savedURL, startTime: region.startTime, endTime: region.endTime))
}
}

results.append((speaker.temporaryName, savedURLs, speaker.embedding, speaker.duration, speaker.words))
results.append((speaker, saved))
}

return results
Expand Down
88 changes: 79 additions & 9 deletions Sources/HeardCore/CoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,13 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
public var firstSeen: Date
public var lastSeen: Date
public var meetingCount: Int
/// Total wall-clock duration of meetings this speaker attended.
public var totalMeetingDuration: TimeInterval
public var totalWordCount: Int
/// Cumulative time this speaker actually spent talking (sum of their
/// transcript segment durations), as opposed to `totalMeetingDuration`
/// which counts the whole meeting for every attendee.
public var totalSpeakingTime: TimeInterval
/// Persisted voice samples for this speaker (used for replay in settings).
/// Ordered best-first; multiple samples help the user disambiguate when one is silent.
public var audioClipURLs: [URL]
Expand All @@ -166,6 +171,7 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
meetingCount: Int,
totalMeetingDuration: TimeInterval = 0,
totalWordCount: Int = 0,
totalSpeakingTime: TimeInterval = 0,
audioClipURLs: [URL] = []
) {
self.id = id
Expand All @@ -176,12 +182,13 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
self.meetingCount = meetingCount
self.totalMeetingDuration = totalMeetingDuration
self.totalWordCount = totalWordCount
self.totalSpeakingTime = totalSpeakingTime
self.audioClipURLs = audioClipURLs
}

private enum CodingKeys: String, CodingKey {
case id, name, embeddings, firstSeen, lastSeen, meetingCount
case totalMeetingDuration, totalWordCount
case totalMeetingDuration, totalWordCount, totalSpeakingTime
case audioClipURLs
case audioClipURL // legacy single-URL field
}
Expand All @@ -196,6 +203,7 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
meetingCount = try c.decode(Int.self, forKey: .meetingCount)
totalMeetingDuration = try c.decodeIfPresent(TimeInterval.self, forKey: .totalMeetingDuration) ?? 0
totalWordCount = try c.decodeIfPresent(Int.self, forKey: .totalWordCount) ?? 0
totalSpeakingTime = try c.decodeIfPresent(TimeInterval.self, forKey: .totalSpeakingTime) ?? 0
if let urls = try c.decodeIfPresent([URL].self, forKey: .audioClipURLs) {
audioClipURLs = urls
} else if let legacy = try c.decodeIfPresent(URL.self, forKey: .audioClipURL) {
Expand All @@ -215,42 +223,65 @@ public struct SpeakerProfile: Codable, Identifiable, Equatable {
try c.encode(meetingCount, forKey: .meetingCount)
try c.encode(totalMeetingDuration, forKey: .totalMeetingDuration)
try c.encode(totalWordCount, forKey: .totalWordCount)
try c.encode(totalSpeakingTime, forKey: .totalSpeakingTime)
try c.encode(audioClipURLs, forKey: .audioClipURLs)
}
}

public struct NamingCandidate: Identifiable, Equatable {
/// Codable so pending candidates survive an app restart (`NamingCandidateStore`).
public struct NamingCandidate: Identifiable, Equatable, Codable {
public let id: UUID
public var temporaryName: String
public var suggestedName: String?
/// Roster names not matched to any known speaker this meeting. All of them are
/// offered as tappable suggestions on every candidate — with several unknown
/// voices there is no reliable way to pre-pair a specific name to a specific
/// voice, so the user picks by ear.
public var suggestedNames: [String]
/// Voice samples for this candidate, ordered best-first. The naming prompt lets the
/// user play any of them so they can disambiguate when one sample is silent or has
/// crosstalk.
public var audioClipURLs: [URL]
public var embedding: [Float]
/// Per-clip embeddings parallel to `audioClipURLs` (an entry may be empty when the
/// diarizer didn't expose chunk embeddings for that region). Powers "Split voices":
/// when the clips of one cluster turn out to be different people, each split part is
/// saved with its own clip-local embedding instead of the polluted cluster centroid.
public var clipEmbeddings: [[Float]]
/// Path to the transcript file that uses this temporary name; used to rewrite the file when the speaker is named.
public var transcriptPath: URL?
public var totalMeetingDuration: TimeInterval
public var totalWordCount: Int
/// Time this voice actually spent talking (sum of its segment durations).
public var totalSpeakingTime: TimeInterval

/// The one name worth pre-filling: only when the roster leaves exactly one
/// unmatched name is the pairing unambiguous enough to type it in for the user.
public var suggestedName: String? {
suggestedNames.count == 1 ? suggestedNames.first : nil
}

public init(
id: UUID,
temporaryName: String,
suggestedName: String? = nil,
suggestedNames: [String] = [],
audioClipURLs: [URL] = [],
embedding: [Float] = [],
clipEmbeddings: [[Float]] = [],
transcriptPath: URL? = nil,
totalMeetingDuration: TimeInterval = 0,
totalWordCount: Int = 0
totalWordCount: Int = 0,
totalSpeakingTime: TimeInterval = 0
) {
self.id = id
self.temporaryName = temporaryName
self.suggestedName = suggestedName
self.suggestedNames = suggestedNames
self.audioClipURLs = audioClipURLs
self.embedding = embedding
self.clipEmbeddings = clipEmbeddings
self.transcriptPath = transcriptPath
self.totalMeetingDuration = totalMeetingDuration
self.totalWordCount = totalWordCount
self.totalSpeakingTime = totalSpeakingTime
}
}

Expand Down Expand Up @@ -332,6 +363,13 @@ public struct AppSettings: Codable, Equatable {
/// tab, which is easier than recovering from a merged-embedding poisoning a
/// profile.
public var diarizationClusteringSimilarity: Double
/// Cosine *distance* below which a detected voice is considered the same person
/// as a stored profile (cross-meeting recognition). Lower = stricter: fewer
/// false matches against the wrong profile, but more "new speaker" naming
/// prompts for people already in the database; higher = looser. Distinct from
/// `diarizationClusteringSimilarity`, which separates voices *within* one
/// meeting.
public var speakerMatchThreshold: Double
public var appearance: AppAppearance
/// Controls whether preprocessing runs the app and mic tracks sequentially (low
/// memory) or concurrently. `.auto` decides based on the machine's physical RAM;
Expand Down Expand Up @@ -382,6 +420,7 @@ public struct AppSettings: Codable, Equatable {
enableZoomDetection: true,
enableWebexDetection: true,
diarizationClusteringSimilarity: 0.65,
speakerMatchThreshold: 0.30,
appearance: .system,
memoryMode: .auto,
speakerRetentionDays: 90,
Expand Down Expand Up @@ -409,6 +448,7 @@ public struct AppSettings: Codable, Equatable {
enableZoomDetection: Bool = true,
enableWebexDetection: Bool = true,
diarizationClusteringSimilarity: Double = 0.65,
speakerMatchThreshold: Double = 0.30,
appearance: AppAppearance = .system,
memoryMode: MemoryMode = .auto,
speakerRetentionDays: Int = 90,
Expand All @@ -434,6 +474,7 @@ public struct AppSettings: Codable, Equatable {
self.enableZoomDetection = enableZoomDetection
self.enableWebexDetection = enableWebexDetection
self.diarizationClusteringSimilarity = diarizationClusteringSimilarity
self.speakerMatchThreshold = speakerMatchThreshold
self.appearance = appearance
self.memoryMode = memoryMode
self.speakerRetentionDays = speakerRetentionDays
Expand Down Expand Up @@ -558,14 +599,43 @@ public struct TranscriptSegment: Identifiable, Equatable {
}
}

/// A diarized voice with no confident match in the speaker database, carrying the
/// per-meeting stats that flow into the profile once the user names (or skips) it.
public struct UnmatchedSpeaker {
public let speakerID: String
public let temporaryName: String
public let embedding: [Float]
/// Wall-clock duration of the meeting this voice appeared in.
public var totalMeetingDuration: TimeInterval
public var totalWordCount: Int
/// Time this voice actually spent talking (sum of its segment durations).
public var totalSpeakingTime: TimeInterval

public init(
speakerID: String,
temporaryName: String,
embedding: [Float],
totalMeetingDuration: TimeInterval = 0,
totalWordCount: Int = 0,
totalSpeakingTime: TimeInterval = 0
) {
self.speakerID = speakerID
self.temporaryName = temporaryName
self.embedding = embedding
self.totalMeetingDuration = totalMeetingDuration
self.totalWordCount = totalWordCount
self.totalSpeakingTime = totalSpeakingTime
}
}

public struct TranscriptDocument {
public var title: String
public var startTime: Date
public var endTime: Date
public var participants: [String]
public var segments: [TranscriptSegment]
/// Unmatched speakers from diarization (speakerID, temporary name, embedding, totalMeetingDuration, totalWordCount).
public var unmatchedSpeakers: [(speakerID: String, temporaryName: String, embedding: [Float], duration: TimeInterval, words: Int)]
/// Unmatched speakers from diarization, pending the naming prompt.
public var unmatchedSpeakers: [UnmatchedSpeaker]
/// Diarization segments with original-time timestamps for clip extraction.
public var diarizationSegments: [(speakerID: String, startTime: TimeInterval, endTime: TimeInterval)]
/// Roster names not matched to known speakers (potential suggested names).
Expand All @@ -582,7 +652,7 @@ public struct TranscriptDocument {
endTime: Date,
participants: [String],
segments: [TranscriptSegment],
unmatchedSpeakers: [(speakerID: String, temporaryName: String, embedding: [Float], duration: TimeInterval, words: Int)] = [],
unmatchedSpeakers: [UnmatchedSpeaker] = [],
diarizationSegments: [(speakerID: String, startTime: TimeInterval, endTime: TimeInterval)] = [],
unmatchedRosterNames: [String] = [],
notes: [MeetingNote] = [],
Expand Down
17 changes: 3 additions & 14 deletions Sources/HeardCore/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,20 +168,9 @@ public struct MenuBarView: View {
}
.frame(width: 268)
.background(HeardTheme.Paper.bg)
.onChange(of: model.showNamingPrompt) { _, show in
NSLog("Heard: MenuBarView observed showNamingPrompt=\(show)")
if show {
openWindow(id: "speaker-naming")
NSApp.activate(ignoringOtherApps: true)
}
}
.onChange(of: model.namingCandidates.isEmpty) { wasEmpty, isEmpty in
if wasEmpty && !isEmpty {
NSLog("Heard: MenuBarView observed namingCandidates became non-empty (\(model.namingCandidates.count))")
openWindow(id: "speaker-naming")
NSApp.activate(ignoringOtherApps: true)
}
}
// Naming is user-initiated: no auto-open. The orange badge, the
// "Name Speakers…" row above, and the Speakers-tab banner are the cues;
// candidates stay pending until the user acts.
}

// MARK: Status Header
Expand Down
Loading
Loading