fix: allow recording while meetings process

This commit is contained in:
2026-08-27 16:05:23 +02:00
parent 204857cdd9
commit 607e1236f7
5 changed files with 92 additions and 63 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.31; MARKETING_VERSION = 1.1.32;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.31; MARKETING_VERSION = 1.1.32;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+27 -15
View File
@@ -73,7 +73,7 @@ final class AudioManager: NSObject, ObservableObject {
private var recordingStartedAt = Date() private var recordingStartedAt = Date()
private var systemDiagnostics = SystemCaptureDiagnostics() private var systemDiagnostics = SystemCaptureDiagnostics()
private override init() { override init() {
super.init() super.init()
observeAudioEngine() observeAudioEngine()
} }
@@ -460,7 +460,8 @@ final class AudioManager: NSObject, ObservableObject {
} }
guard let previousBuffer = pendingBuffer, guard let previousBuffer = pendingBuffer,
let previousInputTime = pendingInputTime else { let previousInputTime = pendingInputTime,
let previousNow = pendingNow else {
pendingBuffer = currentBuffer pendingBuffer = currentBuffer
pendingInputTime = inInputTime.pointee pendingInputTime = inInputTime.pointee
pendingNow = inNow.pointee pendingNow = inNow.pointee
@@ -471,7 +472,9 @@ final class AudioManager: NSObject, ObservableObject {
advertisedSampleRate: advertisedInputFormat.sampleRate, advertisedSampleRate: advertisedInputFormat.sampleRate,
previousFrameLength: previousBuffer.frameLength, previousFrameLength: previousBuffer.frameLength,
previousTimestamp: previousInputTime, previousTimestamp: previousInputTime,
currentTimestamp: inInputTime.pointee currentTimestamp: inInputTime.pointee,
previousHostTimestamp: previousNow,
currentHostTimestamp: inNow.pointee
) )
inputFormat = self.inputFormat( inputFormat = self.inputFormat(
for: inputData, for: inputData,
@@ -552,27 +555,35 @@ final class AudioManager: NSObject, ObservableObject {
advertisedSampleRate: Double, advertisedSampleRate: Double,
previousFrameLength: AVAudioFrameCount, previousFrameLength: AVAudioFrameCount,
previousTimestamp: AudioTimeStamp, previousTimestamp: AudioTimeStamp,
currentTimestamp: AudioTimeStamp currentTimestamp: AudioTimeStamp,
previousHostTimestamp: AudioTimeStamp,
currentHostTimestamp: AudioTimeStamp
) -> Double { ) -> Double {
let timestampsAreValid = previousTimestamp.mFlags.contains(.sampleTimeValid)
&& currentTimestamp.mFlags.contains(.sampleTimeValid)
let sampleTimeDelta = currentTimestamp.mSampleTime - previousTimestamp.mSampleTime let sampleTimeDelta = currentTimestamp.mSampleTime - previousTimestamp.mSampleTime
guard timestampsAreValid, guard advertisedSampleRate > 0, previousFrameLength > 0 else {
advertisedSampleRate > 0, systemDiagnostics.inputRateInference = "advertised (invalid advertised rate or frame count)"
previousFrameLength > 0,
sampleTimeDelta.isFinite,
sampleTimeDelta > 0 else {
systemDiagnostics.inputRateInference = "advertised (sample timestamps unavailable)"
return advertisedSampleRate 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) let plausibleRange = (advertisedSampleRate * 0.25)...(advertisedSampleRate * 1.25)
guard inferredSampleRate.isFinite, plausibleRange.contains(inferredSampleRate) else { guard inferredSampleRate.isFinite, plausibleRange.contains(inferredSampleRate) else {
systemDiagnostics.inputRateInference = String( 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, inferredSampleRate,
previousFrameLength, previousFrameLength,
hostNanoseconds,
sampleTimeDelta sampleTimeDelta
) )
return advertisedSampleRate return advertisedSampleRate
@@ -584,11 +595,12 @@ final class AudioManager: NSObject, ObservableObject {
.flatMap { abs($0 - inferredSampleRate) / $0 <= 0.01 ? $0 : nil } .flatMap { abs($0 - inferredSampleRate) / $0 <= 0.01 ? $0 : nil }
?? inferredSampleRate ?? inferredSampleRate
systemDiagnostics.inputRateInference = String( 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, advertisedSampleRate,
inferredSampleRate, inferredSampleRate,
selectedSampleRate, selectedSampleRate,
previousFrameLength, previousFrameLength,
hostNanoseconds,
sampleTimeDelta sampleTimeDelta
) )
return selectedSampleRate return selectedSampleRate
@@ -2,6 +2,12 @@ import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
struct RecordingCompletion {
let chunks: [TranscriptChunk]
let recoveryAudioFolderName: String?
let transcriptionError: String?
}
/// Manages recording sessions at the app level to persist across navigation /// Manages recording sessions at the app level to persist across navigation
@MainActor @MainActor
class RecordingSessionManager: ObservableObject { class RecordingSessionManager: ObservableObject {
@@ -14,9 +20,11 @@ class RecordingSessionManager: ObservableObject {
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = [] @Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
private let audioManager = AudioManager.shared private var audioManager = AudioManager.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var audioManagerCancellables = Set<AnyCancellable>()
private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>() private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>()
private var processingMeetingIds = Set<UUID>()
// Store transcript chunks for the active recording session // Store transcript chunks for the active recording session
private var activeRecordingTranscriptChunks: [TranscriptChunk] = [] private var activeRecordingTranscriptChunks: [TranscriptChunk] = []
@@ -27,24 +35,19 @@ class RecordingSessionManager: ObservableObject {
} }
private func setupAudioManagerBindings() { private func setupAudioManagerBindings() {
audioManagerCancellables.removeAll()
// Bind to audio manager state // Bind to audio manager state
audioManager.$isRecording audioManager.$isRecording
.sink { [weak self] isRecording in .sink { [weak self] isRecording in
self?.isRecording = isRecording self?.isRecording = isRecording
} }
.store(in: &cancellables) .store(in: &audioManagerCancellables)
audioManager.$isProcessing
.sink { [weak self] isProcessing in
self?.isProcessing = isProcessing
}
.store(in: &cancellables)
audioManager.$errorMessage audioManager.$errorMessage
.sink { [weak self] errorMessage in .sink { [weak self] errorMessage in
self?.errorMessage = errorMessage self?.errorMessage = errorMessage
} }
.store(in: &cancellables) .store(in: &audioManagerCancellables)
// When transcript chunks change, store them for the active recording and send to debouncer // When transcript chunks change, store them for the active recording and send to debouncer
audioManager.$transcriptChunks audioManager.$transcriptChunks
@@ -55,7 +58,7 @@ class RecordingSessionManager: ObservableObject {
self.transcriptUpdateSubject.send(newChunks) self.transcriptUpdateSubject.send(newChunks)
} }
.store(in: &cancellables) .store(in: &audioManagerCancellables)
} }
private func setupDebouncedSaving() { private func setupDebouncedSaving() {
@@ -84,26 +87,40 @@ class RecordingSessionManager: ObservableObject {
audioManager.startRecording(for: meetingId) audioManager.startRecording(for: meetingId)
} }
func stopRecording() async -> [TranscriptChunk] { func stopRecording() async -> RecordingCompletion {
print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")") print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")")
guard let meetingId = activeMeetingId else { guard let meetingId = activeMeetingId else {
audioManager.cancelRecording() audioManager.cancelRecording()
recordingStartedAt = nil recordingStartedAt = nil
return [] return RecordingCompletion(chunks: [], recoveryAudioFolderName: nil, transcriptionError: nil)
} }
let completedAudioManager = audioManager
audioManager = AudioManager()
setupAudioManagerBindings()
activeMeetingId = nil
recordingStartedAt = nil recordingStartedAt = nil
let chunks = await audioManager.stopRecordingAndTranscribe() processingMeetingIds.insert(meetingId)
activeRecordingTranscriptChunks = chunks isProcessing = true
activeRecordingTranscriptChunksUpdated = chunks defer {
processingMeetingIds.remove(meetingId)
isProcessing = !processingMeetingIds.isEmpty
}
let chunks = await completedAudioManager.stopRecordingAndTranscribe()
let completion = RecordingCompletion(
chunks: chunks,
recoveryAudioFolderName: completedAudioManager.lastRecoveryAudioFolderName,
transcriptionError: completedAudioManager.errorMessage
)
updateActiveMeetingTranscript( updateActiveMeetingTranscript(
meetingId: meetingId, meetingId: meetingId,
chunks: chunks, chunks: chunks,
transcriptionError: audioManager.errorMessage recoveryAudioFolderName: completion.recoveryAudioFolderName,
transcriptionError: completion.transcriptionError
) )
activeMeetingId = nil return completion
activeRecordingTranscriptChunks = []
return chunks
} }
func cancelRecording() { func cancelRecording() {
@@ -117,13 +134,14 @@ class RecordingSessionManager: ObservableObject {
return isRecording && activeMeetingId == meetingId return isRecording && activeMeetingId == meetingId
} }
var lastRecoveryAudioFolderName: String? { func isProcessingMeeting(_ meetingId: UUID) -> Bool {
audioManager.lastRecoveryAudioFolderName processingMeetingIds.contains(meetingId)
} }
private func updateActiveMeetingTranscript( private func updateActiveMeetingTranscript(
meetingId: UUID, meetingId: UUID,
chunks: [TranscriptChunk], chunks: [TranscriptChunk],
recoveryAudioFolderName: String? = nil,
transcriptionError: String? = nil transcriptionError: String? = nil
) { ) {
// Load all meetings // Load all meetings
@@ -132,7 +150,7 @@ class RecordingSessionManager: ObservableObject {
// Find and update the active meeting // Find and update the active meeting
if let index = meetings.firstIndex(where: { $0.id == meetingId }) { if let index = meetings.firstIndex(where: { $0.id == meetingId }) {
meetings[index].transcriptChunks = chunks meetings[index].transcriptChunks = chunks
if let recoveryAudioFolderName = lastRecoveryAudioFolderName { if let recoveryAudioFolderName {
meetings[index].recoveryAudioFolderName = recoveryAudioFolderName meetings[index].recoveryAudioFolderName = recoveryAudioFolderName
} }
meetings[index].transcriptionError = transcriptionError meetings[index].transcriptionError = transcriptionError
+13 -13
View File
@@ -182,13 +182,13 @@ private enum LocalAPIRouter {
} }
private enum LocalRecordingError: LocalizedError { private enum LocalRecordingError: LocalizedError {
case processing case stopping
case saveFailed case saveFailed
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .processing: case .stopping:
return "The previous meeting is still processing." return "The current recording is still stopping."
case .saveFailed: case .saveFailed:
return "Could not create a meeting for this recording." return "Could not create a meeting for this recording."
} }
@@ -208,12 +208,12 @@ private final class LocalRecordingController {
func statusPayload() -> [String: Any] { func statusPayload() -> [String: Any] {
let state: String let state: String
if isStopping || recordingManager.isProcessing { if recordingManager.isRecording {
state = "processing"
} else if recordingManager.isRecording {
state = "recording" state = "recording"
} else if recordingManager.activeMeetingId != nil { } else if recordingManager.activeMeetingId != nil {
state = "starting" state = "starting"
} else if isStopping || recordingManager.isProcessing {
state = "processing"
} else { } else {
state = "idle" state = "idle"
} }
@@ -245,8 +245,8 @@ private final class LocalRecordingController {
isStopping = false isStopping = false
return statusPayload() return statusPayload()
} }
if isStopping || recordingManager.isProcessing { if isStopping {
throw LocalRecordingError.processing throw LocalRecordingError.stopping
} }
if recordingManager.activeMeetingId != nil { if recordingManager.activeMeetingId != nil {
return statusPayload() return statusPayload()
@@ -295,15 +295,16 @@ private final class LocalRecordingController {
private func finishRecording() async { private func finishRecording() async {
pendingStopTask = nil pendingStopTask = nil
let meetingID = recordingManager.activeMeetingId let meetingID = recordingManager.activeMeetingId
let chunks = await recordingManager.stopRecording() isStopping = false
let completion = await recordingManager.stopRecording()
guard let meetingID, guard let meetingID,
var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else { var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else {
isStopping = false
return return
} }
meeting.transcriptChunks = chunks meeting.transcriptChunks = completion.chunks
meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName
meeting.transcriptionError = completion.transcriptionError
let templates = LocalStorageManager.shared.loadTemplates() let templates = LocalStorageManager.shared.loadTemplates()
if meeting.templateId == nil { if meeting.templateId == nil {
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates) meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
@@ -352,7 +353,6 @@ private final class LocalRecordingController {
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
} }
} }
isStopping = false
} }
} }
@@ -47,7 +47,7 @@ class MeetingViewModel: ObservableObject {
var isProcessing: Bool { var isProcessing: Bool {
return isRetryingTranscription || return isRetryingTranscription ||
(recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id) recordingSessionManager.isProcessingMeeting(meeting.id)
} }
var canRetryTranscription: Bool { var canRetryTranscription: Bool {
@@ -137,13 +137,11 @@ class MeetingViewModel: ObservableObject {
self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id) 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 recordingSessionManager.$activeRecordingTranscriptChunksUpdated
.dropFirst() .dropFirst()
.sink { [weak self] updatedChunks in .sink { [weak self] updatedChunks in
guard let self = self else { return } 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 { if recordingSessionManager.activeMeetingId == self.meeting.id {
self.meeting.transcriptChunks = updatedChunks self.meeting.transcriptChunks = updatedChunks
} }
@@ -223,10 +221,11 @@ class MeetingViewModel: ObservableObject {
func stopRecording() { func stopRecording() {
isStartingRecording = true isStartingRecording = true
Task { Task {
let chunks = await recordingSessionManager.stopRecording() let completion = await recordingSessionManager.stopRecording()
meeting.transcriptChunks = chunks meeting.transcriptChunks = completion.chunks
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName
meeting.transcriptionError = recordingSessionManager.errorMessage meeting.transcriptionError = completion.transcriptionError
errorMessage = completion.transcriptionError
refreshRecoveryAudioFolder() refreshRecoveryAudioFolder()
saveMeeting() saveMeeting()
if !meeting.formattedTranscript.isEmpty { if !meeting.formattedTranscript.isEmpty {