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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Features that fit the on-device, single-process philosophy but require more code
### Speaker management
- **Manual speaker split.** Inverse of merge — split a speaker profile if the user realizes two voices were collapsed.
- **Split candidate at naming time.** When the naming prompt's clips reveal that diarization merged two voices into one cluster, let the user split the candidate into multiple `SpeakerProfile`s instead of just discarding it. Today the only options are name (and accept the merged embedding) or discard via "Multiple speakers" (no profile created). A future "Split…" action would let the user assign different names per clip and re-cluster the underlying segments — requires segment-level reassignment and a more involved naming UI.
- **Bulk-delete / archive old speakers.** Speakers with no meeting activity in N months.
- **Bulk-delete / archive old speakers.** Speakers with no meeting activity in N months — implemented as automatic deletion at launch based on `speakerRetentionDays` setting (default 90 days), configurable in Advanced → Speaker Archive. Set to 0 to disable.

### Pipeline & output
- **Alternative output formats.** Out of scope per spec today, but worth re-evaluating: plain `.txt`, `.srt`, VTT for video workflows, or a lightweight HTML with anchors.
Expand Down
3 changes: 3 additions & 0 deletions Sources/HeardCore/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ public final class AppModel: ObservableObject {
updateChecker: updateChecker
)

// Archive speaker profiles inactive beyond the configured retention window
speakerStore.archiveInactiveSpeakers(retentionDays: settingsStore.settings.speakerRetentionDays)

// Clean stale recordings (>48h), preserving files referenced by active jobs
let activeJobPaths = Set(
queueStore.jobs
Expand Down
10 changes: 8 additions & 2 deletions Sources/HeardCore/CoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ public struct AppSettings: Codable, Equatable {
/// concurrently, halving peak RAM during the VAD stage (~400 MB instead of ~800 MB).
/// Useful on machines with 8 GB unified memory under heavy load.
public var lowMemoryMode: Bool
/// How many days of inactivity before a speaker profile is automatically deleted.
/// 0 means never delete automatically.
public var speakerRetentionDays: Int

public static let `default` = AppSettings(
userName: "",
Expand All @@ -385,7 +388,8 @@ public struct AppSettings: Codable, Equatable {
enableWebexDetection: true,
diarizationClusteringSimilarity: 0.65,
appearance: .system,
lowMemoryMode: false
lowMemoryMode: false,
speakerRetentionDays: 90
)

public init(
Expand All @@ -410,7 +414,8 @@ public struct AppSettings: Codable, Equatable {
enableWebexDetection: Bool = true,
diarizationClusteringSimilarity: Double = 0.65,
appearance: AppAppearance = .system,
lowMemoryMode: Bool = false
lowMemoryMode: Bool = false,
speakerRetentionDays: Int = 90
) {
self.userName = userName
self.launchAtLogin = launchAtLogin
Expand All @@ -434,6 +439,7 @@ public struct AppSettings: Codable, Equatable {
self.diarizationClusteringSimilarity = diarizationClusteringSimilarity
self.appearance = appearance
self.lowMemoryMode = lowMemoryMode
self.speakerRetentionDays = speakerRetentionDays
}
}

Expand Down
22 changes: 21 additions & 1 deletion Sources/HeardCore/Stores.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ public final class SettingsStore: ObservableObject {
diarizationClusteringSimilarity = val.doubleValue
}

var speakerRetentionDays = base.speakerRetentionDays
if let val = defaults.object(forKey: "speakerRetentionDays") as? NSNumber {
speakerRetentionDays = val.intValue
}

settings = AppSettings(
userName: defaults.string(forKey: "userName") ?? base.userName,
launchAtLogin: defaults.object(forKey: "launchAtLogin") as? Bool ?? base.launchAtLogin,
Expand All @@ -184,7 +189,8 @@ 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,
diarizationClusteringSimilarity: diarizationClusteringSimilarity
diarizationClusteringSimilarity: diarizationClusteringSimilarity,
speakerRetentionDays: speakerRetentionDays
)
}

Expand Down Expand Up @@ -214,6 +220,7 @@ public final class SettingsStore: ObservableObject {
defaults.set(settings.enableZoomDetection, forKey: "enableZoomDetection")
defaults.set(settings.enableWebexDetection, forKey: "enableWebexDetection")
defaults.set(settings.diarizationClusteringSimilarity, forKey: "diarizationClusteringSimilarity")
defaults.set(settings.speakerRetentionDays, forKey: "speakerRetentionDays")
}
}

Expand Down Expand Up @@ -268,6 +275,19 @@ public final class SpeakerStore: ObservableObject {
persist()
}

/// Deletes speakers whose `lastSeen` is older than `retentionDays` days.
/// Pass 0 to skip archiving entirely.
@discardableResult
public func archiveInactiveSpeakers(retentionDays: Int) -> Int {
guard retentionDays > 0 else { return 0 }
let cutoff = Date().addingTimeInterval(-Double(retentionDays) * 86400)
let stale = speakers.filter { $0.lastSeen < cutoff }
for speaker in stale {
delete(id: speaker.id)
}
return stale.count
}

public func merge(primaryID: UUID, secondaryID: UUID) {
guard
let primaryIndex = speakers.firstIndex(where: { $0.id == primaryID }),
Expand Down
28 changes: 28 additions & 0 deletions Sources/HeardCore/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,34 @@ public struct SettingsView: View {
}
}

sectionGroup("Speaker Archive") {
SettingsCard {
CardRow(isLast: false) {
HStack {
Text("Archive speakers after")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(HeardTheme.Paper.ink)
Spacer()
Picker("", selection: settingsBinding(\.speakerRetentionDays)) {
Text("Never").tag(0)
Text("30 days").tag(30)
Text("60 days").tag(60)
Text("90 days").tag(90)
Text("180 days").tag(180)
Text("1 year").tag(365)
}
.labelsHidden()
.fixedSize()
}
}
CardRow(isLast: true) {
Text("Speaker profiles not seen in any meeting for the selected period are automatically deleted on the next app launch. Their audio clips and embeddings are removed.")
.font(.system(size: 11))
.foregroundStyle(HeardTheme.Paper.mute)
}
}
}

sectionGroup("Debugging") {
SettingsCard {
ToggleRow(
Expand Down
7 changes: 6 additions & 1 deletion handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,16 @@ The dictation feature captures mic audio, transcribes in real-time, and injects
- Run with `swift run HeardTests`

### Persistence
- `SettingsStore`: UserDefaults-backed app settings (includes `dictationEnabled`, `dictationHotkey`)
- `SettingsStore`: UserDefaults-backed app settings (includes `dictationEnabled`, `dictationHotkey`, `speakerRetentionDays`)
- `SpeakerStore`: JSON file at `~/Library/Application Support/Heard/speakers.json` — `SpeakerProfile` now carries an optional `audioClipURL` pointing into `speaker_clips/`
- `PipelineQueueStore`: JSON file at `~/Library/Application Support/Heard/queue.json`
- `~/Library/Application Support/Heard/speaker_clips/`: persistent voice samples for replay (kept beyond the 48-hour `recordings/` cleanup)

### Speaker Archive
- `SpeakerStore.archiveInactiveSpeakers(retentionDays:)` deletes profiles whose `lastSeen` is older than N days (and their audio clips). Called at app launch via `AppModel.bootstrap()`.
- Default is 90 days; configurable via `AppSettings.speakerRetentionDays` (0 = never). UI in Advanced → Speaker Archive (Picker: Never / 30 / 60 / 90 / 180 / 365 days).
- Also fixed a pre-existing extraneous `}` in `Views.swift` that was causing the file to fail to compile (struct `SettingsView` was closing one brace too early, pushing pane-helper functions out of scope).

## Architecture

| Target | Purpose |
Expand Down
Loading