Compare commits

..
12 Commits
17 changed files with 979 additions and 118 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 = 20; CURRENT_PROJECT_VERSION = 31;
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.8; MARKETING_VERSION = 1.1.19;
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 = 20; CURRENT_PROJECT_VERSION = 31;
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.8; MARKETING_VERSION = 1.1.19;
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;
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
+153 -84
View File
@@ -3,6 +3,23 @@ import Combine
import Foundation import Foundation
import SwiftUI import SwiftUI
private enum RecoveryTranscriptionError: LocalizedError {
case noAudioFiles
case noSpeech
case requestFailed(String)
var errorDescription: String? {
switch self {
case .noAudioFiles:
return "The saved recovery audio could not be found."
case .noSpeech:
return "No speech was detected in the saved recovery audio."
case .requestFailed(let details):
return "Retry transcription failed for \(details)"
}
}
}
/// Captures microphone and system audio locally, then sends completed files to Coder. /// Captures microphone and system audio locally, then sends completed files to Coder.
@MainActor @MainActor
final class AudioManager: NSObject, ObservableObject { final class AudioManager: NSObject, ObservableObject {
@@ -14,22 +31,20 @@ final class AudioManager: NSObject, ObservableObject {
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var micAudioLevel: Float = 0 @Published var micAudioLevel: Float = 0
@Published var systemAudioLevel: Float = 0 @Published var systemAudioLevel: Float = 0
private(set) var lastRecoveryAudioFolderName: String?
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?
@@ -39,24 +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
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
sessionID = UUID() sessionID = UUID()
self.meetingID = meetingID
recordingStartedAt = Date() recordingStartedAt = Date()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
@@ -69,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
@@ -78,12 +87,92 @@ final class AudioManager: NSObject, ObservableObject {
} }
let model = UserDefaultsManager.shared.transcriptionModel let model = UserDefaultsManager.shared.transcriptionModel
async let micResult = transcribe(files[0], model: model) async let micResult = transcribe(files[0], model: model, diarization: false)
async let systemResult = transcribe(files[1], model: model) async let systemResult = transcribe(files[1], model: model, diarization: true)
let (micTranscription, systemTranscription) = await (micResult, systemResult) let (micTranscription, systemTranscription) = await (micResult, systemResult)
let results = [micTranscription, systemTranscription] let results = [micTranscription, systemTranscription]
var updated = transcriptChunks.filter(\.isFinal) let (updated, failures) = buildTranscriptChunks(
from: results,
captureStartedAt: captureStartedAt,
existingChunks: transcriptChunks.filter(\.isFinal)
)
transcriptChunks = updated
let completedFiles = files.compactMap { $0 }
let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
if !failures.isEmpty {
let retentionDays = UserDefaultsManager.shared.audioRetentionDays
let retentionUnit = retentionDays == 1 ? "day" : "days"
let recoveryMessage = audioFolder == nil
? " The audio remains in the app's temporary folder."
: " 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
} else if audioFolder == nil, !completedFiles.isEmpty {
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its retention folder."
}
return updated
}
func transcribeRecoveryAudio(in folder: URL, captureStartedAt: Date) async throws -> [TranscriptChunk] {
let recoveryFiles = LocalStorageManager.shared.recoveryAudioFiles(in: folder)
guard !recoveryFiles.isEmpty else {
throw RecoveryTranscriptionError.noAudioFiles
}
isProcessing = true
defer { isProcessing = false }
let model = UserDefaultsManager.shared.transcriptionModel
let micURL = recoveryFiles.first(where: { $0.source == .mic })?.url
let systemURL = recoveryFiles.first(where: { $0.source == .system })?.url
async let micResult = transcribe(micURL, model: model, diarization: false)
async let systemResult = transcribe(systemURL, model: model, diarization: true)
let (micTranscription, systemTranscription) = await (micResult, systemResult)
let results = [micTranscription, systemTranscription]
let (chunks, failures) = buildTranscriptChunks(
from: results,
captureStartedAt: captureStartedAt,
existingChunks: []
)
if !failures.isEmpty {
throw RecoveryTranscriptionError.requestFailed(failures.joined(separator: "; "))
}
guard !chunks.isEmpty else {
throw RecoveryTranscriptionError.noSpeech
}
return chunks
}
func cancelRecording() {
cancelCapture(removeFiles: true)
lastRecoveryAudioFolderName = nil
}
private func transcribe(
_ fileURL: URL?,
model: String,
diarization: Bool
) async -> Result<CoderAPIClient.Transcription, Error>? {
guard let fileURL else { return nil }
do {
return .success(try await CoderAPIClient.shared.transcribe(
fileURL: fileURL,
model: model,
diarization: diarization,
maxSpeakerCount: 4
))
} catch {
return .failure(error)
}
}
private func buildTranscriptChunks(
from results: [Result<CoderAPIClient.Transcription, Error>?],
captureStartedAt: Date,
existingChunks: [TranscriptChunk]
) -> ([TranscriptChunk], [String]) {
var updated = existingChunks
var failures: [String] = [] var failures: [String] = []
for (source, result) in zip([AudioSource.mic, .system], results) { for (source, result) in zip([AudioSource.mic, .system], results) {
guard let result else { continue } guard let result else { continue }
@@ -101,6 +190,7 @@ final class AudioManager: NSObject, ObservableObject {
updated.append(TranscriptChunk( updated.append(TranscriptChunk(
timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)), timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)),
source: source, source: source,
speaker: source == .system ? segment.speaker : nil,
text: text, text: text,
isFinal: true isFinal: true
)) ))
@@ -114,31 +204,7 @@ final class AudioManager: NSObject, ObservableObject {
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
return $0.source.rawValue < $1.source.rawValue return $0.source.rawValue < $1.source.rawValue
} }
transcriptChunks = updated return (updated, failures)
let completedFiles = files.compactMap { $0 }
if failures.isEmpty {
removeAudioFiles(completedFiles)
} else {
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID)
let recoveryMessage = recoveryFolder == nil
? " The audio remains in the app's temporary folder."
: " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)."
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
}
return updated
}
func cancelRecording() {
cancelCapture(removeFiles: true)
}
private func transcribe(_ fileURL: URL?, model: String) async -> Result<CoderAPIClient.Transcription, Error>? {
guard let fileURL else { return nil }
do {
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
} catch {
return .failure(error)
}
} }
private func prepareAudioFiles() throws { private func prepareAudioFiles() throws {
@@ -239,8 +305,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)"
@@ -264,21 +329,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
@@ -308,26 +360,64 @@ final class AudioManager: NSObject, ObservableObject {
} }
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
guard let self else { return } guard let self else { return }
// The tap queue is serial. Reusing the converter preserves its
// resampler state instead of discarding audio at every callback.
self.processAudioBuffer( self.processAudioBuffer(
{ AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil) }, { self.copyAudioBuffer(from: inputData, format: inputFormat) },
converter: converter, converter: converter,
targetFormat: targetFormat, targetFormat: targetFormat,
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() }
} }
} }
private func copyAudioBuffer(
from inputData: UnsafePointer<AudioBufferList>,
format: AVAudioFormat
) -> AVAudioPCMBuffer? {
guard let borrowedBuffer = AVAudioPCMBuffer(
pcmFormat: format,
bufferListNoCopy: inputData,
deallocator: nil
), borrowedBuffer.frameLength > 0,
let ownedBuffer = AVAudioPCMBuffer(
pcmFormat: format,
frameCapacity: borrowedBuffer.frameLength
) else { return nil }
ownedBuffer.frameLength = borrowedBuffer.frameLength
let sourceBuffers = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData)
)
let destinationBuffers = UnsafeMutableAudioBufferListPointer(
ownedBuffer.mutableAudioBufferList
)
guard sourceBuffers.count == destinationBuffers.count else { return nil }
for index in 0..<sourceBuffers.count {
let source = sourceBuffers[index]
let destination = destinationBuffers[index]
let byteCount = Int(source.mDataByteSize)
guard byteCount <= Int(destination.mDataByteSize),
let sourceData = source.mData,
let destinationData = destination.mData else { return nil }
memcpy(destinationData, sourceData, byteCount)
destinationBuffers[index].mDataByteSize = source.mDataByteSize
}
return ownedBuffer
}
private func processAudioBuffer( private func processAudioBuffer(
_ inputBufferProvider: () -> AVAudioPCMBuffer?, _ inputBufferProvider: () -> AVAudioPCMBuffer?,
converter: AVAudioConverter, converter: AVAudioConverter,
targetFormat: AVAudioFormat, targetFormat: AVAudioFormat,
source: AudioSource source: AudioSource
) { ) {
// Keep callback-owned buffers alive until conversion finishes. Teardown // The system callback copies its borrowed Core Audio memory while this
// takes this same lock before invalidating the Core Audio process tap. // lock prevents teardown, then conversion operates on the owned copy.
audioFileLock.lock() audioFileLock.lock()
defer { audioFileLock.unlock() } defer { audioFileLock.unlock() }
guard isAcceptingAudio, guard isAcceptingAudio,
@@ -427,29 +517,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 {
@@ -15,6 +16,7 @@ class LocalStorageManager {
private let documentsDirectory: URL private let documentsDirectory: URL
private let meetingsDirectory: URL private let meetingsDirectory: URL
private let templatesDirectory: URL private let templatesDirectory: URL
private let recoveryDirectory: URL
private init() { private init() {
// Get the app's documents directory // Get the app's documents directory
@@ -27,11 +29,17 @@ class LocalStorageManager {
// Create templates subdirectory // Create templates subdirectory
templatesDirectory = documentsDirectory.appendingPathComponent("Templates") templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
// Ensure directories exist // Ensure directories exist
try? FileManager.default.createDirectory(at: meetingsDirectory, try? FileManager.default.createDirectory(at: meetingsDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: templatesDirectory, try? FileManager.default.createDirectory(at: templatesDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: recoveryDirectory,
withIntermediateDirectories: true)
migrateLegacyRecoveryAudio()
purgeExpiredAudioFolders()
} }
// MARK: - Meeting Management // MARK: - Meeting Management
@@ -144,6 +152,179 @@ class LocalStorageManager {
} }
} }
// 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? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty,
URL(fileURLWithPath: trimmedName).lastPathComponent == trimmedName else {
return nil
}
let folder = recoveryDirectory.appendingPathComponent(trimmedName, isDirectory: true)
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory),
isDirectory.boolValue,
!recoveryAudioFiles(in: folder).isEmpty else {
return nil
}
return folder
}
func recoveryAudioFiles(in folder: URL) -> [(url: URL, source: AudioSource)] {
guard let files = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return files.compactMap { url in
let name = url.lastPathComponent.lowercased()
guard ["m4a", "mp3", "wav", "flac", "webm", "mp4"].contains(url.pathExtension.lowercased()) else {
return nil
}
if name.contains("-mic.") {
return (url, .mic)
}
if name.contains("-system.") {
return (url, .system)
}
return nil
}
}
func findRecoveryAudioFolder(for meeting: Meeting) -> URL? {
if let name = meeting.recoveryAudioFolderName,
let folder = recoveryAudioFolder(named: name) {
return folder
}
let claimedFolderNames = Set(
loadMeetings()
.filter { $0.id != meeting.id }
.compactMap(\.recoveryAudioFolderName)
)
guard let folders = try? FileManager.default.contentsOfDirectory(
at: recoveryDirectory,
includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return nil
}
let candidates = folders.compactMap { folder -> (url: URL, distance: TimeInterval)? in
let folderValues = try? folder.resourceValues(
forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey]
)
let files = recoveryAudioFiles(in: folder)
guard folderValues?.isDirectory == true,
!claimedFolderNames.contains(folder.lastPathComponent),
!files.isEmpty else {
return nil
}
let dates = files.compactMap { file -> Date? in
let values = try? file.url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
return values?.creationDate ?? values?.contentModificationDate
}
let referenceDate = dates.min()
?? folderValues?.creationDate
?? folderValues?.contentModificationDate
guard let referenceDate else { return nil }
return (folder, abs(referenceDate.timeIntervalSince(meeting.date)))
}
// This fallback links recovery files created by older app versions.
return candidates
.filter { $0.distance <= 12 * 60 * 60 }
.min(by: { $0.distance < $1.distance })?
.url
}
func deleteRecoveryAudioFolder(_ folder: URL) {
guard folder.deletingLastPathComponent().standardizedFileURL == recoveryDirectory.standardizedFileURL else {
return
}
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] {
@@ -113,6 +113,10 @@ class RecordingSessionManager: ObservableObject {
return isRecording && activeMeetingId == meetingId return isRecording && activeMeetingId == meetingId
} }
var lastRecoveryAudioFolderName: String? {
audioManager.lastRecoveryAudioFolderName
}
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) { private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) {
// Load all meetings // Load all meetings
var meetings = LocalStorageManager.shared.loadMeetings() var meetings = LocalStorageManager.shared.loadMeetings()
@@ -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
@@ -78,7 +79,13 @@ class UserDefaultsManager {
} }
var transcriptionModel: String { var transcriptionModel: String {
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" } get {
let stored = userDefaults.string(forKey: Keys.transcriptionModel)
if stored == "local-whisper/whisper-large-v3-turbo" {
return "local-parakeet/parakeet-tdt-0.6b-v3"
}
return stored ?? "local-parakeet/parakeet-tdt-0.6b-v3"
}
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) } set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
} }
@@ -94,4 +101,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) }
}
} }
+131 -1
View File
@@ -6,12 +6,14 @@
// //
import SwiftUI import SwiftUI
import AppKit
import Sparkle import Sparkle
import PostHog import PostHog
@main @main
struct MeetingnotesApp: App { struct MeetingnotesApp: App {
private let updaterController: SPUStandardUpdaterController private let updaterController: SPUStandardUpdaterController
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
init() { init() {
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil) updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
@@ -34,17 +36,145 @@ struct MeetingnotesApp: App {
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup("Meetingnotes", id: "main") {
ContentView() ContentView()
.frame(minWidth: 700, minHeight: 400) .frame(minWidth: 700, minHeight: 400)
.background(MainWindowConfigurator())
} }
.windowResizability(.automatic) .windowResizability(.automatic)
.defaultSize(width: 1000, height: 600) .defaultSize(width: 1000, height: 600)
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater) CheckForUpdatesView(updater: updaterController.updater)
Divider()
Button("Show Audio Folder") {
LocalStorageManager.shared.showAudioFolderInFinder()
}
} }
} }
MenuBarExtra {
MeetingnotesMenu(
recordingSessionManager: recordingSessionManager,
updater: updaterController.updater
)
} label: {
Image(systemName: recordingSessionManager.isRecording ? "record.circle.fill" : "waveform")
.accessibilityLabel(recordingSessionManager.isRecording ? "Meetingnotes recording" : "Meetingnotes")
}
}
}
private struct MeetingnotesMenu: View {
@ObservedObject var recordingSessionManager: RecordingSessionManager
let updater: SPUUpdater
@Environment(\.openWindow) private var openWindow
var body: some View {
if recordingSessionManager.isRecording {
Label("Recording", systemImage: "record.circle.fill")
} else if recordingSessionManager.isProcessing {
Label("Processing audio", systemImage: "hourglass")
}
Button {
MainWindowController.shared.show(using: openWindow)
} label: {
Label("Open Meetingnotes", systemImage: "macwindow")
}
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Divider()
Button {
updater.checkForUpdates()
} label: {
Label("Check for Updates...", systemImage: "arrow.triangle.2.circlepath")
}
.keyboardShortcut("u")
Button {
NSApplication.shared.terminate(nil)
} label: {
Label("Quit Meetingnotes", systemImage: "power")
}
.keyboardShortcut("q")
}
}
@MainActor
private final class MainWindowController: NSObject {
static let shared = MainWindowController()
private weak var window: NSWindow?
func configure(_ window: NSWindow) {
if self.window !== window {
if let previousWindow = self.window {
NotificationCenter.default.removeObserver(
self,
name: NSWindow.willMiniaturizeNotification,
object: previousWindow
)
}
self.window = window
NotificationCenter.default.addObserver(
self,
selector: #selector(windowWillMiniaturize(_:)),
name: NSWindow.willMiniaturizeNotification,
object: window
)
}
if let minimizeButton = window.standardWindowButton(.miniaturizeButton) {
minimizeButton.target = self
minimizeButton.action = #selector(hideWindow(_:))
}
}
func show(using openWindow: OpenWindowAction) {
if let window {
window.makeKeyAndOrderFront(nil)
} else {
openWindow(id: "main")
}
NSApplication.shared.activate(ignoringOtherApps: true)
}
@objc private func hideWindow(_ sender: NSButton) {
sender.window?.orderOut(nil)
}
@objc private func windowWillMiniaturize(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
DispatchQueue.main.async {
window.deminiaturize(nil)
window.orderOut(nil)
}
}
}
private struct MainWindowConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
configureWindow(for: view)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
configureWindow(for: nsView)
}
private func configureWindow(for view: NSView) {
DispatchQueue.main.async {
guard let window = view.window else { return }
MainWindowController.shared.configure(window)
}
} }
} }
+24 -4
View File
@@ -48,30 +48,46 @@ struct TranscriptChunk: Codable, Identifiable, Hashable {
let id: UUID let id: UUID
let timestamp: Date let timestamp: Date
let source: AudioSource let source: AudioSource
let speaker: Int?
let text: String let text: String
let isFinal: Bool let isFinal: Bool
init(id: UUID = UUID(), timestamp: Date = Date(), source: AudioSource, text: String, isFinal: Bool = false) { init(id: UUID = UUID(), timestamp: Date = Date(), source: AudioSource, speaker: Int? = nil, text: String, isFinal: Bool = false) {
self.id = id self.id = id
self.timestamp = timestamp self.timestamp = timestamp
self.source = source self.source = source
self.speaker = speaker
self.text = text self.text = text
self.isFinal = isFinal self.isFinal = isFinal
} }
var displayName: String {
if source == .mic { return "Me" }
if let speaker { return "Speaker \(speaker)" }
return source.displayName
}
} }
struct CollapsedTranscriptChunk: Identifiable { struct CollapsedTranscriptChunk: Identifiable {
let id: UUID let id: UUID
let timestamp: Date let timestamp: Date
let source: AudioSource let source: AudioSource
let speaker: Int?
let combinedText: String let combinedText: String
init(id: UUID = UUID(), timestamp: Date, source: AudioSource, combinedText: String) { init(id: UUID = UUID(), timestamp: Date, source: AudioSource, speaker: Int? = nil, combinedText: String) {
self.id = id self.id = id
self.timestamp = timestamp self.timestamp = timestamp
self.source = source self.source = source
self.speaker = speaker
self.combinedText = combinedText self.combinedText = combinedText
} }
var displayName: String {
if source == .mic { return "Me" }
if let speaker { return "Speaker \(speaker)" }
return source.displayName
}
} }
struct Meeting: Codable, Identifiable, Hashable { struct Meeting: Codable, Identifiable, Hashable {
@@ -82,6 +98,7 @@ struct Meeting: Codable, Identifiable, Hashable {
var userNotes: String var userNotes: String
var generatedNotes: String var generatedNotes: String
var templateId: UUID? // Add property to track per-meeting template var templateId: UUID? // Add property to track per-meeting template
var recoveryAudioFolderName: String?
// MARK: - Data versioning // MARK: - Data versioning
/// Version of this Meeting record on disk. Useful for migration. /// Version of this Meeting record on disk. Useful for migration.
var dataVersion: Int var dataVersion: Int
@@ -95,6 +112,7 @@ struct Meeting: Codable, Identifiable, Hashable {
userNotes: String = "", userNotes: String = "",
generatedNotes: String = "", generatedNotes: String = "",
templateId: UUID? = nil, templateId: UUID? = nil,
recoveryAudioFolderName: String? = nil,
dataVersion: Int = Meeting.currentDataVersion) { dataVersion: Int = Meeting.currentDataVersion) {
self.id = id self.id = id
self.date = date self.date = date
@@ -103,6 +121,7 @@ struct Meeting: Codable, Identifiable, Hashable {
self.userNotes = userNotes self.userNotes = userNotes
self.generatedNotes = generatedNotes self.generatedNotes = generatedNotes
self.templateId = templateId self.templateId = templateId
self.recoveryAudioFolderName = recoveryAudioFolderName
self.dataVersion = dataVersion self.dataVersion = dataVersion
} }
@@ -112,7 +131,7 @@ struct Meeting: Codable, Identifiable, Hashable {
var transcript: String { var transcript: String {
return transcriptChunks return transcriptChunks
.filter { $0.isFinal } .filter { $0.isFinal }
.map { "[\($0.source.rawValue)] \($0.text)" } .map { "[\($0.displayName)] \($0.text)" }
.joined(separator: " ") .joined(separator: " ")
} }
@@ -121,7 +140,7 @@ struct Meeting: Codable, Identifiable, Hashable {
let finalChunks = transcriptChunks.filter { $0.isFinal } let finalChunks = transcriptChunks.filter { $0.isFinal }
return finalChunks.map { chunk in return finalChunks.map { chunk in
"[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.source.copyPrefix): \(chunk.text)" "[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.displayName): \(chunk.text)"
}.joined(separator: "\n") }.joined(separator: "\n")
} }
@@ -132,6 +151,7 @@ struct Meeting: Codable, Identifiable, Hashable {
id: chunk.id, id: chunk.id,
timestamp: chunk.timestamp, timestamp: chunk.timestamp,
source: chunk.source, source: chunk.source,
speaker: chunk.speaker,
combinedText: chunk.text combinedText: chunk.text
) )
} }
+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"),
+7 -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,12 @@ 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 { // Keep the HAL tap's buffer layout consistent with the default
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.") // output stream. AudioManager performs the stereo-to-mono mix when
} // it converts the captured audio to the 16 kHz transcription file.
tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs) tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.") logger.debug("Configuring a stereo global system audio tap.")
} }
tapDescription.uuid = UUID() tapDescription.uuid = UUID()
+228 -7
View File
@@ -1,3 +1,4 @@
import AVFoundation
import Foundation import Foundation
struct CoderModel: Codable, Identifiable, Hashable { struct CoderModel: Codable, Identifiable, Hashable {
@@ -20,6 +21,7 @@ struct CoderModel: Codable, Identifiable, Hashable {
var supportsChat: Bool { capabilities.isEmpty || capabilities.contains("chat") } var supportsChat: Bool { capabilities.isEmpty || capabilities.contains("chat") }
var supportsTranscription: Bool { capabilities.contains("audio_transcription") } var supportsTranscription: Bool { capabilities.contains("audio_transcription") }
var supportsSpeakerDiarization: Bool { capabilities.contains("speaker_diarization") }
} }
enum CoderAPIError: LocalizedError { enum CoderAPIError: LocalizedError {
@@ -53,6 +55,7 @@ final class CoderAPIClient {
let start: TimeInterval let start: TimeInterval
let end: TimeInterval let end: TimeInterval
let text: String let text: String
let speaker: Int?
} }
let text: String let text: String
@@ -69,10 +72,25 @@ final class CoderAPIClient {
} }
private struct TranscriptionResponse: Decodable { private struct TranscriptionResponse: Decodable {
struct Word: Decodable {
let word: String
let start: TimeInterval
let end: TimeInterval
let speaker: Int?
}
let text: String let text: String
let segments: [Transcription.Segment]? let segments: [Transcription.Segment]?
let words: [Word]?
} }
private struct AudioChunk {
let url: URL
let offset: TimeInterval
let isTemporary: Bool
}
private let transcriptionChunkDuration: TimeInterval = 3 * 60
private let transcriptionSession: URLSession private let transcriptionSession: URLSession
private init() { private init() {
@@ -97,7 +115,11 @@ final class CoderAPIClient {
) )
} }
func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream<String, Error> { func streamChat(
systemPrompt: String,
userPrompt: String = "Create the meeting notes now.",
model: String
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in AsyncThrowingStream { continuation in
Task { Task {
do { do {
@@ -113,7 +135,7 @@ final class CoderAPIClient {
"model": selectedModel, "model": selectedModel,
"messages": [ "messages": [
["role": "system", "content": systemPrompt], ["role": "system", "content": systemPrompt],
["role": "user", "content": "Create the meeting notes now."] ["role": "user", "content": userPrompt]
], ],
"stream": true "stream": true
]) ])
@@ -147,15 +169,85 @@ final class CoderAPIClient {
} }
} }
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> Transcription { func transcribe(
fileURL: URL,
model: String,
language: String = "en",
diarization: Bool = false,
maxSpeakerCount: Int = 4
) async throws -> Transcription {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") } guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "") let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
let chunks = try makeAudioChunks(from: fileURL, preserveSpeakerIdentity: diarization)
defer {
for chunk in chunks where chunk.isTemporary {
try? FileManager.default.removeItem(at: chunk.url)
}
}
var textParts: [String] = []
var segments: [Transcription.Segment] = []
var lastNormalizedText = ""
var consecutiveDuplicateCount = 0
for chunk in chunks {
let transcription = try await transcribeChunk(
chunk.url,
model: selectedModel,
language: language,
apiKey: apiKey,
diarization: diarization,
maxSpeakerCount: maxSpeakerCount
)
if transcription.segments.isEmpty {
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
textParts.append(text)
segments.append(.init(start: chunk.offset, end: chunk.offset, text: text, speaker: nil))
}
continue
}
for segment in transcription.segments {
let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue }
let normalized = text.lowercased()
if normalized == lastNormalizedText {
consecutiveDuplicateCount += 1
} else {
lastNormalizedText = normalized
consecutiveDuplicateCount = 1
}
guard consecutiveDuplicateCount <= 2 else { continue }
textParts.append(text)
segments.append(.init(
start: segment.start + chunk.offset,
end: segment.end + chunk.offset,
text: text,
speaker: segment.speaker
))
}
}
return Transcription(text: textParts.joined(separator: "\n"), segments: segments)
}
private func transcribeChunk(
_ fileURL: URL,
model: String,
language: String,
apiKey: String,
diarization: Bool,
maxSpeakerCount: Int
) async throws -> Transcription {
let boundary = "Meetingnotes-\(UUID().uuidString)" let boundary = "Meetingnotes-\(UUID().uuidString)"
let bodyURL = try makeMultipartBody( let bodyURL = try makeMultipartBody(
audioURL: fileURL, audioURL: fileURL,
model: selectedModel, model: model,
language: language, language: language,
diarization: diarization,
maxSpeakerCount: maxSpeakerCount,
boundary: boundary boundary: boundary
) )
defer { try? FileManager.default.removeItem(at: bodyURL) } defer { try? FileManager.default.removeItem(at: bodyURL) }
@@ -171,7 +263,125 @@ final class CoderAPIClient {
let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL) let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL)
try validate(response: response, data: data) try validate(response: response, data: data)
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data) let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
return Transcription(text: decoded.text, segments: decoded.segments ?? []) let segments = decoded.segments ?? segments(from: decoded.words ?? [])
return Transcription(text: decoded.text, segments: segments)
}
private func makeAudioChunks(from fileURL: URL, preserveSpeakerIdentity: Bool) throws -> [AudioChunk] {
let input = try AVAudioFile(forReading: fileURL)
let format = input.processingFormat
guard format.sampleRate > 0 else { throw CoderAPIError.invalidResponse }
let framesPerChunk = preserveSpeakerIdentity
? max(1, input.length)
: AVAudioFramePosition(format.sampleRate * transcriptionChunkDuration)
var chunks: [AudioChunk] = []
var frameOffset: AVAudioFramePosition = 0
do {
while frameOffset < input.length {
let frameCount = min(framesPerChunk, input.length - frameOffset)
let chunkURL = FileManager.default.temporaryDirectory
.appendingPathComponent("meetingnotes-transcription-\(UUID().uuidString).wav")
try writeAudioChunk(
from: input,
frameCount: frameCount,
format: format,
to: chunkURL
)
chunks.append(AudioChunk(
url: chunkURL,
offset: Double(frameOffset) / format.sampleRate,
isTemporary: true
))
frameOffset += frameCount
}
return chunks
} catch {
for chunk in chunks {
try? FileManager.default.removeItem(at: chunk.url)
}
throw error
}
}
private func writeAudioChunk(
from input: AVAudioFile,
frameCount: AVAudioFramePosition,
format: AVAudioFormat,
to outputURL: URL
) throws {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: format.sampleRate,
AVNumberOfChannelsKey: format.channelCount,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
]
let output = try AVAudioFile(
forWriting: outputURL,
settings: settings,
commonFormat: format.commonFormat,
interleaved: format.isInterleaved
)
var remaining = frameCount
while remaining > 0 {
let requestedFrames = AVAudioFrameCount(min(remaining, 8_192))
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: requestedFrames) else {
throw CoderAPIError.invalidResponse
}
try input.read(into: buffer, frameCount: requestedFrames)
guard buffer.frameLength > 0 else { break }
try output.write(from: buffer)
remaining -= AVAudioFramePosition(buffer.frameLength)
}
}
private func segments(from words: [TranscriptionResponse.Word]) -> [Transcription.Segment] {
var result: [Transcription.Segment] = []
var currentWords: [String] = []
var currentStart: TimeInterval?
var currentEnd: TimeInterval = 0
var currentSpeaker: Int?
func flush() {
guard let start = currentStart, !currentWords.isEmpty else { return }
result.append(.init(
start: start,
end: currentEnd,
text: currentWords.joined(separator: " "),
speaker: currentSpeaker
))
currentWords.removeAll(keepingCapacity: true)
currentStart = nil
currentEnd = 0
currentSpeaker = nil
}
for word in words {
let text = word.word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue }
let speakerChanged = currentStart != nil && word.speaker != currentSpeaker
let longPause = currentStart != nil && word.start - currentEnd > 1.5
if speakerChanged || longPause { flush() }
if currentStart == nil {
currentStart = word.start
currentSpeaker = word.speaker
}
currentWords.append(text)
currentEnd = word.end
let sentenceEnded = text.last.map { ".!?".contains($0) } ?? false
let duration = currentEnd - (currentStart ?? currentEnd)
if currentWords.count >= 40 || (sentenceEnded && (currentWords.count >= 12 || duration >= 8)) {
flush()
}
}
flush()
return result
} }
private func endpoint(baseURL: String, path: String) throws -> URL { private func endpoint(baseURL: String, path: String) throws -> URL {
@@ -203,7 +413,14 @@ final class CoderAPIClient {
} }
} }
private func makeMultipartBody(audioURL: URL, model: String, language: String, boundary: String) throws -> URL { private func makeMultipartBody(
audioURL: URL,
model: String,
language: String,
diarization: Bool,
maxSpeakerCount: Int,
boundary: String
) throws -> URL {
let bodyURL = FileManager.default.temporaryDirectory.appendingPathComponent("meetingnotes-upload-\(UUID().uuidString).body") let bodyURL = FileManager.default.temporaryDirectory.appendingPathComponent("meetingnotes-upload-\(UUID().uuidString).body")
_ = FileManager.default.createFile(atPath: bodyURL.path, contents: nil) _ = FileManager.default.createFile(atPath: bodyURL.path, contents: nil)
let output = try FileHandle(forWritingTo: bodyURL) let output = try FileHandle(forWritingTo: bodyURL)
@@ -215,7 +432,11 @@ final class CoderAPIClient {
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n") if diarization {
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"diarization\"\r\n\r\ntrue\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"max_speaker_count\"\r\n\r\n\(maxSpeakerCount)\r\n")
}
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/wav\r\n\r\n")
let input = try FileHandle(forReadingFrom: audioURL) let input = try FileHandle(forReadingFrom: audioURL)
defer { try? input.close() } defer { try? input.close() }
while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty { while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty {
@@ -286,6 +286,7 @@ private final class LocalRecordingController {
} }
meeting.transcriptChunks = chunks meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName
let templates = LocalStorageManager.shared.loadTemplates() let templates = LocalStorageManager.shared.loadTemplates()
if meeting.templateId == nil { if meeting.templateId == nil {
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates) meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
@@ -309,8 +310,27 @@ private final class LocalRecordingController {
generatedNotes = "" generatedNotes = ""
} }
} }
var meetingChanged = false
if !generatedNotes.isEmpty { if !generatedNotes.isEmpty {
meeting.generatedNotes = generatedNotes meeting.generatedNotes = generatedNotes
meetingChanged = true
}
if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: generatedNotes,
templateId: meeting.templateId
) {
meeting.title = title
meetingChanged = true
}
} catch {
// The recording and generated notes remain valid without a generated title.
print("Meeting title generation failed: \(error)")
}
}
if meetingChanged {
_ = LocalStorageManager.shared.saveMeeting(meeting) _ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
} }
@@ -106,6 +106,88 @@ class NotesGenerator {
} }
} }
/// Generates a short title after meeting notes have been created.
func generateMeetingTitle(
meeting: Meeting,
generatedNotes: String,
templateId: UUID?
) async throws -> String? {
let templates = LocalStorageManager.shared.loadTemplates()
let template = templateId.flatMap { id in
templates.first(where: { $0.id == id })
}
let meetingContext: String
if let template {
meetingContext = "\(template.title): \(template.context)"
} else {
meetingContext = "General meeting"
}
let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines)
let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes
guard !meetingContent.isEmpty else { return nil }
let systemPrompt = """
Create a concise, descriptive title for a recorded meeting from its context and content.
Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period.
Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting.
"""
let userPrompt = """
Meeting context:
\(meetingContext)
Meeting content:
\(meetingContent)
"""
var generatedTitle = ""
let stream = CoderAPIClient.shared.streamChat(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
model: UserDefaultsManager.shared.notesModel
)
for try await content in stream {
generatedTitle += content
}
return normalizedTitle(generatedTitle)
}
private func normalizedTitle(_ value: String) -> String? {
guard var title = value
.split(whereSeparator: \Character.isNewline)
.map(String.init)
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
while title.hasPrefix("#") {
title.removeFirst()
title = title.trimmingCharacters(in: .whitespaces)
}
if title.lowercased().hasPrefix("title:") {
title = String(title.dropFirst("title:".count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
if title.hasSuffix(".") {
title.removeLast()
}
if title.count > 80 {
let prefix = String(title.prefix(80))
if let lastSpace = prefix.lastIndex(of: " ") {
title = String(prefix[..<lastSpace])
} else {
title = prefix
}
}
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
return title.isEmpty ? nil : title
}
/// Validates if the Coder service token is configured /// Validates if the Coder service token is configured
/// - Returns: True if API key exists, false otherwise /// - Returns: True if API key exists, false otherwise
func isConfigured() -> Bool { func isConfigured() -> Bool {
+75 -4
View File
@@ -12,7 +12,7 @@ extension Notification.Name {
enum MeetingViewTab: String, CaseIterable { enum MeetingViewTab: String, CaseIterable {
case myNotes = "My Notes" case myNotes = "My Notes"
case transcript = "Transcript" case transcript = "Transcript"
case enhancedNotes = "Enhanced Notes" case enhancedNotes = "Meeting Notes"
} }
@@ -25,6 +25,8 @@ class MeetingViewModel: ObservableObject {
@Published private var recordingStateChanged = false // Trigger SwiftUI updates @Published private var recordingStateChanged = false // Trigger SwiftUI updates
@Published var isValidatingKey = false // Indicates API key validation in progress @Published var isValidatingKey = false // Indicates API key validation in progress
@Published var isStartingRecording = false // Indicates recording start in progress @Published var isStartingRecording = false // Indicates recording start in progress
@Published var isRetryingTranscription = false
@Published private(set) var recoveryAudioFolderURL: URL?
// Computed property to determine if Generate button should animate // Computed property to determine if Generate button should animate
var shouldAnimateGenerateButton: Bool { var shouldAnimateGenerateButton: Bool {
@@ -44,7 +46,13 @@ class MeetingViewModel: ObservableObject {
} }
var isProcessing: Bool { var isProcessing: Bool {
return recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id return isRetryingTranscription ||
(recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id)
}
var canRetryTranscription: Bool {
recoveryAudioFolderURL != nil &&
!isRecording && !isProcessing && !isStartingRecording && !isValidatingKey
} }
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab @Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
@@ -76,6 +84,7 @@ class MeetingViewModel: ObservableObject {
// Load templates and selected template // Load templates and selected template
loadTemplates() loadTemplates()
refreshRecoveryAudioFolder()
// Observe template selection: save to meeting and regenerate notes on changes (skip initial) // Observe template selection: save to meeting and regenerate notes on changes (skip initial)
$selectedTemplateId $selectedTemplateId
.dropFirst() .dropFirst()
@@ -205,6 +214,8 @@ class MeetingViewModel: ObservableObject {
Task { Task {
let chunks = await recordingSessionManager.stopRecording() let chunks = await recordingSessionManager.stopRecording()
meeting.transcriptChunks = chunks meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName
refreshRecoveryAudioFolder()
saveMeeting() saveMeeting()
if !meeting.formattedTranscript.isEmpty { if !meeting.formattedTranscript.isEmpty {
await generateNotes() await generateNotes()
@@ -213,6 +224,46 @@ class MeetingViewModel: ObservableObject {
} }
} }
func retryTranscription() {
guard let recoveryAudioFolderURL, canRetryTranscription else { return }
isRetryingTranscription = true
errorMessage = nil
Task {
defer { isRetryingTranscription = false }
do {
let chunks = try await AudioManager.shared.transcribeRecoveryAudio(
in: recoveryAudioFolderURL,
captureStartedAt: meeting.date
)
meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
selectedTab = .transcript
guard saveMeeting() else {
throw CocoaError(.fileWriteUnknown)
}
await generateNotes()
} catch {
errorMessage = error.localizedDescription
print("Retry transcription failed: \(error)")
}
}
}
private func refreshRecoveryAudioFolder() {
recoveryAudioFolderURL = LocalStorageManager.shared.findRecoveryAudioFolder(for: meeting)
if let recoveryAudioFolderURL {
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
}
}
func showAudioInFinder() {
guard let recoveryAudioFolderURL else { return }
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
}
func loadTemplates() { func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
@@ -260,20 +311,40 @@ class MeetingViewModel: ObservableObject {
// Only save if there was no error // Only save if there was no error
if !hasError { if !hasError {
await generateTitleIfNeeded()
saveMeeting() saveMeeting()
} }
isGeneratingNotes = false isGeneratingNotes = false
} }
func saveMeeting() { private func generateTitleIfNeeded() async {
if isDeleted { return } guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: meeting.generatedNotes,
templateId: selectedTemplateId
) {
meeting.title = title
}
} catch {
// A title is helpful but should never prevent completed notes from being saved.
print("Meeting title generation failed: \(error)")
}
}
@discardableResult
func saveMeeting() -> Bool {
if isDeleted { return false }
print("💾 Saving meeting: \(meeting.id)") print("💾 Saving meeting: \(meeting.id)")
let success = LocalStorageManager.shared.saveMeeting(meeting) let success = LocalStorageManager.shared.saveMeeting(meeting)
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")") print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
if success { if success {
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
} }
return success
} }
func copyCurrentTabContent() { func copyCurrentTabContent() {
@@ -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 {
+21 -4
View File
@@ -208,12 +208,12 @@ struct CollapsedTranscriptChunkView: View {
.font(.caption) .font(.caption)
.foregroundColor(chunk.source == .mic ? .blue : .orange) .foregroundColor(chunk.source == .mic ? .blue : .orange)
Text(chunk.source.displayName) Text(chunk.displayName)
.font(.caption) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(chunk.source == .mic ? .blue : .orange) .foregroundColor(chunk.source == .mic ? .blue : .orange)
} }
.frame(width: 50, alignment: .leading) .frame(width: 78, alignment: .leading)
// Transcript text // Transcript text
Text(chunk.combinedText) Text(chunk.combinedText)
@@ -259,6 +259,23 @@ struct MeetingDetailContentView: View {
// Ellipsis menu // Ellipsis menu
Menu { Menu {
if viewModel.recoveryAudioFolderURL != nil {
Button {
viewModel.retryTranscription()
} label: {
Label("Retry Transcription", systemImage: "arrow.clockwise")
}
.disabled(!viewModel.canRetryTranscription)
Button {
viewModel.showAudioInFinder()
} label: {
Label("Show Audio in Finder", systemImage: "folder")
}
Divider()
}
Button("Delete Meeting", role: .destructive) { Button("Delete Meeting", role: .destructive) {
showDeleteAlert = true showDeleteAlert = true
} }
@@ -327,7 +344,7 @@ struct MeetingDetailContentView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording) .disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
.help("Generate enhanced notes using a template") .help("Generate meeting notes using a template")
// Recording Button // Recording Button
Button(action: { Button(action: {
@@ -374,7 +391,7 @@ struct MeetingDetailContentView: View {
Spacer() Spacer()
// Edit/Preview button (for My Notes and Enhanced Notes) // Edit/Preview button (for My Notes and Meeting Notes)
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes { if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
Button(action: { Button(action: {
isEditing.toggle() isEditing.toggle()
+23
View File
@@ -58,6 +58,12 @@ struct SettingsView: View {
Text(model.displayName).tag(model.id) Text(model.displayName).tag(model.id)
} }
} }
if viewModel.coderModels.first(where: { $0.id == viewModel.settings.transcriptionModel })?.supportsSpeakerDiarization == true {
Label("Remote participants are labeled Speaker 14; your microphone is labeled Me.", systemImage: "person.2.wave.2")
.font(.caption)
.foregroundColor(.secondary)
}
} }
Text("The token is stored locally in Keychain. Audio and note generation are sent only to this Coder service.") Text("The token is stored locally in Keychain. Audio and note generation are sent only to this Coder service.")
@@ -128,6 +134,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: {