Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 56 additions & 55 deletions Funnel/Funnel/AudioRecorderManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class AudioRecorderManager: NSObject, ObservableObject {
private var levelTimer: Timer?
private(set) var currentRecordingURL: URL?
private var recordingCompletion: ((Result<URL, Error>) -> Void)?

// Live streaming properties
private var audioEngine = AVAudioEngine()
private var webSocket: URLSessionWebSocketTask?
Expand Down Expand Up @@ -46,10 +46,10 @@ class AudioRecorderManager: NSObject, ObservableObject {

func startRecording(completion: @escaping (Result<URL, Error>) -> Void) {
print("AudioRecorderManager: startRecording called")

// For testing live streaming - set this to true to use live streaming
let useLiveStreaming = true

if useLiveStreaming {
startLiveStreaming { result in
switch result {
Expand All @@ -58,7 +58,7 @@ class AudioRecorderManager: NSObject, ObservableObject {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dummyURL = documentsPath.appendingPathComponent("live-stream-\(self.recordingId ?? "unknown").m4a")
completion(.success(dummyURL))
case .failure(let error):
case let .failure(error):
completion(.failure(error))
}
}
Expand Down Expand Up @@ -154,99 +154,100 @@ class AudioRecorderManager: NSObject, ObservableObject {
self.audioLevel = curvedLevel
}
}

// MARK: - Live Streaming Methods

func startLiveStreaming(completion: @escaping (Result<Void, Error>) -> Void) {
print("AudioRecorderManager: Starting live streaming")

recordingId = UUID().uuidString
isLiveStreaming = true
print("AudioRecorderManager: Generated recording ID: \(recordingId!)")

// Setup WebSocket connection
setupWebSocket { [weak self] result in
switch result {
case .success:
self?.startAudioEngine(completion: completion)
case .failure(let error):
case let .failure(error):
completion(.failure(error))
}
}
}

private func setupWebSocket(completion: @escaping (Result<Void, Error>) -> Void) {
guard let recordingId = recordingId else {
completion(.failure(FunnelError.recordingFailed(reason: "No recording ID")))
return
}

// Create URLSession for WebSocket
let config = URLSessionConfiguration.default
urlSession = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())

// Use APIClient to construct WebSocket URL
guard let url = APIClient.shared.webSocketURL(for: "/api/recordings/\(recordingId)/stream") else {
completion(.failure(FunnelError.recordingFailed(reason: "Invalid WebSocket URL")))
return
}

webSocket = urlSession?.webSocketTask(with: url)
webSocket?.resume()

// Listen for messages
receiveWebSocketMessage()

// Send audio format configuration after connection
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
guard let self = self else { return }

// Send configuration message to indicate PCM format
let config: [String: Any] = [
"type": "config",
"format": "pcm16",
"sampleRate": Int(AVAudioSession.sharedInstance().sampleRate), // Send actual device sample rate
"channels": 1
"channels": 1,
]

if let jsonData = try? JSONSerialization.data(withJSONObject: config),
let jsonString = String(data: jsonData, encoding: .utf8) {
let jsonString = String(data: jsonData, encoding: .utf8)
{
self.webSocket?.send(.string(jsonString)) { error in
if let error = error {
print("Failed to send config: \(error)")
}
}
}

completion(.success(()))
}
}

private func receiveWebSocketMessage() {
webSocket?.receive { [weak self] result in
switch result {
case .success(let message):
case let .success(message):
switch message {
case .string(let text):
case let .string(text):
print("WebSocket received text: \(text)")
// Handle transcript responses here if needed
case .data(let data):
// Handle transcript responses here if needed
case let .data(data):
print("WebSocket received data: \(data.count) bytes")
@unknown default:
break
}
// Continue listening
self?.receiveWebSocketMessage()
case .failure(let error):
case let .failure(error):
print("WebSocket receive error: \(error)")
}
}
}

private func startAudioEngine(completion: @escaping (Result<Void, Error>) -> Void) {
let inputNode = audioEngine.inputNode
let inputFormat = inputNode.inputFormat(forBus: 0)

// Create output format - PCM 16-bit as recommended by Deepgram
guard let outputFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
Expand All @@ -257,77 +258,77 @@ class AudioRecorderManager: NSObject, ObservableObject {
completion(.failure(FunnelError.recordingFailed(reason: "Failed to create audio format")))
return
}

// Create converter node
let converterNode = AVAudioMixerNode()
let sinkNode = AVAudioMixerNode()

audioEngine.attach(converterNode)
audioEngine.attach(sinkNode)

// Install tap to capture audio
converterNode.installTap(onBus: 0, bufferSize: 1024, format: converterNode.outputFormat(forBus: 0)) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer)
}

// Connect nodes
audioEngine.connect(inputNode, to: converterNode, format: inputFormat)
audioEngine.connect(converterNode, to: sinkNode, format: outputFormat)

// Prepare and start engine
audioEngine.prepare()

do {
try AVAudioSession.sharedInstance().setCategory(.record)
try audioEngine.start()

isRecording = true
recordingTime = 0

// Start timers
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
self.recordingTime += 0.1
}

// For live streaming, we'll calculate audio levels from the buffer
levelTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
// Audio level will be updated in processAudioBuffer
}

print("AudioRecorderManager: Audio engine started successfully")
completion(.success(()))
} catch {
print("AudioRecorderManager: Failed to start audio engine: \(error)")
completion(.failure(error))
}
}

private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.int16ChannelData else { return }

let channelDataValue = channelData.pointee
let channelDataValueArray = stride(from: 0, to: Int(buffer.frameLength), by: buffer.stride)
.map { channelDataValue[$0] }

// Calculate audio level for visualization
let rms = sqrt(channelDataValueArray
.map { Double($0) * Double($0) }
.reduce(0, +) / Double(channelDataValueArray.count))

let avgPower = 20 * log10(rms / 32768.0) // Convert to dB
let minDb: Float = -50
let maxDb: Float = -10
let normalizedLevel = Float((avgPower - Double(minDb)) / Double(maxDb - minDb))
let clampedLevel = max(0, min(1, normalizedLevel))
let curvedLevel = pow(clampedLevel, 2.5)

DispatchQueue.main.async { [weak self] in
self?.audioLevel = curvedLevel
}

// Convert buffer to Data for WebSocket
let data = toData(buffer: buffer)

// Send data through WebSocket
if let data = data {
webSocket?.send(.data(data)) { error in
Expand All @@ -337,30 +338,30 @@ class AudioRecorderManager: NSObject, ObservableObject {
}
}
}

private func toData(buffer: AVAudioPCMBuffer) -> Data? {
let audioBuffer = buffer.audioBufferList.pointee.mBuffers
return Data(bytes: audioBuffer.mData!, count: Int(audioBuffer.mDataByteSize))
}

private func stopLiveStreaming() {
print("AudioRecorderManager: Stopping live streaming")
print("AudioRecorderManager: Recording ID at stop: \(recordingId ?? "nil")")

// Stop audio engine
audioEngine.stop()
// Remove tap from all attached nodes
audioEngine.attachedNodes.forEach { node in
for node in audioEngine.attachedNodes {
node.removeTap(onBus: 0)
}

// Stop timers
timer?.invalidate()
levelTimer?.invalidate()

// Close WebSocket
webSocket?.cancel(with: .goingAway, reason: nil)

// Reset state
isRecording = false
isLiveStreaming = false
Expand All @@ -372,11 +373,11 @@ class AudioRecorderManager: NSObject, ObservableObject {
}

extension AudioRecorderManager: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
print("WebSocket connected with protocol: \(String(describing: `protocol`))")
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {

func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason _: Data?) {
print("WebSocket closed with code: \(closeCode)")
}
}
Expand Down
2 changes: 1 addition & 1 deletion Funnel/Funnel/Extensions/String+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ extension String {
func prepending(_ prefix: String) -> String {
return prefix + self
}
}
}
2 changes: 1 addition & 1 deletion Funnel/Funnel/FunnelApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import SwiftUI
@main
struct FunnelApp: App {
@StateObject private var debugSettings = DebugSettings()

var sharedModelContainer: ModelContainer = {
let schema = Schema([
Recording.self,
Expand Down
2 changes: 2 additions & 0 deletions Funnel/Funnel/Models/APIModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ struct ErrorResponse: Codable {

struct ProcessedRecording: Codable {
let transcript: String
let editedTranscript: String
let duration: Double
let bulletSummary: [String]
let thingsToThinkAbout: [String]
let diagram: DiagramData

struct DiagramData: Codable {
Expand Down
2 changes: 2 additions & 0 deletions Funnel/Funnel/Models/Recording.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ final class Recording {

// Processed content
var transcript: String?
var editedTranscript: String?
var bulletSummary: [String]?
var thingsToThinkAbout: [String]?
var diagramTitle: String?
var diagramDescription: String?
var diagramContent: String?
Expand Down
6 changes: 6 additions & 0 deletions Funnel/Funnel/Preview Content/PreviewContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ extension ModelContainer {

if processingStatus == .completed {
recording.transcript = sampleTranscripts[index]
recording.editedTranscript = "## Key Ideas\n\n\(sampleTranscripts[index])\n\n### Action Items\n\n- Follow up on main points\n- Schedule next steps"
recording.bulletSummary = sampleBulletSummaries[index]
recording.thingsToThinkAbout = [
"What would be the biggest challenge in implementing this idea?",
"How might this impact our current users?",
"What resources would we need to make this successful?",
]
recording.diagramTitle = sampleDiagramTitles[index]
recording.diagramDescription = "A visual representation of the key concepts discussed"
recording.diagramContent = """
Expand Down
6 changes: 3 additions & 3 deletions Funnel/Funnel/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,17 +162,17 @@ class APIClient {
}

// MARK: - WebSocket URL Generation

func webSocketURL(for endpoint: String) -> URL? {
let scheme = baseURL.hasPrefix("https") ? "wss" : "ws"
let host = baseURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: "")

let wsURLString = "\(scheme)://\(host)\(endpoint)"
return URL(string: wsURLString)
}

// MARK: - Helper Methods

private func mimeType(for pathExtension: String) -> String {
Expand Down
4 changes: 2 additions & 2 deletions Funnel/Funnel/Services/FunnelAPIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ class FunnelAPIService {
fieldName: "audio"
)
}

// MARK: - Live Streaming

/// Finalize a live-streamed recording
func finalizeRecording(recordingId: String) async throws -> ProcessedRecording {
print("FunnelAPIService: Finalizing recording with ID: \(recordingId)")
Expand Down
Loading