|
@@ -242,6 +242,7 @@ final class ViewController: NSViewController {
|
|
|
private enum MeetingTranscriptSource: String, Codable {
|
|
private enum MeetingTranscriptSource: String, Codable {
|
|
|
case meetApi
|
|
case meetApi
|
|
|
case localAudioAppleSpeech
|
|
case localAudioAppleSpeech
|
|
|
|
|
+ case localMultiChannelAppleSpeech
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private enum PaywallFooterAction {
|
|
private enum PaywallFooterAction {
|
|
@@ -266,9 +267,12 @@ final class ViewController: NSViewController {
|
|
|
let startedAt: Date
|
|
let startedAt: Date
|
|
|
let endedAt: Date
|
|
let endedAt: Date
|
|
|
let audioFilePath: String
|
|
let audioFilePath: String
|
|
|
|
|
+ var microphoneAudioFilePath: String?
|
|
|
|
|
+ var systemAudioFilePath: String?
|
|
|
var transcriptStatusRaw: String?
|
|
var transcriptStatusRaw: String?
|
|
|
var transcriptSourceRaw: String?
|
|
var transcriptSourceRaw: String?
|
|
|
var transcriptText: String?
|
|
var transcriptText: String?
|
|
|
|
|
+ var transcriptSegmentsJSON: String?
|
|
|
var transcriptErrorMessage: String?
|
|
var transcriptErrorMessage: String?
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -474,6 +478,8 @@ final class ViewController: NSViewController {
|
|
|
private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
|
|
private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
|
|
|
private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
|
|
private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
|
|
|
private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
|
|
private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
|
|
|
|
|
+ private let meetingTranscriptionService = MeetingTranscriptionService()
|
|
|
|
|
+ private var aiCompanionTranscriptProgressByMeetingId: [String: String] = [:]
|
|
|
private var darkModeEnabled: Bool {
|
|
private var darkModeEnabled: Bool {
|
|
|
get {
|
|
get {
|
|
|
let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
|
|
let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
|
|
@@ -1392,6 +1398,9 @@ private extension ViewController {
|
|
|
case .notRequested:
|
|
case .notRequested:
|
|
|
return "Transcript not requested"
|
|
return "Transcript not requested"
|
|
|
case .processing:
|
|
case .processing:
|
|
|
|
|
+ if let progress = aiCompanionTranscriptProgressByMeetingId[recording.id], progress.isEmpty == false {
|
|
|
|
|
+ return progress
|
|
|
|
|
+ }
|
|
|
return "Transcript processing..."
|
|
return "Transcript processing..."
|
|
|
case .ready:
|
|
case .ready:
|
|
|
return "Transcript ready"
|
|
return "Transcript ready"
|
|
@@ -1604,7 +1613,7 @@ private extension ViewController {
|
|
|
Task { [weak self] in
|
|
Task { [weak self] in
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
if let stopSystemAudio { await stopSystemAudio() }
|
|
if let stopSystemAudio { await stopSystemAudio() }
|
|
|
- let finalURL = await self.finalizeMeetingAudioFile(
|
|
|
|
|
|
|
+ let finalized = await self.finalizeMeetingAudioFile(
|
|
|
systemURL: session.systemAudioFileURL,
|
|
systemURL: session.systemAudioFileURL,
|
|
|
microphoneURL: session.microphoneAudioFileURL,
|
|
microphoneURL: session.microphoneAudioFileURL,
|
|
|
recordingID: session.id
|
|
recordingID: session.id
|
|
@@ -1616,10 +1625,13 @@ private extension ViewController {
|
|
|
meetURLString: session.meetURL.absoluteString,
|
|
meetURLString: session.meetURL.absoluteString,
|
|
|
startedAt: session.startedAt,
|
|
startedAt: session.startedAt,
|
|
|
endedAt: Date(),
|
|
endedAt: Date(),
|
|
|
- audioFilePath: finalURL.path,
|
|
|
|
|
|
|
+ audioFilePath: finalized.mixedURL.path,
|
|
|
|
|
+ microphoneAudioFilePath: finalized.microphoneURL?.path,
|
|
|
|
|
+ systemAudioFilePath: finalized.systemURL?.path,
|
|
|
transcriptStatusRaw: MeetingTranscriptStatus.notRequested.rawValue,
|
|
transcriptStatusRaw: MeetingTranscriptStatus.notRequested.rawValue,
|
|
|
transcriptSourceRaw: nil,
|
|
transcriptSourceRaw: nil,
|
|
|
transcriptText: nil,
|
|
transcriptText: nil,
|
|
|
|
|
+ transcriptSegmentsJSON: nil,
|
|
|
transcriptErrorMessage: nil
|
|
transcriptErrorMessage: nil
|
|
|
)
|
|
)
|
|
|
self.aiCompanionLocalRecordings.insert(summary, at: 0)
|
|
self.aiCompanionLocalRecordings.insert(summary, at: 0)
|
|
@@ -1645,44 +1657,54 @@ private extension ViewController {
|
|
|
return fileSize(at: url) >= minBytes
|
|
return fileSize(at: url) >= minBytes
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func finalizeMeetingAudioFile(systemURL: URL, microphoneURL: URL, recordingID: String) async -> URL {
|
|
|
|
|
|
|
+ private struct FinalizedMeetingAudio {
|
|
|
|
|
+ let mixedURL: URL
|
|
|
|
|
+ let microphoneURL: URL?
|
|
|
|
|
+ let systemURL: URL?
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func finalizeMeetingAudioFile(systemURL: URL, microphoneURL: URL, recordingID: String) async -> FinalizedMeetingAudio {
|
|
|
let destinationURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
|
|
let destinationURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
|
|
|
let hasSystem = hasAudioPayload(at: systemURL)
|
|
let hasSystem = hasAudioPayload(at: systemURL)
|
|
|
let hasMic = hasAudioPayload(at: microphoneURL)
|
|
let hasMic = hasAudioPayload(at: microphoneURL)
|
|
|
|
|
|
|
|
|
|
+ if !hasSystem { try? FileManager.default.removeItem(at: systemURL) }
|
|
|
|
|
+ if !hasMic { try? FileManager.default.removeItem(at: microphoneURL) }
|
|
|
|
|
+
|
|
|
|
|
+ let savedMicURL: URL? = hasMic ? microphoneURL : nil
|
|
|
|
|
+ let savedSystemURL: URL? = hasSystem ? systemURL : nil
|
|
|
|
|
+
|
|
|
if hasSystem && hasMic {
|
|
if hasSystem && hasMic {
|
|
|
do {
|
|
do {
|
|
|
try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: destinationURL)
|
|
try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: destinationURL)
|
|
|
- try? FileManager.default.removeItem(at: systemURL)
|
|
|
|
|
- try? FileManager.default.removeItem(at: microphoneURL)
|
|
|
|
|
- return destinationURL
|
|
|
|
|
|
|
+ return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
|
|
|
} catch {
|
|
} catch {
|
|
|
- // Fall back to best available single track.
|
|
|
|
|
|
|
+ // Fall back to best available single track for playback; per-channel files stay intact.
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let chosenURL: URL
|
|
|
|
|
|
|
+ let chosenURL: URL?
|
|
|
if hasSystem {
|
|
if hasSystem {
|
|
|
chosenURL = systemURL
|
|
chosenURL = systemURL
|
|
|
} else if hasMic {
|
|
} else if hasMic {
|
|
|
chosenURL = microphoneURL
|
|
chosenURL = microphoneURL
|
|
|
} else {
|
|
} else {
|
|
|
- let systemSize = fileSize(at: systemURL)
|
|
|
|
|
- let micSize = fileSize(at: microphoneURL)
|
|
|
|
|
- chosenURL = systemSize >= micSize ? systemURL : microphoneURL
|
|
|
|
|
|
|
+ chosenURL = nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if chosenURL.path != destinationURL.path {
|
|
|
|
|
- try? FileManager.default.removeItem(at: destinationURL)
|
|
|
|
|
- do {
|
|
|
|
|
- try FileManager.default.copyItem(at: chosenURL, to: destinationURL)
|
|
|
|
|
- } catch {
|
|
|
|
|
- return chosenURL
|
|
|
|
|
|
|
+ if let chosenURL {
|
|
|
|
|
+ if chosenURL.path != destinationURL.path {
|
|
|
|
|
+ try? FileManager.default.removeItem(at: destinationURL)
|
|
|
|
|
+ do {
|
|
|
|
|
+ try FileManager.default.copyItem(at: chosenURL, to: destinationURL)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return FinalizedMeetingAudio(mixedURL: chosenURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
|
|
|
}
|
|
}
|
|
|
- try? FileManager.default.removeItem(at: systemURL)
|
|
|
|
|
- try? FileManager.default.removeItem(at: microphoneURL)
|
|
|
|
|
- return destinationURL
|
|
|
|
|
|
|
+
|
|
|
|
|
+ return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: nil, systemURL: nil)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
|
|
private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
|
|
@@ -2990,10 +3012,12 @@ private extension ViewController {
|
|
|
)
|
|
)
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
|
guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
|
|
|
+ self.aiCompanionTranscriptProgressByMeetingId[meetingId] = nil
|
|
|
_ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
|
|
_ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
|
|
|
recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
|
|
recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
|
|
|
recording.transcriptSourceRaw = result.source.rawValue
|
|
recording.transcriptSourceRaw = result.source.rawValue
|
|
|
recording.transcriptText = result.text
|
|
recording.transcriptText = result.text
|
|
|
|
|
+ recording.transcriptSegmentsJSON = result.segmentsJSON
|
|
|
recording.transcriptErrorMessage = nil
|
|
recording.transcriptErrorMessage = nil
|
|
|
}
|
|
}
|
|
|
self.aiCompanionTranscriptTextView?.string = result.text
|
|
self.aiCompanionTranscriptTextView?.string = result.text
|
|
@@ -3006,6 +3030,7 @@ private extension ViewController {
|
|
|
} catch {
|
|
} catch {
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
|
guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
|
|
|
+ self.aiCompanionTranscriptProgressByMeetingId[meetingId] = nil
|
|
|
let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
|
|
let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
|
|
|
_ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
|
|
_ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
|
|
|
recording.transcriptStatusRaw = MeetingTranscriptStatus.failed.rawValue
|
|
recording.transcriptStatusRaw = MeetingTranscriptStatus.failed.rawValue
|
|
@@ -3027,23 +3052,71 @@ private extension ViewController {
|
|
|
meetingId: String,
|
|
meetingId: String,
|
|
|
interactiveAuth: Bool,
|
|
interactiveAuth: Bool,
|
|
|
presentingWindow: NSWindow?
|
|
presentingWindow: NSWindow?
|
|
|
- ) async throws -> (text: String, source: MeetingTranscriptSource) {
|
|
|
|
|
|
|
+ ) async throws -> (text: String, segmentsJSON: String?, source: MeetingTranscriptSource) {
|
|
|
guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
|
|
guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
|
|
|
throw NSError(domain: "AiCompanionTranscript", code: 10, userInfo: [NSLocalizedDescriptionKey: "Recording not found."])
|
|
throw NSError(domain: "AiCompanionTranscript", code: 10, userInfo: [NSLocalizedDescriptionKey: "Recording not found."])
|
|
|
}
|
|
}
|
|
|
_ = interactiveAuth
|
|
_ = interactiveAuth
|
|
|
_ = presentingWindow
|
|
_ = presentingWindow
|
|
|
|
|
|
|
|
- let audioURL = URL(fileURLWithPath: recording.audioFilePath)
|
|
|
|
|
- guard FileManager.default.fileExists(atPath: audioURL.path) else {
|
|
|
|
|
- throw NSError(domain: "AiCompanionTranscript", code: 12, userInfo: [NSLocalizedDescriptionKey: "Local meeting audio is missing."])
|
|
|
|
|
|
|
+ let micURL: URL? = recording.microphoneAudioFilePath
|
|
|
|
|
+ .map { URL(fileURLWithPath: $0) }
|
|
|
|
|
+ .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
|
|
|
|
|
+ let systemURL: URL? = recording.systemAudioFilePath
|
|
|
|
|
+ .map { URL(fileURLWithPath: $0) }
|
|
|
|
|
+ .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
|
|
|
|
|
+
|
|
|
|
|
+ let hasPerChannel = micURL != nil || systemURL != nil
|
|
|
|
|
+
|
|
|
|
|
+ let mixedURL = URL(fileURLWithPath: recording.audioFilePath)
|
|
|
|
|
+ if hasPerChannel == false {
|
|
|
|
|
+ guard FileManager.default.fileExists(atPath: mixedURL.path) else {
|
|
|
|
|
+ throw NSError(domain: "AiCompanionTranscript", code: 12, userInfo: [NSLocalizedDescriptionKey: "Local meeting audio is missing."])
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- let text = try await transcribeLocalAudioWithAppleSpeech(audioURL: audioURL)
|
|
|
|
|
- let cleaned = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
|
|
|
- guard cleaned.isEmpty == false else {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ let progressHandler: @Sendable (MeetingTranscriptionProgress) -> Void = { [weak self] progress in
|
|
|
|
|
+ Task { @MainActor [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ let text: String
|
|
|
|
|
+ if progress.totalChunks > 0 {
|
|
|
|
|
+ text = "Transcribing \(progress.completedChunks)/\(progress.totalChunks) chunks..."
|
|
|
|
|
+ } else {
|
|
|
|
|
+ text = "Transcript processing..."
|
|
|
|
|
+ }
|
|
|
|
|
+ self.aiCompanionTranscriptProgressByMeetingId[meetingId] = text
|
|
|
|
|
+ self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let segments: [TranscriptSegment]
|
|
|
|
|
+ let source: MeetingTranscriptSource
|
|
|
|
|
+ if hasPerChannel {
|
|
|
|
|
+ segments = try await meetingTranscriptionService.transcribeMeeting(
|
|
|
|
|
+ micURL: micURL,
|
|
|
|
|
+ systemURL: systemURL,
|
|
|
|
|
+ onProgress: progressHandler
|
|
|
|
|
+ )
|
|
|
|
|
+ source = .localMultiChannelAppleSpeech
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Backward compatibility: old recordings only have a mixed file.
|
|
|
|
|
+ segments = try await meetingTranscriptionService.transcribeMeeting(
|
|
|
|
|
+ micURL: nil,
|
|
|
|
|
+ systemURL: mixedURL,
|
|
|
|
|
+ onProgress: progressHandler
|
|
|
|
|
+ )
|
|
|
|
|
+ source = .localAudioAppleSpeech
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let renderedText = segments.renderedTimelineText().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard renderedText.isEmpty == false else {
|
|
|
throw NSError(domain: "AiCompanionTranscript", code: 14, userInfo: [NSLocalizedDescriptionKey: "Generated transcript was empty."])
|
|
throw NSError(domain: "AiCompanionTranscript", code: 14, userInfo: [NSLocalizedDescriptionKey: "Generated transcript was empty."])
|
|
|
}
|
|
}
|
|
|
- return (cleaned, .localAudioAppleSpeech)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ let encoder = JSONEncoder()
|
|
|
|
|
+ let segmentsJSON = (try? encoder.encode(segments)).flatMap { String(data: $0, encoding: .utf8) }
|
|
|
|
|
+
|
|
|
|
|
+ return (renderedText, segmentsJSON, source)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
|
|
@objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
|