feat: ability to navigate around while still recording (#43)

This commit is contained in:
Owen Gretzinger
2025-07-25 16:06:01 -04:00
committed by GitHub
parent 35bf122dec
commit 6afbed0fad
6 changed files with 213 additions and 69 deletions
+3 -1
View File
@@ -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<AnyCancellable>()
override init() {
private override init() {
super.init()
NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange,
object: audioEngine,
@@ -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<AnyCancellable>()
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 []
}
}
}
@@ -11,6 +11,7 @@ class MeetingListViewModel: ObservableObject {
@Published var searchText: String = ""
private var cancellables = Set<AnyCancellable>()
private let recordingSessionManager = RecordingSessionManager.shared
// Computed property to filter meetings based on search text
var filteredMeetings: [Meeting] {
+50 -53
View File
@@ -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<AnyCancellable>()
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 {
+1 -1
View File
@@ -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
+26 -14
View File
@@ -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()
}