feat: differentiate between mic and system audio in transcript
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user