fix: make post-meeting transcription reliable

This commit is contained in:
2026-07-15 14:18:27 +02:00
parent 34947e2119
commit b317a0f103
6 changed files with 121 additions and 90 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 = 13;
CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR;
ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 13;
CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR;
ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
+61 -9
View File
@@ -34,6 +34,7 @@ final class AudioManager: NSObject, ObservableObject {
private var systemAudioFile: AVAudioFile?
private var micAudioURL: URL?
private var systemAudioURL: URL?
private var recordingStartedAt = Date()
private override init() {
super.init()
@@ -53,9 +54,10 @@ final class AudioManager: NSObject, ObservableObject {
}
func startRecording() {
sessionID = UUID()
errorMessage = nil
cancelCapture(removeFiles: true)
sessionID = UUID()
recordingStartedAt = Date()
do {
try prepareAudioFiles()
startMicrophoneTap()
@@ -67,11 +69,12 @@ final class AudioManager: NSObject, ObservableObject {
}
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedSessionID = sessionID
let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles()
isProcessing = true
defer {
isProcessing = false
removeAudioFiles(files.compactMap { $0 })
}
let model = UserDefaultsManager.shared.transcriptionModel
@@ -85,18 +88,42 @@ final class AudioManager: NSObject, ObservableObject {
for (source, result) in zip([AudioSource.mic, .system], results) {
guard let result else { continue }
switch result {
case .success(let text):
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
updated.append(TranscriptChunk(source: source, text: trimmed, isFinal: true))
case .success(let transcription):
if transcription.segments.isEmpty {
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
updated.append(TranscriptChunk(timestamp: captureStartedAt, source: source, text: text, isFinal: true))
}
} else {
for segment in transcription.segments {
let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue }
updated.append(TranscriptChunk(
timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)),
source: source,
text: text,
isFinal: true
))
}
}
case .failure(let error):
failures.append("\(source.displayName): \(error.localizedDescription)")
}
}
updated.sort {
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
return $0.source.rawValue < $1.source.rawValue
}
transcriptChunks = updated
if !failures.isEmpty {
errorMessage = "Transcription failed for " + failures.joined(separator: "; ")
let completedFiles = files.compactMap { $0 }
if failures.isEmpty {
removeAudioFiles(completedFiles)
} else {
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID)
let recoveryMessage = recoveryFolder == nil
? " The audio remains in the app's temporary folder."
: " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)."
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
}
return updated
}
@@ -105,7 +132,7 @@ final class AudioManager: NSObject, ObservableObject {
cancelCapture(removeFiles: true)
}
private func transcribe(_ fileURL: URL?, model: String) async -> Result<String, Error>? {
private func transcribe(_ fileURL: URL?, model: String) async -> Result<CoderAPIClient.Transcription, Error>? {
guard let fileURL else { return nil }
do {
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
@@ -389,6 +416,31 @@ final class AudioManager: NSObject, ObservableObject {
private func removeAudioFiles(_ urls: [URL]) {
for url in urls { try? FileManager.default.removeItem(at: url) }
}
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? {
guard !urls.isEmpty,
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let folder = documents
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
.appendingPathComponent(sessionID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
do {
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent))
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
}
private func resetAudioLevels() {
micAudioLevel = 0
+24 -71
View File
@@ -1,5 +1,17 @@
import Foundation
enum TranscriptTimestampFormatter {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
static func string(from date: Date) -> String {
formatter.string(from: date)
}
}
enum AudioSource: String, Codable, CaseIterable {
case mic = "MIC"
case system = "SYS"
@@ -107,81 +119,22 @@ struct Meeting: Codable, Identifiable, Hashable {
// Formatted transcript for copying with collapsed sequential chunks
var formattedTranscript: String {
let finalChunks = transcriptChunks.filter { $0.isFinal }
guard !finalChunks.isEmpty else { return "" }
var result: [String] = []
var currentSource: AudioSource?
var currentTexts: [String] = []
for chunk in finalChunks {
if chunk.source != currentSource {
// Finish previous section if exists
if let source = currentSource, !currentTexts.isEmpty {
let combinedText = currentTexts.joined(separator: " ")
result.append("\(source.copyPrefix): \(combinedText)")
}
// Start new section
currentSource = chunk.source
currentTexts = [chunk.text]
} else {
// Same source, add to current section
currentTexts.append(chunk.text)
}
}
// Finish last section
if let source = currentSource, !currentTexts.isEmpty {
let combinedText = currentTexts.joined(separator: " ")
result.append("\(source.copyPrefix): \(combinedText)")
}
return result.joined(separator: " \n")
return finalChunks.map { chunk in
"[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.source.copyPrefix): \(chunk.text)"
}.joined(separator: "\n")
}
// Collapsed chunks for UI display
var collapsedTranscriptChunks: [CollapsedTranscriptChunk] {
guard !transcriptChunks.isEmpty else { return [] }
var result: [CollapsedTranscriptChunk] = []
var currentSource: AudioSource?
var currentTexts: [String] = []
var currentTimestamp: Date?
for chunk in transcriptChunks {
if chunk.source != currentSource {
// Finish previous section if exists
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
let combinedText = currentTexts.joined(separator: " ")
result.append(CollapsedTranscriptChunk(
timestamp: timestamp,
source: source,
combinedText: combinedText
))
}
// Start new section
currentSource = chunk.source
currentTexts = [chunk.text]
currentTimestamp = chunk.timestamp
} else {
// Same source, add to current section
currentTexts.append(chunk.text)
}
transcriptChunks.filter(\.isFinal).map { chunk in
CollapsedTranscriptChunk(
id: chunk.id,
timestamp: chunk.timestamp,
source: chunk.source,
combinedText: chunk.text
)
}
// Finish last section
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
let combinedText = currentTexts.joined(separator: " ")
result.append(CollapsedTranscriptChunk(
timestamp: timestamp,
source: source,
combinedText: combinedText
))
}
return result
}
// Separate computed properties for mic and system transcripts
@@ -198,4 +151,4 @@ struct Meeting: Codable, Identifiable, Hashable {
.map { $0.text }
.joined(separator: " ")
}
}
}
+2 -2
View File
@@ -141,7 +141,7 @@ final class ProcessTap {
if processObjectIDs.isEmpty {
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.")
}
tapDescription = CATapDescription(stereoMixdownOfProcesses: processObjectIDs)
tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs)
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
}
@@ -496,4 +496,4 @@ final class ProcessTapRecorder {
self.currentAudioLevel = 0.0
}
}
}
}
+25 -4
View File
@@ -48,6 +48,17 @@ enum CoderAPIError: LocalizedError {
final class CoderAPIClient {
static let shared = CoderAPIClient()
struct Transcription {
struct Segment: Decodable {
let start: TimeInterval
let end: TimeInterval
let text: String
}
let text: String
let segments: [Segment]
}
private struct ModelsResponse: Decodable {
let data: [CoderModel]
}
@@ -59,9 +70,17 @@ final class CoderAPIClient {
private struct TranscriptionResponse: Decodable {
let text: String
let segments: [Transcription.Segment]?
}
private init() {}
private let transcriptionSession: URLSession
private init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 2 * 60 * 60
configuration.timeoutIntervalForResource = 2 * 60 * 60
transcriptionSession = URLSession(configuration: configuration)
}
func models(baseURL: String, apiKey: String) async throws -> [CoderModel] {
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models"))
@@ -128,7 +147,7 @@ final class CoderAPIClient {
}
}
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> String {
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> Transcription {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
@@ -149,9 +168,10 @@ final class CoderAPIClient {
let size = attributes[.size] as? NSNumber {
request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length")
}
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: bodyURL)
let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL)
try validate(response: response, data: data)
return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
return Transcription(text: decoded.text, segments: decoded.segments ?? [])
}
private func endpoint(baseURL: String, path: String) throws -> URL {
@@ -194,6 +214,7 @@ final class CoderAPIClient {
}
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n")
let input = try FileHandle(forReadingFrom: audioURL)
defer { try? input.close() }
+5
View File
@@ -197,6 +197,11 @@ struct CollapsedTranscriptChunkView: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(TranscriptTimestampFormatter.string(from: chunk.timestamp))
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
.frame(width: 58, alignment: .leading)
// Source indicator
HStack(spacing: 4) {
Image(systemName: chunk.source.icon)