chore: rename to Meetingnotes

This commit is contained in:
Owen Gretzinger
2025-07-12 14:01:34 -04:00
parent cf87654fa7
commit 78d93015e5
44 changed files with 130 additions and 77 deletions
+488
View File
@@ -0,0 +1,488 @@
// AudioManager.swift
// Unified audio manager for microphone and system audio capture
import AVFoundation
import Foundation
import SwiftUI
import ScreenCaptureKit
/// Manages audio capture from microphone and system audio and handles real-time transcription via OpenAI
class AudioManager: NSObject, ObservableObject {
@Published var transcriptChunks: [TranscriptChunk] = []
@Published var isRecording = false
private var audioEngine = AVAudioEngine()
private var micSocketTask: URLSessionWebSocketTask?
private var systemSocketTask: URLSessionWebSocketTask?
private let realtimeURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")!
// ScreenCaptureKit properties
private var stream: SCStream?
// Add properties near the top, after existing private vars
private var micRetryCount = 0
private let maxMicRetries = 3
// Add current interim transcripts per source
private var currentInterim: [AudioSource: String] = [.mic: "", .system: ""]
override init() {
super.init()
NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange,
object: audioEngine,
queue: .main) { [weak self] _ in
self?.handleAudioEngineConfigurationChange()
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func startRecording() {
print("Starting recording...")
// First ensure everything is stopped and cleaned up
stopRecordingInternal()
// Add a small delay to ensure cleanup is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// Start microphone capture in parallel with system audio
self.startMicrophoneTap()
// Start system audio capture asynchronously
Task {
await self.startSystemAudioCapture()
}
}
}
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 restartMicrophone() {
guard isRecording, micRetryCount < maxMicRetries else { return }
print("🔄 Restarting microphone capture (attempt \(micRetryCount + 1))")
micRetryCount += 1
cleanupAudioEngine()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.startMicrophoneTap()
}
}
/// Starts a microphone tap without creating a new OpenAI connection (used when also capturing system audio)
private func startMicrophoneTap() {
print("🎤 Starting microphone tap...")
do {
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 24000,
channels: 1,
interleaved: false) else {
print("❌ Failed to create target audio format for mic tap")
self.restartMicrophone()
return
}
guard let converter = AVAudioConverter(from: recordingFormat, to: targetFormat) else {
print("❌ Failed to create audio converter for mic tap")
self.restartMicrophone()
return
}
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in
guard let self = self else { return }
// Check for invalid buffer
guard buffer.frameLength > 0, buffer.floatChannelData != nil else {
print("❌ Invalid mic buffer detected - restarting")
self.restartMicrophone()
return
}
// 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)")
// Optional: Check for prolonged silence (e.g., RMS < threshold for multiple buffers)
// But for now, just process
}
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
}
audioEngine.prepare()
try audioEngine.start()
connectToOpenAIRealtime(source: .mic)
print("✅ Microphone tap started successfully")
micRetryCount = 0 // Reset on success
} catch {
print("❌ Failed to start microphone tap: \(error)")
self.restartMicrophone()
}
}
private func cleanupAudioEngine() {
print("🧹 Cleaning up audio engine...")
// Stop the engine first
if audioEngine.isRunning {
audioEngine.stop()
print("⏹️ Audio engine stopped")
}
// Remove any existing taps on the input node
let inputNode = audioEngine.inputNode
inputNode.removeTap(onBus: 0)
print("🔇 Input tap removed")
// Reset the audio engine - this removes all connections and taps
audioEngine.reset()
print("🔄 Audio engine reset")
// Create a fresh audio engine to ensure clean state
audioEngine = AVAudioEngine()
print("✨ Fresh audio engine created")
}
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")
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
}
connectToOpenAIRealtime(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.")
}
}
}
func stopRecording() {
print("Stopping recording...")
// Stop system audio capture
if let stream = stream {
stream.stopCapture()
self.stream = nil
}
// Stop microphone capture
cleanupAudioEngine()
micRetryCount = 0
// 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) {
let processBuffer = buffer
// Convert to target format (24kHz int16 mono) in a single step AVAudioConverter will handle resampling and downmixing
let outputFrameCapacity = AVAudioFrameCount(Double(processBuffer.frameLength) * targetFormat.sampleRate / processBuffer.format.sampleRate)
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else {
return
}
var error: NSError?
let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return processBuffer
}
guard status == .haveData, error == nil else {
return
}
// Convert to Data for OpenAI
guard let channelData = outputBuffer.int16ChannelData?[0] else {
return
}
let frameCount = Int(outputBuffer.frameLength)
let data = Data(bytes: channelData, count: frameCount * 2)
sendAudioData(data, source: source)
}
private func connectToOpenAIRealtime(source: AudioSource) {
guard let key = KeychainHelper.shared.get(forKey: "openAIKey"), !key.isEmpty else {
print("❌ No OpenAI key found")
return
}
let session = URLSession(configuration: .default)
var request = URLRequest(url: realtimeURL)
request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta")
let task = session.webSocketTask(with: request)
task.resume()
// Send initial configuration
let config: [String: Any] = [
"type": "transcription_session.update",
"session": [
"input_audio_format": "pcm16",
"input_audio_transcription": [
"model": "gpt-4o-mini-transcribe",
"language": "en"
],
"turn_detection": [
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 200
]
]
]
do {
let jsonData = try JSONSerialization.data(withJSONObject: config)
if let jsonStr = String(data: jsonData, encoding: .utf8) {
task.send(.string(jsonStr)) { error in
if let error = error {
print("❌ Config send error: \(error)")
}
}
}
} catch {
print("❌ Config JSON error: \(error)")
}
switch source {
case .mic:
micSocketTask = task
case .system:
systemSocketTask = task
}
receiveMessage(for: source)
print("🌐 Connected to OpenAI Realtime (\(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?.parseRealtimeEvent(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?.connectToOpenAIRealtime(source: source)
}
}
}
}
}
private func parseRealtimeEvent(_ 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 else { return }
switch type {
case "conversation.item.input_audio_transcription.delta":
if let delta = json["delta"] as? String {
currentInterim[source]! += delta
DispatchQueue.main.async {
// Remove previous interim chunk from the same source
if let lastIndex = self.transcriptChunks.lastIndex(where: { !$0.isFinal && $0.source == source }) {
self.transcriptChunks.remove(at: lastIndex)
}
let chunk = TranscriptChunk(
timestamp: Date(),
source: source,
text: self.currentInterim[source] ?? "",
isFinal: false
)
self.transcriptChunks.append(chunk)
}
}
case "conversation.item.input_audio_transcription.completed":
if let transcript = json["transcript"] as? String {
DispatchQueue.main.async {
// Remove any interim chunks from the same source
self.transcriptChunks.removeAll { !$0.isFinal && $0.source == source }
let chunk = TranscriptChunk(
timestamp: Date(),
source: source,
text: transcript,
isFinal: true
)
self.transcriptChunks.append(chunk)
}
currentInterim[source] = ""
}
default:
break
}
}
private func sendAudioData(_ data: Data, source: AudioSource) {
let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask
guard let socket = task, socket.state == .running else { return }
let base64 = data.base64EncodedString()
let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64]
do {
let jsonData = try JSONSerialization.data(withJSONObject: message)
if let jsonStr = String(data: jsonData, encoding: .utf8) {
socket.send(.string(jsonStr)) { error in
if let error = error {
print("❌ Send error (\(source)): \(error)")
}
}
}
} catch {
print("❌ JSON send error")
}
}
private func handleAudioEngineConfigurationChange() {
print("🔔 Audio engine configuration changed - restarting mic")
restartMicrophone()
}
}
// 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 OpenAI format
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 24000,
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)
}
}
}