feat: actually implement system audio + refactor
This commit is contained in:
+123
@@ -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
|
||||||
@@ -277,6 +277,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"notetaker/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"notetaker/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = notetaker/Info.plist;
|
INFOPLIST_FILE = notetaker/Info.plist;
|
||||||
@@ -307,6 +308,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"notetaker/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"notetaker/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = notetaker/Info.plist;
|
INFOPLIST_FILE = notetaker/Info.plist;
|
||||||
|
|||||||
@@ -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<Int8>?
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-69
@@ -8,80 +8,24 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var audioManager = SimpleAudioManager()
|
@State private var showingSettings = false
|
||||||
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack {
|
||||||
Text("Notetaker Settings")
|
MeetingListView()
|
||||||
.font(.title)
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .automatic) {
|
||||||
TextField("Deepgram API Key", text: $deepgramKey)
|
Button {
|
||||||
TextField("OpenAI API Key", text: $openAIKey)
|
showingSettings = true
|
||||||
TextField("About Yourself (for prompt)", text: $userBlurb)
|
} label: {
|
||||||
TextEditor(text: $systemPrompt)
|
Label("Settings", systemImage: "gear")
|
||||||
.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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
.sheet(isPresented: $showingSettings) {
|
||||||
deepgramKey = KeychainHelper.shared.get(forKey: "deepgramKey") ?? ""
|
SettingsView()
|
||||||
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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>NSAudioCaptureUsageDescription</key>
|
<key>NSAudioCaptureUsageDescription</key>
|
||||||
<string>Notetaker needs access to capture system audio for transcription.</string>
|
<string>Notetaker needs access to capture system audio for transcription.</string>
|
||||||
|
<key>NSScreenCaptureUsageDescription</key>
|
||||||
|
<string>Notetaker needs screen recording permission to capture system audio for transcription.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AudioBufferList>.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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -4,17 +4,17 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.owen.notetaker</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.screen-capture</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.owen.notetaker</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.security.device.screen-recording</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ struct notetakerApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
|
}
|
||||||
|
.windowResizability(.contentSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user