fix: make post-meeting transcription reliable (#6)

Co-authored-by: superdooper86 <[email protected]>
This commit was merged in pull request #6.
This commit is contained in:
2026-07-15 14:21:24 +02:00
committed by GitHub
co-authored by james
parent 34947e2119
commit d8f5c33141
6 changed files with 121 additions and 90 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 = 13; CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR; DEVELOPMENT_TEAM = ML6HYR5LUR;
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.1; MARKETING_VERSION = 1.1.2;
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 = owen.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = owen.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 = 13; CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR; DEVELOPMENT_TEAM = ML6HYR5LUR;
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.1; MARKETING_VERSION = 1.1.2;
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 = owen.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
+61 -9
View File
@@ -34,6 +34,7 @@ final class AudioManager: NSObject, ObservableObject {
private var systemAudioFile: AVAudioFile? private var systemAudioFile: AVAudioFile?
private var micAudioURL: URL? private var micAudioURL: URL?
private var systemAudioURL: URL? private var systemAudioURL: URL?
private var recordingStartedAt = Date()
private override init() { private override init() {
super.init() super.init()
@@ -53,9 +54,10 @@ final class AudioManager: NSObject, ObservableObject {
} }
func startRecording() { func startRecording() {
sessionID = UUID()
errorMessage = nil errorMessage = nil
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
sessionID = UUID()
recordingStartedAt = Date()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
startMicrophoneTap() startMicrophoneTap()
@@ -67,11 +69,12 @@ final class AudioManager: NSObject, ObservableObject {
} }
func stopRecordingAndTranscribe() async -> [TranscriptChunk] { func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedSessionID = sessionID
let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles() let files = stopCaptureAndCloseFiles()
isProcessing = true isProcessing = true
defer { defer {
isProcessing = false isProcessing = false
removeAudioFiles(files.compactMap { $0 })
} }
let model = UserDefaultsManager.shared.transcriptionModel let model = UserDefaultsManager.shared.transcriptionModel
@@ -85,18 +88,42 @@ final class AudioManager: NSObject, ObservableObject {
for (source, result) in zip([AudioSource.mic, .system], results) { for (source, result) in zip([AudioSource.mic, .system], results) {
guard let result else { continue } guard let result else { continue }
switch result { switch result {
case .success(let text): case .success(let transcription):
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if transcription.segments.isEmpty {
if !trimmed.isEmpty { let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
updated.append(TranscriptChunk(source: source, text: trimmed, isFinal: true)) 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): case .failure(let error):
failures.append("\(source.displayName): \(error.localizedDescription)") 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 transcriptChunks = updated
if !failures.isEmpty { let completedFiles = files.compactMap { $0 }
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") 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 return updated
} }
@@ -105,7 +132,7 @@ final class AudioManager: NSObject, ObservableObject {
cancelCapture(removeFiles: true) 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 } guard let fileURL else { return nil }
do { do {
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model)) 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]) { private func removeAudioFiles(_ urls: [URL]) {
for url in urls { try? FileManager.default.removeItem(at: 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() { private func resetAudioLevels() {
micAudioLevel = 0 micAudioLevel = 0
+24 -71
View File
@@ -1,5 +1,17 @@
import Foundation 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 { enum AudioSource: String, Codable, CaseIterable {
case mic = "MIC" case mic = "MIC"
case system = "SYS" case system = "SYS"
@@ -107,81 +119,22 @@ struct Meeting: Codable, Identifiable, Hashable {
// Formatted transcript for copying with collapsed sequential chunks // Formatted transcript for copying with collapsed sequential chunks
var formattedTranscript: String { var formattedTranscript: String {
let finalChunks = transcriptChunks.filter { $0.isFinal } let finalChunks = transcriptChunks.filter { $0.isFinal }
guard !finalChunks.isEmpty else { return "" } return finalChunks.map { chunk in
"[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.source.copyPrefix): \(chunk.text)"
var result: [String] = [] }.joined(separator: "\n")
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")
} }
// Collapsed chunks for UI display // Collapsed chunks for UI display
var collapsedTranscriptChunks: [CollapsedTranscriptChunk] { var collapsedTranscriptChunks: [CollapsedTranscriptChunk] {
guard !transcriptChunks.isEmpty else { return [] } transcriptChunks.filter(\.isFinal).map { chunk in
CollapsedTranscriptChunk(
var result: [CollapsedTranscriptChunk] = [] id: chunk.id,
var currentSource: AudioSource? timestamp: chunk.timestamp,
var currentTexts: [String] = [] source: chunk.source,
var currentTimestamp: Date? combinedText: chunk.text
)
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)
}
} }
// 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 // Separate computed properties for mic and system transcripts
@@ -198,4 +151,4 @@ struct Meeting: Codable, Identifiable, Hashable {
.map { $0.text } .map { $0.text }
.joined(separator: " ") .joined(separator: " ")
} }
} }
+2 -2
View File
@@ -141,7 +141,7 @@ final class ProcessTap {
if processObjectIDs.isEmpty { if processObjectIDs.isEmpty {
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.") 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.") logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
} }
@@ -496,4 +496,4 @@ final class ProcessTapRecorder {
self.currentAudioLevel = 0.0 self.currentAudioLevel = 0.0
} }
} }
} }
+25 -4
View File
@@ -48,6 +48,17 @@ enum CoderAPIError: LocalizedError {
final class CoderAPIClient { final class CoderAPIClient {
static let shared = 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 { private struct ModelsResponse: Decodable {
let data: [CoderModel] let data: [CoderModel]
} }
@@ -59,9 +70,17 @@ final class CoderAPIClient {
private struct TranscriptionResponse: Decodable { private struct TranscriptionResponse: Decodable {
let text: String 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] { func models(baseURL: String, apiKey: String) async throws -> [CoderModel] {
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models")) 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) 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() ?? "")
@@ -149,9 +168,10 @@ 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 URLSession.shared.upload(for: request, fromFile: bodyURL) let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL)
try validate(response: response, data: data) 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 { 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=\"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=\"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") 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) let input = try FileHandle(forReadingFrom: audioURL)
defer { try? input.close() } defer { try? input.close() }
+5
View File
@@ -197,6 +197,11 @@ struct CollapsedTranscriptChunkView: View {
var body: some View { var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(TranscriptTimestampFormatter.string(from: chunk.timestamp))
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
.frame(width: 58, alignment: .leading)
// Source indicator // Source indicator
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: chunk.source.icon) Image(systemName: chunk.source.icon)