Compare commits

...
11 Commits
17 changed files with 754 additions and 128 deletions
+17 -11
View File
@@ -20,6 +20,9 @@ jobs:
runs-on: macos-15 runs-on: macos-15
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@@ -31,7 +34,7 @@ jobs:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: | run: |
for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD SPARKLE_PRIVATE_KEY; do for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do
if [[ -z "${!variable:-}" ]]; then if [[ -z "${!variable:-}" ]]; then
echo "Missing GitHub Actions secret: $variable" >&2 echo "Missing GitHub Actions secret: $variable" >&2
exit 1 exit 1
@@ -53,7 +56,7 @@ jobs:
fi fi
echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV" echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV"
- name: Build and sign release - name: Build, sign, and notarize release
timeout-minutes: 30 timeout-minutes: 30
run: scripts/package_release.sh run: scripts/package_release.sh
@@ -70,13 +73,16 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
tag="v$VERSION" tag="v$VERSION"
if git rev-parse "$tag" >/dev/null 2>&1; then if gh release view "$tag" >/dev/null 2>&1; then
echo "Tag already exists: $tag" >&2 gh release upload "$tag" \
exit 1 "$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
--clobber
else
gh release create "$tag" \
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
--target "$GITHUB_SHA" \
--title "Meetingnotes $VERSION" \
--generate-notes
fi fi
gh release create "$tag" \
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
--target "$GITHUB_SHA" \
--title "Meetingnotes $VERSION" \
--generate-notes
+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 = 23; CURRENT_PROJECT_VERSION = 33;
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.11; MARKETING_VERSION = 1.1.21;
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 = 23; CURRENT_PROJECT_VERSION = 33;
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.11; MARKETING_VERSION = 1.1.21;
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;
+124 -88
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,44 +204,23 @@ 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 {
let settings: [String: Any] = [ let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC, AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 16_000, AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1, AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 48_000 AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
] ]
let base = FileManager.default.temporaryDirectory let base = FileManager.default.temporaryDirectory
let id = sessionID.uuidString let id = sessionID.uuidString
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a") let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.wav")
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a") let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.wav")
let newMicAudioFile = try AVAudioFile( let newMicAudioFile = try AVAudioFile(
forWriting: micURL, forWriting: micURL,
settings: settings, settings: settings,
@@ -239,8 +308,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 +332,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
@@ -302,12 +357,14 @@ final class AudioManager: NSObject, ObservableObject {
private func startTapIO(_ tap: ProcessTap) throws { private func startTapIO(_ tap: ProcessTap) throws {
guard var description = tap.tapStreamDescription, guard var description = tap.tapStreamDescription,
let inputFormat = AVAudioFormat(streamDescription: &description), let inputFormat = AVAudioFormat(streamDescription: &description),
let targetFormat = systemAudioFile?.processingFormat else { let targetFormat = systemAudioFile?.processingFormat,
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"]) throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
} }
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
guard let self, guard let self else { return }
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) 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(
{ self.copyAudioBuffer(from: inputData, format: inputFormat) }, { self.copyAudioBuffer(from: inputData, format: inputFormat) },
converter: converter, converter: converter,
@@ -315,7 +372,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() }
} }
} }
@@ -463,29 +520,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
@@ -26,12 +28,18 @@ 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] {
@@ -112,6 +112,10 @@ class RecordingSessionManager: ObservableObject {
func isRecordingMeeting(_ meetingId: UUID) -> Bool { func isRecordingMeeting(_ meetingId: UUID) -> Bool {
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
@@ -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,7 @@ class UserDefaultsManager {
} }
var transcriptionModel: String { var transcriptionModel: String {
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" } get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "local-parakeet/parakeet-tdt-0.6b-v3" }
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) } set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
} }
@@ -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 {
+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()
+229 -5
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() {
@@ -151,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) }
@@ -175,7 +263,132 @@ 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)
if fileURL.pathExtension.caseInsensitiveCompare("wav") == .orderedSame,
input.fileFormat.streamDescription.pointee.mFormatID == kAudioFormatLinearPCM,
input.length <= framesPerChunk {
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
}
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 {
@@ -207,7 +420,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)
@@ -219,7 +439,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)
+56 -3
View File
@@ -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()
@@ -212,6 +223,46 @@ class MeetingViewModel: ObservableObject {
isStartingRecording = false isStartingRecording = false
} }
} }
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()
@@ -284,14 +335,16 @@ class MeetingViewModel: ObservableObject {
} }
} }
func saveMeeting() { @discardableResult
if isDeleted { return } 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 {
+19 -2
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
} }
+29
View File
@@ -58,6 +58,18 @@ struct SettingsView: View {
Text(model.displayName).tag(model.id) Text(model.displayName).tag(model.id)
} }
} }
if let selectedModel = viewModel.coderModels.first(where: { $0.id == viewModel.settings.transcriptionModel }) {
if selectedModel.supportsSpeakerDiarization {
Label("Remote participants are labeled Speaker 14; your microphone is labeled Me.", systemImage: "person.2.wave.2")
.font(.caption)
.foregroundColor(.secondary)
} else {
Label("Remote participants are labeled Them; your microphone is labeled Me.", systemImage: "person.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 +140,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: {
+32 -2
View File
@@ -14,6 +14,9 @@ APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
required_variables=( required_variables=(
VERSION VERSION
SIGNING_IDENTITY SIGNING_IDENTITY
APPLE_ID
APPLE_TEAM_ID
APPLE_APP_PASSWORD
SPARKLE_PRIVATE_KEY SPARKLE_PRIVATE_KEY
GITHUB_REPOSITORY GITHUB_REPOSITORY
) )
@@ -80,6 +83,33 @@ ARCHIVE_NAME="$APP_NAME-$VERSION.zip"
ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME" ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
NOTARY_RESULT="$BUILD_ROOT/notary-result.json"
xcrun notarytool submit "$ARCHIVE_PATH" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait \
--timeout 20m \
--output-format json > "$NOTARY_RESULT"
NOTARY_STATUS=$(plutil -extract status raw -o - "$NOTARY_RESULT")
if [[ "$NOTARY_STATUS" != "Accepted" ]]; then
submission_id=$(plutil -extract id raw -o - "$NOTARY_RESULT")
xcrun notarytool log "$submission_id" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" || true
echo "Apple notarization failed with status: $NOTARY_STATUS" >&2
exit 1
fi
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
spctl --assess --type execute --verbose=2 "$APP_PATH"
rm -f "$ARCHIVE_PATH"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
GENERATE_APPCAST=$(find "$DERIVED_DATA/SourcePackages/artifacts" -type f -name generate_appcast -print -quit) GENERATE_APPCAST=$(find "$DERIVED_DATA/SourcePackages/artifacts" -type f -name generate_appcast -print -quit)
if [[ -z "$GENERATE_APPCAST" ]]; then if [[ -z "$GENERATE_APPCAST" ]]; then
echo "Sparkle generate_appcast tool was not found" >&2 echo "Sparkle generate_appcast tool was not found" >&2
@@ -97,8 +127,8 @@ grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml"
grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml" grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf 'Built and Developer ID-signed Meetingnotes %s. The GitHub release is ready to publish.\n' \ printf 'Built, Developer ID-signed, notarized, and stapled Meetingnotes %s. The GitHub release is ready to publish.\n' \
"$VERSION" >> "$GITHUB_STEP_SUMMARY" "$VERSION" >> "$GITHUB_STEP_SUMMARY"
fi fi
echo "Signed release artifacts are ready in $RELEASE_DIR" echo "Signed and notarized release artifacts are ready in $RELEASE_DIR"