From 607e1236f7a93c2b9f3f49bc2fb572eabeaf0419 Mon Sep 17 00:00:00 2001 From: SuperDooper86 Date: Thu, 27 Aug 2026 16:05:23 +0200 Subject: [PATCH] fix: allow recording while meetings process --- Meetingnotes.xcodeproj/project.pbxproj | 8 +-- meetingnotes/Managers/AudioManager.swift | 42 +++++++----- .../Managers/RecordingSessionManager.swift | 64 ++++++++++++------- meetingnotes/Services/LocalAPIServer.swift | 26 ++++---- .../ViewModels/MeetingViewModel.swift | 15 ++--- 5 files changed, 92 insertions(+), 63 deletions(-) diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index 09d865e..d9e752b 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -276,7 +276,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -290,7 +290,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.31; + MARKETING_VERSION = 1.1.32; ONLY_ACTIVE_ARCH = NO; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; @@ -312,7 +312,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -326,7 +326,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.31; + MARKETING_VERSION = 1.1.32; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index 07f5609..0417bb3 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -73,7 +73,7 @@ final class AudioManager: NSObject, ObservableObject { private var recordingStartedAt = Date() private var systemDiagnostics = SystemCaptureDiagnostics() - private override init() { + override init() { super.init() observeAudioEngine() } @@ -460,7 +460,8 @@ final class AudioManager: NSObject, ObservableObject { } guard let previousBuffer = pendingBuffer, - let previousInputTime = pendingInputTime else { + let previousInputTime = pendingInputTime, + let previousNow = pendingNow else { pendingBuffer = currentBuffer pendingInputTime = inInputTime.pointee pendingNow = inNow.pointee @@ -471,7 +472,9 @@ final class AudioManager: NSObject, ObservableObject { advertisedSampleRate: advertisedInputFormat.sampleRate, previousFrameLength: previousBuffer.frameLength, previousTimestamp: previousInputTime, - currentTimestamp: inInputTime.pointee + currentTimestamp: inInputTime.pointee, + previousHostTimestamp: previousNow, + currentHostTimestamp: inNow.pointee ) inputFormat = self.inputFormat( for: inputData, @@ -552,27 +555,35 @@ final class AudioManager: NSObject, ObservableObject { advertisedSampleRate: Double, previousFrameLength: AVAudioFrameCount, previousTimestamp: AudioTimeStamp, - currentTimestamp: AudioTimeStamp + currentTimestamp: AudioTimeStamp, + previousHostTimestamp: AudioTimeStamp, + currentHostTimestamp: AudioTimeStamp ) -> Double { - let timestampsAreValid = previousTimestamp.mFlags.contains(.sampleTimeValid) - && currentTimestamp.mFlags.contains(.sampleTimeValid) let sampleTimeDelta = currentTimestamp.mSampleTime - previousTimestamp.mSampleTime - guard timestampsAreValid, - advertisedSampleRate > 0, - previousFrameLength > 0, - sampleTimeDelta.isFinite, - sampleTimeDelta > 0 else { - systemDiagnostics.inputRateInference = "advertised (sample timestamps unavailable)" + guard advertisedSampleRate > 0, previousFrameLength > 0 else { + systemDiagnostics.inputRateInference = "advertised (invalid advertised rate or frame count)" return advertisedSampleRate } - let inferredSampleRate = advertisedSampleRate * Double(previousFrameLength) / sampleTimeDelta + guard currentHostTimestamp.mHostTime > previousHostTimestamp.mHostTime else { + systemDiagnostics.inputRateInference = "advertised (host timestamps unavailable)" + return advertisedSampleRate + } + let hostTimeDelta = currentHostTimestamp.mHostTime - previousHostTimestamp.mHostTime + let hostNanoseconds = AudioConvertHostTimeToNanos(hostTimeDelta) + guard hostNanoseconds > 0 else { + systemDiagnostics.inputRateInference = "advertised (host-time conversion failed)" + return advertisedSampleRate + } + + let inferredSampleRate = Double(previousFrameLength) * 1_000_000_000 / Double(hostNanoseconds) let plausibleRange = (advertisedSampleRate * 0.25)...(advertisedSampleRate * 1.25) guard inferredSampleRate.isFinite, plausibleRange.contains(inferredSampleRate) else { systemDiagnostics.inputRateInference = String( - format: "advertised (invalid inference %.3f from %u frames / %.3f sample-time units)", + format: "advertised (invalid host inference %.3f from %u frames / %llu ns; sampleTimeDelta=%.3f)", inferredSampleRate, previousFrameLength, + hostNanoseconds, sampleTimeDelta ) return advertisedSampleRate @@ -584,11 +595,12 @@ final class AudioManager: NSObject, ObservableObject { .flatMap { abs($0 - inferredSampleRate) / $0 <= 0.01 ? $0 : nil } ?? inferredSampleRate systemDiagnostics.inputRateInference = String( - format: "advertised=%.3f,inferred=%.3f,selected=%.3f,previousFrames=%u,sampleTimeDelta=%.3f", + format: "source=hostTime,advertised=%.3f,inferred=%.3f,selected=%.3f,previousFrames=%u,hostNanoseconds=%llu,sampleTimeDelta=%.3f", advertisedSampleRate, inferredSampleRate, selectedSampleRate, previousFrameLength, + hostNanoseconds, sampleTimeDelta ) return selectedSampleRate diff --git a/meetingnotes/Managers/RecordingSessionManager.swift b/meetingnotes/Managers/RecordingSessionManager.swift index 1b4eb13..685b15c 100644 --- a/meetingnotes/Managers/RecordingSessionManager.swift +++ b/meetingnotes/Managers/RecordingSessionManager.swift @@ -2,6 +2,12 @@ import Foundation import SwiftUI import Combine +struct RecordingCompletion { + let chunks: [TranscriptChunk] + let recoveryAudioFolderName: String? + let transcriptionError: String? +} + /// Manages recording sessions at the app level to persist across navigation @MainActor class RecordingSessionManager: ObservableObject { @@ -14,9 +20,11 @@ class RecordingSessionManager: ObservableObject { @Published var errorMessage: String? @Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = [] - private let audioManager = AudioManager.shared + private var audioManager = AudioManager.shared private var cancellables = Set() + private var audioManagerCancellables = Set() private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>() + private var processingMeetingIds = Set() // Store transcript chunks for the active recording session private var activeRecordingTranscriptChunks: [TranscriptChunk] = [] @@ -27,24 +35,19 @@ class RecordingSessionManager: ObservableObject { } private func setupAudioManagerBindings() { + audioManagerCancellables.removeAll() // Bind to audio manager state audioManager.$isRecording .sink { [weak self] isRecording in self?.isRecording = isRecording } - .store(in: &cancellables) - - audioManager.$isProcessing - .sink { [weak self] isProcessing in - self?.isProcessing = isProcessing - } - .store(in: &cancellables) + .store(in: &audioManagerCancellables) audioManager.$errorMessage .sink { [weak self] errorMessage in self?.errorMessage = errorMessage } - .store(in: &cancellables) + .store(in: &audioManagerCancellables) // When transcript chunks change, store them for the active recording and send to debouncer audioManager.$transcriptChunks @@ -55,7 +58,7 @@ class RecordingSessionManager: ObservableObject { self.transcriptUpdateSubject.send(newChunks) } - .store(in: &cancellables) + .store(in: &audioManagerCancellables) } private func setupDebouncedSaving() { @@ -84,26 +87,40 @@ class RecordingSessionManager: ObservableObject { audioManager.startRecording(for: meetingId) } - func stopRecording() async -> [TranscriptChunk] { + func stopRecording() async -> RecordingCompletion { print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")") guard let meetingId = activeMeetingId else { audioManager.cancelRecording() recordingStartedAt = nil - return [] + return RecordingCompletion(chunks: [], recoveryAudioFolderName: nil, transcriptionError: nil) } + + let completedAudioManager = audioManager + audioManager = AudioManager() + setupAudioManagerBindings() + activeMeetingId = nil recordingStartedAt = nil - let chunks = await audioManager.stopRecordingAndTranscribe() - activeRecordingTranscriptChunks = chunks - activeRecordingTranscriptChunksUpdated = chunks + processingMeetingIds.insert(meetingId) + isProcessing = true + defer { + processingMeetingIds.remove(meetingId) + isProcessing = !processingMeetingIds.isEmpty + } + + let chunks = await completedAudioManager.stopRecordingAndTranscribe() + let completion = RecordingCompletion( + chunks: chunks, + recoveryAudioFolderName: completedAudioManager.lastRecoveryAudioFolderName, + transcriptionError: completedAudioManager.errorMessage + ) updateActiveMeetingTranscript( meetingId: meetingId, chunks: chunks, - transcriptionError: audioManager.errorMessage + recoveryAudioFolderName: completion.recoveryAudioFolderName, + transcriptionError: completion.transcriptionError ) - activeMeetingId = nil - activeRecordingTranscriptChunks = [] - return chunks + return completion } func cancelRecording() { @@ -117,13 +134,14 @@ class RecordingSessionManager: ObservableObject { return isRecording && activeMeetingId == meetingId } - var lastRecoveryAudioFolderName: String? { - audioManager.lastRecoveryAudioFolderName + func isProcessingMeeting(_ meetingId: UUID) -> Bool { + processingMeetingIds.contains(meetingId) } - + private func updateActiveMeetingTranscript( meetingId: UUID, chunks: [TranscriptChunk], + recoveryAudioFolderName: String? = nil, transcriptionError: String? = nil ) { // Load all meetings @@ -132,7 +150,7 @@ class RecordingSessionManager: ObservableObject { // Find and update the active meeting if let index = meetings.firstIndex(where: { $0.id == meetingId }) { meetings[index].transcriptChunks = chunks - if let recoveryAudioFolderName = lastRecoveryAudioFolderName { + if let recoveryAudioFolderName { meetings[index].recoveryAudioFolderName = recoveryAudioFolderName } meetings[index].transcriptionError = transcriptionError diff --git a/meetingnotes/Services/LocalAPIServer.swift b/meetingnotes/Services/LocalAPIServer.swift index f0fa97f..8b79307 100644 --- a/meetingnotes/Services/LocalAPIServer.swift +++ b/meetingnotes/Services/LocalAPIServer.swift @@ -182,13 +182,13 @@ private enum LocalAPIRouter { } private enum LocalRecordingError: LocalizedError { - case processing + case stopping case saveFailed var errorDescription: String? { switch self { - case .processing: - return "The previous meeting is still processing." + case .stopping: + return "The current recording is still stopping." case .saveFailed: return "Could not create a meeting for this recording." } @@ -208,12 +208,12 @@ private final class LocalRecordingController { func statusPayload() -> [String: Any] { let state: String - if isStopping || recordingManager.isProcessing { - state = "processing" - } else if recordingManager.isRecording { + if recordingManager.isRecording { state = "recording" } else if recordingManager.activeMeetingId != nil { state = "starting" + } else if isStopping || recordingManager.isProcessing { + state = "processing" } else { state = "idle" } @@ -245,8 +245,8 @@ private final class LocalRecordingController { isStopping = false return statusPayload() } - if isStopping || recordingManager.isProcessing { - throw LocalRecordingError.processing + if isStopping { + throw LocalRecordingError.stopping } if recordingManager.activeMeetingId != nil { return statusPayload() @@ -295,15 +295,16 @@ private final class LocalRecordingController { private func finishRecording() async { pendingStopTask = nil let meetingID = recordingManager.activeMeetingId - let chunks = await recordingManager.stopRecording() + isStopping = false + let completion = await recordingManager.stopRecording() guard let meetingID, var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else { - isStopping = false return } - meeting.transcriptChunks = chunks - meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName + meeting.transcriptChunks = completion.chunks + meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName + meeting.transcriptionError = completion.transcriptionError let templates = LocalStorageManager.shared.loadTemplates() if meeting.templateId == nil { meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates) @@ -352,7 +353,6 @@ private final class LocalRecordingController { NotificationCenter.default.post(name: .meetingSaved, object: meeting) } } - isStopping = false } } diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index d50d8b4..9b1e2c4 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -47,7 +47,7 @@ class MeetingViewModel: ObservableObject { var isProcessing: Bool { return isRetryingTranscription || - (recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id) + recordingSessionManager.isProcessingMeeting(meeting.id) } var canRetryTranscription: Bool { @@ -137,13 +137,11 @@ class MeetingViewModel: ObservableObject { self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id) } - // Listen for final transcript updates for this meeting. + // Listen for transcript updates emitted while this meeting is recording. recordingSessionManager.$activeRecordingTranscriptChunksUpdated .dropFirst() .sink { [weak self] updatedChunks in guard let self = self else { return } - // Stop publishes the final chunks after capture has ended but - // before the active meeting is cleared. if recordingSessionManager.activeMeetingId == self.meeting.id { self.meeting.transcriptChunks = updatedChunks } @@ -223,10 +221,11 @@ class MeetingViewModel: ObservableObject { func stopRecording() { isStartingRecording = true Task { - let chunks = await recordingSessionManager.stopRecording() - meeting.transcriptChunks = chunks - meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName - meeting.transcriptionError = recordingSessionManager.errorMessage + let completion = await recordingSessionManager.stopRecording() + meeting.transcriptChunks = completion.chunks + meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName + meeting.transcriptionError = completion.transcriptionError + errorMessage = completion.transcriptionError refreshRecoveryAudioFolder() saveMeeting() if !meeting.formattedTranscript.isEmpty {