From 297bb40b89afca264afea6fce4113fc4794d46be Mon Sep 17 00:00:00 2001 From: Owen Gretzinger Date: Thu, 10 Jul 2025 16:45:19 -0400 Subject: [PATCH] feat: actually implement system audio + refactor --- .gitignore | 123 ++++++ notetaker.xcodeproj/project.pbxproj | 2 + notetaker/AudioManager.swift | 196 --------- notetaker/ContentView.swift | 90 +--- notetaker/Info.plist | 2 + notetaker/KeychainHelper.swift | 42 -- notetaker/Managers/AudioManager.swift | 392 ++++++++++++++++++ notetaker/Managers/KeychainHelper.swift | 70 ++++ notetaker/Managers/LocalStorageManager.swift | 112 +++++ notetaker/Models/Meeting.swift | 21 + notetaker/Models/Settings.swift | 20 + notetaker/Services/NotesGenerator.swift | 59 +++ notetaker/SimpleAudioManager.swift | 298 ------------- .../ViewModels/MeetingListViewModel.swift | 35 ++ notetaker/ViewModels/MeetingViewModel.swift | 85 ++++ notetaker/ViewModels/SettingsViewModel.swift | 51 +++ notetaker/Views/MeetingDetailView.swift | 136 ++++++ notetaker/Views/MeetingListView.swift | 78 ++++ notetaker/Views/SettingsView.swift | 96 +++++ notetaker/notetaker.entitlements | 12 +- notetaker/notetakerApp.swift | 2 + 21 files changed, 1307 insertions(+), 615 deletions(-) create mode 100644 .gitignore delete mode 100644 notetaker/AudioManager.swift delete mode 100644 notetaker/KeychainHelper.swift create mode 100644 notetaker/Managers/AudioManager.swift create mode 100644 notetaker/Managers/KeychainHelper.swift create mode 100644 notetaker/Managers/LocalStorageManager.swift create mode 100644 notetaker/Models/Meeting.swift create mode 100644 notetaker/Models/Settings.swift create mode 100644 notetaker/Services/NotesGenerator.swift delete mode 100644 notetaker/SimpleAudioManager.swift create mode 100644 notetaker/ViewModels/MeetingListViewModel.swift create mode 100644 notetaker/ViewModels/MeetingViewModel.swift create mode 100644 notetaker/ViewModels/SettingsViewModel.swift create mode 100644 notetaker/Views/MeetingDetailView.swift create mode 100644 notetaker/Views/MeetingListView.swift create mode 100644 notetaker/Views/SettingsView.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8925cf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,123 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata dir, do not check in +# Package.swift +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# API Keys and sensitive data +*.plist.local +APIKeys.swift +.env +.env.local diff --git a/notetaker.xcodeproj/project.pbxproj b/notetaker.xcodeproj/project.pbxproj index ae4afbd..a1320eb 100644 --- a/notetaker.xcodeproj/project.pbxproj +++ b/notetaker.xcodeproj/project.pbxproj @@ -277,6 +277,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"notetaker/Preview Content\""; DEVELOPMENT_TEAM = ML6HYR5LUR; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = notetaker/Info.plist; @@ -307,6 +308,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"notetaker/Preview Content\""; DEVELOPMENT_TEAM = ML6HYR5LUR; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = notetaker/Info.plist; diff --git a/notetaker/AudioManager.swift b/notetaker/AudioManager.swift deleted file mode 100644 index 2e3cf57..0000000 --- a/notetaker/AudioManager.swift +++ /dev/null @@ -1,196 +0,0 @@ -// AudioManager.swift -import AVFoundation -import Foundation -import SwiftUI -import ScreenCaptureKit - -class AudioManager: NSObject, ObservableObject { - @Published var transcript = "" - @Published var isRecording = false - - private var stream: SCStream? - private var webSocketTask: URLSessionWebSocketTask? - private let deepgramURL = URL(string: "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=48000&channels=1&interim_results=true")! - - func startRecording() { - // Check if ScreenCaptureKit is available - guard #available(macOS 13.0, *) else { - print("ScreenCaptureKit requires macOS 13.0 or later") - return - } - - Task { - do { - // Request permission for screen recording (includes system audio) - let canRecord = await requestScreenRecordingPermission() - if !canRecord { - print("Screen recording permission denied") - DispatchQueue.main.async { - self.isRecording = false - } - return - } - - // Set up screen capture configuration - let config = SCStreamConfiguration() - config.capturesAudio = true - config.captureMicrophone = true - config.sampleRate = 48000 - config.channelCount = 1 - - // Create content filter for desktop audio capture - let availableContent = try await SCShareableContent.current - guard let display = availableContent.displays.first else { - print("No displays available for capture") - DispatchQueue.main.async { - self.isRecording = false - } - return - } - let filter = SCContentFilter(display: display, excludingWindows: []) - - // Create stream - stream = SCStream(filter: filter, configuration: config, delegate: self) - - // Add audio output - try stream?.addStreamOutput(self, type: .audio, sampleHandlerQueue: DispatchQueue(label: "AudioQueue")) - try stream?.addStreamOutput(self, type: .microphone, sampleHandlerQueue: DispatchQueue(label: "MicQueue")) - - // Start capture - try await stream?.startCapture() - - DispatchQueue.main.async { - self.isRecording = true - } - - // Connect to Deepgram - connectToDeepgram() - - } catch { - print("Failed to start recording: \(error)") - DispatchQueue.main.async { - self.isRecording = false - } - } - } - } - - func stopRecording() { - Task { - do { - try await stream?.stopCapture() - stream = nil - webSocketTask?.cancel(with: .normalClosure, reason: nil) - webSocketTask = nil - - DispatchQueue.main.async { - self.isRecording = false - } - } catch { - print("Failed to stop recording: \(error)") - } - } - } - - private func requestScreenRecordingPermission() async -> Bool { - // ScreenCaptureKit handles permission requests automatically - // This will show a system dialog if needed - do { - let _ = try await SCShareableContent.current - return true - } catch { - return false - } - } - - private func connectToDeepgram() { - let session = URLSession(configuration: .default) - var request = URLRequest(url: deepgramURL) - if let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty { - request.addValue("Token \(key)", forHTTPHeaderField: "Authorization") - } else { - print("No Deepgram key found") - return - } - webSocketTask = session.webSocketTask(with: request) - webSocketTask?.resume() - receiveMessage() - } - - private func sendAudioData(_ data: Data) { - webSocketTask?.send(.data(data)) { error in - if let error = error { - print("Send error: \(error)") - } - } - } - - private func receiveMessage() { - webSocketTask?.receive { result in - switch result { - case .success(let message): - switch message { - case .string(let text): - self.parseTranscription(text) - case .data: - break - @unknown default: - break - } - self.receiveMessage() // Continue receiving - case .failure(let error): - print("Receive error: \(error)") - } - } - } - - private func parseTranscription(_ text: String) { - guard let data = text.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = json["type"] as? String, type == "Results", - let channel = json["channel"] as? [String: Any], - let alternatives = channel["alternatives"] as? [[String: Any]], - let alt = alternatives.first, - let transcript = alt["transcript"] as? String, - !transcript.isEmpty else { return } - - DispatchQueue.main.async { - if let isFinal = json["is_final"] as? Bool, isFinal { - self.transcript += transcript + " " - } - } - } -} - -// MARK: - SCStreamDelegate -extension AudioManager: SCStreamDelegate { - func stream(_ stream: SCStream, didStopWithError error: Error) { - print("Stream stopped with error: \(error)") - DispatchQueue.main.async { - self.isRecording = false - } - } -} - -// MARK: - SCStreamOutput -extension AudioManager: SCStreamOutput { - func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { - // Convert sample buffer to audio data and send to Deepgram - guard let audioBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { - print("Failed to get data buffer from sample buffer") - return - } - - var length: Int = 0 - var dataPointer: UnsafeMutablePointer? - let status = CMBlockBufferGetDataPointer(audioBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) - - guard status == noErr, let pointer = dataPointer, length > 0 else { - print("Failed to get audio data pointer, status: \(status), length: \(length)") - return - } - - let data = Data(bytes: pointer, count: length) - sendAudioData(data) - } -} \ No newline at end of file diff --git a/notetaker/ContentView.swift b/notetaker/ContentView.swift index c505f9b..26debea 100644 --- a/notetaker/ContentView.swift +++ b/notetaker/ContentView.swift @@ -8,80 +8,24 @@ import SwiftUI struct ContentView: View { - @StateObject private var audioManager = SimpleAudioManager() - @State private var deepgramKey = "" - @State private var openAIKey = "" - @State private var userBlurb = "" - @State private var systemPrompt = "Generate meeting notes from transcript and notes." - - var body: some View { - VStack(spacing: 20) { - Text("Notetaker Settings") - .font(.title) - - TextField("Deepgram API Key", text: $deepgramKey) - TextField("OpenAI API Key", text: $openAIKey) - TextField("About Yourself (for prompt)", text: $userBlurb) - TextEditor(text: $systemPrompt) - .frame(height: 100) - - Button("Save") { - saveSettings() - } - - Divider() - - Toggle("Capture System Audio", isOn: $audioManager.captureSystemAudio) - .disabled(audioManager.isRecording) - - HStack { - Button("Start Recording") { - audioManager.startRecording() - } - .disabled(audioManager.isRecording) - - Button("Stop Recording") { - audioManager.stopRecording() - } - .disabled(!audioManager.isRecording) - } - - if audioManager.isRecording { - Text(audioManager.captureSystemAudio ? "🔴 Recording microphone + system audio..." : "🔴 Recording microphone...") - .foregroundColor(.red) - } - - Text("Live Transcript:") - .font(.headline) - - ScrollView { - Text(audioManager.transcript.isEmpty ? "No transcript yet..." : audioManager.transcript) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } - .frame(height: 150) - .border(Color.gray) - } - .padding() - .frame(minWidth: 500, minHeight: 600) - .onAppear { - loadSettings() - } - } + @State private var showingSettings = false - func saveSettings() { - _ = KeychainHelper.shared.save(deepgramKey, forKey: "deepgramKey") - _ = KeychainHelper.shared.save(openAIKey, forKey: "openAIKey") - _ = KeychainHelper.shared.save(userBlurb, forKey: "userBlurb") - _ = KeychainHelper.shared.save(systemPrompt, forKey: "systemPrompt") - print("Settings saved!") - } - - private func loadSettings() { - deepgramKey = KeychainHelper.shared.get(forKey: "deepgramKey") ?? "" - openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? "" - userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? "" - systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? "Generate meeting notes from transcript and notes." + var body: some View { + VStack { + MeetingListView() + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + showingSettings = true + } label: { + Label("Settings", systemImage: "gear") + } + } + } + } + .sheet(isPresented: $showingSettings) { + SettingsView() + } } } diff --git a/notetaker/Info.plist b/notetaker/Info.plist index 86e8d89..f8182fc 100644 --- a/notetaker/Info.plist +++ b/notetaker/Info.plist @@ -4,5 +4,7 @@ NSAudioCaptureUsageDescription Notetaker needs access to capture system audio for transcription. + NSScreenCaptureUsageDescription + Notetaker needs screen recording permission to capture system audio for transcription. diff --git a/notetaker/KeychainHelper.swift b/notetaker/KeychainHelper.swift deleted file mode 100644 index 33478dd..0000000 --- a/notetaker/KeychainHelper.swift +++ /dev/null @@ -1,42 +0,0 @@ -// KeychainHelper.swift -// Helper for secure storage - -import Foundation -import Security - -class KeychainHelper { - static let shared = KeychainHelper() - - private init() {} - - func save(_ value: String, forKey key: String) -> Bool { - guard let data = value.data(using: .utf8) else { return false } - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: data, - kSecAttrService as String: "owen.notetaker" // Replace with your bundle ID - ] - - SecItemDelete(query as CFDictionary) // Delete if exists - let status = SecItemAdd(query as CFDictionary, nil) - return status == errSecSuccess - } - - func get(forKey key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecAttrService as String: "owen.notetaker", - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true - ] - - var item: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &item) - - guard status == errSecSuccess, let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) - } -} \ No newline at end of file diff --git a/notetaker/Managers/AudioManager.swift b/notetaker/Managers/AudioManager.swift new file mode 100644 index 0000000..7412793 --- /dev/null +++ b/notetaker/Managers/AudioManager.swift @@ -0,0 +1,392 @@ +// AudioManager.swift +// Unified audio manager for microphone and system audio capture + +import AVFoundation +import Foundation +import SwiftUI +import ScreenCaptureKit + +// Distinguish which source (mic vs system) an audio buffer belongs to +private enum AudioSource { + case mic + case system +} + +/// Manages audio capture from microphone and/or system audio and handles real-time transcription via Deepgram +class AudioManager: NSObject, ObservableObject { + @Published var transcript = "" + @Published var isRecording = false + @Published var captureSystemAudio = true + + private var audioEngine = AVAudioEngine() + private var micSocketTask: URLSessionWebSocketTask? + private var systemSocketTask: URLSessionWebSocketTask? + private let deepgramURL = URL(string: "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&interim_results=true")! + + // ScreenCaptureKit properties + private var stream: SCStream? + + func startRecording() { + print("Starting recording...") + + // First ensure everything is stopped and cleaned up + stopRecordingInternal() + + if captureSystemAudio { + // Start microphone capture in parallel with system audio + startMicrophoneTap() + // Start system audio capture asynchronously + Task { + await startSystemAudioCapture() + } + } else { + startMicrophoneOnly() + } + } + + private func stopRecordingInternal() { + print("Internal cleanup...") + + // Stop system audio capture + if let stream = stream { + stream.stopCapture() + self.stream = nil + print("System audio capture stopped") + } + + // Stop microphone capture + cleanupAudioEngine() + + // Close WebSocket + micSocketTask?.cancel(with: .normalClosure, reason: nil) + micSocketTask = nil + systemSocketTask?.cancel(with: .normalClosure, reason: nil) + systemSocketTask = nil + + // Reset state + DispatchQueue.main.async { + self.isRecording = false + } + + print("Internal cleanup completed") + } + + private func startMicrophoneOnly() { + print("Starting microphone-only recording...") + + // Ensure audio engine is stopped and cleaned up + cleanupAudioEngine() + + // Set up audio engine for microphone capture + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + + // Convert to the format Deepgram expects: 16kHz, 16-bit, mono + let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: false)! + + let converter = AVAudioConverter(from: recordingFormat, to: targetFormat)! + + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] (buffer, time) in + self?.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic) + } + + audioEngine.prepare() + + do { + try audioEngine.start() + DispatchQueue.main.async { + self.isRecording = true + } + connectToDeepgram(source: .mic) + print("Microphone recording started") + } catch { + print("Failed to start audio engine: \(error)") + } + } + + /// Starts a microphone tap without creating a new Deepgram connection (used when also capturing system audio) + private func startMicrophoneTap() { + // Ensure audio engine is stopped and cleaned up + cleanupAudioEngine() + + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + + let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: false)! + + let converter = AVAudioConverter(from: recordingFormat, to: targetFormat)! + + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in + // Debug mic RMS + 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)) + print("🎤 Mic RMS: \(rms)") + } + self?.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic) + } + + audioEngine.prepare() + + do { + try audioEngine.start() + connectToDeepgram(source: .mic) + } catch { + print("Failed to start microphone tap: \(error)") + } + } + + private func cleanupAudioEngine() { + if audioEngine.isRunning { + audioEngine.stop() + } + + // Reset the audio engine - this safely removes all taps + audioEngine.reset() + } + + private func startSystemAudioCapture() async { + print("🎧 Starting system audio capture...") + + do { + // Request screen capture permission + let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) + + // Exclude self to avoid feedback + let excludedApps = content.applications.filter { app in + Bundle.main.bundleIdentifier == app.bundleIdentifier + } + + guard let display = content.displays.first else { + print("❌ No display found") + startMicrophoneOnly() + return + } + + // Create filter + let filter = SCContentFilter(display: display, excludingApplications: excludedApps, exceptingWindows: []) + + // Configure stream + let configuration = SCStreamConfiguration() + configuration.width = 2 // Minimal video settings + configuration.height = 2 + configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale.max) + configuration.capturesAudio = true + configuration.sampleRate = 48000 + configuration.channelCount = 2 + + // Create stream + let stream = SCStream(filter: filter, configuration: configuration, delegate: self) + + // Add stream output for audio processing + try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: .global(qos: .userInitiated)) + // Add a minimal screen output so SCStream doesn't complain about missing video output + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .global(qos: .userInitiated)) + + // Start capture + try await stream.startCapture() + + // Store reference + self.stream = stream + + DispatchQueue.main.async { + self.isRecording = true + } + + connectToDeepgram(source: .system) + print("✅ System audio capture started successfully") + + } catch { + print("❌ Failed to start system audio capture: \(error)") + + if case SCStreamError.userDeclined = error { + print("📍 Permission denied. User needs to enable screen recording in System Settings.") + } + + // Fallback to microphone only + startMicrophoneOnly() + } + } + + func stopRecording() { + print("Stopping recording...") + + // Stop system audio capture + if let stream = stream { + stream.stopCapture() + self.stream = nil + } + + // Stop microphone capture + cleanupAudioEngine() + + // Close WebSocket + micSocketTask?.cancel(with: .normalClosure, reason: nil) + micSocketTask = nil + systemSocketTask?.cancel(with: .normalClosure, reason: nil) + systemSocketTask = nil + + DispatchQueue.main.async { + self.isRecording = false + } + + print("Recording stopped") + } + + private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat, source: AudioSource) { + var processBuffer = buffer + + // Convert to target format (16kHz 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 Deepgram + 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 connectToDeepgram(source: AudioSource) { + guard let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty else { + print("❌ No Deepgram key found") + return + } + + let session = URLSession(configuration: .default) + var request = URLRequest(url: deepgramURL) + request.addValue("Token \(key)", forHTTPHeaderField: "Authorization") + + let task = session.webSocketTask(with: request) + task.resume() + + switch source { + case .mic: + micSocketTask = task + case .system: + systemSocketTask = task + } + + receiveMessage(for: source) + print("🌐 Connected to Deepgram (\(source))") + } + + private func receiveMessage(for source: AudioSource) { + 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?.parseTranscription(text, source: source) + case .data: + break + @unknown default: + break + } + self?.receiveMessage(for: source) // continue loop + case .failure(let error): + print("❌ Receive error (\(source)): \(error)") + // Attempt reconnect if still recording + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + if self?.isRecording == true { + self?.connectToDeepgram(source: source) + } + } + } + } + } + + private func parseTranscription(_ text: String, source: AudioSource) { + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["type"] as? String, type == "Results", + let channel = json["channel"] as? [String: Any], + let alternatives = channel["alternatives"] as? [[String: Any]], + let alt = alternatives.first, + let transcriptText = alt["transcript"] as? String, + !transcriptText.isEmpty else { return } + + let prefix = (source == .mic) ? "[MIC]" : "[SYS]" + DispatchQueue.main.async { + if let isFinal = json["is_final"] as? Bool, isFinal { + self.transcript += "\(prefix) \(transcriptText) " + } + } + } + + private func sendAudioData(_ data: Data, source: AudioSource) { + let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask + + guard let socket = task, socket.state == .running else { return } + + socket.send(.data(data)) { error in + if let error = error { + print("❌ Send error (\(source)): \(error)") + } + } + } +} + +// MARK: - SCStreamDelegate & SCStreamOutput +extension AudioManager: SCStreamDelegate, SCStreamOutput { + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .audio else { return } + guard sampleBuffer.isValid else { return } + + // Convert CMSampleBuffer to AVAudioPCMBuffer + guard let pcmBuffer = sampleBuffer.asPCMBuffer else { return } + + // Create converter for Deepgram format + let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: false)! + + guard let converter = AVAudioConverter(from: pcmBuffer.format, to: targetFormat) else { return } + + processAudioBuffer(pcmBuffer, converter: converter, targetFormat: targetFormat, source: .system) + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + print("❌ Stream stopped with error: \(error)") + DispatchQueue.main.async { + self.stream = nil + self.isRecording = false + } + } +} + +// MARK: - CMSampleBuffer Extension +extension CMSampleBuffer { + var asPCMBuffer: AVAudioPCMBuffer? { + try? self.withAudioBufferList { audioBufferList, _ -> AVAudioPCMBuffer? in + guard let absd = self.formatDescription?.audioStreamBasicDescription else { return nil } + guard let format = AVAudioFormat(standardFormatWithSampleRate: absd.mSampleRate, channels: absd.mChannelsPerFrame) else { return nil } + return AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: audioBufferList.unsafePointer) + } + } +} \ No newline at end of file diff --git a/notetaker/Managers/KeychainHelper.swift b/notetaker/Managers/KeychainHelper.swift new file mode 100644 index 0000000..c8e879d --- /dev/null +++ b/notetaker/Managers/KeychainHelper.swift @@ -0,0 +1,70 @@ +// KeychainHelper.swift +// Secure storage helper for API keys and sensitive data + +import Foundation +import Security + +/// Manages secure storage of sensitive data using the macOS Keychain +class KeychainHelper { + static let shared = KeychainHelper() + + private let serviceName = "owen.notetaker" + + private init() {} + + /// Saves a string value to the keychain + /// - Parameters: + /// - value: The string value to save + /// - key: The key to save the value under + /// - Returns: True if the save was successful, false otherwise + func save(_ value: String, forKey key: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrService as String: serviceName + ] + + // Delete any existing item + SecItemDelete(query as CFDictionary) + + // Add the new item + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + /// Retrieves a string value from the keychain + /// - Parameter key: The key to retrieve the value for + /// - Returns: The string value if found, nil otherwise + func get(forKey key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: serviceName, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true + ] + + var item: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Deletes a value from the keychain + /// - Parameter key: The key to delete + /// - Returns: True if the deletion was successful, false otherwise + func delete(forKey key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: serviceName + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess + } +} \ No newline at end of file diff --git a/notetaker/Managers/LocalStorageManager.swift b/notetaker/Managers/LocalStorageManager.swift new file mode 100644 index 0000000..3e1772a --- /dev/null +++ b/notetaker/Managers/LocalStorageManager.swift @@ -0,0 +1,112 @@ +// LocalStorageManager.swift +// Handles local storage of meetings and app data + +import Foundation + +/// Manages local file storage for meetings and app data +class LocalStorageManager { + static let shared = LocalStorageManager() + + private let documentsDirectory: URL + private let meetingsDirectory: URL + + private init() { + // Get the app's documents directory + documentsDirectory = FileManager.default.urls(for: .documentDirectory, + in: .userDomainMask).first! + + // Create meetings subdirectory + meetingsDirectory = documentsDirectory.appendingPathComponent("Meetings") + + // Ensure directory exists + try? FileManager.default.createDirectory(at: meetingsDirectory, + withIntermediateDirectories: true) + } + + // MARK: - Meeting Management + + /// Saves a meeting to local storage + /// - Parameter meeting: The meeting to save + /// - Returns: True if successful, false otherwise + func saveMeeting(_ meeting: Meeting) -> Bool { + let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json") + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + encoder.dateEncodingStrategy = .iso8601 + + let data = try encoder.encode(meeting) + try data.write(to: fileURL) + + print("✅ Saved meeting: \(meeting.id)") + return true + } catch { + print("❌ Failed to save meeting: \(error)") + return false + } + } + + /// Loads all meetings from local storage + /// - Returns: Array of meetings, sorted by date (newest first) + func loadMeetings() -> [Meeting] { + do { + let fileURLs = try FileManager.default.contentsOfDirectory(at: meetingsDirectory, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let meetings = fileURLs.compactMap { url -> Meeting? in + guard let data = try? Data(contentsOf: url), + let meeting = try? decoder.decode(Meeting.self, from: data) else { + print("⚠️ Failed to decode meeting at: \(url)") + return nil + } + return meeting + } + + return meetings.sorted { $0.date > $1.date } + } catch { + print("❌ Failed to load meetings: \(error)") + return [] + } + } + + /// Deletes a meeting from local storage + /// - Parameter meeting: The meeting to delete + /// - Returns: True if successful, false otherwise + func deleteMeeting(_ meeting: Meeting) -> Bool { + let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json") + + do { + try FileManager.default.removeItem(at: fileURL) + print("✅ Deleted meeting: \(meeting.id)") + return true + } catch { + print("❌ Failed to delete meeting: \(error)") + return false + } + } + + // MARK: - Settings Management + + /// Saves non-sensitive settings to local storage + /// - Parameter settings: The settings to save (sensitive data should use Keychain) + func saveSettings(_ settings: Settings) -> Bool { + // For now, all settings are stored in Keychain + // This method is here for future non-sensitive settings + return true + } + + /// Gets the app's documents directory URL + var documentsDirectoryURL: URL { + documentsDirectory + } + + /// Gets the meetings directory URL + var meetingsDirectoryURL: URL { + meetingsDirectory + } +} \ No newline at end of file diff --git a/notetaker/Models/Meeting.swift b/notetaker/Models/Meeting.swift new file mode 100644 index 0000000..8d3949d --- /dev/null +++ b/notetaker/Models/Meeting.swift @@ -0,0 +1,21 @@ +import Foundation + +struct Meeting: Codable, Identifiable, Hashable { + let id: UUID + let date: Date + var transcript: String + var userNotes: String + var generatedNotes: String + + init(id: UUID = UUID(), + date: Date = Date(), + transcript: String = "", + userNotes: String = "", + generatedNotes: String = "") { + self.id = id + self.date = date + self.transcript = transcript + self.userNotes = userNotes + self.generatedNotes = generatedNotes + } +} \ No newline at end of file diff --git a/notetaker/Models/Settings.swift b/notetaker/Models/Settings.swift new file mode 100644 index 0000000..5f00418 --- /dev/null +++ b/notetaker/Models/Settings.swift @@ -0,0 +1,20 @@ +import Foundation + +struct Settings: Codable { + var deepgramKey: String + var openAIKey: String + var userBlurb: String + var systemPrompt: String + + static let defaultSystemPrompt = "Generate concise meeting notes from the transcript and any additional notes provided. Focus on key points, action items, and decisions made." + + init(deepgramKey: String = "", + openAIKey: String = "", + userBlurb: String = "", + systemPrompt: String = Settings.defaultSystemPrompt) { + self.deepgramKey = deepgramKey + self.openAIKey = openAIKey + self.userBlurb = userBlurb + self.systemPrompt = systemPrompt + } +} \ No newline at end of file diff --git a/notetaker/Services/NotesGenerator.swift b/notetaker/Services/NotesGenerator.swift new file mode 100644 index 0000000..17e472e --- /dev/null +++ b/notetaker/Services/NotesGenerator.swift @@ -0,0 +1,59 @@ +// NotesGenerator.swift +// Handles AI-powered note generation using OpenAI + +import Foundation + +/// Generates meeting notes using OpenAI API +class NotesGenerator { + static let shared = NotesGenerator() + + private init() {} + + /// Generates meeting notes from transcript and user notes + /// - Parameters: + /// - transcript: The meeting transcript + /// - userNotes: Additional notes from the user + /// - userBlurb: Information about the user for context + /// - systemPrompt: The system prompt for AI generation + /// - Returns: Generated meeting notes + func generateNotes(transcript: String, + userNotes: String, + userBlurb: String, + systemPrompt: String) async throws -> String { + + // TODO: Implement OpenAI API integration + // For now, return a placeholder + + // Simulate API delay + try await Task.sleep(nanoseconds: 2_000_000_000) + + // Placeholder response + return """ + # Meeting Notes + + ## Summary + This is a placeholder for AI-generated meeting notes. + + ## Key Points + - Transcript length: \(transcript.count) characters + - User notes provided: \(!userNotes.isEmpty) + + ## Action Items + - Implement OpenAI API integration + - Add proper prompt engineering + + ## Next Steps + Once OpenAI API is integrated, this will provide comprehensive meeting notes based on the transcript and any additional context provided. + """ + } + + /// Validates if OpenAI API key is configured + /// - Returns: True if API key exists, false otherwise + func isConfigured() -> Bool { + guard let key = KeychainHelper.shared.get(forKey: "openAIKey"), + !key.isEmpty else { + return false + } + return true + } +} \ No newline at end of file diff --git a/notetaker/SimpleAudioManager.swift b/notetaker/SimpleAudioManager.swift deleted file mode 100644 index 17670fd..0000000 --- a/notetaker/SimpleAudioManager.swift +++ /dev/null @@ -1,298 +0,0 @@ -// SimpleAudioManager.swift -// Audio manager for microphone and system audio capture - -import AVFoundation -import Foundation -import SwiftUI -import ScreenCaptureKit - -class SimpleAudioManager: NSObject, ObservableObject { - @Published var transcript = "" - @Published var isRecording = false - @Published var captureSystemAudio = true - - private var audioEngine = AVAudioEngine() - private var systemAudioEngine = AVAudioEngine() - private var webSocketTask: URLSessionWebSocketTask? - private let deepgramURL = URL(string: "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&interim_results=true")! - - // ScreenCaptureKit properties - private var stream: SCStream? - private var streamConfiguration: SCStreamConfiguration? - - func startRecording() { - print("Starting recording...") - - if captureSystemAudio { - startSystemAudioCapture() - } else { - startMicrophoneOnly() - } - } - - private func startMicrophoneOnly() { - print("Starting microphone-only recording...") - - // Set up audio engine for microphone capture - let inputNode = audioEngine.inputNode - let recordingFormat = inputNode.outputFormat(forBus: 0) - - // Convert to the format Deepgram expects: 16kHz, 16-bit, mono - let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: 16000, - channels: 1, - interleaved: false)! - - let converter = AVAudioConverter(from: recordingFormat, to: targetFormat)! - - inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] (buffer, time) in - self?.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat) - } - - audioEngine.prepare() - - do { - try audioEngine.start() - DispatchQueue.main.async { - self.isRecording = true - } - connectToDeepgram() - print("Microphone recording started") - } catch { - print("Failed to start audio engine: \(error)") - } - } - - private func startSystemAudioCapture() { - print("Starting system audio capture...") - - Task { - do { - // Request screen recording permission - let canRecord = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - - // Get the main display - guard let display = canRecord.displays.first else { - print("❌ No display found") - return - } - - // Configure stream for audio only - let configuration = SCStreamConfiguration() - configuration.capturesAudio = true - configuration.excludesCurrentProcessAudio = false - configuration.sampleRate = 16000 - configuration.channelCount = 1 - - // Create content filter (we only want audio, but need to include display) - let filter = SCContentFilter(display: display, excludingWindows: []) - - // Create and start stream - let stream = SCStream(filter: filter, configuration: configuration, delegate: self) - - try await stream.startCapture() - - self.stream = stream - self.streamConfiguration = configuration - - DispatchQueue.main.async { - self.isRecording = true - } - - connectToDeepgram() - print("System audio capture started") - - } catch { - print("❌ Failed to start system audio capture: \(error)") - // Fallback to microphone only - startMicrophoneOnly() - } - } - } - - func stopRecording() { - print("Stopping recording...") - - // Stop system audio capture - if let stream = stream { - Task { - try? await stream.stopCapture() - } - self.stream = nil - } - - // Stop microphone capture - if audioEngine.isRunning { - audioEngine.stop() - audioEngine.inputNode.removeTap(onBus: 0) - } - - // Close WebSocket - webSocketTask?.cancel(with: .normalClosure, reason: nil) - webSocketTask = nil - - DispatchQueue.main.async { - self.isRecording = false - } - print("Recording stopped") - } - - private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat) { - // Create output buffer for conversion - let outputFrameCapacity = AVAudioFrameCount(Double(buffer.frameLength) * targetFormat.sampleRate / buffer.format.sampleRate) - guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else { - print("❌ Failed to create output buffer") - return - } - - var error: NSError? - let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in - outStatus.pointee = .haveData - return buffer - } - - guard status == .haveData, error == nil else { - print("❌ Audio conversion failed: \(error?.localizedDescription ?? "Unknown error")") - return - } - - // Convert to Data for Deepgram - guard let channelData = outputBuffer.int16ChannelData?[0] else { - print("❌ No channel data available") - return - } - - let frameCount = Int(outputBuffer.frameLength) - let data = Data(bytes: channelData, count: frameCount * 2) - - print("🎵 Sending audio data: \(data.count) bytes") - sendAudioData(data) - } - - private func connectToDeepgram() { - guard let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty else { - print("❌ No Deepgram key found") - return - } - - print("🔑 Using Deepgram key: \(String(key.prefix(8)))...") - - let session = URLSession(configuration: .default) - var request = URLRequest(url: deepgramURL) - request.addValue("Token \(key)", forHTTPHeaderField: "Authorization") - - webSocketTask = session.webSocketTask(with: request) - webSocketTask?.resume() - receiveMessage() - print("🌐 Attempting to connect to Deepgram...") - } - - private func sendAudioData(_ data: Data) { - guard webSocketTask?.state == .running else { - print("⚠️ WebSocket not connected, skipping audio data") - return - } - - webSocketTask?.send(.data(data)) { error in - if let error = error { - print("❌ Send error: \(error)") - } - } - } - - private func receiveMessage() { - webSocketTask?.receive { [weak self] result in - switch result { - case .success(let message): - switch message { - case .string(let text): - print("📝 Received: \(text)") - self?.parseTranscription(text) - case .data: - break - @unknown default: - break - } - self?.receiveMessage() // Continue receiving - case .failure(let error): - print("❌ Receive error: \(error)") - // Try to reconnect after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - if self?.isRecording == true { - print("🔄 Attempting to reconnect...") - self?.connectToDeepgram() - } - } - } - } - } - - private func parseTranscription(_ text: String) { - guard let data = text.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = json["type"] as? String, type == "Results", - let channel = json["channel"] as? [String: Any], - let alternatives = channel["alternatives"] as? [[String: Any]], - let alt = alternatives.first, - let transcript = alt["transcript"] as? String, - !transcript.isEmpty else { return } - - DispatchQueue.main.async { - if let isFinal = json["is_final"] as? Bool, isFinal { - self.transcript += transcript + " " - } - } - } -} - -// MARK: - SCStreamDelegate -extension SimpleAudioManager: SCStreamDelegate { - func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { - guard type == .audio else { return } - - // Convert CMSampleBuffer to AVAudioPCMBuffer - guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else { return } - let audioStreamBasicDescription = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) - - guard let audioStreamBasicDescription = audioStreamBasicDescription else { return } - - let format = AVAudioFormat(streamDescription: audioStreamBasicDescription) - guard let format = format else { return } - - let frameCount = CMSampleBufferGetNumSamples(sampleBuffer) - guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)) else { return } - - // Copy audio data - var blockBuffer: CMBlockBuffer? - var audioBufferListOut = AudioBufferList() - var audioBufferListSize = MemoryLayout.size - - let status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( - sampleBuffer, - bufferListSizeNeededOut: &audioBufferListSize, - bufferListOut: &audioBufferListOut, - bufferListSize: audioBufferListSize, - blockBufferAllocator: nil, - blockBufferMemoryAllocator: nil, - flags: 0, - blockBufferOut: &blockBuffer - ) - - guard status == noErr else { return } - - buffer.frameLength = AVAudioFrameCount(frameCount) - - // Convert to target format and send - let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: 16000, - channels: 1, - interleaved: false)! - - let converter = AVAudioConverter(from: format, to: targetFormat)! - processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat) - } - - func stream(_ stream: SCStream, didStopWithError error: Error) { - print("❌ Stream stopped with error: \(error)") - } -} \ No newline at end of file diff --git a/notetaker/ViewModels/MeetingListViewModel.swift b/notetaker/ViewModels/MeetingListViewModel.swift new file mode 100644 index 0000000..b1a42cf --- /dev/null +++ b/notetaker/ViewModels/MeetingListViewModel.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftUI + +@MainActor +class MeetingListViewModel: ObservableObject { + @Published var meetings: [Meeting] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + init() { + loadMeetings() + } + + func loadMeetings() { + isLoading = true + errorMessage = nil + + DispatchQueue.main.async { [weak self] in + self?.meetings = LocalStorageManager.shared.loadMeetings() + self?.isLoading = false + } + } + + func deleteMeeting(_ meeting: Meeting) { + meetings.removeAll { $0.id == meeting.id } + _ = LocalStorageManager.shared.deleteMeeting(meeting) + } + + func createNewMeeting() -> Meeting { + let newMeeting = Meeting() + meetings.insert(newMeeting, at: 0) + _ = LocalStorageManager.shared.saveMeeting(newMeeting) + return newMeeting + } +} \ No newline at end of file diff --git a/notetaker/ViewModels/MeetingViewModel.swift b/notetaker/ViewModels/MeetingViewModel.swift new file mode 100644 index 0000000..4ef2920 --- /dev/null +++ b/notetaker/ViewModels/MeetingViewModel.swift @@ -0,0 +1,85 @@ +import Foundation +import SwiftUI +import Combine + +@MainActor +class MeetingViewModel: ObservableObject { + @Published var meeting: Meeting + @Published var isGeneratingNotes = false + @Published var errorMessage: String? + @Published var isRecording = false + + private let audioManager = AudioManager() + private var cancellables = Set() + + init(meeting: Meeting = Meeting()) { + self.meeting = meeting + + // Update meeting transcript when audio manager transcript changes + audioManager.$transcript + .sink { [weak self] newTranscript in + self?.meeting.transcript = newTranscript + } + .store(in: &cancellables) + + // Update isRecording when audio manager recording state changes + audioManager.$isRecording + .sink { [weak self] isRecording in + self?.isRecording = isRecording + } + .store(in: &cancellables) + } + + var captureSystemAudio: Bool { + get { audioManager.captureSystemAudio } + set { audioManager.captureSystemAudio = newValue } + } + + func startRecording() { + audioManager.startRecording() + } + + func stopRecording() { + audioManager.stopRecording() + saveMeeting() + } + + func generateNotes() async { + isGeneratingNotes = true + errorMessage = nil + + do { + // Load settings for generation + let userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? "" + let systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt + + // Generate notes + meeting.generatedNotes = try await NotesGenerator.shared.generateNotes( + transcript: meeting.transcript, + userNotes: meeting.userNotes, + userBlurb: userBlurb, + systemPrompt: systemPrompt + ) + + saveMeeting() + } catch { + errorMessage = "Failed to generate notes: \(error.localizedDescription)" + } + + isGeneratingNotes = false + } + + func saveMeeting() { + _ = LocalStorageManager.shared.saveMeeting(meeting) + } + + func copyTranscript() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(meeting.transcript, forType: .string) + } + + func copyNotes() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(meeting.generatedNotes, forType: .string) + } +} \ No newline at end of file diff --git a/notetaker/ViewModels/SettingsViewModel.swift b/notetaker/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..d68e71b --- /dev/null +++ b/notetaker/ViewModels/SettingsViewModel.swift @@ -0,0 +1,51 @@ +import Foundation +import SwiftUI + +@MainActor +class SettingsViewModel: ObservableObject { + @Published var settings: Settings + @Published var isSaving = false + @Published var saveSuccessful = false + @Published var errorMessage: String? + + init() { + self.settings = Settings() + loadSettings() + } + + func loadSettings() { + // Load API keys from Keychain + settings.deepgramKey = KeychainHelper.shared.get(forKey: "deepgramKey") ?? "" + settings.openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? "" + settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? "" + settings.systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt + } + + func saveSettings() { + isSaving = true + errorMessage = nil + saveSuccessful = false + + // Save to Keychain + let deepgramSaved = KeychainHelper.shared.save(settings.deepgramKey, forKey: "deepgramKey") + let openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey") + let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb") + let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt") + + if deepgramSaved && openAISaved && blurbSaved && promptSaved { + saveSuccessful = true + // Show success briefly + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.saveSuccessful = false + } + } else { + errorMessage = "Failed to save some settings. Please try again." + } + + isSaving = false + } + + func resetToDefaults() { + settings.systemPrompt = Settings.defaultSystemPrompt + } +} \ No newline at end of file diff --git a/notetaker/Views/MeetingDetailView.swift b/notetaker/Views/MeetingDetailView.swift new file mode 100644 index 0000000..52e77d3 --- /dev/null +++ b/notetaker/Views/MeetingDetailView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct MeetingDetailView: View { + @StateObject private var viewModel: MeetingViewModel + + init(meeting: Meeting) { + self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting)) + } + + var body: some View { + VStack(spacing: 20) { + // Recording Controls + VStack(spacing: 12) { + Toggle("Capture System Audio", isOn: $viewModel.captureSystemAudio) + .disabled(viewModel.isRecording) + + HStack { + Button { + viewModel.startRecording() + } label: { + Label("Start Recording", systemImage: "record.circle") + } + .disabled(viewModel.isRecording) + .buttonStyle(.borderedProminent) + + Button { + viewModel.stopRecording() + } label: { + Label("Stop Recording", systemImage: "stop.circle") + } + .disabled(!viewModel.isRecording) + .buttonStyle(.bordered) + } + + if viewModel.isRecording { + Label("Recording...", systemImage: "dot.radiowaves.left.and.right") + .foregroundColor(.red) + } + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(10) + + // Transcript and Notes + HStack(spacing: 20) { + // Transcript Column + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Live Transcript") + .font(.headline) + Spacer() + Button { + viewModel.copyTranscript() + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + + ScrollView { + Text(viewModel.meeting.transcript.isEmpty ? "Transcript will appear here..." : viewModel.meeting.transcript) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .frame(maxHeight: .infinity) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + } + + // Notes Column + VStack(alignment: .leading, spacing: 8) { + Text("Your Notes") + .font(.headline) + + TextEditor(text: $viewModel.meeting.userNotes) + .font(.body) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + } + } + .frame(maxHeight: 300) + + // Generated Notes Section + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Generated Notes") + .font(.headline) + Spacer() + + if viewModel.isGeneratingNotes { + ProgressView() + .scaleEffect(0.7) + } else { + Button { + Task { + await viewModel.generateNotes() + } + } label: { + Label("Generate", systemImage: "sparkles") + } + .disabled(viewModel.meeting.transcript.isEmpty) + + Button { + viewModel.copyNotes() + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .disabled(viewModel.meeting.generatedNotes.isEmpty) + } + } + + TextEditor(text: $viewModel.meeting.generatedNotes) + .font(.body) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + .frame(minHeight: 150) + } + } + .padding() + .navigationTitle("Meeting Notes") + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + } +} + +#Preview { + NavigationStack { + MeetingDetailView(meeting: Meeting()) + } +} \ No newline at end of file diff --git a/notetaker/Views/MeetingListView.swift b/notetaker/Views/MeetingListView.swift new file mode 100644 index 0000000..ae699cc --- /dev/null +++ b/notetaker/Views/MeetingListView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct MeetingListView: View { + @StateObject private var viewModel = MeetingListViewModel() + @State private var navigationPath = NavigationPath() + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + if viewModel.meetings.isEmpty && !viewModel.isLoading { + ContentUnavailableView( + "No Meetings Yet", + systemImage: "mic.slash", + description: Text("Start a new meeting to begin transcribing") + ) + } else { + ForEach(viewModel.meetings) { meeting in + NavigationLink(value: meeting) { + MeetingRowView(meeting: meeting) + } + } + .onDelete { indexSet in + for index in indexSet { + viewModel.deleteMeeting(viewModel.meetings[index]) + } + } + } + } + .navigationTitle("Meetings") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + let newMeeting = viewModel.createNewMeeting() + navigationPath.append(newMeeting) + } label: { + Label("New Meeting", systemImage: "plus") + } + } + } + .navigationDestination(for: Meeting.self) { meeting in + MeetingDetailView(meeting: meeting) + } + } + .overlay { + if viewModel.isLoading { + ProgressView("Loading meetings...") + } + } + } +} + +struct MeetingRowView: View { + let meeting: Meeting + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(meeting.date, style: .date) + .font(.headline) + + if !meeting.transcript.isEmpty { + Text(meeting.transcript) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } else { + Text("No transcript") + .font(.caption) + .foregroundColor(.secondary) + .italic() + } + } + .padding(.vertical, 4) + } +} + +#Preview { + MeetingListView() +} \ No newline at end of file diff --git a/notetaker/Views/SettingsView.swift b/notetaker/Views/SettingsView.swift new file mode 100644 index 0000000..1585cb9 --- /dev/null +++ b/notetaker/Views/SettingsView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct SettingsView: View { + @StateObject private var viewModel = SettingsViewModel() + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("API Keys") { + SecureField("Deepgram API Key", text: $viewModel.settings.deepgramKey) + .textFieldStyle(.roundedBorder) + + SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey) + .textFieldStyle(.roundedBorder) + } + + Section("Personal Information") { + VStack(alignment: .leading) { + Text("About Yourself") + .font(.caption) + .foregroundColor(.secondary) + TextEditor(text: $viewModel.settings.userBlurb) + .frame(minHeight: 80) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + } + } + + Section("AI Generation") { + VStack(alignment: .leading) { + HStack { + Text("System Prompt") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Button("Reset to Default") { + viewModel.resetToDefaults() + } + .font(.caption) + } + TextEditor(text: $viewModel.settings.systemPrompt) + .frame(minHeight: 120) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + } + } + } + .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.saveSettings() + } + .disabled(viewModel.isSaving) + } + } + .overlay { + if viewModel.saveSuccessful { + VStack { + Spacer() + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Settings saved successfully") + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(10) + .padding() + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + } + } +} + +#Preview { + SettingsView() +} \ No newline at end of file diff --git a/notetaker/notetaker.entitlements b/notetaker/notetaker.entitlements index e64c6c0..5c85285 100644 --- a/notetaker/notetaker.entitlements +++ b/notetaker/notetaker.entitlements @@ -4,17 +4,17 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + group.owen.notetaker + com.apple.security.device.audio-input + com.apple.security.device.screen-capture + com.apple.security.files.user-selected.read-only com.apple.security.network.client - com.apple.security.application-groups - - group.owen.notetaker - - com.apple.security.device.screen-recording - diff --git a/notetaker/notetakerApp.swift b/notetaker/notetakerApp.swift index 8b2c507..cfa0174 100644 --- a/notetaker/notetakerApp.swift +++ b/notetaker/notetakerApp.swift @@ -12,6 +12,8 @@ struct notetakerApp: App { var body: some Scene { WindowGroup { ContentView() + .frame(minWidth: 800, minHeight: 600) } + .windowResizability(.contentSize) } }