feat: enhance transcript handling with collapsed chunks
This commit is contained in:
@@ -7,9 +7,18 @@ enum AudioSource: String, Codable, CaseIterable {
|
|||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mic:
|
case .mic:
|
||||||
return "Microphone"
|
return "Me"
|
||||||
case .system:
|
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 {
|
struct Meeting: Codable, Identifiable, Hashable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let date: Date
|
let date: Date
|
||||||
@@ -66,6 +89,86 @@ struct Meeting: Codable, Identifiable, Hashable {
|
|||||||
.joined(separator: " ")
|
.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
|
// Separate computed properties for mic and system transcripts
|
||||||
var micTranscript: String {
|
var micTranscript: String {
|
||||||
return transcriptChunks
|
return transcriptChunks
|
||||||
|
|||||||
@@ -75,17 +75,7 @@ class MeetingViewModel: ObservableObject {
|
|||||||
|
|
||||||
func copyTranscript() {
|
func copyTranscript() {
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(meeting.transcript, forType: .string)
|
NSPasteboard.general.setString(meeting.formattedTranscript, 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() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TranscriptChunkView: View {
|
struct CollapsedTranscriptChunkView: View {
|
||||||
let chunk: TranscriptChunk
|
let chunk: CollapsedTranscriptChunk
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
@@ -11,58 +11,30 @@ struct TranscriptChunkView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(chunk.source == .mic ? .blue : .orange)
|
.foregroundColor(chunk.source == .mic ? .blue : .orange)
|
||||||
|
|
||||||
Text(chunk.source.rawValue)
|
Text(chunk.source.displayName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(chunk.source == .mic ? .blue : .orange)
|
.foregroundColor(chunk.source == .mic ? .blue : .orange)
|
||||||
}
|
}
|
||||||
.frame(width: 60, alignment: .leading)
|
.frame(width: 50, alignment: .leading)
|
||||||
|
|
||||||
// Transcript text
|
// Transcript text
|
||||||
Text(chunk.text)
|
Text(chunk.combinedText)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(chunk.isFinal ? .primary : .secondary)
|
.foregroundColor(.primary)
|
||||||
.italic(!chunk.isFinal)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.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
|
||||||
@@ -105,50 +77,23 @@ struct MeetingDetailView: View {
|
|||||||
Text("Live Transcript")
|
Text("Live Transcript")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button {
|
||||||
// Filter controls
|
viewModel.copyTranscript()
|
||||||
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 {
|
||||||
if filteredTranscriptChunks.isEmpty {
|
if viewModel.meeting.collapsedTranscriptChunks.isEmpty {
|
||||||
Text("Transcript will appear here...")
|
Text("Transcript will appear here...")
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding()
|
.padding()
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
} else {
|
||||||
LazyVStack(alignment: .leading, spacing: 4) {
|
LazyVStack(alignment: .leading, spacing: 4) {
|
||||||
ForEach(filteredTranscriptChunks) { chunk in
|
ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in
|
||||||
TranscriptChunkView(chunk: chunk)
|
CollapsedTranscriptChunkView(chunk: chunk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -226,4 +171,5 @@ struct MeetingDetailView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
MeetingDetailView(meeting: Meeting())
|
MeetingDetailView(meeting: Meeting())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user