Compare commits

..
5 Commits
13 changed files with 304 additions and 49 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 = 25; CURRENT_PROJECT_VERSION = 30;
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.13; MARKETING_VERSION = 1.1.18;
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 = 25; CURRENT_PROJECT_VERSION = 30;
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.13; MARKETING_VERSION = 1.1.18;
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;
+20 -36
View File
@@ -35,6 +35,7 @@ final class AudioManager: NSObject, ObservableObject {
private var audioEngine = AVAudioEngine() private var audioEngine = AVAudioEngine()
private var sessionID = UUID() private var sessionID = UUID()
private var meetingID = UUID()
private var processTap: ProcessTap? private var processTap: ProcessTap?
private let 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)
@@ -59,11 +60,12 @@ final class AudioManager: NSObject, ObservableObject {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
func startRecording() { func startRecording(for meetingID: UUID) {
errorMessage = nil errorMessage = nil
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = nil
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
sessionID = UUID() sessionID = UUID()
self.meetingID = meetingID
recordingStartedAt = Date() recordingStartedAt = Date()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
@@ -76,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
@@ -97,16 +99,17 @@ final class AudioManager: NSObject, ObservableObject {
) )
transcriptChunks = updated transcriptChunks = updated
let completedFiles = files.compactMap { $0 } let completedFiles = files.compactMap { $0 }
if failures.isEmpty { let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
removeAudioFiles(completedFiles) if !failures.isEmpty {
} else { let retentionDays = UserDefaultsManager.shared.audioRetentionDays
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID) let retentionUnit = retentionDays == 1 ? "day" : "days"
lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent let recoveryMessage = audioFolder == nil
let recoveryMessage = recoveryFolder == nil
? " The audio remains in the app's temporary folder." ? " The audio remains in the app's temporary folder."
: " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)." : " Audio was kept for \(retentionDays) \(retentionUnit). Use Show Audio Folder in the Meetingnotes menu to find it."
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
} else if audioFolder == nil, !completedFiles.isEmpty {
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its retention folder."
} }
return updated return updated
} }
@@ -341,12 +344,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,
@@ -502,29 +507,8 @@ final class AudioManager: NSObject, ObservableObject {
for url in urls { try? FileManager.default.removeItem(at: url) } for url in urls { try? FileManager.default.removeItem(at: url) }
} }
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? { private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
guard !urls.isEmpty, LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let folder = documents
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
.appendingPathComponent(sessionID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
do {
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent))
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
} }
private func resetAudioLevels() { private func resetAudioLevels() {
@@ -2,6 +2,7 @@
// Handles local storage of meetings and app data // Handles local storage of meetings and app data
import Foundation import Foundation
import AppKit
/// Manages local file storage for meetings and app data /// Manages local file storage for meetings and app data
class LocalStorageManager { class LocalStorageManager {
@@ -28,7 +29,7 @@ class LocalStorageManager {
// Create templates subdirectory // Create templates subdirectory
templatesDirectory = documentsDirectory.appendingPathComponent("Templates") templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery") recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
// Ensure directories exist // Ensure directories exist
try? FileManager.default.createDirectory(at: meetingsDirectory, try? FileManager.default.createDirectory(at: meetingsDirectory,
@@ -37,6 +38,8 @@ class LocalStorageManager {
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: recoveryDirectory, try? FileManager.default.createDirectory(at: recoveryDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
migrateLegacyRecoveryAudio()
purgeExpiredAudioFolders()
} }
// MARK: - Meeting Management // MARK: - Meeting Management
@@ -151,6 +154,67 @@ class LocalStorageManager {
// MARK: - Recovery Audio // MARK: - Recovery Audio
func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? {
guard !urls.isEmpty else { return nil }
purgeExpiredAudioFolders()
let folder = recoveryDirectory.appendingPathComponent(meetingID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
let destination = folder.appendingPathComponent(url.lastPathComponent)
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: url, to: destination)
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
}
func purgeExpiredAudioFolders(now: Date = Date()) {
guard let folders = try? FileManager.default.contentsOfDirectory(
at: recoveryDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
let retentionInterval = TimeInterval(UserDefaultsManager.shared.audioRetentionDays) * 24 * 60 * 60
let expirationDate = now.addingTimeInterval(-retentionInterval)
for folder in folders {
guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
let audioFiles = recoveryAudioFiles(in: folder)
let newestDate = audioFiles.compactMap { file -> Date? in
let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey])
return values?.contentModificationDate ?? values?.creationDate
}.max()
guard let newestDate else {
try? FileManager.default.removeItem(at: folder)
continue
}
if newestDate < expirationDate {
try? FileManager.default.removeItem(at: folder)
}
}
}
@MainActor
func showAudioFolderInFinder(_ folder: URL? = nil) {
let target = folder ?? recoveryDirectory
try? FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
NSWorkspace.shared.open(target)
}
func recoveryAudioFolder(named name: String) -> URL? { func recoveryAudioFolder(named name: String) -> URL? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, guard !trimmedName.isEmpty,
@@ -245,6 +309,22 @@ class LocalStorageManager {
try? FileManager.default.removeItem(at: folder) try? FileManager.default.removeItem(at: folder)
} }
private func migrateLegacyRecoveryAudio() {
let legacyDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
guard let folders = try? FileManager.default.contentsOfDirectory(
at: legacyDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
for folder in folders {
let destination = recoveryDirectory.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
guard !FileManager.default.fileExists(atPath: destination.path) else { continue }
try? FileManager.default.moveItem(at: folder, to: destination)
}
try? FileManager.default.removeItem(at: legacyDirectory)
}
/// Imports meeting JSON files from a folder selected by the user. /// Imports meeting JSON files from a folder selected by the user.
func importMeetings(from directory: URL) throws -> MeetingImportResult { func importMeetings(from directory: URL) throws -> MeetingImportResult {
let didStartAccess = directory.startAccessingSecurityScopedResource() let didStartAccess = directory.startAccessingSecurityScopedResource()
@@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject {
activeMeetingId = meetingId activeMeetingId = meetingId
recordingStartedAt = Date() recordingStartedAt = Date()
audioManager.startRecording() audioManager.startRecording(for: meetingId)
} }
func stopRecording() async -> [TranscriptChunk] { func stopRecording() async -> [TranscriptChunk] {
@@ -23,6 +23,7 @@ class UserDefaultsManager {
static let transcriptionModel = "transcriptionModel" static let transcriptionModel = "transcriptionModel"
static let muteDeckAPIEnabled = "muteDeckAPIEnabled" static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
static let muteDeckAPIPort = "muteDeckAPIPort" static let muteDeckAPIPort = "muteDeckAPIPort"
static let audioRetentionDays = "audioRetentionDays"
} }
// MARK: - User Blurb // MARK: - User Blurb
@@ -94,4 +95,12 @@ class UserDefaultsManager {
} }
set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) } set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) }
} }
var audioRetentionDays: Int {
get {
guard userDefaults.object(forKey: Keys.audioRetentionDays) != nil else { return 3 }
return min(max(userDefaults.integer(forKey: Keys.audioRetentionDays), 1), 365)
}
set { userDefaults.set(min(max(newValue, 1), 365), forKey: Keys.audioRetentionDays) }
}
} }
+10
View File
@@ -46,6 +46,10 @@ struct MeetingnotesApp: App {
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater) CheckForUpdatesView(updater: updaterController.updater)
Divider()
Button("Show Audio Folder") {
LocalStorageManager.shared.showAudioFolderInFinder()
}
} }
} }
@@ -79,6 +83,12 @@ private struct MeetingnotesMenu: View {
Label("Open Meetingnotes", systemImage: "macwindow") Label("Open Meetingnotes", systemImage: "macwindow")
} }
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Divider() Divider()
Button { Button {
+5
View File
@@ -54,6 +54,11 @@ struct Settings: Codable {
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue } set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
} }
var audioRetentionDays: Int {
get { UserDefaultsManager.shared.audioRetentionDays }
set { UserDefaultsManager.shared.audioRetentionDays = newValue }
}
// System prompt default loading // System prompt default loading
static func defaultSystemPrompt() -> String { static func defaultSystemPrompt() -> String {
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"), guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
+5 -2
View File
@@ -138,8 +138,11 @@ final class ProcessTap {
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: case .systemAudio:
tapDescription = CATapDescription(monoGlobalTapButExcludeProcesses: []) // Keep the HAL tap's buffer layout consistent with the default
logger.debug("Configuring a global system audio tap.") // output stream. AudioManager performs the stereo-to-mono mix when
// it converts the captured audio to the 16 kHz transcription file.
tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
logger.debug("Configuring a stereo global system audio tap.")
} }
tapDescription.uuid = UUID() tapDescription.uuid = UUID()
+140 -1
View File
@@ -1,3 +1,4 @@
import AVFoundation
import Foundation import Foundation
struct CoderModel: Codable, Identifiable, Hashable { struct CoderModel: Codable, Identifiable, Hashable {
@@ -73,6 +74,13 @@ final class CoderAPIClient {
let segments: [Transcription.Segment]? let segments: [Transcription.Segment]?
} }
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() {
@@ -155,10 +163,67 @@ final class CoderAPIClient {
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)
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
)
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))
}
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
))
}
}
return Transcription(text: textParts.joined(separator: "\n"), segments: segments)
}
private func transcribeChunk(
_ fileURL: URL,
model: String,
language: String,
apiKey: String
) 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,
boundary: boundary boundary: boundary
) )
@@ -178,6 +243,80 @@ final class CoderAPIClient {
return Transcription(text: decoded.text, segments: decoded.segments ?? []) return Transcription(text: decoded.text, segments: decoded.segments ?? [])
} }
private func makeAudioChunks(from fileURL: URL) throws -> [AudioChunk] {
let input = try AVAudioFile(forReading: fileURL)
let format = input.processingFormat
guard format.sampleRate > 0 else {
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
}
let duration = Double(input.length) / format.sampleRate
guard duration > transcriptionChunkDuration else {
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
}
let framesPerChunk = 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).m4a")
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: kAudioFormatMPEG4AAC,
AVSampleRateKey: format.sampleRate,
AVNumberOfChannelsKey: format.channelCount,
AVEncoderBitRateKey: 48_000 * max(1, Int(format.channelCount))
]
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 endpoint(baseURL: String, path: String) throws -> URL { private func endpoint(baseURL: String, path: String) throws -> URL {
guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)), guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = components.scheme?.lowercased(), let scheme = components.scheme?.lowercased(),
@@ -244,10 +244,6 @@ class MeetingViewModel: ObservableObject {
throw CocoaError(.fileWriteUnknown) throw CocoaError(.fileWriteUnknown)
} }
LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL)
meeting.recoveryAudioFolderName = nil
self.recoveryAudioFolderURL = nil
saveMeeting()
await generateNotes() await generateNotes()
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
@@ -263,6 +259,11 @@ class MeetingViewModel: ObservableObject {
} }
} }
func showAudioInFinder() {
guard let recoveryAudioFolderURL else { return }
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
}
func loadTemplates() { func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
@@ -71,6 +71,7 @@ class SettingsViewModel: ObservableObject {
// via computed properties when they're modified // via computed properties when they're modified
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey) let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
LocalAPIServer.shared.applyConfiguration() LocalAPIServer.shared.applyConfiguration()
LocalStorageManager.shared.purgeExpiredAudioFolders()
if showMessage { if showMessage {
if coderSaved { if coderSaved {
+6
View File
@@ -267,6 +267,12 @@ struct MeetingDetailContentView: View {
} }
.disabled(!viewModel.canRetryTranscription) .disabled(!viewModel.canRetryTranscription)
Button {
viewModel.showAudioInFinder()
} label: {
Label("Show Audio in Finder", systemImage: "folder")
}
Divider() Divider()
} }
+17
View File
@@ -128,6 +128,23 @@ struct SettingsView: View {
Text("Meeting Storage") Text("Meeting Storage")
.font(.headline) .font(.headline)
LabeledContent("Audio retention") {
Stepper(
value: $viewModel.settings.audioRetentionDays,
in: 1...365
) {
Text("\(viewModel.settings.audioRetentionDays) \(viewModel.settings.audioRetentionDays == 1 ? "day" : "days")")
.monospacedDigit()
.frame(minWidth: 70, alignment: .trailing)
}
}
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Button { Button {
showingMeetingImporter = true showingMeetingImporter = true
} label: { } label: {