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
22 changes: 12 additions & 10 deletions Sources/HeardCore/CoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
)
Expand All @@ -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
) {
Expand All @@ -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
}
Expand Down
13 changes: 7 additions & 6 deletions Sources/HeardCore/Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions Sources/HeardCore/Stores.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
)
}

Expand Down Expand Up @@ -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")
}
}

Expand Down
16 changes: 8 additions & 8 deletions Sources/HeardCore/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1484,36 +1484,36 @@ 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)
}
CardRow(isLast: true) {
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))
Expand Down
2 changes: 1 addition & 1 deletion handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading