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_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 42;
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.30;
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 = 42;
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.30;
MARKETING_VERSION = 1.1.32;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+113 -4
View File
@@ -27,6 +27,7 @@ private struct SystemCaptureDiagnostics {
var targetFormat = "unavailable"
var selectedInputSampleRate: Double?
var targetSampleRate: Double?
var inputRateInference = "unavailable"
var firstBufferLayout: String?
var callbackCount: UInt64 = 0
var inputFrameCount: UInt64 = 0
@@ -72,7 +73,7 @@ final class AudioManager: NSObject, ObservableObject {
private var recordingStartedAt = Date()
private var systemDiagnostics = SystemCaptureDiagnostics()
private override init() {
override init() {
super.init()
observeAudioEngine()
}
@@ -443,18 +444,70 @@ final class AudioManager: NSObject, ObservableObject {
systemDiagnostics.targetSampleRate = targetFormat.sampleRate
var inputFormat: AVAudioFormat?
var converter: AVAudioConverter?
var pendingBuffer: AVAudioPCMBuffer?
var pendingInputTime: AudioTimeStamp?
var pendingNow: AudioTimeStamp?
try tap.run(on: tapQueue) { [weak self] inNow, inputData, inInputTime, _, _ in
guard let self else { return }
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(
for: inputData,
advertisedFormat: advertisedInputFormat
sampleRate: selectedSampleRate
)
if let inputFormat {
self.systemDiagnostics.selectedInputFormat = self.audioFormatSummary(inputFormat)
self.systemDiagnostics.selectedInputSampleRate = inputFormat.sampleRate
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 }
// The tap queue is serial. Reusing the converter preserves its
@@ -475,7 +528,7 @@ final class AudioManager: NSObject, ObservableObject {
private func inputFormat(
for inputData: UnsafePointer<AudioBufferList>,
advertisedFormat: AVAudioFormat
sampleRate: Double
) -> AVAudioFormat? {
let buffers = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData)
@@ -492,12 +545,67 @@ final class AudioManager: NSObject, ObservableObject {
let isInterleaved = buffers.count == 1 && channelCount > 1
return AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: advertisedFormat.sampleRate,
sampleRate: sampleRate,
channels: AVAudioChannelCount(channelCount),
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(
from inputData: UnsafePointer<AudioBufferList>,
format: AVAudioFormat
@@ -863,6 +971,7 @@ final class AudioManager: NSObject, ObservableObject {
"tapAdvertisedFormat=\(systemDiagnostics.tapAdvertisedFormat)",
"aggregateInputFormat=\(systemDiagnostics.aggregateInputFormat)",
"selectedInputFormat=\(systemDiagnostics.selectedInputFormat)",
"inputRateInference=\(systemDiagnostics.inputRateInference)",
"targetFormat=\(systemDiagnostics.targetFormat)",
"firstBufferLayout=\(systemDiagnostics.firstBufferLayout ?? "unavailable")",
"callbackCount=\(systemDiagnostics.callbackCount)",
@@ -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<AnyCancellable>()
private var audioManagerCancellables = Set<AnyCancellable>()
private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>()
private var processingMeetingIds = Set<UUID>()
// 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
+35 -2
View File
@@ -30,6 +30,7 @@ enum CoderAPIError: LocalizedError {
case missingModel(String)
case invalidResponse
case serviceError(Int, String)
case audioPreparationFailed(String, String)
var errorDescription: String? {
switch self {
@@ -43,6 +44,8 @@ enum CoderAPIError: LocalizedError {
return "Coder returned an invalid response."
case .serviceError(let status, let 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)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
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 {
for chunk in chunks where chunk.isTemporary {
try? FileManager.default.removeItem(at: chunk.url)
@@ -260,13 +268,38 @@ final class CoderAPIClient {
let size = attributes[.size] as? NSNumber {
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)
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
let segments = decoded.segments ?? segments(from: decoded.words ?? [])
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] {
let input = try AVAudioFile(forReading: fileURL)
let format = input.processingFormat
+13 -13
View File
@@ -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
}
}
@@ -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 {