diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6ef3458 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,21 @@ +name: Build + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + macos: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Build Meetingnotes + run: >- + xcodebuild + -project Meetingnotes.xcodeproj + -scheme meetingnotes + -configuration Debug + -destination 'platform=macOS' + CODE_SIGNING_ALLOWED=NO + build diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index 5ac2db7..02c58a0 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - CC5718382E2020490042BA88 /* OpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = CC5718372E2020490042BA88 /* OpenAI */; }; CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = CCBA53E62E212F1C00469D84 /* Sparkle */; }; CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = CCC33F802E236B6F00EDE382 /* PostHog */; }; /* End PBXBuildFile section */ @@ -30,7 +29,6 @@ buildActionMask = 2147483647; files = ( CCC33F812E236B6F00EDE382 /* PostHog in Frameworks */, - CC5718382E2020490042BA88 /* OpenAI in Frameworks */, CCBA53E72E212F1C00469D84 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -82,7 +80,6 @@ ); name = Meetingnotes; packageProductDependencies = ( - CC5718372E2020490042BA88 /* OpenAI */, CCBA53E62E212F1C00469D84 /* Sparkle */, CCC33F802E236B6F00EDE382 /* PostHog */, ); @@ -115,7 +112,6 @@ mainGroup = CC57180F2E201D8D0042BA88; minimizedProjectReferenceProxies = 1; packageReferences = ( - CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */, CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */, CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */, ); @@ -365,14 +361,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MacPaw/OpenAI"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.4.4; - }; - }; CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; @@ -392,11 +380,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - CC5718372E2020490042BA88 /* OpenAI */ = { - isa = XCSwiftPackageProductDependency; - package = CC5718362E2020490042BA88 /* XCRemoteSwiftPackageReference "OpenAI" */; - productName = OpenAI; - }; CCBA53E62E212F1C00469D84 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = CCBA53E52E212F1C00469D84 /* XCRemoteSwiftPackageReference "Sparkle" */; diff --git a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f36cf3b..8606731 100644 --- a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { "originHash" : "121a6c330462966c69b74d5d4f619f194dc62623e87741aa10ee74337154e7b5", "pins" : [ - { - "identity" : "openai", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MacPaw/OpenAI", - "state" : { - "revision" : "cedec2fc80aedafa332f7395b7004f79f547c794", - "version" : "0.4.4" - } - }, { "identity" : "posthog-ios", "kind" : "remoteSourceControl", diff --git a/README.md b/README.md index 1fa9241..0b59222 100644 --- a/README.md +++ b/README.md @@ -100,14 +100,14 @@ Instructions for cloning the repo, installing packages, configuring environment Implemented: - Recording mic & system audio -- Live transcript +- End-of-meeting transcription through your Coder service - Ability to also write down additional notes - AI generated enhanced notes - Copy functionality - Meeting deletion functionality - Meeting search functionality - Abilty to edit system prompt -- Use your own API key +- Select any compatible Coder model for transcription and note generation - Auto updates - Text formatting - Different note templates @@ -116,7 +116,7 @@ Implemented: Todo: -- check for funds / validity of openai api key +- improve provider balance and token validation errors - add padding to text inputs - add confirmation when clicking the copy button @@ -125,8 +125,6 @@ Later: - Cool recording indicator (dancing bars) - Connecting to your Google calendar - AI chat for asking questions about a meeting -- Ability to use different models -- Ability to use different STT providers - Integrations for email, Slack, Notion, etc. ## Local Development diff --git a/meetingnotes/Info.plist b/meetingnotes/Info.plist index b1fb75b..c4bff09 100644 --- a/meetingnotes/Info.plist +++ b/meetingnotes/Info.plist @@ -2,12 +2,17 @@ + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSAudioCaptureUsageDescription Meetingnotes needs access to capture system audio for transcription. NSMicrophoneUsageDescription Meetingnotes needs access to your microphone for transcription. SUFeedURL - https://raw.githubusercontent.com/owengretzinger/meetingnotes/main/appcast.xml + https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml SUPublicEDKey BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg= SUEnableInstallerLauncherService diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index 491414b..bf99438 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -1,74 +1,46 @@ -// AudioManager.swift -// Unified audio manager for microphone and system audio capture - import AVFoundation +import Combine import Foundation import SwiftUI -import OSLog -import Combine -/// Manages audio capture from microphone and system audio and handles real-time transcription via OpenAI +/// Captures microphone and system audio locally, then sends completed files to Coder. @MainActor -class AudioManager: NSObject, ObservableObject { +final class AudioManager: NSObject, ObservableObject { static let shared = AudioManager() @Published var transcriptChunks: [TranscriptChunk] = [] @Published var isRecording = false + @Published var isProcessing = false @Published var errorMessage: String? - @Published var micAudioLevel: Float = 0.0 - @Published var systemAudioLevel: Float = 0.0 + @Published var micAudioLevel: Float = 0 + @Published var systemAudioLevel: Float = 0 private var audioEngine = AVAudioEngine() - private var micSocketTask: URLSessionWebSocketTask? - private var systemSocketTask: URLSessionWebSocketTask? - private let realtimeURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")! - - // Unique identifier for the current recording session private var sessionID = UUID() - - // ProcessTap properties private var processTap: ProcessTap? private let audioProcessController = AudioProcessController() private let permission = AudioRecordingPermission() private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated) private var isTapActive = false private var isRestartingSystemTap = false - - // Add properties near the top, after existing private vars private var micRetryCount = 0 private let maxMicRetries = 3 - - // Add current interim transcripts per source - private var currentInterim: [AudioSource: String] = [.mic: "", .system: ""] - - // Add ping timers to keep WebSocket connections alive - private var pingTimers: [AudioSource: Timer] = [:] private var cancellables = Set() - // Session refresh timers to prevent 30-minute expiry - private var sessionRefreshTimers: [AudioSource: Timer] = [:] + private var micAudioFile: AVAudioFile? + private var systemAudioFile: AVAudioFile? + private var micAudioURL: URL? + private var systemAudioURL: URL? private override init() { super.init() - NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, - object: audioEngine, - queue: .main) { [weak self] _ in - self?.handleAudioEngineConfigurationChange() - } - - // Activate the process controller to start monitoring audio-producing apps + observeAudioEngine() audioProcessController.activate() - - // When the list of running applications changes, check if we need to restart the system audio tap NSWorkspace.shared.publisher(for: \.runningApplications) .debounce(for: .seconds(1), scheduler: RunLoop.main) .sink { [weak self] _ in guard let self, self.isTapActive else { return } - - print("๐ŸŽค Running applications changed, checking if tap restart is needed.") - Task { - await self.restartSystemAudioTapIfNeeded() - } + Task { await self.restartSystemAudioTapIfNeeded() } } .store(in: &cancellables) } @@ -78,848 +50,323 @@ class AudioManager: NSObject, ObservableObject { } func startRecording() { - print("Starting recording...") - - // Bump session ID so any old async callbacks can be ignored sessionID = UUID() + errorMessage = nil + cancelCapture(removeFiles: true) + do { + try prepareAudioFiles() + startMicrophoneTap() + Task { await startSystemAudioTap() } + } catch { + errorMessage = "Could not prepare meeting audio: \(error.localizedDescription)" + cancelCapture(removeFiles: true) + } + } - // Clear any previous errors - DispatchQueue.main.async { - self.errorMessage = nil + func stopRecordingAndTranscribe() async -> [TranscriptChunk] { + let files = stopCaptureAndCloseFiles() + isProcessing = true + defer { + isProcessing = false + removeAudioFiles(files.compactMap { $0 }) } - // Stop any in-progress recording - stopRecordingInternal() + let model = UserDefaultsManager.shared.transcriptionModel + async let micResult = transcribe(files[0], model: model) + async let systemResult = transcribe(files[1], model: model) + let (micTranscription, systemTranscription) = await (micResult, systemResult) + let results = [micTranscription, systemTranscription] - // Validate API key and account status before connecting - Task { - let validationResult = await APIKeyValidator.shared.validateCurrentAPIKey() - switch validationResult { + var updated = transcriptChunks.filter(\.isFinal) + var failures: [String] = [] + for (source, result) in zip([AudioSource.mic, .system], results) { + guard let result else { continue } + switch result { + case .success(let text): + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + updated.append(TranscriptChunk(source: source, text: trimmed, isFinal: true)) + } case .failure(let error): - let errorMsg = error.localizedDescription - print("โŒ API key validation failed: \(errorMsg)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } - case .success: - // Proceed with taps after cleanup - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // Start microphone capture - self.startMicrophoneTap() - // Start system audio capture asynchronously - Task { - await self.startSystemAudioTap() - } - } + failures.append("\(source.displayName): \(error.localizedDescription)") } } + transcriptChunks = updated + if !failures.isEmpty { + errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + } + return updated + } + + func cancelRecording() { + cancelCapture(removeFiles: true) + } + + private func transcribe(_ fileURL: URL?, model: String) async -> Result? { + guard let fileURL else { return nil } + do { + return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model)) + } catch { + return .failure(error) + } } - private func stopRecordingInternal() { - print("Internal cleanup...") - - // Stop system audio capture - if isTapActive { - self.processTap?.invalidate() - self.processTap = nil - isTapActive = false - print("System audio tap invalidated") + private func prepareAudioFiles() throws { + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 16_000, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 48_000 + ] + let base = FileManager.default.temporaryDirectory + let id = sessionID.uuidString + let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a") + let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a") + micAudioFile = try AVAudioFile( + forWriting: micURL, + settings: settings, + commonFormat: .pcmFormatFloat32, + interleaved: false + ) + systemAudioFile = try AVAudioFile( + forWriting: systemURL, + settings: settings, + commonFormat: .pcmFormatFloat32, + interleaved: false + ) + micAudioURL = micURL + systemAudioURL = systemURL + } + + private func startMicrophoneTap() { + do { + let inputNode = audioEngine.inputNode + let inputFormat = inputNode.outputFormat(forBus: 0) + guard let targetFormat = micAudioFile?.processingFormat, + let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { + throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"]) + } + inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in + guard let self, buffer.frameLength > 0 else { return } + self.updateAudioLevel(buffer, source: .mic) + self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic) + } + audioEngine.prepare() + try audioEngine.start() + micRetryCount = 0 + } catch { + errorMessage = "Could not start microphone capture: \(error.localizedDescription)" + restartMicrophone() } - - // Stop microphone capture - cleanupAudioEngine() - - // Close WebSocket - micSocketTask?.cancel(with: .normalClosure, reason: nil) - micSocketTask = nil - systemSocketTask?.cancel(with: .normalClosure, reason: nil) - systemSocketTask = nil - - // Invalidate ping timers - pingTimers.values.forEach { $0.invalidate() } - pingTimers.removeAll() - - // Invalidate session refresh timers - sessionRefreshTimers.values.forEach { $0.invalidate() } - sessionRefreshTimers.removeAll() - - // Reset state - // (isRecording already cleared in stopRecording) - - print("Internal cleanup completed") } private func restartMicrophone() { - guard isRecording, micRetryCount < maxMicRetries else { return } - - print("๐Ÿ”„ Restarting microphone capture (attempt \(micRetryCount + 1))") + guard (isRecording || micAudioFile != nil), micRetryCount < maxMicRetries else { return } micRetryCount += 1 - cleanupAudioEngine() - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.startMicrophoneTap() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.startMicrophoneTap() } } - - /// Starts a microphone tap without creating a new OpenAI connection (used when also capturing system audio) - private func startMicrophoneTap() { - print("๐ŸŽค Starting microphone tap...") - do { - let inputNode = audioEngine.inputNode - let recordingFormat = inputNode.outputFormat(forBus: 0) - - guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: 24000, - channels: 1, - interleaved: false) else { - print("โŒ Failed to create target audio format for mic tap") - self.restartMicrophone() - return - } - - guard let converter = AVAudioConverter(from: recordingFormat, to: targetFormat) else { - print("โŒ Failed to create audio converter for mic tap") - self.restartMicrophone() - return - } - - inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in - guard let self = self else { return } - - // Check for invalid buffer - guard buffer.frameLength > 0, buffer.floatChannelData != nil else { - print("โŒ Invalid mic buffer detected - restarting") - self.restartMicrophone() - return - } - - // Calculate audio level for visual indicator - if let ch = buffer.floatChannelData?[0] { - let frameCount = Int(buffer.frameLength) - let samples = UnsafeBufferPointer(start: ch, count: frameCount) - let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(frameCount)) - - // Update the published audio level on main thread - DispatchQueue.main.async { - self.micAudioLevel = rms - AudioLevelManager.shared.updateMicLevel(rms) - } - } - - self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic) - } - - audioEngine.prepare() - try audioEngine.start() - connectToOpenAIRealtime(source: .mic) - print("โœ… Microphone tap started successfully") - micRetryCount = 0 // Reset on success - - } catch { - print("โŒ Failed to start microphone tap: \(error)") - self.restartMicrophone() - } - } - private func cleanupAudioEngine() { - print("๐Ÿงน Cleaning up audio engine...") - - // Stop the engine first - if audioEngine.isRunning { - audioEngine.stop() - print("โน๏ธ Audio engine stopped") - } - - // Remove any existing taps on the input node - let inputNode = audioEngine.inputNode - inputNode.removeTap(onBus: 0) - print("๐Ÿ”‡ Input tap removed") - - // Reset the audio engine - this removes all connections and taps + if audioEngine.isRunning { audioEngine.stop() } + audioEngine.inputNode.removeTap(onBus: 0) audioEngine.reset() - print("๐Ÿ”„ Audio engine reset") - - // Create a fresh audio engine to ensure clean state audioEngine = AVAudioEngine() - print("โœจ Fresh audio engine created") + observeAudioEngine() } + private func observeAudioEngine() { + NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: audioEngine, + queue: .main + ) { [weak self] _ in + self?.handleAudioEngineConfigurationChange() + } + } + private func startSystemAudioTap(isRestart: Bool = false) async { - print(isRestart ? "๐ŸŽง Restarting system audio tap logic..." : "๐ŸŽง Starting system audio tap for the first time...") - - if !isRestart { - guard await checkSystemAudioPermissions() else { - let errorMsg = "System audio recording permission denied." - print("โŒ \(errorMsg)") - self.errorMessage = errorMsg - return - } - } - - // Get all running processes that are producing audio - let allProcessObjectIDs = audioProcessController.processes.map { $0.objectID } - if allProcessObjectIDs.isEmpty { - print("โš ๏ธ No audio-producing processes found. System audio tap might not capture anything.") - } - - // Configure the tap for system-wide audio - let target = TapTarget.systemAudio(processObjectIDs: allProcessObjectIDs) - let newTap = ProcessTap(target: target) - newTap.activate() - - // Check for activation errors - if let tapError = newTap.errorMessage { - let errorMsg = "Failed to activate system audio tap: \(tapError)" - print("โŒ \(errorMsg)") - self.errorMessage = errorMsg - if !isRestart { stopRecording() } + if !isRestart, !(await checkSystemAudioPermissions()) { + errorMessage = "System audio recording permission denied." + cancelCapture(removeFiles: true) return } - self.processTap = newTap - self.isTapActive = true + let processIDs = audioProcessController.processes.map(\.objectID) + let newTap = ProcessTap(target: .systemAudio(processObjectIDs: processIDs)) + newTap.activate() + if let tapError = newTap.errorMessage { + errorMessage = "Failed to activate system audio capture: \(tapError)" + if !isRestart { cancelCapture(removeFiles: true) } + return + } - // Start receiving audio data from the tap + processTap = newTap + isTapActive = true do { try startTapIO(newTap) - if !isRestart { - connectToOpenAIRealtime(source: .system) - self.isRecording = true + isRecording = true AudioLevelManager.shared.updateRecordingState(true) } - print("โœ… System audio tap started successfully (isRestart: \(isRestart))") - } catch { - let errorMsg = "Failed to start system audio tap IO: \(error.localizedDescription)" - print("โŒ \(errorMsg)") - self.errorMessage = errorMsg + errorMessage = "Failed to capture system audio: \(error.localizedDescription)" newTap.invalidate() - self.isTapActive = false - if !isRestart { stopRecording() } + isTapActive = false + if !isRestart { cancelCapture(removeFiles: true) } } } private func restartSystemAudioTapIfNeeded() async { - let newProcessObjectIDs = Set(audioProcessController.processes.map { $0.objectID }) - let currentProcessObjectIDs: Set - - if case .systemAudio(let processObjectIDs) = self.processTap?.target { - currentProcessObjectIDs = Set(processObjectIDs) + let next = Set(audioProcessController.processes.map(\.objectID)) + let current: Set + if case .systemAudio(let processIDs) = processTap?.target { + current = Set(processIDs) } else { - currentProcessObjectIDs = [] - } - - if newProcessObjectIDs != currentProcessObjectIDs { - print("Process list has changed. Restarting system audio tap.") - await restartSystemAudioTap() - } else { - print("Process list is the same. No restart needed.") + current = [] } + if next != current { await restartSystemAudioTap() } } private func restartSystemAudioTap() async { - print("๐Ÿ”„ Restarting system audio tap...") - - guard isRecording else { - print("Recording was stopped, aborting tap restart.") - return - } - + guard isRecording else { return } isRestartingSystemTap = true defer { isRestartingSystemTap = false } - - // 1. Invalidate existing tap if isTapActive { processTap?.invalidate() processTap = nil isTapActive = false - print("System audio tap invalidated for restart.") } - - // A small delay to let things settle. try? await Task.sleep(for: .milliseconds(250)) - - guard self.isRecording else { - print("Recording was stopped during tap restart. Aborting.") - return - } - - // 2. Start a new one, but don't re-connect to OpenAI or change recording state + guard isRecording else { return } await startSystemAudioTap(isRestart: true) } - @MainActor private func checkSystemAudioPermissions() async -> Bool { - if permission.status == .authorized { - return true - } - + if permission.status == .authorized { return true } permission.request() - - // Poll for a short time to see if permission is granted for _ in 0..<10 { - if permission.status == .authorized { - return true - } - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + if permission.status == .authorized { return true } + try? await Task.sleep(nanoseconds: 500_000_000) } - return permission.status == .authorized } private func startTapIO(_ tap: ProcessTap) throws { - guard var streamDescription = tap.tapStreamDescription else { - throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get audio format from tap."]) + guard var description = tap.tapStreamDescription, + let inputFormat = AVAudioFormat(streamDescription: &description), + let targetFormat = systemAudioFile?.processingFormat, + let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { + throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"]) } - - guard let format = AVAudioFormat(streamDescription: &streamDescription) else { - throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create AVAudioFormat from tap."]) - } - - try tap.run(on: tapQueue) { [weak self] _, inInputData, _, _, _ in - guard let self = self, - let buffer = AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: inInputData, deallocator: nil) else { - return - } - - let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: 24000, - channels: 1, - interleaved: false)! - - guard let converter = AVAudioConverter(from: format, to: targetFormat) else { - return - } - - // Calculate audio level for visual indicator - if let ch = buffer.floatChannelData?[0] { - let frameCount = Int(buffer.frameLength) - let samples = UnsafeBufferPointer(start: ch, count: frameCount) - let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(frameCount)) - - // Update the published audio level on main thread - DispatchQueue.main.async { - self.systemAudioLevel = rms - AudioLevelManager.shared.updateSystemLevel(rms) - } - } - + try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in + guard let self, + let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil), + buffer.frameLength > 0 else { return } + self.updateAudioLevel(buffer, source: .system) self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system) - } invalidationHandler: { [weak self] _ in - guard let self else { return } - print("Audio tap was invalidated.") + guard let self, !self.isRestartingSystemTap, self.isRecording else { return } + Task { await self.restartSystemAudioTap() } + } + } + + private func processAudioBuffer( + _ inputBuffer: AVAudioPCMBuffer, + converter: AVAudioConverter, + targetFormat: AVAudioFormat, + source: AudioSource + ) { + let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate + let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio))) + guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return } + var suppliedInput = false + var conversionError: NSError? + let status = converter.convert(to: outputBuffer, error: &conversionError) { _, outputStatus in + if suppliedInput { + outputStatus.pointee = .noDataNow + return nil + } + suppliedInput = true + outputStatus.pointee = .haveData + return inputBuffer + } + guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return } + do { + switch source { + case .mic: + try micAudioFile?.write(from: outputBuffer) + case .system: + try systemAudioFile?.write(from: outputBuffer) + } + } catch { + DispatchQueue.main.async { [weak self] in + self?.errorMessage = "Could not save meeting audio: \(error.localizedDescription)" + } + } + } - if !self.isRestartingSystemTap { - print("Tap invalidated unexpectedly. Restarting system audio tap.") - Task { - await self.restartSystemAudioTap() - } - } else { - print("Tap invalidated as part of a restart. Not stopping recording.") + private func updateAudioLevel(_ buffer: AVAudioPCMBuffer, source: AudioSource) { + guard let channel = buffer.floatChannelData?[0], buffer.frameLength > 0 else { return } + let samples = UnsafeBufferPointer(start: channel, count: Int(buffer.frameLength)) + let rms = sqrt(samples.reduce(0) { $0 + ($1 * $1) } / Float(buffer.frameLength)) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + switch source { + case .mic: + self.micAudioLevel = rms + AudioLevelManager.shared.updateMicLevel(rms) + case .system: + self.systemAudioLevel = rms + AudioLevelManager.shared.updateSystemLevel(rms) } } } - func stopRecording() { - // Immediately mark as not recording to prevent stale callbacks - self.isRecording = false + private func stopCaptureAndCloseFiles() -> [URL?] { + isRecording = false AudioLevelManager.shared.updateRecordingState(false) - print("Stopping recording...") - - // Reset audio levels - micAudioLevel = 0.0 - systemAudioLevel = 0.0 - AudioLevelManager.shared.updateMicLevel(0.0) - AudioLevelManager.shared.updateSystemLevel(0.0) - - // Stop system audio capture if isTapActive { - self.processTap?.invalidate() - self.processTap = nil + processTap?.invalidate() + processTap = nil isTapActive = false - print("System audio tap invalidated") } - - // Stop microphone capture cleanupAudioEngine() micRetryCount = 0 - - // Close WebSocket - micSocketTask?.cancel(with: .normalClosure, reason: nil) - micSocketTask = nil - systemSocketTask?.cancel(with: .normalClosure, reason: nil) - systemSocketTask = nil - - // Invalidate ping timers - pingTimers.values.forEach { $0.invalidate() } - pingTimers.removeAll() - - // Invalidate session refresh timers - sessionRefreshTimers.values.forEach { $0.invalidate() } - sessionRefreshTimers.removeAll() - - print("Recording stopped") + resetAudioLevels() + + let micHasAudio = (micAudioFile?.length ?? 0) > 0 + let systemHasAudio = (systemAudioFile?.length ?? 0) > 0 + micAudioFile = nil + systemAudioFile = nil + let files: [URL?] = [micHasAudio ? micAudioURL : nil, systemHasAudio ? systemAudioURL : nil] + if !micHasAudio, let micAudioURL { try? FileManager.default.removeItem(at: micAudioURL) } + if !systemHasAudio, let systemAudioURL { try? FileManager.default.removeItem(at: systemAudioURL) } + micAudioURL = nil + systemAudioURL = nil + return files } - private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat, source: AudioSource) { - let processBuffer = buffer - - // Convert to target format (24kHz int16 mono) in a single step โ€“ AVAudioConverter will handle resampling and downmixing - let outputFrameCapacity = AVAudioFrameCount(Double(processBuffer.frameLength) * targetFormat.sampleRate / processBuffer.format.sampleRate) - guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else { - return - } - - var error: NSError? - let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in - outStatus.pointee = .haveData - return processBuffer - } - - guard status == .haveData, error == nil else { - return - } - - // Convert to Data for OpenAI - guard let channelData = outputBuffer.int16ChannelData?[0] else { - return - } - - let frameCount = Int(outputBuffer.frameLength) - let data = Data(bytes: channelData, count: frameCount * 2) - - sendAudioData(data, source: source) - } - - private func connectToOpenAIRealtime(source: AudioSource) { - guard let key = KeychainHelper.shared.getAPIKey(), !key.isEmpty else { - let errorMsg = ErrorMessage.noAPIKey - print("โŒ \(errorMsg)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } - return - } - - let session = URLSession(configuration: .default) - var request = URLRequest(url: realtimeURL) - request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization") - request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta") - - let task = session.webSocketTask(with: request) - - // Add connection monitoring - task.resume() - - // Set up ping timer to keep connection alive - pingTimers[source]?.invalidate() - let pingTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in - guard let self = self else { return } - let task = source == .mic ? self.micSocketTask : self.systemSocketTask - guard let socket = task, socket.state == .running else { return } - socket.sendPing { error in - if let error = error { - print("โŒ Ping failed for \(source): \(error)") - } else { - print("๐Ÿ“ Ping sent for \(source)") - } - } - } - pingTimers[source] = pingTimer - - // Set up session refresh timer to prevent 30-minute expiry (refresh after 28 minutes) - sessionRefreshTimers[source]?.invalidate() - let sessionRefreshTimer = Timer.scheduledTimer(withTimeInterval: 28 * 60.0, repeats: false) { [weak self] _ in - guard let self = self, self.isRecording else { return } - print("๐Ÿ“ Proactively refreshing session for \(source) to prevent expiry...") - self.connectToOpenAIRealtime(source: source) - } - sessionRefreshTimers[source] = sessionRefreshTimer - - let thisSession = sessionID - // Monitor connection state (ignore if session changed or recording stopped) - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak task] in - guard let self = self, self.sessionID == thisSession, self.isRecording else { return } - guard let task = task, task.state != .running else { return } - let errorMsg = ErrorMessage.connectionTimeout - print("โŒ \(errorMsg)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } - } - - // Send initial configuration - let config: [String: Any] = [ - "type": "transcription_session.update", - "session": [ - "input_audio_format": "pcm16", - "input_audio_transcription": [ - "model": "gpt-4o-mini-transcribe", - "language": "en" - ], - "turn_detection": [ - "type": "server_vad", - "threshold": 0.5, - "prefix_padding_ms": 300, - "silence_duration_ms": 200 - ] - ] - ] - - do { - let jsonData = try JSONSerialization.data(withJSONObject: config) - if let jsonStr = String(data: jsonData, encoding: .utf8) { - task.send(.string(jsonStr)) { [weak self] error in - if let error = error { - guard let self = self, self.sessionID == thisSession else { return } - - // Ignore cancellation errors, which are expected when stopping a session. - if (error as? URLError)?.code == .cancelled { - return - } - - let errorMsg = "\(ErrorMessage.configurationFailed): \(ErrorHandler.shared.handleError(error))" - print("โŒ \(errorMsg)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } - } - } - } - } catch { - let errorMsg = "\(ErrorMessage.configurationFailed): \(ErrorHandler.shared.handleError(error))" - print("โŒ \(errorMsg)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } - } - - switch source { - case .mic: - micSocketTask = task - case .system: - systemSocketTask = task - } - - receiveMessage(for: source, sessionID: thisSession) - print("๐ŸŒ Connected to OpenAI Realtime (\(source))") + private func cancelCapture(removeFiles: Bool) { + let files = stopCaptureAndCloseFiles().compactMap { $0 } + if removeFiles { removeAudioFiles(files) } + isProcessing = false } - private func receiveMessage(for source: AudioSource, sessionID: UUID) { - let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask - task?.receive { [weak self] result in - switch result { - case .success(let message): - switch message { - case .string(let text): - self?.parseRealtimeEvent(text, source: source) - case .data: - break - @unknown default: - break - } - // Continue loop for this session - if let self = self, self.sessionID == sessionID { - self.receiveMessage(for: source, sessionID: sessionID) - } - case .failure(let error): - guard let self = self, self.sessionID == sessionID else { return } // Stale callback - // Ignore errors caused by intentional socket closure after recording stops - if self.isRecording == false { return } - - let errorMsg = self.handleWebSocketError(error, source: source) - print("โŒ Receive error (\(source)): \(error)") - - // Check if this is a session expiry - if so, don't show as persistent error - let isSessionExpiry = errorMsg == ErrorMessage.sessionExpired - - if isSessionExpiry { - // For session expiry, show temporary message - DispatchQueue.main.async { - self.errorMessage = errorMsg - // Clear the message after a few seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if self.errorMessage == errorMsg { - self.errorMessage = nil - } - } - } - } else { - DispatchQueue.main.async { - self.errorMessage = errorMsg - } - - // Only attempt reconnect for network errors, not API errors - if ErrorHandler.shared.shouldRetry(error) { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard let self = self, self.isRecording, self.sessionID == sessionID else { return } - self.connectToOpenAIRealtime(source: source) - } - } - } - } - } + private func removeAudioFiles(_ urls: [URL]) { + for url in urls { try? FileManager.default.removeItem(at: url) } } - - private func handleWebSocketError(_ error: Error, source: AudioSource) -> String { - // Check for session expiry in error description first - let errorDescription = error.localizedDescription.lowercased() - if errorDescription.contains("session hit the maximum duration") || - errorDescription.contains("session expired") { - // Handle session expiry by automatically restarting the connection - print("๐Ÿ“ Session expired for \(source) (WebSocket error), attempting to restart connection...") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard let self = self, self.isRecording else { return } - self.connectToOpenAIRealtime(source: source) - } - // Return session expired message but don't stop recording - return ErrorMessage.sessionExpired - } - // Check for WebSocket close codes - if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int { - return ErrorHandler.shared.handleWebSocketCloseCode(closeCode) - } - - // Use centralized error handler for all other errors - return ErrorHandler.shared.handleError(error) - } - - - - private func parseRealtimeEvent(_ text: String, source: AudioSource) { - // Parse JSON message - guard let data = text.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } - - // Early error handling for any payload with "error" key - if let errorDict = json["error"] as? [String: Any] { - let errorType = errorDict["type"] as? String ?? "unknown_error" - let errorCode = errorDict["code"] as? String ?? "" - let errorMessage = errorDict["message"] as? String ?? "Unknown error occurred" - print("โŒ OpenAI Realtime API Error (\(source)) - Type: \(errorType), Code: \(errorCode), Message: \(errorMessage)") - - // Map common error codes to user-friendly messages - let userFriendlyMessage: String - switch errorCode { - case "insufficient_quota", "quota_exceeded": - userFriendlyMessage = ErrorMessage.insufficientFunds - case "invalid_api_key", "authentication_failed": - userFriendlyMessage = ErrorMessage.invalidAPIKey - case "rate_limit_exceeded": - userFriendlyMessage = ErrorMessage.rateLimited - case "server_error": - userFriendlyMessage = ErrorMessage.apiServerError - case "access_denied", "forbidden": - userFriendlyMessage = ErrorMessage.accessForbidden - case "session_expired": - // Handle session expiry by automatically restarting the connection - print("๐Ÿ“ Session expired for \(source), attempting to restart connection...") - userFriendlyMessage = ErrorMessage.sessionExpired - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard let self = self, self.isRecording else { return } - self.connectToOpenAIRealtime(source: source) - } - // Show informational message but don't stop recording - DispatchQueue.main.async { - self.errorMessage = userFriendlyMessage - // Clear the message after a few seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if self.errorMessage == userFriendlyMessage { - self.errorMessage = nil - } - } - } - return - default: - // Check for session expiry in the error message - if errorMessage.lowercased().contains("session hit the maximum duration") || - errorMessage.lowercased().contains("session expired") { - // Handle session expiry by automatically restarting the connection - print("๐Ÿ“ Session expired for \(source), attempting to restart connection...") - userFriendlyMessage = ErrorMessage.sessionExpired - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard let self = self, self.isRecording else { return } - self.connectToOpenAIRealtime(source: source) - } - // Show informational message but don't stop recording - DispatchQueue.main.async { - self.errorMessage = userFriendlyMessage - // Clear the message after a few seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if self.errorMessage == userFriendlyMessage { - self.errorMessage = nil - } - } - } - return - } - // Check if this is a transcription failure (often indicates insufficient funds) - else if errorMessage.lowercased().contains("input transcription failed") || - errorMessage.lowercased().contains("transcription failed") { - userFriendlyMessage = "\(errorMessage)\n\nNote: This error typically occurs when your OpenAI account has insufficient funds. Please check your account balance and add credits if needed." - } else { - userFriendlyMessage = "Transcription error: \(errorMessage)" - } - } - - DispatchQueue.main.async { - self.errorMessage = userFriendlyMessage - // Stop recording when transcription errors occur - if self.isRecording { - self.stopRecording() - } - } - return - } - - guard let type = json["type"] as? String else { return } - - // Check for general failure status in any event - if let status = json["status"] as? String, status == "failed" { - let itemId = json["item_id"] as? String ?? json["id"] as? String ?? "unknown" - print("โŒ Event failed (\(source)): type=\(type), item=\(itemId)") - let errorMessage = "Transcription failed for \(type) (item: \(itemId))\n\nNote: This error typically occurs when your OpenAI account has insufficient funds. Please check your account balance and add credits if needed." - DispatchQueue.main.async { - self.errorMessage = errorMessage - // Stop recording when transcription errors occur - if self.isRecording { - self.stopRecording() - } - } - return - } - - // Debug logging for key events (can be removed later) - if type.contains("transcription") || type.contains("error") { - print("๐Ÿ” Event (\(source)): \(type) - \(String(data: data, encoding: .utf8) ?? "invalid")") - } - - switch type { - case "conversation.item.input_audio_transcription.delta": - if let delta = json["delta"] as? String { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Safely accumulate interim text for this source - self.currentInterim[source, default: ""] += delta - - // Remove previous interim chunk from the same source (if any) - if let lastIndex = self.transcriptChunks.lastIndex(where: { !$0.isFinal && $0.source == source }) { - self.transcriptChunks.remove(at: lastIndex) - } - - // Append updated interim chunk - let chunk = TranscriptChunk( - timestamp: Date(), - source: source, - text: self.currentInterim[source] ?? "", - isFinal: false - ) - self.transcriptChunks.append(chunk) - } - } - case "conversation.item.input_audio_transcription.completed": - if let transcript = json["transcript"] as? String { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Remove any interim chunks for this source - self.transcriptChunks.removeAll { !$0.isFinal && $0.source == source } - - // Append final chunk - let chunk = TranscriptChunk( - timestamp: Date(), - source: source, - text: transcript, - isFinal: true - ) - self.transcriptChunks.append(chunk) - - // Reset interim buffer for this source - self.currentInterim[source] = "" - } - } else if let status = json["status"] as? String, status == "failed" { - // Handle transcription failure (often due to insufficient credits) - let itemId = json["item_id"] as? String ?? "unknown" - print("โŒ Transcription failed for item: \(itemId)") - let errorMessage = "Audio transcription failed for item: \(itemId)\n\nNote: This error typically occurs when your OpenAI account has insufficient funds. Please check your account balance and add credits if needed." - DispatchQueue.main.async { - self.errorMessage = errorMessage - // Stop recording when transcription errors occur - if self.isRecording { - self.stopRecording() - } - } - } - case "error": - // This case is now handled by the early error handling above. - // If we reach here, it means the error was not caught by the early check. - // We can add specific handling for this case if needed, but for now, - // the early error handling covers it. - break - case "session.updated", "session.created": - // Log session events for debugging - print("๐Ÿ“‹ Session event (\(source)): \(type)") - case "response.done", "response.created": - // Log response events for debugging (these don't contain transcription data) - print("๐Ÿ”„ Response event (\(source)): \(type)") - case "rate_limits.updated": - // Log rate limit updates - if let rateLimits = json["rate_limits"] as? [[String: Any]] { - for limit in rateLimits { - if let name = limit["name"] as? String, - let remaining = limit["remaining"] as? Int, - let total = limit["limit"] as? Int { - print("๐Ÿ“Š Rate limit (\(source)) - \(name): \(remaining)/\(total)") - - // Warn when approaching limits - if name == "tokens" && remaining < 1000 { - print("โš ๏ธ Warning: Low token balance remaining: \(remaining)") - } - } - } - } - default: - break - } - } - - private func sendAudioData(_ data: Data, source: AudioSource) { - let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask - - guard let socket = task, socket.state == .running else { return } - - let base64 = data.base64EncodedString() - let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64] - - let thisSession = self.sessionID - do { - let jsonData = try JSONSerialization.data(withJSONObject: message) - if let jsonStr = String(data: jsonData, encoding: .utf8) { - socket.send(.string(jsonStr)) { [weak self] error in - if let error = error { - guard let self = self, self.sessionID == thisSession else { return } - - // Ignore cancellation errors, which are expected when stopping recording. - if (error as? URLError)?.code == .cancelled { - return - } - print("โŒ Send error (\(source)): \(error)") - } - } - } - } catch { - print("โŒ JSON send error") - } + private func resetAudioLevels() { + micAudioLevel = 0 + systemAudioLevel = 0 + AudioLevelManager.shared.updateMicLevel(0) + AudioLevelManager.shared.updateSystemLevel(0) } private func handleAudioEngineConfigurationChange() { - print("๐Ÿ”” Audio engine configuration changed - restarting mic") restartMicrophone() } } diff --git a/meetingnotes/Managers/KeychainHelper.swift b/meetingnotes/Managers/KeychainHelper.swift index e75538d..c6579f0 100644 --- a/meetingnotes/Managers/KeychainHelper.swift +++ b/meetingnotes/Managers/KeychainHelper.swift @@ -14,15 +14,15 @@ class KeychainHelper { /// Gets the API key directly from keychain /// - Returns: The API key string if found, nil otherwise - func getAPIKey() -> String? { - return get(forKey: "openAIKey") + func getCoderAPIKey() -> String? { + return get(forKey: "coderAPIKey") } /// Saves the API key to keychain /// - Parameter apiKey: The API key to save /// - Returns: True if the save was successful, false otherwise - func saveAPIKey(_ apiKey: String) -> Bool { - return save(apiKey, forKey: "openAIKey") + func saveCoderAPIKey(_ apiKey: String) -> Bool { + return save(apiKey, forKey: "coderAPIKey") } /// Saves a string value to the keychain @@ -80,4 +80,4 @@ class KeychainHelper { let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess } -} \ No newline at end of file +} diff --git a/meetingnotes/Managers/RecordingSessionManager.swift b/meetingnotes/Managers/RecordingSessionManager.swift index 43a97c7..b248d27 100644 --- a/meetingnotes/Managers/RecordingSessionManager.swift +++ b/meetingnotes/Managers/RecordingSessionManager.swift @@ -8,6 +8,7 @@ class RecordingSessionManager: ObservableObject { static let shared = RecordingSessionManager() @Published var isRecording = false + @Published var isProcessing = false @Published var activeMeetingId: UUID? @Published var errorMessage: String? @Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = [] @@ -32,6 +33,12 @@ class RecordingSessionManager: ObservableObject { } .store(in: &cancellables) + audioManager.$isProcessing + .sink { [weak self] isProcessing in + self?.isProcessing = isProcessing + } + .store(in: &cancellables) + audioManager.$errorMessage .sink { [weak self] errorMessage in self?.errorMessage = errorMessage @@ -75,16 +82,24 @@ class RecordingSessionManager: ObservableObject { audioManager.startRecording() } - func stopRecording() { + func stopRecording() async -> [TranscriptChunk] { print("๐Ÿ›‘ Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")") - audioManager.stopRecording() - - // Perform a final, immediate save of transcript chunks to the meeting - if let activeMeetingId = activeMeetingId { - updateActiveMeetingTranscript(meetingId: activeMeetingId, chunks: activeRecordingTranscriptChunks) + guard let meetingId = activeMeetingId else { + audioManager.cancelRecording() + return [] } + let chunks = await audioManager.stopRecordingAndTranscribe() + activeRecordingTranscriptChunks = chunks + activeRecordingTranscriptChunksUpdated = chunks + updateActiveMeetingTranscript(meetingId: meetingId, chunks: chunks) + activeMeetingId = nil + activeRecordingTranscriptChunks = [] + return chunks + } + func cancelRecording() { + audioManager.cancelRecording() activeMeetingId = nil activeRecordingTranscriptChunks = [] } diff --git a/meetingnotes/Managers/UserDefaultsManager.swift b/meetingnotes/Managers/UserDefaultsManager.swift index 2e54521..54f6708 100644 --- a/meetingnotes/Managers/UserDefaultsManager.swift +++ b/meetingnotes/Managers/UserDefaultsManager.swift @@ -18,6 +18,9 @@ class UserDefaultsManager { static let hasCompletedOnboarding = "hasCompletedOnboarding" static let hasAcceptedTerms = "hasAcceptedTerms" static let selectedTemplateId = "selectedTemplateId" + static let coderBaseURL = "coderBaseURL" + static let notesModel = "notesModel" + static let transcriptionModel = "transcriptionModel" } // MARK: - User Blurb @@ -61,4 +64,19 @@ class UserDefaultsManager { } } } -} \ No newline at end of file + + var coderBaseURL: String { + get { userDefaults.string(forKey: Keys.coderBaseURL) ?? "http://dev:8787/v1" } + set { userDefaults.set(newValue, forKey: Keys.coderBaseURL) } + } + + var notesModel: String { + get { userDefaults.string(forKey: Keys.notesModel) ?? "gpt-5.4-mini" } + set { userDefaults.set(newValue, forKey: Keys.notesModel) } + } + + var transcriptionModel: String { + get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" } + set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) } + } +} diff --git a/meetingnotes/Models/Settings.swift b/meetingnotes/Models/Settings.swift index 583c8b0..5ddcc81 100644 --- a/meetingnotes/Models/Settings.swift +++ b/meetingnotes/Models/Settings.swift @@ -1,8 +1,7 @@ import Foundation struct Settings: Codable { - // Only store API key in memory - will be loaded from keychain when needed - var openAIKey: String = "" + var coderAPIKey: String = "" // Computed properties that access UserDefaults var userBlurb: String { @@ -30,6 +29,21 @@ struct Settings: Codable { set { UserDefaultsManager.shared.hasAcceptedTerms = newValue } } + var coderBaseURL: String { + get { UserDefaultsManager.shared.coderBaseURL } + set { UserDefaultsManager.shared.coderBaseURL = newValue } + } + + var notesModel: String { + get { UserDefaultsManager.shared.notesModel } + set { UserDefaultsManager.shared.notesModel = newValue } + } + + var transcriptionModel: String { + get { UserDefaultsManager.shared.transcriptionModel } + set { UserDefaultsManager.shared.transcriptionModel = newValue } + } + // System prompt default loading static func defaultSystemPrompt() -> String { guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"), @@ -57,12 +71,12 @@ struct Settings: Codable { return result } - init(openAIKey: String = "") { - self.openAIKey = openAIKey + init(coderAPIKey: String = "") { + self.coderAPIKey = coderAPIKey } // MARK: - Codable conformance for API key only private enum CodingKeys: String, CodingKey { - case openAIKey + case coderAPIKey } -} \ No newline at end of file +} diff --git a/meetingnotes/Services/APIKeyValidator.swift b/meetingnotes/Services/APIKeyValidator.swift index f47bc44..585de73 100644 --- a/meetingnotes/Services/APIKeyValidator.swift +++ b/meetingnotes/Services/APIKeyValidator.swift @@ -1,88 +1,47 @@ -// APIKeyValidator.swift -// Service to validate OpenAI API keys and check for sufficient funds - import Foundation -/// Service to validate OpenAI API keys -class APIKeyValidator { - static let shared = APIKeyValidator() +final class CoderAPIValidator { + static let shared = CoderAPIValidator() private init() {} - /// Validates the OpenAI API key by making a test request - /// - Parameter apiKey: The API key to validate - /// - Returns: Result indicating success or failure with error message func validateAPIKey(_ apiKey: String) async -> Result { - guard !apiKey.isEmpty else { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .failure(.emptyKey) } - - // Make a simple request to validate the key - guard let url = URL(string: "https://api.openai.com/v1/models") else { - return .failure(.invalidURL) - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - return .failure(.networkError("Invalid response")) - } - - switch httpResponse.statusCode { - case 200: - // Key is valid - check if models are available - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let models = json["data"] as? [[String: Any]], !models.isEmpty { - return .success(()) - } else { - return .failure(.noModelsAvailable) - } - default: - let errorMessage = ErrorHandler.shared.handleHTTPStatusCode(httpResponse.statusCode) - return .failure(.httpError(errorMessage)) - } + let models = try await CoderAPIClient.shared.models( + baseURL: UserDefaultsManager.shared.coderBaseURL, + apiKey: apiKey + ) + return models.isEmpty ? .failure(.noModelsAvailable) : .success(()) + } catch let error as CoderAPIError { + return .failure(.connection(error.localizedDescription)) } catch { - let errorMessage = ErrorHandler.shared.handleError(error) - return .failure(.networkError(errorMessage)) + return .failure(.connection(error.localizedDescription)) } } - /// Validates the currently stored API key - /// - Returns: Result indicating success or failure with error message func validateCurrentAPIKey() async -> Result { - guard let apiKey = KeychainHelper.shared.getAPIKey() else { + guard let apiKey = KeychainHelper.shared.getCoderAPIKey() else { return .failure(.emptyKey) } - return await validateAPIKey(apiKey) } } -/// Errors that can occur during API key validation enum APIKeyValidationError: Error, LocalizedError { case emptyKey - case invalidURL case noModelsAvailable - case networkError(String) - case httpError(String) + case connection(String) var errorDescription: String? { switch self { case .emptyKey: return ErrorMessage.noAPIKey - case .invalidURL: - return ErrorMessage.invalidURL case .noModelsAvailable: return ErrorMessage.noModelsAvailable - case .networkError(let message): - return message - case .httpError(let message): + case .connection(let message): return message } } diff --git a/meetingnotes/Services/CoderAPIClient.swift b/meetingnotes/Services/CoderAPIClient.swift new file mode 100644 index 0000000..d84185c --- /dev/null +++ b/meetingnotes/Services/CoderAPIClient.swift @@ -0,0 +1,206 @@ +import Foundation + +struct CoderModel: Codable, Identifiable, Hashable { + let id: String + let name: String? + let ownedBy: String + let capabilities: [String] + + enum CodingKeys: String, CodingKey { + case id + case name + case ownedBy = "owned_by" + case capabilities + } + + var displayName: String { + let label = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return label.isEmpty ? id : label + } + + var supportsChat: Bool { capabilities.isEmpty || capabilities.contains("chat") } + var supportsTranscription: Bool { capabilities.contains("audio_transcription") } +} + +enum CoderAPIError: LocalizedError { + case invalidBaseURL + case missingAPIKey + case missingModel(String) + case invalidResponse + case serviceError(Int, String) + + var errorDescription: String? { + switch self { + case .invalidBaseURL: + return "Enter a valid Coder service URL." + case .missingAPIKey: + return "Enter the Coder service token." + case .missingModel(let purpose): + return "Select a Coder model for \(purpose)." + case .invalidResponse: + return "Coder returned an invalid response." + case .serviceError(let status, let message): + return "Coder request failed (\(status)): \(message)" + } + } +} + +final class CoderAPIClient { + static let shared = CoderAPIClient() + + private struct ModelsResponse: Decodable { + let data: [CoderModel] + } + + private struct ErrorEnvelope: Decodable { + struct ServiceError: Decodable { let message: String } + let error: ServiceError + } + + private struct TranscriptionResponse: Decodable { + let text: String + } + + private init() {} + + func models(baseURL: String, apiKey: String) async throws -> [CoderModel] { + var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models")) + request.setValue("Bearer \(try requiredAPIKey(apiKey))", forHTTPHeaderField: "Authorization") + let (data, response) = try await URLSession.shared.data(for: request) + try validate(response: response, data: data) + return try JSONDecoder().decode(ModelsResponse.self, from: data).data + } + + func models() async throws -> [CoderModel] { + try await models( + baseURL: UserDefaultsManager.shared.coderBaseURL, + apiKey: KeychainHelper.shared.getCoderAPIKey() ?? "" + ) + } + + func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("notes") } + let baseURL = UserDefaultsManager.shared.coderBaseURL + let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "") + var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "chat/completions")) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "model": selectedModel, + "messages": [ + ["role": "system", "content": systemPrompt], + ["role": "user", "content": "Create the meeting notes now."] + ], + "stream": true + ]) + + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw CoderAPIError.invalidResponse + } + guard (200...299).contains(httpResponse.statusCode) else { + throw CoderAPIError.serviceError(httpResponse.statusCode, HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)) + } + for try await line in bytes.lines { + guard line.hasPrefix("data:") else { continue } + let payload = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces) + if payload == "[DONE]" { break } + guard let data = payload.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let delta = choices.first?["delta"] as? [String: Any], + let content = delta["content"] as? String, + !content.isEmpty else { + continue + } + continuation.yield(content) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> String { + let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") } + let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "") + let boundary = "Meetingnotes-\(UUID().uuidString)" + let bodyURL = try makeMultipartBody( + audioURL: fileURL, + model: selectedModel, + language: language, + boundary: boundary + ) + defer { try? FileManager.default.removeItem(at: bodyURL) } + + var request = URLRequest(url: try endpoint(baseURL: UserDefaultsManager.shared.coderBaseURL, path: "audio/transcriptions")) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + if let attributes = try? FileManager.default.attributesOfItem(atPath: bodyURL.path), + let size = attributes[.size] as? NSNumber { + request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length") + } + let (data, response) = try await URLSession.shared.upload(for: request, fromFile: bodyURL) + try validate(response: response, data: data) + return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text + } + + private func endpoint(baseURL: String, path: String) throws -> URL { + guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host != nil else { + throw CoderAPIError.invalidBaseURL + } + var basePath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if basePath.isEmpty { basePath = "v1" } + components.path = "/\(basePath)/\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))" + guard let url = components.url else { throw CoderAPIError.invalidBaseURL } + return url + } + + private func requiredAPIKey(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw CoderAPIError.missingAPIKey } + return trimmed + } + + private func validate(response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { throw CoderAPIError.invalidResponse } + guard (200...299).contains(httpResponse.statusCode) else { + let message = (try? JSONDecoder().decode(ErrorEnvelope.self, from: data).error.message) + ?? HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) + throw CoderAPIError.serviceError(httpResponse.statusCode, message) + } + } + + private func makeMultipartBody(audioURL: URL, model: String, language: String, boundary: String) throws -> URL { + let bodyURL = FileManager.default.temporaryDirectory.appendingPathComponent("meetingnotes-upload-\(UUID().uuidString).body") + _ = FileManager.default.createFile(atPath: bodyURL.path, contents: nil) + let output = try FileHandle(forWritingTo: bodyURL) + defer { try? output.close() } + + func write(_ value: String) throws { + try output.write(contentsOf: Data(value.utf8)) + } + 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=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n") + let input = try FileHandle(forReadingFrom: audioURL) + defer { try? input.close() } + while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty { + try output.write(contentsOf: chunk) + } + try write("\r\n--\(boundary)--\r\n") + return bodyURL + } +} diff --git a/meetingnotes/Services/ErrorHandler.swift b/meetingnotes/Services/ErrorHandler.swift index 215f53e..519b9a0 100644 --- a/meetingnotes/Services/ErrorHandler.swift +++ b/meetingnotes/Services/ErrorHandler.swift @@ -1,5 +1,5 @@ // ErrorHandler.swift -// Centralized error handling service for OpenAI API and network errors +// Centralized error handling service for Coder API and network errors import Foundation @@ -9,7 +9,7 @@ class ErrorHandler { private init() {} - /// Handles errors from OpenAI API calls and network requests + /// Handles errors from Coder API calls and network requests /// - Parameter error: The error to handle /// - Returns: User-friendly error message func handleError(_ error: Error) -> String { @@ -23,10 +23,10 @@ class ErrorHandler { return handleHTTPError(httpError) } - // Handle OpenAI API errors by checking error description + // Handle common API errors by checking the description. let errorDescription = error.localizedDescription.lowercased() - if let openAIError = categorizeOpenAIError(errorDescription) { - return openAIError + if let apiError = categorizeAPIError(errorDescription) { + return apiError } // Generic error fallback @@ -125,9 +125,9 @@ class ErrorHandler { case .timedOut: return "Request timed out. Please try again." case .cannotFindHost: - return "Cannot reach OpenAI servers. Please check your internet connection." + return "Cannot reach the Coder service. Check its URL and network connection." case .cannotConnectToHost: - return "Cannot connect to OpenAI servers. Please check your internet connection." + return "Cannot connect to the Coder service. Check its URL and network connection." case .networkConnectionLost: return "Network connection lost. Please try again." case .httpTooManyRedirects: @@ -145,7 +145,7 @@ class ErrorHandler { return handleHTTPStatusCode(httpError.statusCode, message: httpError.message) } - private func categorizeOpenAIError(_ errorDescription: String) -> String? { + private func categorizeAPIError(_ errorDescription: String) -> String? { if errorDescription.contains("unauthorized") || errorDescription.contains("401") { return ErrorMessage.invalidAPIKey } else if errorDescription.contains("insufficient") || errorDescription.contains("402") { @@ -177,23 +177,23 @@ struct HTTPError: Error { /// Common error messages enum ErrorMessage { - static let noAPIKey = "OpenAI API key not found. Please configure your API key in Settings." + static let noAPIKey = "Coder service token not found. Configure the connection in Settings." static let noTemplate = "No template content found. Please select a valid template." static let noTranscript = "No transcript available. Please record some audio first." - static let connectionTimeout = "Failed to connect to OpenAI transcription service. Please check your internet connection and API key." + static let connectionTimeout = "Failed to connect to the Coder service. Check the URL, token, and network connection." static let configurationFailed = "Failed to configure transcription session." static let invalidURL = "Invalid API URL configuration." - static let noModelsAvailable = "No models available with your API key. Please check your account status." + static let noModelsAvailable = "Coder did not return any available models." // Centralized messages used across handlers static let success = "Success" static let badRequest = "Bad request. Please check your input." - static let invalidAPIKey = "Invalid OpenAI API key. Please check your API key in Settings." - static let insufficientFunds = "Insufficient funds in your OpenAI account. Please add credits to your account." + static let invalidAPIKey = "Invalid Coder service token. Check the token in Settings." + static let insufficientFunds = "The selected Coder provider has insufficient balance." static let accessForbidden = "Access forbidden. Please check your API key permissions." static let apiEndpointNotFound = "API endpoint not found. Please update the app." - static let rateLimited = "OpenAI API rate limit exceeded. Please try again later." - static let apiServerError = "OpenAI server error. Please try again later." + static let rateLimited = "The selected Coder provider is rate limited. Please try again later." + static let apiServerError = "Coder or its selected provider returned a server error." static let requestTimeout = "Request timeout. Please try again." static let requestTooLarge = "Request too large. Please try again." static let unsupportedData = "Unsupported data format. Please update the app." diff --git a/meetingnotes/Services/NotesGenerator.swift b/meetingnotes/Services/NotesGenerator.swift index baddb0a..978e03e 100644 --- a/meetingnotes/Services/NotesGenerator.swift +++ b/meetingnotes/Services/NotesGenerator.swift @@ -1,8 +1,7 @@ // NotesGenerator.swift -// Handles AI-powered note generation using OpenAI +// Handles AI-powered note generation through Coder import Foundation -import OpenAI /// Result type for note generation streaming enum GenerationResult { @@ -10,7 +9,7 @@ enum GenerationResult { case error(String) } -/// Generates meeting notes using OpenAI API +/// Generates meeting notes using the selected Coder model class NotesGenerator { static let shared = NotesGenerator() @@ -31,14 +30,14 @@ class NotesGenerator { return AsyncStream(GenerationResult.self) { continuation in Task { do { - guard let apiKey = KeychainHelper.shared.getAPIKey(), !apiKey.isEmpty else { + guard let apiKey = KeychainHelper.shared.getCoderAPIKey(), !apiKey.isEmpty else { continuation.yield(.error(ErrorMessage.noAPIKey)) continuation.finish() return } // Validate API key before proceeding - let validationResult = await APIKeyValidator.shared.validateAPIKey(apiKey) + let validationResult = await CoderAPIValidator.shared.validateAPIKey(apiKey) switch validationResult { case .failure(let error): continuation.yield(.error(error.localizedDescription)) @@ -48,8 +47,6 @@ class NotesGenerator { break } - let openAI = OpenAI(apiToken: apiKey) - // Create date formatter for meeting date let dateFormatter = DateFormatter() dateFormatter.dateStyle = .full @@ -90,18 +87,12 @@ class NotesGenerator { // Process the system prompt template let systemContent = Settings.processTemplate(systemPrompt, with: templateVariables) - let systemMessage = ChatQuery.ChatCompletionMessageParam(role: .system, content: systemContent)! - - print(systemContent) - - let query = ChatQuery(messages: [systemMessage], model: .gpt4_1) - - let stream: AsyncThrowingStream = openAI.chatsStream(query: query) - - for try await result in stream { - if let content = result.choices.first?.delta.content { - continuation.yield(.content(content)) - } + let stream = CoderAPIClient.shared.streamChat( + systemPrompt: systemContent, + model: UserDefaultsManager.shared.notesModel + ) + for try await content in stream { + continuation.yield(.content(content)) } continuation.finish() @@ -115,10 +106,10 @@ class NotesGenerator { } } - /// Validates if OpenAI API key is configured + /// Validates if the Coder service token is configured /// - Returns: True if API key exists, false otherwise func isConfigured() -> Bool { - guard let key = KeychainHelper.shared.getAPIKey(), + guard let key = KeychainHelper.shared.getCoderAPIKey(), !key.isEmpty else { return false } diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index 529726f..461b8ca 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -28,20 +28,24 @@ class MeetingViewModel: ObservableObject { // Computed property to determine if Generate button should animate var shouldAnimateGenerateButton: Bool { - let generateButtonEnabled = !meeting.transcript.isEmpty && !isGeneratingNotes && !isRecording && !isStartingRecording + let generateButtonEnabled = !meeting.transcript.isEmpty && !isGeneratingNotes && !isRecording && !isProcessing && !isStartingRecording let noEnhancedNotesYet = meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return generateButtonEnabled && noEnhancedNotesYet } - // Computed property to determine if Transcribe button should animate + // Computed property to determine if the record button should animate var shouldAnimateTranscribeButton: Bool { - return !isRecording && meeting.transcriptChunks.isEmpty && !isStartingRecording + return !isRecording && !isProcessing && meeting.transcriptChunks.isEmpty && !isStartingRecording } // Computed property that always uses the direct RecordingSessionManager check var isRecording: Bool { return recordingSessionManager.isRecordingMeeting(meeting.id) } + + var isProcessing: Bool { + return recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id + } @Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab @Published var isDeleted = false @@ -110,6 +114,12 @@ class MeetingViewModel: ObservableObject { } .store(in: &cancellables) + recordingSessionManager.$isProcessing + .sink { [weak self] _ in + self?.recordingStateChanged.toggle() + } + .store(in: &cancellables) + // Update error message when recording session manager encounters errors recordingSessionManager.$errorMessage .compactMap { $0 } @@ -130,7 +140,7 @@ class MeetingViewModel: ObservableObject { self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id) } - // Listen to real-time transcript updates for this meeting if it's being recorded + // Listen for final transcript updates for this meeting. recordingSessionManager.$activeRecordingTranscriptChunksUpdated .dropFirst() .sink { [weak self] updatedChunks in @@ -158,18 +168,20 @@ class MeetingViewModel: ObservableObject { var recordingButtonText: String { - // Use the same computed isRecording property for perfect consistency + if isProcessing { + return "Processing" + } if isRecording { return "Stop" } else { // Check if there's existing transcript content - return meeting.transcriptChunks.isEmpty ? "Transcribe" : "Resume" + return meeting.transcriptChunks.isEmpty ? "Record" : "Resume" } } func toggleRecording() { // Prevent duplicate actions while validating API key or starting recording - if isValidatingKey || isStartingRecording { return } + if isValidatingKey || isStartingRecording || isProcessing { return } // Use the same computed isRecording property for perfect consistency if isRecording { stopRecording() @@ -183,7 +195,7 @@ class MeetingViewModel: ObservableObject { isValidatingKey = true isStartingRecording = true Task { - let validationResult = await APIKeyValidator.shared.validateCurrentAPIKey() + let validationResult = await CoderAPIValidator.shared.validateCurrentAPIKey() defer { isValidatingKey = false } switch validationResult { @@ -201,8 +213,16 @@ class MeetingViewModel: ObservableObject { } func stopRecording() { - recordingSessionManager.stopRecording() - saveMeeting() + isStartingRecording = true + Task { + let chunks = await recordingSessionManager.stopRecording() + meeting.transcriptChunks = chunks + saveMeeting() + if !meeting.formattedTranscript.isEmpty { + await generateNotes() + } + isStartingRecording = false + } } func loadTemplates() { @@ -301,7 +321,7 @@ class MeetingViewModel: ObservableObject { // If this meeting is currently being recorded, stop the recording first if recordingSessionManager.isRecordingMeeting(meeting.id) { print("๐Ÿ›‘ Stopping recording for meeting being deleted: \(meeting.id)") - recordingSessionManager.stopRecording() + recordingSessionManager.cancelRecording() } let success = LocalStorageManager.shared.deleteMeeting(meeting) @@ -312,11 +332,11 @@ class MeetingViewModel: ObservableObject { } func deleteIfEmpty() { - if isEmpty && !isRecording { + if isEmpty && !isRecording && !isProcessing { print("๐Ÿ—‘๏ธ Auto-deleting empty meeting") deleteMeeting() } else { saveMeeting() } } -} \ No newline at end of file +} diff --git a/meetingnotes/ViewModels/SettingsViewModel.swift b/meetingnotes/ViewModels/SettingsViewModel.swift index cdf977d..d310fa1 100644 --- a/meetingnotes/ViewModels/SettingsViewModel.swift +++ b/meetingnotes/ViewModels/SettingsViewModel.swift @@ -7,15 +7,43 @@ class SettingsViewModel: ObservableObject { @Published var saveMessage = "" @Published var showingSaveMessage = false @Published var templates: [NoteTemplate] = [] + @Published var coderModels: [CoderModel] = [] + @Published var isLoadingModels = false + @Published var connectionMessage = "" init() { loadTemplates() } - /// Loads the API key from keychain (only called when actually needed) + /// Loads the Coder service token from Keychain. func loadAPIKey() { - if settings.openAIKey.isEmpty { - settings.openAIKey = KeychainHelper.shared.getAPIKey() ?? "" + if settings.coderAPIKey.isEmpty { + settings.coderAPIKey = KeychainHelper.shared.getCoderAPIKey() ?? "" + } + } + + @MainActor + func refreshModels() async { + isLoadingModels = true + connectionMessage = "" + defer { isLoadingModels = false } + do { + coderModels = try await CoderAPIClient.shared.models( + baseURL: settings.coderBaseURL, + apiKey: settings.coderAPIKey + ) + let chatModels = coderModels.filter(\.supportsChat) + let transcriptionModels = coderModels.filter(\.supportsTranscription) + if !chatModels.contains(where: { $0.id == settings.notesModel }), let first = chatModels.first { + settings.notesModel = first.id + } + if !transcriptionModels.contains(where: { $0.id == settings.transcriptionModel }), let first = transcriptionModels.first { + settings.transcriptionModel = first.id + } + connectionMessage = "Connected to Coder" + } catch { + coderModels = [] + connectionMessage = error.localizedDescription } } @@ -55,10 +83,10 @@ class SettingsViewModel: ObservableObject { // Only save API key to keychain - other values are automatically saved to UserDefaults // via computed properties when they're modified - let openAISaved = KeychainHelper.shared.saveAPIKey(settings.openAIKey) + let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey) if showMessage { - if openAISaved { + if coderSaved { saveMessage = "Settings saved successfully!" } else { saveMessage = "Error saving settings" diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index 5fcdbae..2d3a942 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -321,7 +321,7 @@ struct MeetingDetailContentView: View { ) } .buttonStyle(.plain) - .disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isStartingRecording) + .disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording) .help("Generate enhanced notes using a template") // Recording Button @@ -329,8 +329,14 @@ struct MeetingDetailContentView: View { viewModel.toggleRecording() }) { HStack(spacing: 4) { - Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle") - .foregroundColor(viewModel.isRecording ? .red : .accentColor) + if viewModel.isProcessing { + ProgressView() + .scaleEffect(0.55) + .frame(width: 14, height: 14) + } else { + Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle") + .foregroundColor(viewModel.isRecording ? .red : .accentColor) + } Text(viewModel.recordingButtonText) } .frame(minWidth: 110, minHeight: 36) @@ -347,7 +353,7 @@ struct MeetingDetailContentView: View { ) } .buttonStyle(.plain) - .disabled(cannotStartRecording || viewModel.isValidatingKey || viewModel.isStartingRecording) + .disabled(cannotStartRecording || viewModel.isValidatingKey || viewModel.isStartingRecording || viewModel.isProcessing) .help(cannotStartRecording ? "Another meeting is currently being recorded" : "Start or stop recording for this meeting") } } diff --git a/meetingnotes/Views/OnboardingView.swift b/meetingnotes/Views/OnboardingView.swift index 67e6a3d..4ce14b7 100644 --- a/meetingnotes/Views/OnboardingView.swift +++ b/meetingnotes/Views/OnboardingView.swift @@ -43,33 +43,60 @@ struct OnboardingView: View { } .frame(maxWidth: .infinity, alignment: .leading) - // API Key Section + // Coder service VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 4) { - Text("OpenAI API Key") + Text("Coder Service") .font(.title2) .fontWeight(.semibold) - Text("Uses gpt-4o-mini-transcribe and gpt-4.1. Typical cost is ~$0.20/hour. Your Mac communicates directly with OpenAI.") + Text("Meeting audio is transcribed after recording, then summarized with the Coder models you select.") .font(.body) - .foregroundColor(.secondary) + .foregroundColor(.secondary) } - Button("Get API Key from OpenAI") { - if let url = URL(string: "https://platform.openai.com/api-keys") { - NSWorkspace.shared.open(url) - } - } - .buttonStyle(.link) + TextField("http://coder-host:8787/v1", text: $settingsViewModel.settings.coderBaseURL) + .textFieldStyle(.roundedBorder) + .font(.body) - SecureField("OpenAI API Key", text: $apiKey) + SecureField("Coder service token", text: $apiKey) .textFieldStyle(.roundedBorder) .font(.body) + HStack { + Button { + settingsViewModel.settings.coderAPIKey = apiKey + Task { await settingsViewModel.refreshModels() } + } label: { + Label(settingsViewModel.isLoadingModels ? "Connecting" : "Connect", systemImage: "arrow.clockwise") + } + .disabled(settingsViewModel.isLoadingModels) + + if !settingsViewModel.connectionMessage.isEmpty { + Text(settingsViewModel.connectionMessage) + .font(.caption) + .foregroundColor(settingsViewModel.coderModels.isEmpty ? .red : .secondary) + } + } + + if !settingsViewModel.coderModels.isEmpty { + Picker("Notes model", selection: $settingsViewModel.settings.notesModel) { + ForEach(settingsViewModel.coderModels.filter(\.supportsChat)) { model in + Text(model.displayName).tag(model.id) + } + } + + Picker("Transcription model", selection: $settingsViewModel.settings.transcriptionModel) { + ForEach(settingsViewModel.coderModels.filter(\.supportsTranscription)) { model in + Text(model.displayName).tag(model.id) + } + } + } + HStack { Image(systemName: "info.circle") .foregroundColor(.blue) - Text("Stored locally and encrypted in Keychain.") + Text("The token is stored locally in Keychain. The app does not connect directly to an AI provider.") .font(.caption) .foregroundColor(.secondary) } @@ -130,7 +157,7 @@ struct OnboardingView: View { Spacer() Button("Get Started") { // Complete onboarding - settingsViewModel.settings.openAIKey = apiKey + settingsViewModel.settings.coderAPIKey = apiKey settingsViewModel.completeOnboarding() } .buttonStyle(.borderedProminent) @@ -155,7 +182,7 @@ struct OnboardingView: View { .onAppear { checkPermissions() settingsViewModel.loadAPIKey() - apiKey = settingsViewModel.settings.openAIKey + apiKey = settingsViewModel.settings.coderAPIKey hasAcceptedTerms = settingsViewModel.settings.hasAcceptedTerms } .onChange(of: audioRecordingPermission.status) { oldValue, newValue in @@ -172,6 +199,8 @@ struct OnboardingView: View { return micPermissionGranted && systemAudioPermissionGranted && !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !settingsViewModel.coderModels.filter(\.supportsChat).isEmpty && + !settingsViewModel.coderModels.filter(\.supportsTranscription).isEmpty && hasAcceptedTerms } diff --git a/meetingnotes/Views/SettingsView.swift b/meetingnotes/Views/SettingsView.swift index b7e1d01..c174456 100644 --- a/meetingnotes/Views/SettingsView.swift +++ b/meetingnotes/Views/SettingsView.swift @@ -13,19 +13,50 @@ struct SettingsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - // API Configuration Section + // Coder service configuration VStack(alignment: .leading, spacing: 8) { - Text("OpenAI API Key") + Text("Coder Service") .font(.headline) .foregroundColor(.primary) - Text("Stored locally and encrypted in Keychain.") + TextField("http://coder-host:8787/v1", text: $viewModel.settings.coderBaseURL) + .textFieldStyle(.roundedBorder) + + SecureField("Service token", text: $viewModel.settings.coderAPIKey) + .textFieldStyle(.roundedBorder) + + HStack { + Button { + Task { await viewModel.refreshModels() } + } label: { + Label(viewModel.isLoadingModels ? "Loading" : "Refresh Models", systemImage: "arrow.clockwise") + } + .disabled(viewModel.isLoadingModels) + + if !viewModel.connectionMessage.isEmpty { + Text(viewModel.connectionMessage) + .font(.caption) + .foregroundColor(viewModel.coderModels.isEmpty ? .red : .secondary) + } + } + + if !viewModel.coderModels.isEmpty { + Picker("Notes model", selection: $viewModel.settings.notesModel) { + ForEach(viewModel.coderModels.filter(\.supportsChat)) { model in + Text(model.displayName).tag(model.id) + } + } + + Picker("Transcription model", selection: $viewModel.settings.transcriptionModel) { + ForEach(viewModel.coderModels.filter(\.supportsTranscription)) { model in + Text(model.displayName).tag(model.id) + } + } + } + + Text("The token is stored locally in Keychain. Audio and note generation are sent only to this Coder service.") .font(.caption) .foregroundColor(.secondary) - - SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) } // Note Templates Section: only the Manage Templates button @@ -113,7 +144,7 @@ struct SettingsView: View { // Link to GitHub repository Link("GitHub", - destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!) + destination: URL(string: "https://github.com/superdooper86/meetingnotes")!) .foregroundColor(.blue) // Link to landing page @@ -171,6 +202,7 @@ struct SettingsView: View { .onAppear { viewModel.loadTemplates() viewModel.loadAPIKey() + Task { await viewModel.refreshModels() } } .onDisappear { DispatchQueue.main.async {