feat: enhance transcript handling with collapsed chunks

This commit is contained in:
Owen Gretzinger
2025-07-10 17:34:59 -04:00
parent dfb1b79f0c
commit 619223fa1e
3 changed files with 119 additions and 80 deletions
+105 -2
View File
@@ -7,9 +7,18 @@ enum AudioSource: String, Codable, CaseIterable {
var displayName: String {
switch self {
case .mic:
return "Microphone"
return "Me"
case .system:
return "System Audio"
return "Them"
}
}
var copyPrefix: String {
switch self {
case .mic:
return "Me"
case .system:
return "Them"
}
}
@@ -39,6 +48,20 @@ struct TranscriptChunk: Codable, Identifiable, Hashable {
}
}
struct CollapsedTranscriptChunk: Identifiable {
let id: UUID
let timestamp: Date
let source: AudioSource
let combinedText: String
init(id: UUID = UUID(), timestamp: Date, source: AudioSource, combinedText: String) {
self.id = id
self.timestamp = timestamp
self.source = source
self.combinedText = combinedText
}
}
struct Meeting: Codable, Identifiable, Hashable {
let id: UUID
let date: Date
@@ -66,6 +89,86 @@ struct Meeting: Codable, Identifiable, Hashable {
.joined(separator: " ")
}
// Formatted transcript for copying with collapsed sequential chunks
var formattedTranscript: String {
let finalChunks = transcriptChunks.filter { $0.isFinal }
guard !finalChunks.isEmpty else { return "" }
var result: [String] = []
var currentSource: AudioSource?
var currentTexts: [String] = []
for chunk in finalChunks {
if chunk.source != currentSource {
// Finish previous section if exists
if let source = currentSource, !currentTexts.isEmpty {
let combinedText = currentTexts.joined(separator: " ")
result.append("\(source.copyPrefix): \(combinedText)")
}
// Start new section
currentSource = chunk.source
currentTexts = [chunk.text]
} else {
// Same source, add to current section
currentTexts.append(chunk.text)
}
}
// Finish last section
if let source = currentSource, !currentTexts.isEmpty {
let combinedText = currentTexts.joined(separator: " ")
result.append("\(source.copyPrefix): \(combinedText)")
}
return result.joined(separator: " \n")
}
// Collapsed chunks for UI display
var collapsedTranscriptChunks: [CollapsedTranscriptChunk] {
guard !transcriptChunks.isEmpty else { return [] }
var result: [CollapsedTranscriptChunk] = []
var currentSource: AudioSource?
var currentTexts: [String] = []
var currentTimestamp: Date?
for chunk in transcriptChunks {
if chunk.source != currentSource {
// Finish previous section if exists
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
let combinedText = currentTexts.joined(separator: " ")
result.append(CollapsedTranscriptChunk(
timestamp: timestamp,
source: source,
combinedText: combinedText
))
}
// Start new section
currentSource = chunk.source
currentTexts = [chunk.text]
currentTimestamp = chunk.timestamp
} else {
// Same source, add to current section
currentTexts.append(chunk.text)
}
}
// Finish last section
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
let combinedText = currentTexts.joined(separator: " ")
result.append(CollapsedTranscriptChunk(
timestamp: timestamp,
source: source,
combinedText: combinedText
))
}
return result
}
// Separate computed properties for mic and system transcripts
var micTranscript: String {
return transcriptChunks
+1 -11
View File
@@ -75,17 +75,7 @@ class MeetingViewModel: ObservableObject {
func copyTranscript() {
NSPasteboard.general.clearContents()
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)
NSPasteboard.general.setString(meeting.formattedTranscript, forType: .string)
}
func copyNotes() {
+13 -67
View File
@@ -1,7 +1,7 @@
import SwiftUI
struct TranscriptChunkView: View {
let chunk: TranscriptChunk
struct CollapsedTranscriptChunkView: View {
let chunk: CollapsedTranscriptChunk
var body: some View {
HStack(alignment: .top, spacing: 8) {
@@ -11,58 +11,30 @@ struct TranscriptChunkView: View {
.font(.caption)
.foregroundColor(chunk.source == .mic ? .blue : .orange)
Text(chunk.source.rawValue)
Text(chunk.source.displayName)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(chunk.source == .mic ? .blue : .orange)
}
.frame(width: 60, alignment: .leading)
.frame(width: 50, alignment: .leading)
// Transcript text
Text(chunk.text)
Text(chunk.combinedText)
.font(.body)
.foregroundColor(chunk.isFinal ? .primary : .secondary)
.italic(!chunk.isFinal)
.foregroundColor(.primary)
.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
@@ -105,50 +77,23 @@ struct MeetingDetailView: View {
Text("Live Transcript")
.font(.headline)
Spacer()
// 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")
}
Button {
viewModel.copyTranscript()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
ScrollView {
if filteredTranscriptChunks.isEmpty {
if viewModel.meeting.collapsedTranscriptChunks.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)
ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in
CollapsedTranscriptChunkView(chunk: chunk)
}
}
.padding()
@@ -226,4 +171,5 @@ struct MeetingDetailView: View {
NavigationStack {
MeetingDetailView(meeting: Meeting())
}
}
}