Compare commits

..
3 Commits
Author SHA1 Message Date
james 0f45f2d066 feat: make audio retention configurable 2026-07-22 16:31:01 +02:00
james f1d57da227 feat: retain meeting audio for three days 2026-07-22 16:24:08 +02:00
james 94d8469eba fix: capture all system audio 2026-07-22 16:14:33 +02:00
12 changed files with 160 additions and 78 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.12; MARKETING_VERSION = 1.1.15;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.12; MARKETING_VERSION = 1.1.15;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+17 -61
View File
@@ -35,19 +35,16 @@ final class AudioManager: NSObject, ObservableObject {
private var audioEngine = AVAudioEngine() private var audioEngine = AVAudioEngine()
private var sessionID = UUID() private var sessionID = UUID()
private var meetingID = UUID()
private var processTap: ProcessTap? private var processTap: ProcessTap?
private let audioProcessController = AudioProcessController()
private let permission = AudioRecordingPermission() private let permission = AudioRecordingPermission()
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated) private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
private let audioFileLock = NSLock() private let audioFileLock = NSLock()
private var isTapActive = false private var isTapActive = false
private var isRestartingSystemTap = false
private var isAcceptingAudio = false private var isAcceptingAudio = false
private var micRetryCount = 0 private var micRetryCount = 0
private var pendingMicRestart: DispatchWorkItem? private var pendingMicRestart: DispatchWorkItem?
private let maxMicRetries = 3 private let maxMicRetries = 3
private var cancellables = Set<AnyCancellable>()
private var micAudioFile: AVAudioFile? private var micAudioFile: AVAudioFile?
private var systemAudioFile: AVAudioFile? private var systemAudioFile: AVAudioFile?
private var micAudioURL: URL? private var micAudioURL: URL?
@@ -57,25 +54,18 @@ final class AudioManager: NSObject, ObservableObject {
private override init() { private override init() {
super.init() super.init()
observeAudioEngine() observeAudioEngine()
audioProcessController.activate()
NSWorkspace.shared.publisher(for: \.runningApplications)
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.sink { [weak self] _ in
guard let self, self.isTapActive else { return }
Task { await self.restartSystemAudioTapIfNeeded() }
}
.store(in: &cancellables)
} }
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
func startRecording() { func startRecording(for meetingID: UUID) {
errorMessage = nil errorMessage = nil
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = nil
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
sessionID = UUID() sessionID = UUID()
self.meetingID = meetingID
recordingStartedAt = Date() recordingStartedAt = Date()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
@@ -88,7 +78,7 @@ final class AudioManager: NSObject, ObservableObject {
} }
func stopRecordingAndTranscribe() async -> [TranscriptChunk] { func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedSessionID = sessionID let completedMeetingID = meetingID
let captureStartedAt = recordingStartedAt let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles() let files = stopCaptureAndCloseFiles()
isProcessing = true isProcessing = true
@@ -109,16 +99,17 @@ final class AudioManager: NSObject, ObservableObject {
) )
transcriptChunks = updated transcriptChunks = updated
let completedFiles = files.compactMap { $0 } let completedFiles = files.compactMap { $0 }
if failures.isEmpty { let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
removeAudioFiles(completedFiles) if !failures.isEmpty {
} else { let retentionDays = UserDefaultsManager.shared.audioRetentionDays
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID) let retentionUnit = retentionDays == 1 ? "day" : "days"
lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent let recoveryMessage = audioFolder == nil
let recoveryMessage = recoveryFolder == nil
? " The audio remains in the app's temporary folder." ? " The audio remains in the app's temporary folder."
: " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)." : " Audio was kept for \(retentionDays) \(retentionUnit). Use Show Audio Folder in the Meetingnotes menu to find it."
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
} else if audioFolder == nil, !completedFiles.isEmpty {
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its retention folder."
} }
return updated return updated
} }
@@ -304,8 +295,7 @@ final class AudioManager: NSObject, ObservableObject {
return return
} }
let processIDs = audioProcessController.processes.map(\.objectID) let newTap = ProcessTap(target: .systemAudio)
let newTap = ProcessTap(target: .systemAudio(processObjectIDs: processIDs))
newTap.activate() newTap.activate()
if let tapError = newTap.errorMessage { if let tapError = newTap.errorMessage {
errorMessage = "Failed to activate system audio capture: \(tapError)" errorMessage = "Failed to activate system audio capture: \(tapError)"
@@ -329,21 +319,8 @@ final class AudioManager: NSObject, ObservableObject {
} }
} }
private func restartSystemAudioTapIfNeeded() async {
let next = Set(audioProcessController.processes.map(\.objectID))
let current: Set<AudioObjectID>
if case .systemAudio(let processIDs) = processTap?.target {
current = Set(processIDs)
} else {
current = []
}
if next != current { await restartSystemAudioTap() }
}
private func restartSystemAudioTap() async { private func restartSystemAudioTap() async {
guard isRecording else { return } guard isRecording else { return }
isRestartingSystemTap = true
defer { isRestartingSystemTap = false }
if isTapActive { if isTapActive {
processTap?.invalidate() processTap?.invalidate()
processTap = nil processTap = nil
@@ -380,7 +357,7 @@ final class AudioManager: NSObject, ObservableObject {
source: .system source: .system
) )
} invalidationHandler: { [weak self] _ in } invalidationHandler: { [weak self] _ in
guard let self, !self.isRestartingSystemTap, self.isRecording else { return } guard let self, self.isRecording else { return }
Task { await self.restartSystemAudioTap() } Task { await self.restartSystemAudioTap() }
} }
} }
@@ -528,29 +505,8 @@ final class AudioManager: NSObject, ObservableObject {
for url in urls { try? FileManager.default.removeItem(at: url) } for url in urls { try? FileManager.default.removeItem(at: url) }
} }
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? { private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
guard !urls.isEmpty, LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let folder = documents
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
.appendingPathComponent(sessionID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
do {
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent))
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
} }
private func resetAudioLevels() { private func resetAudioLevels() {
@@ -2,6 +2,7 @@
// Handles local storage of meetings and app data // Handles local storage of meetings and app data
import Foundation import Foundation
import AppKit
/// Manages local file storage for meetings and app data /// Manages local file storage for meetings and app data
class LocalStorageManager { class LocalStorageManager {
@@ -28,7 +29,7 @@ class LocalStorageManager {
// Create templates subdirectory // Create templates subdirectory
templatesDirectory = documentsDirectory.appendingPathComponent("Templates") templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery") recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
// Ensure directories exist // Ensure directories exist
try? FileManager.default.createDirectory(at: meetingsDirectory, try? FileManager.default.createDirectory(at: meetingsDirectory,
@@ -37,6 +38,8 @@ class LocalStorageManager {
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: recoveryDirectory, try? FileManager.default.createDirectory(at: recoveryDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
migrateLegacyRecoveryAudio()
purgeExpiredAudioFolders()
} }
// MARK: - Meeting Management // MARK: - Meeting Management
@@ -151,6 +154,67 @@ class LocalStorageManager {
// MARK: - Recovery Audio // MARK: - Recovery Audio
func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? {
guard !urls.isEmpty else { return nil }
purgeExpiredAudioFolders()
let folder = recoveryDirectory.appendingPathComponent(meetingID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
let destination = folder.appendingPathComponent(url.lastPathComponent)
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: url, to: destination)
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
}
func purgeExpiredAudioFolders(now: Date = Date()) {
guard let folders = try? FileManager.default.contentsOfDirectory(
at: recoveryDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
let retentionInterval = TimeInterval(UserDefaultsManager.shared.audioRetentionDays) * 24 * 60 * 60
let expirationDate = now.addingTimeInterval(-retentionInterval)
for folder in folders {
guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
let audioFiles = recoveryAudioFiles(in: folder)
let newestDate = audioFiles.compactMap { file -> Date? in
let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey])
return values?.contentModificationDate ?? values?.creationDate
}.max()
guard let newestDate else {
try? FileManager.default.removeItem(at: folder)
continue
}
if newestDate < expirationDate {
try? FileManager.default.removeItem(at: folder)
}
}
}
@MainActor
func showAudioFolderInFinder(_ folder: URL? = nil) {
let target = folder ?? recoveryDirectory
try? FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
NSWorkspace.shared.open(target)
}
func recoveryAudioFolder(named name: String) -> URL? { func recoveryAudioFolder(named name: String) -> URL? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, guard !trimmedName.isEmpty,
@@ -245,6 +309,22 @@ class LocalStorageManager {
try? FileManager.default.removeItem(at: folder) try? FileManager.default.removeItem(at: folder)
} }
private func migrateLegacyRecoveryAudio() {
let legacyDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
guard let folders = try? FileManager.default.contentsOfDirectory(
at: legacyDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
for folder in folders {
let destination = recoveryDirectory.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
guard !FileManager.default.fileExists(atPath: destination.path) else { continue }
try? FileManager.default.moveItem(at: folder, to: destination)
}
try? FileManager.default.removeItem(at: legacyDirectory)
}
/// Imports meeting JSON files from a folder selected by the user. /// Imports meeting JSON files from a folder selected by the user.
func importMeetings(from directory: URL) throws -> MeetingImportResult { func importMeetings(from directory: URL) throws -> MeetingImportResult {
let didStartAccess = directory.startAccessingSecurityScopedResource() let didStartAccess = directory.startAccessingSecurityScopedResource()
@@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject {
activeMeetingId = meetingId activeMeetingId = meetingId
recordingStartedAt = Date() recordingStartedAt = Date()
audioManager.startRecording() audioManager.startRecording(for: meetingId)
} }
func stopRecording() async -> [TranscriptChunk] { func stopRecording() async -> [TranscriptChunk] {
@@ -23,6 +23,7 @@ class UserDefaultsManager {
static let transcriptionModel = "transcriptionModel" static let transcriptionModel = "transcriptionModel"
static let muteDeckAPIEnabled = "muteDeckAPIEnabled" static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
static let muteDeckAPIPort = "muteDeckAPIPort" static let muteDeckAPIPort = "muteDeckAPIPort"
static let audioRetentionDays = "audioRetentionDays"
} }
// MARK: - User Blurb // MARK: - User Blurb
@@ -94,4 +95,12 @@ class UserDefaultsManager {
} }
set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) } set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) }
} }
var audioRetentionDays: Int {
get {
guard userDefaults.object(forKey: Keys.audioRetentionDays) != nil else { return 3 }
return min(max(userDefaults.integer(forKey: Keys.audioRetentionDays), 1), 365)
}
set { userDefaults.set(min(max(newValue, 1), 365), forKey: Keys.audioRetentionDays) }
}
} }
+10
View File
@@ -46,6 +46,10 @@ struct MeetingnotesApp: App {
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater) CheckForUpdatesView(updater: updaterController.updater)
Divider()
Button("Show Audio Folder") {
LocalStorageManager.shared.showAudioFolderInFinder()
}
} }
} }
@@ -79,6 +83,12 @@ private struct MeetingnotesMenu: View {
Label("Open Meetingnotes", systemImage: "macwindow") Label("Open Meetingnotes", systemImage: "macwindow")
} }
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Divider() Divider()
Button { Button {
+5
View File
@@ -54,6 +54,11 @@ struct Settings: Codable {
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue } set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
} }
var audioRetentionDays: Int {
get { UserDefaultsManager.shared.audioRetentionDays }
set { UserDefaultsManager.shared.audioRetentionDays = newValue }
}
// System prompt default loading // System prompt default loading
static func defaultSystemPrompt() -> String { static func defaultSystemPrompt() -> String {
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"), guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
+4 -7
View File
@@ -5,7 +5,7 @@ import AVFoundation
enum TapTarget { enum TapTarget {
case singleProcess(AudioProcess) case singleProcess(AudioProcess)
case systemAudio(processObjectIDs: [AudioObjectID]) case systemAudio
var displayName: String { var displayName: String {
switch self { switch self {
@@ -137,12 +137,9 @@ final class ProcessTap {
case .singleProcess(let process): case .singleProcess(let process):
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID]) tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
logger.debug("Configuring tap for single process objectID: \(process.objectID)") logger.debug("Configuring tap for single process objectID: \(process.objectID)")
case .systemAudio(let processObjectIDs): case .systemAudio:
if processObjectIDs.isEmpty { tapDescription = CATapDescription(monoGlobalTapButExcludeProcesses: [])
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.") logger.debug("Configuring a global system audio tap.")
}
tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs)
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
} }
tapDescription.uuid = UUID() tapDescription.uuid = UUID()
@@ -244,10 +244,6 @@ class MeetingViewModel: ObservableObject {
throw CocoaError(.fileWriteUnknown) throw CocoaError(.fileWriteUnknown)
} }
LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL)
meeting.recoveryAudioFolderName = nil
self.recoveryAudioFolderURL = nil
saveMeeting()
await generateNotes() await generateNotes()
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
@@ -263,6 +259,11 @@ class MeetingViewModel: ObservableObject {
} }
} }
func showAudioInFinder() {
guard let recoveryAudioFolderURL else { return }
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
}
func loadTemplates() { func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
@@ -71,6 +71,7 @@ class SettingsViewModel: ObservableObject {
// via computed properties when they're modified // via computed properties when they're modified
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey) let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
LocalAPIServer.shared.applyConfiguration() LocalAPIServer.shared.applyConfiguration()
LocalStorageManager.shared.purgeExpiredAudioFolders()
if showMessage { if showMessage {
if coderSaved { if coderSaved {
+6
View File
@@ -267,6 +267,12 @@ struct MeetingDetailContentView: View {
} }
.disabled(!viewModel.canRetryTranscription) .disabled(!viewModel.canRetryTranscription)
Button {
viewModel.showAudioInFinder()
} label: {
Label("Show Audio in Finder", systemImage: "folder")
}
Divider() Divider()
} }
+17
View File
@@ -128,6 +128,23 @@ struct SettingsView: View {
Text("Meeting Storage") Text("Meeting Storage")
.font(.headline) .font(.headline)
LabeledContent("Audio retention") {
Stepper(
value: $viewModel.settings.audioRetentionDays,
in: 1...365
) {
Text("\(viewModel.settings.audioRetentionDays) \(viewModel.settings.audioRetentionDays == 1 ? "day" : "days")")
.monospacedDigit()
.frame(minWidth: 70, alignment: .trailing)
}
}
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Button { Button {
showingMeetingImporter = true showingMeetingImporter = true
} label: { } label: {