feat: differentiate between mic and system audio in transcript

This commit is contained in:
Owen Gretzinger
2025-07-10 16:54:27 -04:00
parent 297bb40b89
commit dfb1b79f0c
4 changed files with 197 additions and 21 deletions
+21 -10
View File
@@ -6,15 +6,9 @@ import Foundation
import SwiftUI import SwiftUI
import ScreenCaptureKit 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 /// Manages audio capture from microphone and/or system audio and handles real-time transcription via Deepgram
class AudioManager: NSObject, ObservableObject { class AudioManager: NSObject, ObservableObject {
@Published var transcript = "" @Published var transcriptChunks: [TranscriptChunk] = []
@Published var isRecording = false @Published var isRecording = false
@Published var captureSystemAudio = true @Published var captureSystemAudio = true
@@ -330,10 +324,27 @@ class AudioManager: NSObject, ObservableObject {
let transcriptText = alt["transcript"] as? String, let transcriptText = alt["transcript"] as? String,
!transcriptText.isEmpty else { return } !transcriptText.isEmpty else { return }
let prefix = (source == .mic) ? "[MIC]" : "[SYS]" let isFinal = json["is_final"] as? Bool ?? false
DispatchQueue.main.async { DispatchQueue.main.async {
if let isFinal = json["is_final"] as? Bool, isFinal { let chunk = TranscriptChunk(
self.transcript += "\(prefix) \(transcriptText) " 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)
} }
} }
} }
+65 -3
View File
@@ -1,21 +1,83 @@
import Foundation 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 { struct Meeting: Codable, Identifiable, Hashable {
let id: UUID let id: UUID
let date: Date let date: Date
var transcript: String var transcriptChunks: [TranscriptChunk]
var userNotes: String var userNotes: String
var generatedNotes: String var generatedNotes: String
init(id: UUID = UUID(), init(id: UUID = UUID(),
date: Date = Date(), date: Date = Date(),
transcript: String = "", transcriptChunks: [TranscriptChunk] = [],
userNotes: String = "", userNotes: String = "",
generatedNotes: String = "") { generatedNotes: String = "") {
self.id = id self.id = id
self.date = date self.date = date
self.transcript = transcript self.transcriptChunks = transcriptChunks
self.userNotes = userNotes self.userNotes = userNotes
self.generatedNotes = generatedNotes 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: " ")
}
} }
+14 -4
View File
@@ -15,10 +15,10 @@ class MeetingViewModel: ObservableObject {
init(meeting: Meeting = Meeting()) { init(meeting: Meeting = Meeting()) {
self.meeting = meeting self.meeting = meeting
// Update meeting transcript when audio manager transcript changes // Update meeting transcript chunks when audio manager transcript chunks change
audioManager.$transcript audioManager.$transcriptChunks
.sink { [weak self] newTranscript in .sink { [weak self] newChunks in
self?.meeting.transcript = newTranscript self?.meeting.transcriptChunks = newChunks
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -78,6 +78,16 @@ class MeetingViewModel: ObservableObject {
NSPasteboard.general.setString(meeting.transcript, forType: .string) 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() { func copyNotes() {
NSPasteboard.general.clearContents() NSPasteboard.general.clearContents()
NSPasteboard.general.setString(meeting.generatedNotes, forType: .string) NSPasteboard.general.setString(meeting.generatedNotes, forType: .string)
+97 -4
View File
@@ -1,12 +1,68 @@
import SwiftUI 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 { struct MeetingDetailView: View {
@StateObject private var viewModel: MeetingViewModel @StateObject private var viewModel: MeetingViewModel
@State private var selectedTranscriptFilter: TranscriptFilter = .all
init(meeting: Meeting) { init(meeting: Meeting) {
self._viewModel = StateObject(wrappedValue: MeetingViewModel(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 { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
// Recording Controls // Recording Controls
@@ -49,17 +105,54 @@ struct MeetingDetailView: View {
Text("Live Transcript") Text("Live Transcript")
.font(.headline) .font(.headline)
Spacer() 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: {
Label("Copy", systemImage: "doc.on.doc") Label("Copy", systemImage: "doc.on.doc")
} }
} }
ScrollView { ScrollView {
Text(viewModel.meeting.transcript.isEmpty ? "Transcript will appear here..." : viewModel.meeting.transcript) if filteredTranscriptChunks.isEmpty {
.frame(maxWidth: .infinity, alignment: .leading) 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() .padding()
}
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.background(Color.gray.opacity(0.05)) .background(Color.gray.opacity(0.05))