|
|
@@ -231,6 +231,18 @@ private final class StoreKitCoordinator {
|
|
|
}
|
|
|
|
|
|
final class ViewController: NSViewController {
|
|
|
+ private enum MeetingTranscriptStatus: String, Codable {
|
|
|
+ case notRequested
|
|
|
+ case processing
|
|
|
+ case ready
|
|
|
+ case failed
|
|
|
+ }
|
|
|
+
|
|
|
+ private enum MeetingTranscriptSource: String, Codable {
|
|
|
+ case meetApi
|
|
|
+ case localAudioOpenAI
|
|
|
+ }
|
|
|
+
|
|
|
private enum PaywallFooterAction {
|
|
|
case manageSubscription
|
|
|
case restorePurchase
|
|
|
@@ -253,6 +265,10 @@ final class ViewController: NSViewController {
|
|
|
let startedAt: Date
|
|
|
let endedAt: Date
|
|
|
let audioFilePath: String
|
|
|
+ var transcriptStatusRaw: String?
|
|
|
+ var transcriptSourceRaw: String?
|
|
|
+ var transcriptText: String?
|
|
|
+ var transcriptErrorMessage: String?
|
|
|
}
|
|
|
|
|
|
private struct ActiveMeetingRecordingSession {
|
|
|
@@ -260,7 +276,8 @@ final class ViewController: NSViewController {
|
|
|
let title: String
|
|
|
let meetURL: URL
|
|
|
let startedAt: Date
|
|
|
- let audioFileURL: URL
|
|
|
+ let systemAudioFileURL: URL
|
|
|
+ let microphoneAudioFileURL: URL
|
|
|
}
|
|
|
|
|
|
private var palette = Palette(isDarkMode: true)
|
|
|
@@ -290,10 +307,10 @@ final class ViewController: NSViewController {
|
|
|
private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
|
private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
|
|
|
private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
|
- private var aiCompanionTranscriptTextByMeetingId = [String: String]()
|
|
|
private var aiCompanionTranscriptCurrentRequestId: UUID?
|
|
|
private var aiCompanionTranscriptWindow: NSWindow?
|
|
|
private weak var aiCompanionTranscriptTextView: NSTextView?
|
|
|
+ private var aiCompanionTranscriptTaskByMeetingId = [String: Task<Void, Never>]()
|
|
|
private var aiCompanionAudioPlayer: AVPlayer?
|
|
|
private var aiCompanionLocalAudioPlayer: AVAudioPlayer?
|
|
|
private var aiCompanionCurrentlyPlayingURL: URL?
|
|
|
@@ -455,7 +472,10 @@ final class ViewController: NSViewController {
|
|
|
private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
|
|
|
private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
|
|
|
private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
|
|
|
+ private let openAIAPIKeyDefaultsKey = "openai.apiKey"
|
|
|
+ private let openAIAPIKeyPlistKey = "OpenAIAPIKey"
|
|
|
private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
|
|
|
+ private let openAITranscriptionClient = OpenAITranscriptionClient()
|
|
|
private var darkModeEnabled: Bool {
|
|
|
get {
|
|
|
let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
|
|
|
@@ -1284,6 +1304,73 @@ private extension ViewController {
|
|
|
UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
|
|
|
}
|
|
|
|
|
|
+ private func configuredOpenAIAPIKey() -> String? {
|
|
|
+ let value = UserDefaults.standard.string(forKey: openAIAPIKeyDefaultsKey)?
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if let value, value.isEmpty == false {
|
|
|
+ return value
|
|
|
+ }
|
|
|
+ let plistValue = Bundle.main.object(forInfoDictionaryKey: openAIAPIKeyPlistKey) as? String
|
|
|
+ let trimmedPlist = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if let trimmedPlist, trimmedPlist.isEmpty == false {
|
|
|
+ return trimmedPlist
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionTranscriptStatus(for recording: MeetingRecordingSummary) -> MeetingTranscriptStatus {
|
|
|
+ guard let raw = recording.transcriptStatusRaw, let status = MeetingTranscriptStatus(rawValue: raw) else {
|
|
|
+ return .notRequested
|
|
|
+ }
|
|
|
+ return status
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionTranscriptStatusText(for recording: MeetingRecordingSummary) -> String {
|
|
|
+ switch aiCompanionTranscriptStatus(for: recording) {
|
|
|
+ case .notRequested:
|
|
|
+ return "Transcript not requested"
|
|
|
+ case .processing:
|
|
|
+ return "Transcript processing..."
|
|
|
+ case .ready:
|
|
|
+ return "Transcript ready"
|
|
|
+ case .failed:
|
|
|
+ let error = recording.transcriptErrorMessage?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if let error, error.isEmpty == false {
|
|
|
+ return "Transcript unavailable (tap to retry)"
|
|
|
+ }
|
|
|
+ return "Transcript unavailable (tap to retry)"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionMeetingFromRecording(_ recording: MeetingRecordingSummary) -> ScheduledMeeting? {
|
|
|
+ guard let meetURL = URL(string: recording.meetURLString) else { return nil }
|
|
|
+ return ScheduledMeeting(
|
|
|
+ id: recording.id,
|
|
|
+ title: recording.title,
|
|
|
+ subtitle: nil,
|
|
|
+ startDate: recording.startedAt,
|
|
|
+ endDate: recording.endedAt,
|
|
|
+ meetURL: meetURL,
|
|
|
+ isAllDay: false
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ @discardableResult
|
|
|
+ private func aiCompanionUpdateRecording(meetingId: String, mutate: (inout MeetingRecordingSummary) -> Void) -> MeetingRecordingSummary? {
|
|
|
+ guard let idx = aiCompanionLocalRecordings.firstIndex(where: { $0.id == meetingId }) else { return nil }
|
|
|
+ mutate(&aiCompanionLocalRecordings[idx])
|
|
|
+ persistAiCompanionLocalRecordings()
|
|
|
+ return aiCompanionLocalRecordings[idx]
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionRefreshTranscriptStatusLabels(forMeetingID meetingId: String) {
|
|
|
+ guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else { return }
|
|
|
+ let statusText = aiCompanionTranscriptStatusText(for: recording)
|
|
|
+ for (buttonId, linkedMeetingId) in aiCompanionTranscriptMeetingIdByView where linkedMeetingId == meetingId {
|
|
|
+ aiCompanionTranscriptStatusLabelByView[buttonId]?.stringValue = statusText
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private func localRecordingDirectoryURL() -> URL {
|
|
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
|
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
|
@@ -1378,18 +1465,22 @@ private extension ViewController {
|
|
|
|
|
|
private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
|
|
|
let recordingID = UUID().uuidString
|
|
|
- let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
|
|
|
+ let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-system.m4a")
|
|
|
+ let microphoneURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-mic.m4a")
|
|
|
|
|
|
activeMeetingRecordingSession = ActiveMeetingRecordingSession(
|
|
|
id: recordingID,
|
|
|
title: meetingTitle,
|
|
|
meetURL: meetingURL,
|
|
|
startedAt: Date(),
|
|
|
- audioFileURL: outputURL
|
|
|
+ systemAudioFileURL: outputURL,
|
|
|
+ microphoneAudioFileURL: microphoneURL
|
|
|
)
|
|
|
pageCache[.aiCompanion] = nil
|
|
|
if selectedSidebarPage == .aiCompanion { showSidebarPage(.aiCompanion) }
|
|
|
|
|
|
+ startMicrophoneRecording(at: microphoneURL, showToast: false)
|
|
|
+
|
|
|
if #available(macOS 13.0, *) {
|
|
|
let systemRecorder = MeetingSystemAudioRecorder(outputURL: outputURL)
|
|
|
activeMeetingSystemAudioStopper = { [systemRecorder] in
|
|
|
@@ -1400,22 +1491,22 @@ private extension ViewController {
|
|
|
do {
|
|
|
try await systemRecorder.start()
|
|
|
await MainActor.run {
|
|
|
- self.showTopToast(message: "Meeting recording started (meeting audio)", isError: false)
|
|
|
+ self.showTopToast(message: "Meeting recording started (meeting + microphone)", isError: false)
|
|
|
}
|
|
|
} catch {
|
|
|
await MainActor.run {
|
|
|
self.activeMeetingSystemAudioStopper = nil
|
|
|
- self.startMicrophoneFallbackRecording(at: outputURL)
|
|
|
+ self.showTopToast(message: "System audio unavailable. Recording microphone only.", isError: true)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- startMicrophoneFallbackRecording(at: outputURL)
|
|
|
+ showTopToast(message: "Meeting recording started (microphone only)", isError: false)
|
|
|
}
|
|
|
|
|
|
- private func startMicrophoneFallbackRecording(at outputURL: URL) {
|
|
|
+ private func startMicrophoneRecording(at outputURL: URL, showToast: Bool) {
|
|
|
let settings: [String: Any] = [
|
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
|
AVSampleRateKey: 44_100,
|
|
|
@@ -1427,7 +1518,9 @@ private extension ViewController {
|
|
|
recorder.prepareToRecord()
|
|
|
recorder.record()
|
|
|
activeMeetingAudioRecorder = recorder
|
|
|
- showTopToast(message: "Meeting recording started (microphone only)", isError: false)
|
|
|
+ if showToast {
|
|
|
+ showTopToast(message: "Meeting recording started (microphone only)", isError: false)
|
|
|
+ }
|
|
|
} catch {
|
|
|
activeMeetingRecordingSession = nil
|
|
|
showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
|
|
|
@@ -1442,26 +1535,126 @@ private extension ViewController {
|
|
|
guard let session = activeMeetingRecordingSession else { return }
|
|
|
let stopSystemAudio = activeMeetingSystemAudioStopper
|
|
|
activeMeetingSystemAudioStopper = nil
|
|
|
- if let stopSystemAudio { Task { await stopSystemAudio() } }
|
|
|
activeMeetingAudioRecorder?.stop()
|
|
|
activeMeetingAudioRecorder = nil
|
|
|
activeMeetingRecordingSession = nil
|
|
|
+ Task { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ if let stopSystemAudio { await stopSystemAudio() }
|
|
|
+ let finalURL = await self.finalizeMeetingAudioFile(
|
|
|
+ systemURL: session.systemAudioFileURL,
|
|
|
+ microphoneURL: session.microphoneAudioFileURL,
|
|
|
+ recordingID: session.id
|
|
|
+ )
|
|
|
+ await MainActor.run {
|
|
|
+ let summary = MeetingRecordingSummary(
|
|
|
+ id: session.id,
|
|
|
+ title: session.title,
|
|
|
+ meetURLString: session.meetURL.absoluteString,
|
|
|
+ startedAt: session.startedAt,
|
|
|
+ endedAt: Date(),
|
|
|
+ audioFilePath: finalURL.path,
|
|
|
+ transcriptStatusRaw: MeetingTranscriptStatus.notRequested.rawValue,
|
|
|
+ transcriptSourceRaw: nil,
|
|
|
+ transcriptText: nil,
|
|
|
+ transcriptErrorMessage: nil
|
|
|
+ )
|
|
|
+ self.aiCompanionLocalRecordings.insert(summary, at: 0)
|
|
|
+ self.aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
|
|
|
+ self.persistAiCompanionLocalRecordings()
|
|
|
+ self.aiCompanionStartTranscriptProcessing(forMeetingID: summary.id, requestId: nil, interactiveAuth: false, forceRegenerate: false)
|
|
|
+ self.pageCache[.aiCompanion] = nil
|
|
|
+ self.showTopToast(message: "Meeting recording saved", isError: false)
|
|
|
+ if self.selectedSidebarPage == .aiCompanion {
|
|
|
+ self.showSidebarPage(.aiCompanion)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- let summary = MeetingRecordingSummary(
|
|
|
- id: session.id,
|
|
|
- title: session.title,
|
|
|
- meetURLString: session.meetURL.absoluteString,
|
|
|
- startedAt: session.startedAt,
|
|
|
- endedAt: Date(),
|
|
|
- audioFilePath: session.audioFileURL.path
|
|
|
- )
|
|
|
- aiCompanionLocalRecordings.insert(summary, at: 0)
|
|
|
- aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
|
|
|
- persistAiCompanionLocalRecordings()
|
|
|
- pageCache[.aiCompanion] = nil
|
|
|
- showTopToast(message: "Meeting recording saved", isError: false)
|
|
|
- if selectedSidebarPage == .aiCompanion {
|
|
|
- showSidebarPage(.aiCompanion)
|
|
|
+ private func fileSize(at url: URL) -> Int64 {
|
|
|
+ let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
|
|
+ return attrs?[.size] as? Int64 ?? 0
|
|
|
+ }
|
|
|
+
|
|
|
+ private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
|
|
|
+ guard FileManager.default.fileExists(atPath: url.path) else { return false }
|
|
|
+ return fileSize(at: url) >= minBytes
|
|
|
+ }
|
|
|
+
|
|
|
+ private func finalizeMeetingAudioFile(systemURL: URL, microphoneURL: URL, recordingID: String) async -> URL {
|
|
|
+ let destinationURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
|
|
|
+ let hasSystem = hasAudioPayload(at: systemURL)
|
|
|
+ let hasMic = hasAudioPayload(at: microphoneURL)
|
|
|
+
|
|
|
+ if hasSystem && hasMic {
|
|
|
+ do {
|
|
|
+ try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: destinationURL)
|
|
|
+ try? FileManager.default.removeItem(at: systemURL)
|
|
|
+ try? FileManager.default.removeItem(at: microphoneURL)
|
|
|
+ return destinationURL
|
|
|
+ } catch {
|
|
|
+ // Fall back to best available single track.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let chosenURL: URL
|
|
|
+ if hasSystem {
|
|
|
+ chosenURL = systemURL
|
|
|
+ } else if hasMic {
|
|
|
+ chosenURL = microphoneURL
|
|
|
+ } else {
|
|
|
+ let systemSize = fileSize(at: systemURL)
|
|
|
+ let micSize = fileSize(at: microphoneURL)
|
|
|
+ chosenURL = systemSize >= micSize ? systemURL : microphoneURL
|
|
|
+ }
|
|
|
+
|
|
|
+ if chosenURL.path != destinationURL.path {
|
|
|
+ try? FileManager.default.removeItem(at: destinationURL)
|
|
|
+ do {
|
|
|
+ try FileManager.default.copyItem(at: chosenURL, to: destinationURL)
|
|
|
+ } catch {
|
|
|
+ return chosenURL
|
|
|
+ }
|
|
|
+ }
|
|
|
+ try? FileManager.default.removeItem(at: systemURL)
|
|
|
+ try? FileManager.default.removeItem(at: microphoneURL)
|
|
|
+ return destinationURL
|
|
|
+ }
|
|
|
+
|
|
|
+ private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
|
|
|
+ try? FileManager.default.removeItem(at: destinationURL)
|
|
|
+
|
|
|
+ let composition = AVMutableComposition()
|
|
|
+ guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
|
|
|
+ let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
|
|
+ throw NSError(domain: "MeetingAudioMix", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to create audio composition tracks."])
|
|
|
+ }
|
|
|
+
|
|
|
+ let systemAsset = AVURLAsset(url: systemURL)
|
|
|
+ let micAsset = AVURLAsset(url: microphoneURL)
|
|
|
+
|
|
|
+ if let src = try await systemAsset.loadTracks(withMediaType: .audio).first {
|
|
|
+ let duration = try await systemAsset.load(.duration)
|
|
|
+ try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
+ }
|
|
|
+ if let src = try await micAsset.loadTracks(withMediaType: .audio).first {
|
|
|
+ let duration = try await micAsset.load(.duration)
|
|
|
+ try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
|
|
|
+ throw NSError(domain: "MeetingAudioMix", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unable to create audio export session."])
|
|
|
+ }
|
|
|
+ export.outputURL = destinationURL
|
|
|
+ export.outputFileType = .m4a
|
|
|
+ await withCheckedContinuation { continuation in
|
|
|
+ export.exportAsynchronously {
|
|
|
+ continuation.resume()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if export.status != .completed {
|
|
|
+ throw export.error ?? NSError(domain: "MeetingAudioMix", code: 3, userInfo: [NSLocalizedDescriptionKey: "Audio mix export failed."])
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2228,7 +2421,8 @@ private extension ViewController {
|
|
|
aiCompanionSpeechTextByView.removeAll()
|
|
|
aiCompanionTranscriptMeetingIdByView.removeAll()
|
|
|
aiCompanionTranscriptStatusLabelByView.removeAll()
|
|
|
- aiCompanionTranscriptTextByMeetingId.removeAll()
|
|
|
+ aiCompanionTranscriptTaskByMeetingId.values.forEach { $0.cancel() }
|
|
|
+ aiCompanionTranscriptTaskByMeetingId.removeAll()
|
|
|
aiCompanionTranscriptCurrentRequestId = nil
|
|
|
aiCompanionTranscriptWindow?.close()
|
|
|
aiCompanionTranscriptWindow = nil
|
|
|
@@ -2424,7 +2618,7 @@ private extension ViewController {
|
|
|
|
|
|
aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = recording.id
|
|
|
|
|
|
- let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
|
|
|
+ let transcriptStatusLabel = textLabel(aiCompanionTranscriptStatusText(for: recording), font: typography.fieldLabel, color: palette.textMuted)
|
|
|
transcriptStatusLabel.alignment = .left
|
|
|
transcriptStatusLabel.maximumNumberOfLines = 2
|
|
|
transcriptStatusLabel.lineBreakMode = .byTruncatingTail
|
|
|
@@ -2663,47 +2857,144 @@ private extension ViewController {
|
|
|
@objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
|
|
|
let senderId = ObjectIdentifier(sender)
|
|
|
guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
|
|
|
- guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else {
|
|
|
- aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
|
|
|
- showSimpleAlert(title: "Transcript unavailable", message: "Transcript fetch is available for Google Calendar meetings only.")
|
|
|
+ guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
|
|
|
+ aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable (tap to retry)"
|
|
|
+ showSimpleAlert(title: "Transcript unavailable", message: "Could not find recording details for this meeting.")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
|
|
|
- aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
|
|
|
- aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
|
|
|
+ if aiCompanionTranscriptStatus(for: recording) == .ready,
|
|
|
+ let cached = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
+ cached.isEmpty == false {
|
|
|
+ aiCompanionPresentTranscriptWindow(meetingTitle: recording.title, initialText: cached)
|
|
|
+ aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = aiCompanionTranscriptStatusText(for: recording)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Loading transcript..."
|
|
|
+ aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript processing..."
|
|
|
|
|
|
let requestId = UUID()
|
|
|
aiCompanionTranscriptCurrentRequestId = requestId
|
|
|
|
|
|
- aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: "Loading transcript...")
|
|
|
+ aiCompanionPresentTranscriptWindow(meetingTitle: recording.title, initialText: "Transcript processing...")
|
|
|
+ aiCompanionStartTranscriptProcessing(forMeetingID: meetingId, requestId: requestId, interactiveAuth: true, forceRegenerate: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionStartTranscriptProcessing(
|
|
|
+ forMeetingID meetingId: String,
|
|
|
+ requestId: UUID?,
|
|
|
+ interactiveAuth: Bool,
|
|
|
+ forceRegenerate: Bool
|
|
|
+ ) {
|
|
|
+ if !forceRegenerate,
|
|
|
+ let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }),
|
|
|
+ aiCompanionTranscriptStatus(for: recording) == .ready {
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- Task { [weak self] in
|
|
|
+ aiCompanionTranscriptTaskByMeetingId[meetingId]?.cancel()
|
|
|
+ _ = aiCompanionUpdateRecording(meetingId: meetingId) { recording in
|
|
|
+ recording.transcriptStatusRaw = MeetingTranscriptStatus.processing.rawValue
|
|
|
+ recording.transcriptErrorMessage = nil
|
|
|
+ if forceRegenerate {
|
|
|
+ recording.transcriptText = nil
|
|
|
+ recording.transcriptSourceRaw = nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+ aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
|
|
|
+ if selectedSidebarPage == .aiCompanion {
|
|
|
+ pageCache[.aiCompanion] = nil
|
|
|
+ showSidebarPage(.aiCompanion)
|
|
|
+ }
|
|
|
+
|
|
|
+ let presentingWindow = view.window
|
|
|
+ let task = Task { [weak self] in
|
|
|
guard let self else { return }
|
|
|
+ defer { Task { @MainActor [weak self] in self?.aiCompanionTranscriptTaskByMeetingId[meetingId] = nil } }
|
|
|
|
|
|
do {
|
|
|
- let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
- let text = try await self.aiCompanionFetchTranscriptText(for: meeting, accessToken: token)
|
|
|
-
|
|
|
+ let result = try await self.aiCompanionFetchOrGenerateTranscript(
|
|
|
+ meetingId: meetingId,
|
|
|
+ interactiveAuth: interactiveAuth,
|
|
|
+ presentingWindow: presentingWindow
|
|
|
+ )
|
|
|
await MainActor.run {
|
|
|
- guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return } // stale request
|
|
|
- self.aiCompanionTranscriptTextByMeetingId[meetingId] = text
|
|
|
- self.aiCompanionTranscriptTextView?.string = text
|
|
|
- self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
|
|
|
+ guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
|
+ _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
|
|
|
+ recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
|
|
|
+ recording.transcriptSourceRaw = result.source.rawValue
|
|
|
+ recording.transcriptText = result.text
|
|
|
+ recording.transcriptErrorMessage = nil
|
|
|
+ }
|
|
|
+ self.aiCompanionTranscriptTextView?.string = result.text
|
|
|
+ self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
|
|
|
+ if self.selectedSidebarPage == .aiCompanion {
|
|
|
+ self.pageCache[.aiCompanion] = nil
|
|
|
+ self.showSidebarPage(.aiCompanion)
|
|
|
+ }
|
|
|
}
|
|
|
} catch {
|
|
|
await MainActor.run {
|
|
|
- guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
|
+ guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
|
let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
|
|
|
+ _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
|
|
|
+ recording.transcriptStatusRaw = MeetingTranscriptStatus.failed.rawValue
|
|
|
+ recording.transcriptErrorMessage = msg
|
|
|
+ }
|
|
|
self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
|
|
|
- self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
|
|
|
+ self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
|
|
|
+ if self.selectedSidebarPage == .aiCompanion {
|
|
|
+ self.pageCache[.aiCompanion] = nil
|
|
|
+ self.showSidebarPage(.aiCompanion)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ aiCompanionTranscriptTaskByMeetingId[meetingId] = task
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionFetchOrGenerateTranscript(
|
|
|
+ meetingId: String,
|
|
|
+ interactiveAuth: Bool,
|
|
|
+ presentingWindow: NSWindow?
|
|
|
+ ) async throws -> (text: String, source: MeetingTranscriptSource) {
|
|
|
+ guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
|
|
|
+ throw NSError(domain: "AiCompanionTranscript", code: 10, userInfo: [NSLocalizedDescriptionKey: "Recording not found."])
|
|
|
+ }
|
|
|
+
|
|
|
+ if let meeting = aiCompanionMeetingFromRecording(recording) {
|
|
|
+ do {
|
|
|
+ let accessToken: String
|
|
|
+ if interactiveAuth {
|
|
|
+ accessToken = try await googleOAuth.validAccessToken(presentingWindow: presentingWindow)
|
|
|
+ } else if let token = googleOAuth.loadTokens()?.accessToken {
|
|
|
+ accessToken = token
|
|
|
+ } else {
|
|
|
+ throw NSError(domain: "AiCompanionTranscript", code: 11, userInfo: [NSLocalizedDescriptionKey: "Google account not connected."])
|
|
|
}
|
|
|
+ let text = try await aiCompanionFetchTranscriptText(for: meeting, accessToken: accessToken)
|
|
|
+ let cleaned = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
|
+ if cleaned.isEmpty == false {
|
|
|
+ return (cleaned, .meetApi)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // Continue to OpenAI fallback below.
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ 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."])
|
|
|
+ }
|
|
|
+ guard let apiKey = configuredOpenAIAPIKey() else {
|
|
|
+ throw NSError(domain: "AiCompanionTranscript", code: 13, userInfo: [NSLocalizedDescriptionKey: "OpenAI API key missing. Add OpenAIAPIKey to Info.plist or openai.apiKey to UserDefaults."])
|
|
|
+ }
|
|
|
+ let text = try await openAITranscriptionClient.transcribeAudioFile(fileURL: audioURL, apiKey: apiKey)
|
|
|
+ let cleaned = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
|
+ guard cleaned.isEmpty == false else {
|
|
|
+ throw NSError(domain: "AiCompanionTranscript", code: 14, userInfo: [NSLocalizedDescriptionKey: "Generated transcript was empty."])
|
|
|
+ }
|
|
|
+ return (cleaned, .localAudioOpenAI)
|
|
|
}
|
|
|
|
|
|
@objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
|
|
|
@@ -5766,6 +6057,8 @@ private final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStre
|
|
|
config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
|
|
|
config.queueDepth = 1
|
|
|
config.capturesAudio = true
|
|
|
+ config.sampleRate = 48_000
|
|
|
+ config.channelCount = 2
|
|
|
if #available(macOS 13.0, *) {
|
|
|
config.excludesCurrentProcessAudio = true
|
|
|
}
|