diff --git a/notetaker/Managers/AudioManager.swift b/notetaker/Managers/AudioManager.swift index 7412793..ddb281c 100644 --- a/notetaker/Managers/AudioManager.swift +++ b/notetaker/Managers/AudioManager.swift @@ -6,15 +6,9 @@ import Foundation import SwiftUI import ScreenCaptureKit -// Distinguish which source (mic vs system) an audio buffer belongs to -private enum AudioSource { - case mic - case system -} - /// Manages audio capture from microphone and/or system audio and handles real-time transcription via Deepgram class AudioManager: NSObject, ObservableObject { - @Published var transcript = "" + @Published var transcriptChunks: [TranscriptChunk] = [] @Published var isRecording = false @Published var captureSystemAudio = true @@ -330,10 +324,27 @@ class AudioManager: NSObject, ObservableObject { let transcriptText = alt["transcript"] as? String, !transcriptText.isEmpty else { return } - let prefix = (source == .mic) ? "[MIC]" : "[SYS]" + let isFinal = json["is_final"] as? Bool ?? false + DispatchQueue.main.async { - if let isFinal = json["is_final"] as? Bool, isFinal { - self.transcript += "\(prefix) \(transcriptText) " + let chunk = TranscriptChunk( + timestamp: Date(), + source: source, + text: transcriptText, + isFinal: isFinal + ) + + // For interim results, replace the last interim chunk from the same source + if !isFinal { + // Remove the last interim chunk from the same source + if let lastIndex = self.transcriptChunks.lastIndex(where: { !$0.isFinal && $0.source == source }) { + self.transcriptChunks.remove(at: lastIndex) + } + self.transcriptChunks.append(chunk) + } else { + // For final results, remove any interim chunks from the same source and add the final chunk + self.transcriptChunks.removeAll { !$0.isFinal && $0.source == source } + self.transcriptChunks.append(chunk) } } } diff --git a/notetaker/Models/Meeting.swift b/notetaker/Models/Meeting.swift index 8d3949d..6730210 100644 --- a/notetaker/Models/Meeting.swift +++ b/notetaker/Models/Meeting.swift @@ -1,21 +1,83 @@ import Foundation +enum AudioSource: String, Codable, CaseIterable { + case mic = "MIC" + case system = "SYS" + + var displayName: String { + switch self { + case .mic: + return "Microphone" + case .system: + return "System Audio" + } + } + + var icon: String { + switch self { + case .mic: + return "mic.fill" + case .system: + return "speaker.wave.2.fill" + } + } +} + +struct TranscriptChunk: Codable, Identifiable, Hashable { + let id: UUID + let timestamp: Date + let source: AudioSource + let text: String + let isFinal: Bool + + init(id: UUID = UUID(), timestamp: Date = Date(), source: AudioSource, text: String, isFinal: Bool = false) { + self.id = id + self.timestamp = timestamp + self.source = source + self.text = text + self.isFinal = isFinal + } +} + struct Meeting: Codable, Identifiable, Hashable { let id: UUID let date: Date - var transcript: String + var transcriptChunks: [TranscriptChunk] var userNotes: String var generatedNotes: String init(id: UUID = UUID(), date: Date = Date(), - transcript: String = "", + transcriptChunks: [TranscriptChunk] = [], userNotes: String = "", generatedNotes: String = "") { self.id = id self.date = date - self.transcript = transcript + self.transcriptChunks = transcriptChunks self.userNotes = userNotes self.generatedNotes = generatedNotes } + + // Computed property for backward compatibility with existing code + var transcript: String { + return transcriptChunks + .filter { $0.isFinal } + .map { "[\($0.source.rawValue)] \($0.text)" } + .joined(separator: " ") + } + + // Separate computed properties for mic and system transcripts + var micTranscript: String { + return transcriptChunks + .filter { $0.source == .mic && $0.isFinal } + .map { $0.text } + .joined(separator: " ") + } + + var systemTranscript: String { + return transcriptChunks + .filter { $0.source == .system && $0.isFinal } + .map { $0.text } + .joined(separator: " ") + } } \ No newline at end of file diff --git a/notetaker/ViewModels/MeetingViewModel.swift b/notetaker/ViewModels/MeetingViewModel.swift index 4ef2920..15af9a7 100644 --- a/notetaker/ViewModels/MeetingViewModel.swift +++ b/notetaker/ViewModels/MeetingViewModel.swift @@ -15,10 +15,10 @@ class MeetingViewModel: ObservableObject { init(meeting: Meeting = Meeting()) { self.meeting = meeting - // Update meeting transcript when audio manager transcript changes - audioManager.$transcript - .sink { [weak self] newTranscript in - self?.meeting.transcript = newTranscript + // Update meeting transcript chunks when audio manager transcript chunks change + audioManager.$transcriptChunks + .sink { [weak self] newChunks in + self?.meeting.transcriptChunks = newChunks } .store(in: &cancellables) @@ -78,6 +78,16 @@ class MeetingViewModel: ObservableObject { NSPasteboard.general.setString(meeting.transcript, forType: .string) } + func copyMicTranscript() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(meeting.micTranscript, forType: .string) + } + + func copySystemTranscript() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(meeting.systemTranscript, forType: .string) + } + func copyNotes() { NSPasteboard.general.clearContents() NSPasteboard.general.setString(meeting.generatedNotes, forType: .string) diff --git a/notetaker/Views/MeetingDetailView.swift b/notetaker/Views/MeetingDetailView.swift index 52e77d3..d2da02d 100644 --- a/notetaker/Views/MeetingDetailView.swift +++ b/notetaker/Views/MeetingDetailView.swift @@ -1,12 +1,68 @@ import SwiftUI +struct TranscriptChunkView: View { + let chunk: TranscriptChunk + + var body: some View { + HStack(alignment: .top, spacing: 8) { + // Source indicator + HStack(spacing: 4) { + Image(systemName: chunk.source.icon) + .font(.caption) + .foregroundColor(chunk.source == .mic ? .blue : .orange) + + Text(chunk.source.rawValue) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(chunk.source == .mic ? .blue : .orange) + } + .frame(width: 60, alignment: .leading) + + // Transcript text + Text(chunk.text) + .font(.body) + .foregroundColor(chunk.isFinal ? .primary : .secondary) + .italic(!chunk.isFinal) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + .opacity(chunk.isFinal ? 1.0 : 0.7) + } +} + struct MeetingDetailView: View { @StateObject private var viewModel: MeetingViewModel + @State private var selectedTranscriptFilter: TranscriptFilter = .all init(meeting: Meeting) { self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting)) } + enum TranscriptFilter: String, CaseIterable { + case all = "All" + case mic = "Microphone" + case system = "System Audio" + + var icon: String { + switch self { + case .all: return "waveform" + case .mic: return "mic.fill" + case .system: return "speaker.wave.2.fill" + } + } + } + + private var filteredTranscriptChunks: [TranscriptChunk] { + switch selectedTranscriptFilter { + case .all: + return viewModel.meeting.transcriptChunks + case .mic: + return viewModel.meeting.transcriptChunks.filter { $0.source == .mic } + case .system: + return viewModel.meeting.transcriptChunks.filter { $0.source == .system } + } + } + var body: some View { VStack(spacing: 20) { // Recording Controls @@ -49,17 +105,54 @@ struct MeetingDetailView: View { Text("Live Transcript") .font(.headline) Spacer() - Button { - viewModel.copyTranscript() + + // Filter controls + Picker("Filter", selection: $selectedTranscriptFilter) { + ForEach(TranscriptFilter.allCases, id: \.self) { filter in + Label(filter.rawValue, systemImage: filter.icon) + .tag(filter) + } + } + .pickerStyle(SegmentedPickerStyle()) + .frame(width: 200) + + Menu { + Button { + viewModel.copyTranscript() + } label: { + Label("Copy All", systemImage: "doc.on.doc") + } + + Button { + viewModel.copyMicTranscript() + } label: { + Label("Copy Microphone Only", systemImage: "mic.fill") + } + + Button { + viewModel.copySystemTranscript() + } label: { + Label("Copy System Audio Only", systemImage: "speaker.wave.2.fill") + } } label: { Label("Copy", systemImage: "doc.on.doc") } } ScrollView { - Text(viewModel.meeting.transcript.isEmpty ? "Transcript will appear here..." : viewModel.meeting.transcript) - .frame(maxWidth: .infinity, alignment: .leading) + if filteredTranscriptChunks.isEmpty { + Text("Transcript will appear here...") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .foregroundColor(.secondary) + } else { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(filteredTranscriptChunks) { chunk in + TranscriptChunkView(chunk: chunk) + } + } .padding() + } } .frame(maxHeight: .infinity) .background(Color.gray.opacity(0.05))