From 6afbed0fad866e46d1b85b087d1ce37b4ada017c Mon Sep 17 00:00:00 2001 From: Owen Gretzinger Date: Fri, 25 Jul 2025 16:06:01 -0400 Subject: [PATCH] feat: ability to navigate around while still recording (#43) --- meetingnotes/Managers/AudioManager.swift | 4 +- .../Managers/RecordingSessionManager.swift | 132 ++++++++++++++++++ .../ViewModels/MeetingListViewModel.swift | 1 + .../ViewModels/MeetingViewModel.swift | 103 +++++++------- meetingnotes/Views/AudioLevelWindow.swift | 2 +- meetingnotes/Views/MeetingListView.swift | 40 ++++-- 6 files changed, 213 insertions(+), 69 deletions(-) create mode 100644 meetingnotes/Managers/RecordingSessionManager.swift diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index 9b3053d..85a994e 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -10,6 +10,8 @@ import Combine /// Manages audio capture from microphone and system audio and handles real-time transcription via OpenAI @MainActor class AudioManager: NSObject, ObservableObject { + static let shared = AudioManager() + @Published var transcriptChunks: [TranscriptChunk] = [] @Published var isRecording = false @Published var errorMessage: String? @@ -43,7 +45,7 @@ class AudioManager: NSObject, ObservableObject { private var pingTimers: [AudioSource: Timer] = [:] private var cancellables = Set() - override init() { + private override init() { super.init() NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: audioEngine, diff --git a/meetingnotes/Managers/RecordingSessionManager.swift b/meetingnotes/Managers/RecordingSessionManager.swift new file mode 100644 index 0000000..43a97c7 --- /dev/null +++ b/meetingnotes/Managers/RecordingSessionManager.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftUI +import Combine + +/// Manages recording sessions at the app level to persist across navigation +@MainActor +class RecordingSessionManager: ObservableObject { + static let shared = RecordingSessionManager() + + @Published var isRecording = false + @Published var activeMeetingId: UUID? + @Published var errorMessage: String? + @Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = [] + + private let audioManager = AudioManager.shared + private var cancellables = Set() + private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>() + + // Store transcript chunks for the active recording session + private var activeRecordingTranscriptChunks: [TranscriptChunk] = [] + + private init() { + setupAudioManagerBindings() + setupDebouncedSaving() + } + + private func setupAudioManagerBindings() { + // Bind to audio manager state + audioManager.$isRecording + .sink { [weak self] isRecording in + self?.isRecording = isRecording + } + .store(in: &cancellables) + + audioManager.$errorMessage + .sink { [weak self] errorMessage in + self?.errorMessage = errorMessage + } + .store(in: &cancellables) + + // When transcript chunks change, store them for the active recording and send to debouncer + audioManager.$transcriptChunks + .sink { [weak self] newChunks in + guard let self = self, self.isRecording, self.activeMeetingId != nil else { return } + self.activeRecordingTranscriptChunks = newChunks + self.activeRecordingTranscriptChunksUpdated = newChunks + + self.transcriptUpdateSubject.send(newChunks) + } + .store(in: &cancellables) + } + + private func setupDebouncedSaving() { + transcriptUpdateSubject + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .sink { [weak self] chunks in + guard let self = self, let activeMeetingId = self.activeMeetingId else { return } + print("💾 Debounced save triggered for meeting: \(activeMeetingId.uuidString)") + self.updateActiveMeetingTranscript(meetingId: activeMeetingId, chunks: chunks) + } + .store(in: &cancellables) + } + + func startRecording(for meetingId: UUID) { + print("🎙️ Starting recording for meeting: \(meetingId)") + + // Load the meeting to get existing transcript chunks + if let existingMeeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingId }) { + activeRecordingTranscriptChunks = existingMeeting.transcriptChunks + // Seed the audio manager with existing chunks + audioManager.transcriptChunks = existingMeeting.transcriptChunks + } + + activeMeetingId = meetingId + audioManager.startRecording() + } + + func stopRecording() { + print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")") + + audioManager.stopRecording() + + // Perform a final, immediate save of transcript chunks to the meeting + if let activeMeetingId = activeMeetingId { + updateActiveMeetingTranscript(meetingId: activeMeetingId, chunks: activeRecordingTranscriptChunks) + } + + activeMeetingId = nil + activeRecordingTranscriptChunks = [] + } + + func isRecordingMeeting(_ meetingId: UUID) -> Bool { + return isRecording && activeMeetingId == meetingId + } + + private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) { + // Load all meetings + var meetings = LocalStorageManager.shared.loadMeetings() + + // Find and update the active meeting + if let index = meetings.firstIndex(where: { $0.id == meetingId }) { + meetings[index].transcriptChunks = chunks + + // Save the updated meeting + let success = LocalStorageManager.shared.saveMeeting(meetings[index]) + if success { + print("✅ Saved meeting transcript: \(meetingId.uuidString)") + NotificationCenter.default.post(name: .meetingSaved, object: meetings[index]) + } else { + print("❌ Failed to save meeting transcript: \(meetingId.uuidString)") + } + } + } + + func getActiveRecordingTranscriptChunks() -> [TranscriptChunk] { + return activeRecordingTranscriptChunks + } + + /// Get transcript chunks for a specific meeting, ensuring proper data separation + func getTranscriptChunks(for meetingId: UUID) -> [TranscriptChunk] { + if isRecording && activeMeetingId == meetingId { + // Return live transcript chunks for the active recording + return activeRecordingTranscriptChunks + } else { + // Load saved transcript chunks from storage for non-active meetings + if let savedMeeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingId }) { + return savedMeeting.transcriptChunks + } + return [] + } + } +} \ No newline at end of file diff --git a/meetingnotes/ViewModels/MeetingListViewModel.swift b/meetingnotes/ViewModels/MeetingListViewModel.swift index 549a979..26d6e80 100644 --- a/meetingnotes/ViewModels/MeetingListViewModel.swift +++ b/meetingnotes/ViewModels/MeetingListViewModel.swift @@ -11,6 +11,7 @@ class MeetingListViewModel: ObservableObject { @Published var searchText: String = "" private var cancellables = Set() + private let recordingSessionManager = RecordingSessionManager.shared // Computed property to filter meetings based on search text var filteredMeetings: [Meeting] { diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index b16313a..41e5ded 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -15,24 +15,26 @@ enum MeetingViewTab: String, CaseIterable { case enhancedNotes = "Enhanced Notes" } -enum RecordingState { - case idle // Not recording, shows "Transcribe" or "Resume" based on transcript content - case recording // Recording, shows "Stop" -} + @MainActor class MeetingViewModel: ObservableObject { @Published var meeting: Meeting @Published var isGeneratingNotes = false @Published var errorMessage: String? - @Published var isRecording = false + @Published private var recordingStateChanged = false // Trigger SwiftUI updates + + // Computed property that always uses the direct RecordingSessionManager check + var isRecording: Bool { + return recordingSessionManager.isRecordingMeeting(meeting.id) + } @Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab - @Published var recordingState: RecordingState = .idle + @Published var isDeleted = false @Published var templates: [NoteTemplate] = [] @Published var selectedTemplateId: UUID? - private let audioManager = AudioManager() + private let recordingSessionManager = RecordingSessionManager.shared private var cancellables = Set() private var isNewMeeting = false @@ -54,6 +56,8 @@ class MeetingViewModel: ObservableObject { self.meeting = meeting } + + // Detect if this is a new meeting based on content, not storage existence isNewMeeting = isEmpty @@ -78,31 +82,38 @@ class MeetingViewModel: ObservableObject { } .store(in: &cancellables) - // NEW: Seed the audio manager with any existing transcript chunks so the initial - // published value doesn't overwrite the saved transcript with an empty array. - audioManager.transcriptChunks = self.meeting.transcriptChunks - - // Update meeting transcript chunks when audio manager transcript chunks change - audioManager.$transcriptChunks - .sink { [weak self] newChunks in - self?.meeting.transcriptChunks = newChunks + // Trigger SwiftUI updates when recording state changes + Publishers.CombineLatest(recordingSessionManager.$isRecording, recordingSessionManager.$activeMeetingId) + .sink { [weak self] (isRecording, activeMeetingId) in + guard let self = self else { return } + // Toggle the dummy property to trigger SwiftUI re-render + self.recordingStateChanged.toggle() } .store(in: &cancellables) - // Update isRecording when audio manager recording state changes - audioManager.$isRecording - .sink { [weak self] isRecording in - self?.isRecording = isRecording - self?.updateRecordingState() - } - .store(in: &cancellables) - - // Update error message when audio manager encounters errors - audioManager.$errorMessage + // Update error message when recording session manager encounters errors + recordingSessionManager.$errorMessage .compactMap { $0 } .sink { [weak self] errorMessage in self?.errorMessage = errorMessage - print("🚨 Audio Manager Error: \(errorMessage)") + print("🚨 Recording Session Manager Error: \(errorMessage)") + } + .store(in: &cancellables) + + // If currently recording this meeting, load live transcript chunks + if recordingSessionManager.isRecordingMeeting(meeting.id) { + self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id) + } + + // Listen to real-time transcript updates for this meeting if it's being recorded + recordingSessionManager.$activeRecordingTranscriptChunksUpdated + .dropFirst() + .sink { [weak self] updatedChunks in + guard let self = self else { return } + // Only update if this meeting is the active recording + if recordingSessionManager.isRecordingMeeting(self.meeting.id) { + self.meeting.transcriptChunks = updatedChunks + } } .store(in: &cancellables) @@ -117,37 +128,26 @@ class MeetingViewModel: ObservableObject { } .store(in: &cancellables) - // Auto-start recording for empty meetings - if isNewMeeting { - print("🚀 Auto-starting recording for empty meeting") - startRecording() - } - } - - private func updateRecordingState() { - if isRecording { - recordingState = .recording - } else { - recordingState = .idle - } + } + var recordingButtonText: String { - switch recordingState { - case .idle: + // Use the same computed isRecording property for perfect consistency + if isRecording { + return "Stop" + } else { // Check if there's existing transcript content return meeting.transcriptChunks.isEmpty ? "Transcribe" : "Resume" - case .recording: - return "Stop" } } func toggleRecording() { - switch recordingState { - case .idle: - startRecording() - case .recording: + // Use the same computed isRecording property for perfect consistency + if isRecording { stopRecording() + } else { + startRecording() } } @@ -159,7 +159,7 @@ class MeetingViewModel: ObservableObject { switch validationResult { case .success(): // Key is valid, proceed with recording - audioManager.startRecording() + recordingSessionManager.startRecording(for: meeting.id) case .failure(let error): // Show error message errorMessage = error.localizedDescription @@ -169,7 +169,7 @@ class MeetingViewModel: ObservableObject { } func stopRecording() { - audioManager.stopRecording() + recordingSessionManager.stopRecording() saveMeeting() // Auto-generate notes if there's a transcript and no existing notes @@ -198,9 +198,6 @@ class MeetingViewModel: ObservableObject { isGeneratingNotes = true errorMessage = nil - // Clear any audio manager errors as well - audioManager.errorMessage = nil - // Clear existing notes for streaming meeting.generatedNotes = "" @@ -287,7 +284,7 @@ class MeetingViewModel: ObservableObject { } func deleteIfEmpty() { - if isEmpty { + if isEmpty && !isRecording { print("🗑️ Auto-deleting empty meeting") deleteMeeting() } else { diff --git a/meetingnotes/Views/AudioLevelWindow.swift b/meetingnotes/Views/AudioLevelWindow.swift index f03772d..13ea098 100644 --- a/meetingnotes/Views/AudioLevelWindow.swift +++ b/meetingnotes/Views/AudioLevelWindow.swift @@ -57,7 +57,7 @@ struct DancingAudioBars: View { private func getDancingBarHeight(index: Int, level: Float) -> CGFloat { let baseHeight: CGFloat = 4 // Flat when no audio - let maxHeight: CGFloat = 32 // Max dancing height + let maxHeight: CGFloat = 24 // Max dancing height if level > 0.03 { // Only dance when there's actual audio // Each bar responds to the same audio level but with different scaling diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index d2d64a9..fbe75ee 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -3,6 +3,7 @@ import SwiftUI struct MeetingListView: View { @StateObject private var viewModel = MeetingListViewModel() @ObservedObject var settingsViewModel: SettingsViewModel + @StateObject private var recordingSessionManager = RecordingSessionManager.shared @State private var selectedMeeting: Meeting? @State private var navigationPath = NavigationPath() @@ -113,7 +114,8 @@ struct MeetingListView: View { } label: { Image(systemName: "plus") } - .help("New Meeting") + .disabled(recordingSessionManager.isRecording) + .help(recordingSessionManager.isRecording ? "Cannot create new meeting while recording is active" : "New Meeting") } } .navigationDestination(for: String.self) { path in @@ -160,14 +162,21 @@ struct DayGroup { struct MeetingRowView: View { let meeting: Meeting + @StateObject private var recordingSessionManager = RecordingSessionManager.shared var body: some View { VStack(alignment: .leading, spacing: 6) { // Title or default - Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title) - .font(.headline) - .lineLimit(1) - + HStack(spacing: 4) { + if recordingSessionManager.isRecordingMeeting(meeting.id) { + Image(systemName: "record.circle") + .foregroundColor(.red) + .font(.headline) + } + Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title) + .font(.headline) + .lineLimit(1) + } // Date HStack { Text(meeting.date, style: .time) @@ -213,6 +222,7 @@ struct CollapsedTranscriptChunkView: View { struct MeetingDetailContentView: View { @StateObject private var viewModel: MeetingViewModel + @StateObject private var recordingSessionManager = RecordingSessionManager.shared @State private var showDeleteAlert = false @State private var isEditing = false @State private var showCopyConfirmation = false @@ -223,6 +233,12 @@ struct MeetingDetailContentView: View { self.onDelete = onDelete } + // Computed property to determine if recording button should be disabled + private var cannotStartRecording: Bool { + // Disable if another meeting is recording (not this one) + return recordingSessionManager.isRecording && !recordingSessionManager.isRecordingMeeting(viewModel.meeting.id) + } + var body: some View { VStack(alignment: .leading, spacing: 20) { // Meeting Title with Menu @@ -272,15 +288,17 @@ struct MeetingDetailContentView: View { viewModel.toggleRecording() }) { HStack(spacing: 4) { - Image(systemName: viewModel.recordingState == .recording ? "stop.circle.fill" : "record.circle") - .foregroundColor(viewModel.recordingState == .recording ? .red : .accentColor) + Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle") + .foregroundColor(viewModel.isRecording ? .red : .accentColor) Text(viewModel.recordingButtonText) } .frame(minWidth: 110, minHeight: 36) - .background(viewModel.recordingState == .recording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1)) + .background(viewModel.isRecording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1)) .cornerRadius(8) } .buttonStyle(.plain) + .disabled(cannotStartRecording) + .help(cannotStartRecording ? "Another meeting is currently being recorded" : "Start or stop recording for this meeting") Button(action: { viewModel.copyCurrentTabContent() @@ -336,12 +354,6 @@ struct MeetingDetailContentView: View { Text("Are you sure you want to delete this meeting? This action cannot be undone.") } .onDisappear { - // Stop recording if it's in progress when leaving the page - if viewModel.isRecording { - print("🛑 Stopping recording because user is leaving the page") - viewModel.stopRecording() - } - // Auto-delete empty meetings when leaving, otherwise save viewModel.deleteIfEmpty() }