diff --git a/Sources/HeardCore/CoreModels.swift b/Sources/HeardCore/CoreModels.swift index a0d94aa..e3bfcf8 100644 --- a/Sources/HeardCore/CoreModels.swift +++ b/Sources/HeardCore/CoreModels.swift @@ -345,13 +345,15 @@ public struct AppSettings: Codable, Equatable { public var enableZoomDetection: Bool /// Detect Cisco Webex meetings. public var enableWebexDetection: Bool - /// Cosine distance threshold for speaker clustering during diarization. - /// Lower = stricter separation (more clusters, fewer merges); higher = looser - /// (fewer clusters, more chance of two voices collapsing into one). FluidAudio's - /// default is 0.6; we bias slightly stricter so the user can always merge - /// over-split speakers in the Speakers tab, which is easier than recovering - /// from a merged-embedding poisoning a profile. - public var diarizationClusteringThreshold: Double + /// Cosine similarity threshold for speaker clustering during diarization. + /// FluidAudio's AHC merges two embeddings when their cosine similarity is at or + /// above this value, so higher = stricter separation (more clusters, fewer + /// merges); lower = looser (fewer clusters, more chance of two voices + /// collapsing into one). FluidAudio's default is 0.6; we bias slightly + /// stricter so the user can always merge over-split speakers in the Speakers + /// tab, which is easier than recovering from a merged-embedding poisoning a + /// profile. + public var diarizationClusteringSimilarity: Double public var appearance: AppAppearance /// When enabled, preprocessing runs the app and mic tracks sequentially rather than /// concurrently, halving peak RAM during the VAD stage (~400 MB instead of ~800 MB). @@ -381,7 +383,7 @@ public struct AppSettings: Codable, Equatable { enableTeamsDetection: true, enableZoomDetection: true, enableWebexDetection: true, - diarizationClusteringThreshold: 0.5, + diarizationClusteringSimilarity: 0.65, appearance: .system, lowMemoryMode: false ) @@ -406,7 +408,7 @@ public struct AppSettings: Codable, Equatable { enableTeamsDetection: Bool = true, enableZoomDetection: Bool = true, enableWebexDetection: Bool = true, - diarizationClusteringThreshold: Double = 0.5, + diarizationClusteringSimilarity: Double = 0.65, appearance: AppAppearance = .system, lowMemoryMode: Bool = false ) { @@ -429,7 +431,7 @@ public struct AppSettings: Codable, Equatable { self.enableTeamsDetection = enableTeamsDetection self.enableZoomDetection = enableZoomDetection self.enableWebexDetection = enableWebexDetection - self.diarizationClusteringThreshold = diarizationClusteringThreshold + self.diarizationClusteringSimilarity = diarizationClusteringSimilarity self.appearance = appearance self.lowMemoryMode = lowMemoryMode } diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index 8e9ca7e..6c3df29 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -2269,12 +2269,13 @@ public final class PipelineProcessor: ObservableObject { return } - // Configure clustering threshold so the user can bias toward more - // separations (lower) vs fewer (higher). The Speakers tab supports - // merging, but recovering from a merged embedding is harder, so we - // default to stricter than FluidAudio's 0.6. - let threshold = settingsStore.settings.diarizationClusteringThreshold - let config = OfflineDiarizerConfig(clusteringThreshold: threshold) + // FluidAudio's clusteringThreshold is a cosine *similarity* (despite the + // name) — AHC merges when cos_sim ≥ threshold. Higher = stricter + // separation (more clusters); lower = more merging. The Speakers tab + // supports merging, but recovering from a merged embedding is harder, + // so we default to stricter than FluidAudio's 0.6. + let similarity = settingsStore.settings.diarizationClusteringSimilarity + let config = OfflineDiarizerConfig(clusteringThreshold: similarity) let diarizer = OfflineDiarizerManager(config: config) try await diarizer.prepareModels() appDiarization = try await diarizer.process(audio: track.samples) diff --git a/Sources/HeardCore/Stores.swift b/Sources/HeardCore/Stores.swift index d475654..dd0a002 100644 --- a/Sources/HeardCore/Stores.swift +++ b/Sources/HeardCore/Stores.swift @@ -160,9 +160,9 @@ public final class SettingsStore: ObservableObject { transcriptionModel = decoded } - var diarizationClusteringThreshold = base.diarizationClusteringThreshold - if let val = defaults.object(forKey: "diarizationClusteringThreshold") as? NSNumber { - diarizationClusteringThreshold = val.doubleValue + var diarizationClusteringSimilarity = base.diarizationClusteringSimilarity + if let val = defaults.object(forKey: "diarizationClusteringSimilarity") as? NSNumber { + diarizationClusteringSimilarity = val.doubleValue } settings = AppSettings( @@ -184,7 +184,7 @@ public final class SettingsStore: ObservableObject { enableTeamsDetection: defaults.object(forKey: "enableTeamsDetection") as? Bool ?? base.enableTeamsDetection, enableZoomDetection: defaults.object(forKey: "enableZoomDetection") as? Bool ?? base.enableZoomDetection, enableWebexDetection: defaults.object(forKey: "enableWebexDetection") as? Bool ?? base.enableWebexDetection, - diarizationClusteringThreshold: diarizationClusteringThreshold + diarizationClusteringSimilarity: diarizationClusteringSimilarity ) } @@ -213,7 +213,7 @@ public final class SettingsStore: ObservableObject { defaults.set(settings.enableTeamsDetection, forKey: "enableTeamsDetection") defaults.set(settings.enableZoomDetection, forKey: "enableZoomDetection") defaults.set(settings.enableWebexDetection, forKey: "enableWebexDetection") - defaults.set(settings.diarizationClusteringThreshold, forKey: "diarizationClusteringThreshold") + defaults.set(settings.diarizationClusteringSimilarity, forKey: "diarizationClusteringSimilarity") } } diff --git a/Sources/HeardCore/Views.swift b/Sources/HeardCore/Views.swift index 91d540b..133e969 100644 --- a/Sources/HeardCore/Views.swift +++ b/Sources/HeardCore/Views.swift @@ -1484,27 +1484,27 @@ public struct SettingsView: View { .font(.system(size: 12, weight: .medium)) .foregroundStyle(HeardTheme.Paper.ink) Spacer() - Text(String(format: "%.2f", model.settingsStore.settings.diarizationClusteringThreshold)) + Text(String(format: "%.2f", model.settingsStore.settings.diarizationClusteringSimilarity)) .font(.system(size: 12, design: .monospaced)) .foregroundStyle(HeardTheme.Paper.mute) } HStack(spacing: 8) { - Text("More speakers") + Text("Fewer speakers") .font(.system(size: 10)) .foregroundStyle(HeardTheme.Paper.mute) Slider( - value: settingsBinding(\.diarizationClusteringThreshold), - in: 0.30...0.80, + value: settingsBinding(\.diarizationClusteringSimilarity), + in: 0.40...0.85, step: 0.05 ) - Text("Fewer speakers") + Text("More speakers") .font(.system(size: 10)) .foregroundStyle(HeardTheme.Paper.mute) } } } CardRow(isLast: false) { - Text("Cosine-distance threshold for clustering voice embeddings. Lower values err on the side of splitting one person across two profiles (which you can merge in the Speakers tab); higher values may collapse two voices into one (harder to recover from). Default: 0.50.") + Text("Cosine-similarity threshold for clustering voice embeddings. Higher values err on the side of splitting one person across two profiles (which you can merge in the Speakers tab); lower values may collapse two voices into one (harder to recover from). Default: 0.65.") .font(.system(size: 11)) .foregroundStyle(HeardTheme.Paper.mute) } @@ -1512,8 +1512,8 @@ public struct SettingsView: View { HStack { Spacer() Button("Reset to Default") { - model.settingsStore.settings.diarizationClusteringThreshold = - AppSettings.default.diarizationClusteringThreshold + model.settingsStore.settings.diarizationClusteringSimilarity = + AppSettings.default.diarizationClusteringSimilarity } .buttonStyle(.plain) .font(.system(size: 11, weight: .medium)) diff --git a/handoff.md b/handoff.md index 0d0b3ef..4c20952 100644 --- a/handoff.md +++ b/handoff.md @@ -37,7 +37,7 @@ The app builds cleanly with `swift build` and runs as a menu bar app on macOS 15 - Sequential job queue with stages: queued → preprocessing → transcribing → diarizing → assigning → complete - **Preprocessing**: Resample to 16kHz mono via `AudioConverter`, Silero VAD silence trimming, `VadSegmentMap` for timestamp remapping - **Transcription**: Parakeet TDT V2/V3 (user-selectable) via `AsrManager` with 16k sample minimum guard. Each transcribe call uses a fresh `TdtDecoderState`, so no context bleeds between tracks or jobs. Always passes `language: .english` — required by FluidAudio 0.14.x to keep v3 from emitting Cyrillic for short Latin-script utterances; ignored by v2. -- **Diarization**: `OfflineDiarizerManager` on app track only (mic track is a single known speaker, diarization was unused). Clustering threshold is user-configurable via `AppSettings.diarizationClusteringThreshold` (default 0.50, range 0.30–0.80 in 0.05 steps; FluidAudio's library default is 0.60). Lower = stricter separation, more clusters. The 0.50 default biases toward over-splitting because merging in the Speakers tab is easier than recovering a polluted embedding. +- **Diarization**: `OfflineDiarizerManager` on app track only (mic track is a single known speaker, diarization was unused). Clustering parameter is user-configurable via `AppSettings.diarizationClusteringSimilarity` (default 0.65, range 0.40–0.85 in 0.05 steps; FluidAudio's library default is 0.60). FluidAudio's `OfflineDiarizerConfig(clusteringThreshold:)` is actually a cosine *similarity* — AHC merges when cos_sim ≥ threshold, so higher = stricter separation, more clusters. The 0.65 default biases toward over-splitting because merging in the Speakers tab is easier than recovering a polluted embedding. - **Speaker Assignment**: Cosine distance matching against `SpeakerStore`, confidence margin filtering, embedding diversity management - Non-retryable errors (no audio, too short) fail immediately; transient errors retry 3x per session with backoff (5s, 30s, 5min) via `PipelineProcessor.executeWithRetry` (closure-driven, testable) - `retryCount` is cumulative across sessions with a lifetime cap of 6 (`PipelineProcessor.lifetimeRetryLimit`). User-initiated retry (`retryFailedJob`) resets `retryCount = 0` for a fresh budget.