Compare commits

...
2 Commits
Author SHA1 Message Date
coder 607e1236f7 fix: allow recording while meetings process 2026-08-27 16:05:23 +02:00
coder 204857cdd9 fix: infer actual system audio clock rate 2026-08-27 13:02:00 +02:00
6 changed files with 213 additions and 54 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 = 42; 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.30; 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 = 42; 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.30; 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;
+113 -4
View File
@@ -27,6 +27,7 @@ private struct SystemCaptureDiagnostics {
var targetFormat = "unavailable" var targetFormat = "unavailable"
var selectedInputSampleRate: Double? var selectedInputSampleRate: Double?
var targetSampleRate: Double? var targetSampleRate: Double?
var inputRateInference = "unavailable"
var firstBufferLayout: String? var firstBufferLayout: String?
var callbackCount: UInt64 = 0 var callbackCount: UInt64 = 0
var inputFrameCount: UInt64 = 0 var inputFrameCount: UInt64 = 0
@@ -72,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()
} }
@@ -443,18 +444,70 @@ final class AudioManager: NSObject, ObservableObject {
systemDiagnostics.targetSampleRate = targetFormat.sampleRate systemDiagnostics.targetSampleRate = targetFormat.sampleRate
var inputFormat: AVAudioFormat? var inputFormat: AVAudioFormat?
var converter: AVAudioConverter? var converter: AVAudioConverter?
var pendingBuffer: AVAudioPCMBuffer?
var pendingInputTime: AudioTimeStamp?
var pendingNow: AudioTimeStamp?
try tap.run(on: tapQueue) { [weak self] inNow, inputData, inInputTime, _, _ in try tap.run(on: tapQueue) { [weak self] inNow, inputData, inInputTime, _, _ in
guard let self else { return } guard let self else { return }
if inputFormat == nil { if inputFormat == nil {
guard let callbackFormat = self.inputFormat(
for: inputData,
sampleRate: advertisedInputFormat.sampleRate
),
let currentBuffer = self.copyAudioBuffer(from: inputData, format: callbackFormat) else {
self.systemDiagnostics.discardedCallbackCount += 1
return
}
guard let previousBuffer = pendingBuffer,
let previousInputTime = pendingInputTime,
let previousNow = pendingNow else {
pendingBuffer = currentBuffer
pendingInputTime = inInputTime.pointee
pendingNow = inNow.pointee
return
}
let selectedSampleRate = self.effectiveInputSampleRate(
advertisedSampleRate: advertisedInputFormat.sampleRate,
previousFrameLength: previousBuffer.frameLength,
previousTimestamp: previousInputTime,
currentTimestamp: inInputTime.pointee,
previousHostTimestamp: previousNow,
currentHostTimestamp: inNow.pointee
)
inputFormat = self.inputFormat( inputFormat = self.inputFormat(
for: inputData, for: inputData,
advertisedFormat: advertisedInputFormat sampleRate: selectedSampleRate
) )
if let inputFormat { if let inputFormat {
self.systemDiagnostics.selectedInputFormat = self.audioFormatSummary(inputFormat) self.systemDiagnostics.selectedInputFormat = self.audioFormatSummary(inputFormat)
self.systemDiagnostics.selectedInputSampleRate = inputFormat.sampleRate self.systemDiagnostics.selectedInputSampleRate = inputFormat.sampleRate
converter = AVAudioConverter(from: inputFormat, to: targetFormat) converter = AVAudioConverter(from: inputFormat, to: targetFormat)
} }
guard let inputFormat, let converter else { return }
self.processAudioBuffer(
{ self.copyAudioBuffer(from: previousBuffer.audioBufferList, format: inputFormat) },
converter: converter,
targetFormat: targetFormat,
source: .system,
callbackTimestamp: previousInputTime,
ioTimestamp: pendingNow
)
pendingBuffer = nil
pendingInputTime = nil
pendingNow = nil
self.processAudioBuffer(
{ self.copyAudioBuffer(from: currentBuffer.audioBufferList, format: inputFormat) },
converter: converter,
targetFormat: targetFormat,
source: .system,
callbackTimestamp: inInputTime.pointee,
ioTimestamp: inNow.pointee
)
return
} }
guard let inputFormat, let converter else { return } guard let inputFormat, let converter else { return }
// The tap queue is serial. Reusing the converter preserves its // The tap queue is serial. Reusing the converter preserves its
@@ -475,7 +528,7 @@ final class AudioManager: NSObject, ObservableObject {
private func inputFormat( private func inputFormat(
for inputData: UnsafePointer<AudioBufferList>, for inputData: UnsafePointer<AudioBufferList>,
advertisedFormat: AVAudioFormat sampleRate: Double
) -> AVAudioFormat? { ) -> AVAudioFormat? {
let buffers = UnsafeMutableAudioBufferListPointer( let buffers = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData) UnsafeMutablePointer(mutating: inputData)
@@ -492,12 +545,67 @@ final class AudioManager: NSObject, ObservableObject {
let isInterleaved = buffers.count == 1 && channelCount > 1 let isInterleaved = buffers.count == 1 && channelCount > 1
return AVAudioFormat( return AVAudioFormat(
commonFormat: .pcmFormatFloat32, commonFormat: .pcmFormatFloat32,
sampleRate: advertisedFormat.sampleRate, sampleRate: sampleRate,
channels: AVAudioChannelCount(channelCount), channels: AVAudioChannelCount(channelCount),
interleaved: isInterleaved interleaved: isInterleaved
) )
} }
private func effectiveInputSampleRate(
advertisedSampleRate: Double,
previousFrameLength: AVAudioFrameCount,
previousTimestamp: AudioTimeStamp,
currentTimestamp: AudioTimeStamp,
previousHostTimestamp: AudioTimeStamp,
currentHostTimestamp: AudioTimeStamp
) -> Double {
let sampleTimeDelta = currentTimestamp.mSampleTime - previousTimestamp.mSampleTime
guard advertisedSampleRate > 0, previousFrameLength > 0 else {
systemDiagnostics.inputRateInference = "advertised (invalid advertised rate or frame count)"
return advertisedSampleRate
}
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 host inference %.3f from %u frames / %llu ns; sampleTimeDelta=%.3f)",
inferredSampleRate,
previousFrameLength,
hostNanoseconds,
sampleTimeDelta
)
return advertisedSampleRate
}
let commonSampleRates: [Double] = [8_000, 11_025, 12_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 88_200, 96_000]
let selectedSampleRate = commonSampleRates
.min(by: { abs($0 - inferredSampleRate) < abs($1 - inferredSampleRate) })
.flatMap { abs($0 - inferredSampleRate) / $0 <= 0.01 ? $0 : nil }
?? inferredSampleRate
systemDiagnostics.inputRateInference = String(
format: "source=hostTime,advertised=%.3f,inferred=%.3f,selected=%.3f,previousFrames=%u,hostNanoseconds=%llu,sampleTimeDelta=%.3f",
advertisedSampleRate,
inferredSampleRate,
selectedSampleRate,
previousFrameLength,
hostNanoseconds,
sampleTimeDelta
)
return selectedSampleRate
}
private func copyAudioBuffer( private func copyAudioBuffer(
from inputData: UnsafePointer<AudioBufferList>, from inputData: UnsafePointer<AudioBufferList>,
format: AVAudioFormat format: AVAudioFormat
@@ -863,6 +971,7 @@ final class AudioManager: NSObject, ObservableObject {
"tapAdvertisedFormat=\(systemDiagnostics.tapAdvertisedFormat)", "tapAdvertisedFormat=\(systemDiagnostics.tapAdvertisedFormat)",
"aggregateInputFormat=\(systemDiagnostics.aggregateInputFormat)", "aggregateInputFormat=\(systemDiagnostics.aggregateInputFormat)",
"selectedInputFormat=\(systemDiagnostics.selectedInputFormat)", "selectedInputFormat=\(systemDiagnostics.selectedInputFormat)",
"inputRateInference=\(systemDiagnostics.inputRateInference)",
"targetFormat=\(systemDiagnostics.targetFormat)", "targetFormat=\(systemDiagnostics.targetFormat)",
"firstBufferLayout=\(systemDiagnostics.firstBufferLayout ?? "unavailable")", "firstBufferLayout=\(systemDiagnostics.firstBufferLayout ?? "unavailable")",
"callbackCount=\(systemDiagnostics.callbackCount)", "callbackCount=\(systemDiagnostics.callbackCount)",
@@ -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
+35 -2
View File
@@ -30,6 +30,7 @@ enum CoderAPIError: LocalizedError {
case missingModel(String) case missingModel(String)
case invalidResponse case invalidResponse
case serviceError(Int, String) case serviceError(Int, String)
case audioPreparationFailed(String, String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@@ -43,6 +44,8 @@ enum CoderAPIError: LocalizedError {
return "Coder returned an invalid response." return "Coder returned an invalid response."
case .serviceError(let status, let message): case .serviceError(let status, let message):
return "Coder request failed (\(status)): \(message)" return "Coder request failed (\(status)): \(message)"
case .audioPreparationFailed(let filename, let message):
return "Could not prepare \(filename) for transcription: \(message)"
} }
} }
} }
@@ -179,7 +182,12 @@ final class CoderAPIClient {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") } guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "") let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
let chunks = try makeAudioChunks(from: fileURL, preserveSpeakerIdentity: diarization) let chunks: [AudioChunk]
do {
chunks = try makeAudioChunks(from: fileURL, preserveSpeakerIdentity: diarization)
} catch {
throw CoderAPIError.audioPreparationFailed(fileURL.lastPathComponent, error.localizedDescription)
}
defer { defer {
for chunk in chunks where chunk.isTemporary { for chunk in chunks where chunk.isTemporary {
try? FileManager.default.removeItem(at: chunk.url) try? FileManager.default.removeItem(at: chunk.url)
@@ -260,13 +268,38 @@ final class CoderAPIClient {
let size = attributes[.size] as? NSNumber { let size = attributes[.size] as? NSNumber {
request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length") request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length")
} }
let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL) let (data, response) = try await uploadTranscription(request: request, bodyURL: bodyURL)
try validate(response: response, data: data) try validate(response: response, data: data)
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data) let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
let segments = decoded.segments ?? segments(from: decoded.words ?? []) let segments = decoded.segments ?? segments(from: decoded.words ?? [])
return Transcription(text: decoded.text, segments: segments) return Transcription(text: decoded.text, segments: segments)
} }
private func uploadTranscription(request: URLRequest, bodyURL: URL) async throws -> (Data, URLResponse) {
let retryDelays: [UInt64] = [3, 10]
for attempt in 0...retryDelays.count {
do {
return try await transcriptionSession.upload(for: request, fromFile: bodyURL)
} catch {
guard attempt < retryDelays.count, isRetryableTranscriptionError(error) else {
throw error
}
try await Task.sleep(nanoseconds: retryDelays[attempt] * 1_000_000_000)
}
}
throw CoderAPIError.invalidResponse
}
private func isRetryableTranscriptionError(_ error: Error) -> Bool {
guard let urlError = error as? URLError else { return false }
switch urlError.code {
case .networkConnectionLost, .cannotConnectToHost, .timedOut:
return true
default:
return false
}
}
private func makeAudioChunks(from fileURL: URL, preserveSpeakerIdentity: Bool) throws -> [AudioChunk] { private func makeAudioChunks(from fileURL: URL, preserveSpeakerIdentity: Bool) throws -> [AudioChunk] {
let input = try AVAudioFile(forReading: fileURL) let input = try AVAudioFile(forReading: fileURL)
let format = input.processingFormat let format = input.processingFormat
+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 {