From 7c582f469c0caffba10117a6a42f27dd8460716f Mon Sep 17 00:00:00 2001 From: Szymon Sypniewicz Date: Fri, 20 Mar 2026 19:00:46 +0000 Subject: [PATCH 1/9] Add menu bar status item and popover NSStatusItem with two icon states: waveform.circle (idle) and waveform.circle.fill (recording). Icon updates reactively via withObservationTracking (zero wakeups when idle). Popover shows live recording status with elapsed timer computed from actual session start time, Start/Stop button with consent gate, Show App, and Quit. Uses NSPopover with NSHostingController for reliable sizing and focus behavior. --- .../OpenOats/App/MenuBarController.swift | 86 +++++++++ .../OpenOats/Views/MenuBarPopoverView.swift | 164 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 OpenOats/Sources/OpenOats/App/MenuBarController.swift create mode 100644 OpenOats/Sources/OpenOats/Views/MenuBarPopoverView.swift diff --git a/OpenOats/Sources/OpenOats/App/MenuBarController.swift b/OpenOats/Sources/OpenOats/App/MenuBarController.swift new file mode 100644 index 00000000..894b7350 --- /dev/null +++ b/OpenOats/Sources/OpenOats/App/MenuBarController.swift @@ -0,0 +1,86 @@ +import AppKit +import Observation +import SwiftUI + +@MainActor +final class MenuBarController { + private let statusItem: NSStatusItem + private let popover: NSPopover + private let coordinator: AppCoordinator + private let settings: AppSettings + private var iconUpdateTask: Task? + + var onShowMainWindow: (() -> Void)? + var onQuitApp: (() -> Void)? + + init(coordinator: AppCoordinator, settings: AppSettings) { + self.coordinator = coordinator + self.settings = settings + + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + self.popover = NSPopover() + popover.contentSize = NSSize(width: 280, height: 160) + popover.behavior = .transient + popover.animates = true + + let popoverView = MenuBarPopoverView( + coordinator: coordinator, + settings: settings, + onShowMainWindow: { [weak self] in + self?.popover.performClose(nil) + self?.onShowMainWindow?() + }, + onQuit: { [weak self] in + self?.popover.performClose(nil) + self?.onQuitApp?() + } + ) + popover.contentViewController = NSHostingController(rootView: popoverView) + + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "waveform.circle", accessibilityDescription: "OpenOats") + button.image?.isTemplate = true + button.target = self + button.action = #selector(togglePopover(_:)) + } + + startIconObservation() + } + + deinit { + iconUpdateTask?.cancel() + } + + @objc private func togglePopover(_ sender: Any?) { + if popover.isShown { + popover.performClose(sender) + } else if let button = statusItem.button { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + } + } + + private func startIconObservation() { + iconUpdateTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { break } + updateIcon() + await withCheckedContinuation { continuation in + withObservationTracking { + _ = self.coordinator.isRecording + } onChange: { + continuation.resume() + } + } + } + } + } + + private func updateIcon() { + let symbolName = coordinator.isRecording ? "waveform.circle.fill" : "waveform.circle" + statusItem.button?.image = NSImage( + systemSymbolName: symbolName, + accessibilityDescription: "OpenOats" + ) + statusItem.button?.image?.isTemplate = true + } +} diff --git a/OpenOats/Sources/OpenOats/Views/MenuBarPopoverView.swift b/OpenOats/Sources/OpenOats/Views/MenuBarPopoverView.swift new file mode 100644 index 00000000..5191cfc1 --- /dev/null +++ b/OpenOats/Sources/OpenOats/Views/MenuBarPopoverView.swift @@ -0,0 +1,164 @@ +import SwiftUI + +struct MenuBarPopoverView: View { + let coordinator: AppCoordinator + let settings: AppSettings + let onShowMainWindow: () -> Void + let onQuit: () -> Void + + @State private var elapsedSeconds: Int = 0 + @State private var timerTask: Task? + + private var recordingStartedAt: Date? { + if case .recording(let metadata) = coordinator.state { + return metadata.startedAt + } + return nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + statusLine + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 10) + + Divider() + + primaryAction + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + Button(action: onShowMainWindow) { + HStack { + Text("Show OpenOats") + Spacer() + } + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 8) + + Divider() + + Button(action: onQuit) { + HStack { + Text("Quit OpenOats") + Spacer() + } + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .padding(.bottom, 4) + } + .frame(width: 280) + .onAppear { + if coordinator.isRecording { + startTimer() + } + } + .onDisappear { + stopTimer() + } + .onChange(of: coordinator.isRecording) { _, recording in + if recording { + startTimer() + } else { + stopTimer() + } + } + } + + private var statusLine: some View { + HStack(spacing: 6) { + if coordinator.isRecording { + Circle() + .fill(.red) + .frame(width: 8, height: 8) + Text("Recording - \(formattedTime)") + .font(.system(size: 13, weight: .medium)) + } else if settings.meetingAutoDetectEnabled { + Circle() + .fill(.secondary) + .frame(width: 8, height: 8) + Text("Listening for meetings...") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } else { + Circle() + .fill(.secondary.opacity(0.5)) + .frame(width: 8, height: 8) + Text("Idle") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + @ViewBuilder + private var primaryAction: some View { + if coordinator.isRecording { + Button(action: { + coordinator.handle(.userStopped, settings: settings) + }) { + Text("Stop Recording") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.regular) + } else { + Button(action: { + guard settings.hasAcknowledgedRecordingConsent else { + onShowMainWindow() + return + } + coordinator.handle(.userStarted(.manual()), settings: settings) + }) { + Text("Start Recording") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + } + + private var formattedTime: String { + let minutes = elapsedSeconds / 60 + let seconds = elapsedSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private func startTimer() { + updateElapsed() + stopTimer() + timerTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } + updateElapsed() + } + } + } + + private func updateElapsed() { + if let start = recordingStartedAt { + elapsedSeconds = max(0, Int(Date().timeIntervalSince(start))) + } else { + elapsedSeconds = 0 + } + } + + private func stopTimer() { + timerTask?.cancel() + timerTask = nil + elapsedSeconds = 0 + } +} From f8550a5a4e51cc290ce757a7d5bc1bff8a530f1a Mon Sep 17 00:00:00 2001 From: Yazin's AI Date: Fri, 20 Mar 2026 22:13:34 +0300 Subject: [PATCH 2/9] Add acoustic echo cancellation via Apple Voice Processing (#89) Add acoustic echo cancellation via Apple Voice Processing. Enables setVoiceProcessingEnabled on AVAudioEngine mic input to cancel speaker echo that causes duplicate transcription. On by default with toggle in Settings > Recording. Closes #88 --- .../Sources/OpenOats/Audio/MicCapture.swift | 12 ++++++++++- .../OpenOats/Settings/AppSettings.swift | 20 +++++++++++++++++++ .../Transcription/TranscriptionEngine.swift | 2 +- .../Sources/OpenOats/Views/SettingsView.swift | 6 ++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/OpenOats/Sources/OpenOats/Audio/MicCapture.swift b/OpenOats/Sources/OpenOats/Audio/MicCapture.swift index 8c1e657b..93a00dcd 100644 --- a/OpenOats/Sources/OpenOats/Audio/MicCapture.swift +++ b/OpenOats/Sources/OpenOats/Audio/MicCapture.swift @@ -33,7 +33,7 @@ final class MicCapture: @unchecked Sendable { ) } - func bufferStream(deviceID: AudioDeviceID? = nil) -> AsyncStream { + func bufferStream(deviceID: AudioDeviceID? = nil, echoCancellation: Bool = false) -> AsyncStream { // Defensive cleanup of any prior state _streamContinuation.withLock { $0?.finish(); $0 = nil } engine.inputNode.removeTap(onBus: 0) @@ -55,6 +55,16 @@ final class MicCapture: @unchecked Sendable { let inputNode = engine.inputNode diagLog("[MIC-1b] input node ready") + // Enable voice processing (AEC + noise suppression) if requested + if echoCancellation { + do { + try inputNode.setVoiceProcessingEnabled(true) + diagLog("[MIC-1c] voice processing (AEC) enabled") + } catch { + diagLog("[MIC-1c] failed to enable voice processing: \(error.localizedDescription)") + } + } + // Set input device before accessing inputNode format var resolvedDeviceID: AudioDeviceID? if let id = deviceID { diff --git a/OpenOats/Sources/OpenOats/Settings/AppSettings.swift b/OpenOats/Sources/OpenOats/Settings/AppSettings.swift index 4ade36d5..a3ca365e 100644 --- a/OpenOats/Sources/OpenOats/Settings/AppSettings.swift +++ b/OpenOats/Sources/OpenOats/Settings/AppSettings.swift @@ -416,6 +416,19 @@ final class AppSettings { } } + /// When true, Apple's voice-processing IO is enabled on the mic input to cancel + /// speaker echo and reduce double-transcription when using built-in speakers + mic. + @ObservationIgnored nonisolated(unsafe) private var _enableEchoCancellation: Bool + var enableEchoCancellation: Bool { + get { access(keyPath: \.enableEchoCancellation); return _enableEchoCancellation } + set { + withMutation(keyPath: \.enableEchoCancellation) { + _enableEchoCancellation = newValue + defaults.set(newValue, forKey: "enableEchoCancellation") + } + } + } + /// When true, uses the LLM to clean up filler words and fix punctuation in real-time. @ObservationIgnored nonisolated(unsafe) private var _enableTranscriptRefinement: Bool var enableTranscriptRefinement: Bool { @@ -555,6 +568,13 @@ final class AppSettings { self._saveAudioRecording = defaults.bool(forKey: "saveAudioRecording") self._enableTranscriptRefinement = defaults.bool(forKey: "enableTranscriptRefinement") + // Echo cancellation — default to enabled + if defaults.object(forKey: "enableEchoCancellation") == nil { + self._enableEchoCancellation = true + } else { + self._enableEchoCancellation = defaults.bool(forKey: "enableEchoCancellation") + } + // Default to true (shown) if key has never been set if defaults.object(forKey: "showLiveTranscript") == nil { self._showLiveTranscript = true diff --git a/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift b/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift index 5f63ef5b..bd3b3524 100644 --- a/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift +++ b/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift @@ -547,7 +547,7 @@ final class TranscriptionEngine { vadManager: VadManager, deviceID: AudioDeviceID ) { - var micStream = micCapture.bufferStream(deviceID: deviceID) + var micStream = micCapture.bufferStream(deviceID: deviceID, echoCancellation: settings.enableEchoCancellation) if let recorder = audioRecorder { micStream = Self.tappedStream(micStream) { buffer in recorder.writeMicBuffer(buffer) diff --git a/OpenOats/Sources/OpenOats/Views/SettingsView.swift b/OpenOats/Sources/OpenOats/Views/SettingsView.swift index 0e645650..1b40301a 100644 --- a/OpenOats/Sources/OpenOats/Views/SettingsView.swift +++ b/OpenOats/Sources/OpenOats/Views/SettingsView.swift @@ -149,6 +149,12 @@ struct SettingsView: View { Text("Save a local audio file (.m4a) alongside each transcript. Audio never leaves your device.") .font(.system(size: 11)) .foregroundStyle(.secondary) + + Toggle("Echo cancellation", isOn: $settings.enableEchoCancellation) + .font(.system(size: 12)) + Text("Reduces duplicate transcription when using speakers and microphone simultaneously. Takes effect on next session.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) } Section("Transcription") { From 9f6fb1ba055d739731153354d57d17d6ec4471b4 Mon Sep 17 00:00:00 2001 From: Yazin's AI Date: Fri, 20 Mar 2026 22:19:53 +0300 Subject: [PATCH 3/9] Redesign NotesView: 2-tab model with batch cleanup (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign NotesView: 2-tab model with batch cleanup Replaces the 3-state Raw/Refined/Notes detail view with a clean 2-tab segmented picker (Transcript / Notes). Adds batch transcript cleanup via TranscriptCleanupEngine for on-demand refinement of past sessions. Based on the design direction from @Newarr in PRs #82 and #83 — thank you for the original work! Reimplemented to keep drain() timeout at 5s and strip unrelated scope changes. Closes #82, closes #83. --- .../OpenOats/Storage/SessionStore.swift | 37 +- .../Sources/OpenOats/Views/ControlBar.swift | 6 +- .../Sources/OpenOats/Views/NotesView.swift | 460 +++++++++++++----- .../Sources/OpenOats/Views/SettingsView.swift | 4 +- 4 files changed, 371 insertions(+), 136 deletions(-) diff --git a/OpenOats/Sources/OpenOats/Storage/SessionStore.swift b/OpenOats/Sources/OpenOats/Storage/SessionStore.swift index 564b8aee..799fce93 100644 --- a/OpenOats/Sources/OpenOats/Storage/SessionStore.swift +++ b/OpenOats/Sources/OpenOats/Storage/SessionStore.swift @@ -128,28 +128,33 @@ actor SessionStore { } } - /// Rewrite the current JSONL file, backfilling `refinedText` from the in-memory TranscriptStore. - /// - /// The 5-second delayed write often captures `refinedText` as nil because the LLM refinement - /// call hasn't finished yet. This method is called after both the refinement engine and pending - /// writes have drained, so the TranscriptStore now has the final refined text for all utterances. + /// Backfill refined text into the current session's JSONL from the in-memory TranscriptStore. func backfillRefinedText(from utterances: [Utterance]) { guard let currentFile else { return } - // Close the file handle so we can read/rewrite the file safely try? fileHandle?.close() fileHandle = nil - guard let content = try? String(contentsOf: currentFile, encoding: .utf8) else { return } + rewriteJSONLWithRefinedText(file: currentFile, utterances: utterances) + + fileHandle = try? FileHandle(forWritingTo: currentFile) + } + + /// Backfill refined text into a past session's JSONL. + func backfillRefinedText(sessionID: String, from utterances: [Utterance]) { + rewriteJSONLWithRefinedText(file: jsonlURL(for: sessionID), utterances: utterances) + } + + @discardableResult + private func rewriteJSONLWithRefinedText(file: URL, utterances: [Utterance]) -> Bool { + guard let content = try? String(contentsOf: file, encoding: .utf8) else { return false } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } - guard !lines.isEmpty else { return } + guard !lines.isEmpty else { return false } - // Build a lookup from (timestamp, speaker) -> refinedText - // Uses ISO8601 string representation of the date for reliable matching let iso8601Formatter = ISO8601DateFormatter() iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] var refinedLookup: [String: String] = [:] @@ -159,11 +164,7 @@ actor SessionStore { refinedLookup[key] = refined } - guard !refinedLookup.isEmpty else { - // No refined text to backfill; reopen file handle and return - fileHandle = try? FileHandle(forWritingTo: currentFile) - return - } + guard !refinedLookup.isEmpty else { return false } var updatedLines: [String] = [] var anyUpdated = false @@ -175,7 +176,6 @@ actor SessionStore { continue } - // Only backfill if the record doesn't already have refinedText if record.refinedText == nil { let key = "\(iso8601Formatter.string(from: record.timestamp))|\(record.speaker.rawValue)" if let refined = refinedLookup[key] { @@ -204,11 +204,10 @@ actor SessionStore { if anyUpdated { let newContent = updatedLines.joined(separator: "\n") + "\n" - try? newContent.write(to: currentFile, atomically: true, encoding: .utf8) + try? newContent.write(to: file, atomically: true, encoding: .utf8) } - // Reopen file handle for any subsequent writes before endSession() - fileHandle = try? FileHandle(forWritingTo: currentFile) + return anyUpdated } func endSession() { diff --git a/OpenOats/Sources/OpenOats/Views/ControlBar.swift b/OpenOats/Sources/OpenOats/Views/ControlBar.swift index 4ccd94df..cacd05ae 100644 --- a/OpenOats/Sources/OpenOats/Views/ControlBar.swift +++ b/OpenOats/Sources/OpenOats/Views/ControlBar.swift @@ -16,7 +16,7 @@ struct ControlBar: View { // Error banner if let error = errorMessage { Text(error) - .font(.system(size: 10)) + .font(.system(size: 12)) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) @@ -45,9 +45,9 @@ struct ControlBar: View { if let status = statusMessage, status != "Ready" { HStack(spacing: 6) { ProgressView() - .controlSize(.mini) + .controlSize(.small) Text(status) - .font(.system(size: 11)) + .font(.system(size: 12)) .foregroundStyle(.secondary) .accessibilityIdentifier("app.controlBar.status") } diff --git a/OpenOats/Sources/OpenOats/Views/NotesView.swift b/OpenOats/Sources/OpenOats/Views/NotesView.swift index 22e29a57..b9e6adbd 100644 --- a/OpenOats/Sources/OpenOats/Views/NotesView.swift +++ b/OpenOats/Sources/OpenOats/Views/NotesView.swift @@ -12,23 +12,32 @@ struct NotesView: View { @State private var sessionToDelete: String? @State private var showDeleteConfirmation = false + enum DetailViewMode: String, CaseIterable { + case transcript = "Transcript" + case notes = "Notes" + } + + @State private var detailViewMode: DetailViewMode = .transcript + @State private var showingOriginal = false + var body: some View { - NavigationSplitView { + HStack(spacing: 0) { sidebar - } detail: { + .frame(width: 250) + Divider() detailContent + .frame(maxWidth: .infinity, maxHeight: .infinity) } .task { await coordinator.loadHistory() if let requested = coordinator.consumeRequestedSessionSelection() { selectedSessionID = requested + detailViewMode = .notes } else if let last = coordinator.lastEndedSession { selectedSessionID = last.id } } .onChange(of: coordinator.lastEndedSession?.id) { - // When a new session ends (even if Notes window is already open), - // refresh history and auto-select it if let last = coordinator.lastEndedSession { Task { await coordinator.loadHistory() @@ -39,11 +48,10 @@ struct NotesView: View { .onChange(of: coordinator.requestedSessionSelectionID) { if let requested = coordinator.consumeRequestedSessionSelection() { selectedSessionID = requested + // Deep links target notes, so default to the Notes tab + detailViewMode = .notes } } - .onChange(of: coordinator.sessionHistory.count) { - // Refresh sidebar when history changes - } } // MARK: - Sidebar @@ -102,9 +110,8 @@ struct NotesView: View { } } } - .navigationTitle("Sessions") - .frame(minWidth: 200) - .accessibilityIdentifier("notes.sessionList") + .listStyle(.sidebar) + .frame(maxHeight: .infinity) .onChange(of: selectedSessionID) { loadSelectedSession() } @@ -126,143 +133,316 @@ struct NotesView: View { private var detailContent: some View { if let sessionID = selectedSessionID { VStack(spacing: 0) { - if coordinator.notesEngine.isGenerating { - generatingView - } else if let notes = loadedNotes { - notesReadyView(notes) - } else { - noNotesView(sessionID: sessionID) + detailToolbar + Divider() + detailBody(sessionID: sessionID) + } + .background { + Group { + Button("") { detailViewMode = .transcript } + .keyboardShortcut("1", modifiers: .command) + Button("") { detailViewMode = .notes } + .keyboardShortcut("2", modifiers: .command) } + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) } } else { ContentUnavailableView("Select a Session", systemImage: "doc.text", description: Text("Choose a session from the sidebar to view or generate notes.")) } } + private enum CleanupState { + case notCleaned + case inProgress + case partiallyCleaned + case cleaned + } + + private var cleanupState: CleanupState { + if coordinator.cleanupEngine.isCleaningUp { return .inProgress } + guard !loadedTranscript.isEmpty else { return .notCleaned } + let hasAnyRefined = loadedTranscript.contains(where: { $0.refinedText != nil }) + if !hasAnyRefined { return .notCleaned } + let allRefined = !loadedTranscript.contains(where: { $0.refinedText == nil }) + return allRefined ? .cleaned : .partiallyCleaned + } + + @ViewBuilder + private var detailToolbar: some View { + HStack(spacing: 8) { + Picker("View", selection: $detailViewMode) { + ForEach(DetailViewMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(minWidth: 120, maxWidth: 220) + .layoutPriority(1) + + Spacer(minLength: 4) + + if detailViewMode == .transcript { + transcriptToolbarActions + } else if detailViewMode == .notes { + notesToolbarActions + } + + Button { + copyCurrentContent() + } label: { + Label("Copy", systemImage: "doc.on.doc") + .font(.system(size: 12)) + } + .labelStyle(.iconOnly) + .buttonStyle(.bordered) + .disabled(copyContentIsEmpty) + .help("Copy to clipboard") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private var transcriptToolbarActions: some View { + switch cleanupState { + case .notCleaned: + Button { + cleanUpTranscript() + } label: { + Label("Clean Up", systemImage: "sparkles") + .font(.system(size: 12)) + } + .buttonStyle(.borderedProminent) + .disabled(loadedTranscript.isEmpty) + .help("Remove filler words and fix punctuation") + + case .inProgress: + HStack(spacing: 6) { + Text("\(coordinator.cleanupEngine.chunksCompleted)/\(coordinator.cleanupEngine.totalChunks) cleaning...") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Button("Cancel") { + coordinator.cleanupEngine.cancel() + } + .buttonStyle(.bordered) + .font(.system(size: 11)) + .controlSize(.small) + } + + case .partiallyCleaned: + Button { + cleanUpTranscript() + } label: { + Label("Clean Up", systemImage: "sparkles") + .font(.system(size: 12)) + } + .buttonStyle(.borderedProminent) + .help("Clean up remaining utterances") + + Button { + showingOriginal.toggle() + } label: { + Label("Show Original", systemImage: showingOriginal ? "text.badge.checkmark" : "text.badge.minus") + .font(.system(size: 12)) + } + .buttonStyle(.bordered) + .tint(showingOriginal ? .accentColor : nil) + .help(showingOriginal ? "Showing original transcript" : "Show original transcript") + + case .cleaned: + Button { + showingOriginal.toggle() + } label: { + Label("Show Original", systemImage: showingOriginal ? "text.badge.checkmark" : "text.badge.minus") + .font(.system(size: 12)) + } + .buttonStyle(.bordered) + .tint(showingOriginal ? .accentColor : nil) + .help(showingOriginal ? "Showing original transcript" : "Show original transcript") + } + } + + @ViewBuilder + private var notesToolbarActions: some View { + if let notes = loadedNotes { + Menu { + ForEach(coordinator.templateStore.templates) { template in + Button { + regenerateNotes(with: template) + } label: { + Label(template.name, systemImage: template.icon) + } + .disabled(notes.template.id == template.id) + } + } label: { + Label(notes.template.name, systemImage: notes.template.icon) + .font(.system(size: 12)) + } primaryAction: { + regenerateNotes() + } + .menuStyle(.button) + .buttonStyle(.bordered) + .fixedSize() + .help("Click to regenerate, or pick a different template") + } + } + + @ViewBuilder + private func detailBody(sessionID: String) -> some View { + Group { + switch detailViewMode { + case .transcript: + transcriptView + case .notes: + notesTab(sessionID: sessionID) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func notesTab(sessionID: String) -> some View { + if coordinator.notesEngine.isGenerating { + generatingView + } else if let notes = loadedNotes { + notesContentView(notes) + } else { + notesEmptyState(sessionID: sessionID) + } + } + private var generatingView: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack { ProgressView() - .scaleEffect(0.8) + .controlSize(.small) Text("Generating notes...") - .font(.system(size: 13)) + .font(.system(size: 12)) .foregroundStyle(.secondary) .accessibilityIdentifier("notes.generating") Spacer() Button("Cancel") { coordinator.notesEngine.cancel() } - .buttonStyle(.plain) - .foregroundStyle(.red) + .buttonStyle(.bordered) + .font(.system(size: 11)) } markdownContent(coordinator.notesEngine.generatedMarkdown) } - .padding(20) + .padding(16) } } - private func notesReadyView(_ notes: EnhancedNotes) -> some View { - VStack(spacing: 0) { - // Toolbar - HStack { - Label(notes.template.name, systemImage: notes.template.icon) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - Spacer() - Text("Generated \(notes.generatedAt, style: .relative) ago") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(notes.markdown, forType: .string) - } label: { - Label("Copy", systemImage: "doc.on.doc") - .font(.system(size: 12)) - } - .buttonStyle(.bordered) - - Button { - regenerateNotes() - } label: { - Label("Regenerate", systemImage: "arrow.clockwise") - .font(.system(size: 12)) - } - .buttonStyle(.bordered) - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - - Divider() - - ScrollView { - markdownContent(notes.markdown) - .padding(20) - .accessibilityIdentifier("notes.renderedMarkdown") - } + private func notesContentView(_ notes: EnhancedNotes) -> some View { + ScrollView { + markdownContent(notes.markdown) + .padding(16) + .accessibilityIdentifier("notes.renderedMarkdown") } } - private func noNotesView(sessionID: String) -> some View { - VStack(spacing: 16) { + private func notesEmptyState(sessionID: String) -> some View { + ContentUnavailableView { + Label("Generate Notes", systemImage: "sparkles") + } description: { + Text("Summarize this transcript into structured meeting notes.") + } actions: { if let error = coordinator.notesEngine.error { Text(error) .foregroundStyle(.red) .font(.system(size: 12)) } - if !loadedTranscript.isEmpty { - // Transcript preview - ScrollView { - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(loadedTranscript.prefix(20).enumerated()), id: \.offset) { _, record in - HStack(alignment: .top, spacing: 8) { - Text(record.speaker == .you ? "You" : "Them") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(record.speaker == .you ? .blue : .green) - .frame(width: 35, alignment: .trailing) - Text(record.text) - .font(.system(size: 12)) - .foregroundStyle(.primary) - } - } - if loadedTranscript.count > 20 { - Text("... and \(loadedTranscript.count - 20) more utterances") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - .padding(.top, 4) - } - } - .padding(16) - } - .frame(maxHeight: 300) - .background(.quaternary.opacity(0.3)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + Button { + generateNotes(sessionID: sessionID) + } label: { + Label("Generate Notes", systemImage: "sparkles") } + .buttonStyle(.borderedProminent) + .disabled(loadedTranscript.isEmpty) + .accessibilityIdentifier("notes.generateButton") + } + } - // Template picker for generation - HStack { - Picker("Template", selection: $selectedTemplateForGeneration) { - ForEach(coordinator.templateStore.templates) { template in - Label(template.name, systemImage: template.icon).tag(Optional(template)) + // MARK: - Transcript Views + + @ViewBuilder + private var transcriptView: some View { + if loadedTranscript.isEmpty { + ContentUnavailableView("No Transcript", systemImage: "waveform", description: Text("This session has no recorded utterances.")) + } else { + ScrollView { + if coordinator.cleanupEngine.isCleaningUp { + cleanupProgressBanner + } + if let cleanupError = coordinator.cleanupEngine.error { + Text(cleanupError) + .font(.system(size: 12)) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + LazyVStack(alignment: .leading, spacing: 8) { + let isCleaning = coordinator.cleanupEngine.isCleaningUp + ForEach(Array(loadedTranscript.enumerated()), id: \.offset) { _, record in + transcriptRow(record: record, isCleaning: isCleaning) } } - .frame(maxWidth: 200) + .padding(16) + } + } + } - Button { - generateNotes(sessionID: sessionID) - } label: { - Label("Generate Notes", systemImage: "sparkles") - } - .buttonStyle(.borderedProminent) - .disabled(loadedTranscript.isEmpty) - .accessibilityIdentifier("notes.generateButton") + private var cleanupProgressBanner: some View { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Cleaning up transcript... \(coordinator.cleanupEngine.chunksCompleted)/\(coordinator.cleanupEngine.totalChunks) sections") + .font(.system(size: 12)) + .lineLimit(1) + .foregroundStyle(.secondary) + Spacer() + Button("Cancel") { + coordinator.cleanupEngine.cancel() } + .buttonStyle(.bordered) + .font(.system(size: 11)) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.bar) + } + + @ViewBuilder + private func transcriptRow(record: SessionRecord, isCleaning: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(record.speaker == .you ? "You" : "Them") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(record.speaker == .you ? Color.youColor : Color.themColor) + .frame(width: 36, alignment: .trailing) + + let displayText = showingOriginal ? record.text : (record.refinedText ?? record.text) + Text(displayText) + .font(.system(size: 13)) + .foregroundStyle( + isCleaning && record.refinedText == nil ? .secondary : .primary + ) + .textSelection(.enabled) + } + } + + private var copyContentIsEmpty: Bool { + switch detailViewMode { + case .transcript: + return loadedTranscript.isEmpty + case .notes: + return loadedNotes == nil } - .padding(20) - .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Markdown Rendering @@ -334,7 +514,6 @@ struct NotesView: View { } } - // Final section if currentHeading != nil || !currentBody.isEmpty { sections.append(MarkdownSection(heading: currentHeading, level: currentLevel, body: currentBody.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines))) } @@ -344,6 +523,23 @@ struct NotesView: View { // MARK: - Actions + private func copyCurrentContent() { + let text: String + switch detailViewMode { + case .transcript: + text = loadedTranscript.map { record in + let label = record.speaker == .you ? "You" : "Them" + let content = showingOriginal ? record.text : (record.refinedText ?? record.text) + return "[\(Self.transcriptTimeFormatter.string(from: record.timestamp))] \(label): \(content)" + }.joined(separator: "\n") + case .notes: + text = loadedNotes?.markdown ?? "" + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + private func loadSelectedSession() { guard let sessionID = selectedSessionID else { loadedNotes = nil @@ -351,11 +547,20 @@ struct NotesView: View { return } + loadedNotes = nil + loadedTranscript = [] + showingOriginal = false + coordinator.cleanupEngine.cancel() + Task { - loadedNotes = await coordinator.sessionStore.loadNotes(sessionID: sessionID) - loadedTranscript = await coordinator.sessionStore.loadTranscript(sessionID: sessionID) + let notes = await coordinator.sessionStore.loadNotes(sessionID: sessionID) + let transcript = await coordinator.sessionStore.loadTranscript(sessionID: sessionID) + + guard selectedSessionID == sessionID else { return } + + loadedNotes = notes + loadedTranscript = transcript - // Default template for generation let session = coordinator.sessionHistory.first { $0.id == sessionID } if let snapID = session?.templateSnapshot?.id { selectedTemplateForGeneration = coordinator.templateStore.template(for: snapID) @@ -377,7 +582,6 @@ struct NotesView: View { settings: settings ) - // Save completed notes if !coordinator.notesEngine.generatedMarkdown.isEmpty { let notes = EnhancedNotes( template: coordinator.templateStore.snapshot(of: template), @@ -387,7 +591,6 @@ struct NotesView: View { await coordinator.sessionStore.saveNotes(sessionID: sessionID, notes: notes) loadedNotes = notes - // Refresh history to update hasNotes await coordinator.loadHistory() } } @@ -413,9 +616,42 @@ struct NotesView: View { } } - private func regenerateNotes() { + private func regenerateNotes(with template: MeetingTemplate? = nil) { guard let sessionID = selectedSessionID else { return } + if let template { + selectedTemplateForGeneration = template + } loadedNotes = nil generateNotes(sessionID: sessionID) } + + private static let transcriptTimeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f + }() + + private func cleanUpTranscript() { + guard let sessionID = selectedSessionID, !loadedTranscript.isEmpty else { return } + + Task { + let updated = await coordinator.cleanupEngine.cleanup( + records: loadedTranscript, + settings: settings + ) + + let utterances = updated.map { record in + Utterance( + text: record.text, + speaker: record.speaker, + timestamp: record.timestamp, + refinedText: record.refinedText + ) + } + await coordinator.sessionStore.backfillRefinedText(sessionID: sessionID, from: utterances) + + guard selectedSessionID == sessionID else { return } + loadedTranscript = await coordinator.sessionStore.loadTranscript(sessionID: sessionID) + } + } } diff --git a/OpenOats/Sources/OpenOats/Views/SettingsView.swift b/OpenOats/Sources/OpenOats/Views/SettingsView.swift index 1b40301a..e985d698 100644 --- a/OpenOats/Sources/OpenOats/Views/SettingsView.swift +++ b/OpenOats/Sources/OpenOats/Views/SettingsView.swift @@ -185,9 +185,9 @@ struct SettingsView: View { .font(.system(size: 11)) .foregroundStyle(.secondary) - Toggle("Refine transcript", isOn: $settings.enableTranscriptRefinement) + Toggle("Clean up transcript during recording", isOn: $settings.enableTranscriptRefinement) .font(.system(size: 12)) - Text("Uses your LLM provider to clean up filler words and fix punctuation in real-time. Original text is preserved.") + Text("Automatically removes filler words and fixes punctuation as you record. You can always clean up past transcripts manually from the Notes window.") .font(.system(size: 11)) .foregroundStyle(.secondary) From f14b7183f0877b62a3348c1ec212ae7460726bda Mon Sep 17 00:00:00 2001 From: Newarr <22638839+Newarr@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:13:19 +0000 Subject: [PATCH 4/9] Add meeting format specification (openoats/v1) (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add structured Markdown meeting output (openoats/v1) Produces `.md` files in ~/Documents/OpenOats/ alongside existing `.txt` output. YAML frontmatter with date, duration, participants, ASR engine, and meeting app. Body sections: Summary, Action Items, Decisions, Transcript (high-signal first). Speaker-attributed transcript lines with relative timestamps. Three processing stages: raw transcript (always written at finalization), LLM-enriched sections inserted when notes are generated. Existing .txt output is preserved unchanged. Includes format specification, example transcript, and 42 unit tests. Closes no issues — new capability. --- .../Sources/OpenOats/App/AppCoordinator.swift | 26 +- .../Intelligence/MarkdownMeetingWriter.swift | 583 +++++++++++++ OpenOats/Sources/OpenOats/Models/Models.swift | 4 + .../OpenOats/Storage/SessionStore.swift | 8 +- .../Sources/OpenOats/Views/NotesView.swift | 12 + .../MarkdownMeetingWriterTests.swift | 554 ++++++++++++ docs/example-transcript.md | 110 +++ docs/meeting-format-spec.md | 796 ++++++++++++++++++ 8 files changed, 2090 insertions(+), 3 deletions(-) create mode 100644 OpenOats/Sources/OpenOats/Intelligence/MarkdownMeetingWriter.swift create mode 100644 OpenOats/Tests/OpenOatsTests/MarkdownMeetingWriterTests.swift create mode 100644 docs/example-transcript.md create mode 100644 docs/meeting-format-spec.md diff --git a/OpenOats/Sources/OpenOats/App/AppCoordinator.swift b/OpenOats/Sources/OpenOats/App/AppCoordinator.swift index ee5b947b..7dadd6ce 100644 --- a/OpenOats/Sources/OpenOats/App/AppCoordinator.swift +++ b/OpenOats/Sources/OpenOats/App/AppCoordinator.swift @@ -256,6 +256,17 @@ final class AppCoordinator { let title = transcriptStore.conversationState.currentTopic.isEmpty ? nil : transcriptStore.conversationState.currentTopic + // Extract meeting app name from state machine metadata (available in .ending state) + let meetingAppName: String? + if case .ending(let metadata) = state { + meetingAppName = metadata.detectionContext?.meetingApp?.name + } else { + meetingAppName = nil + } + + // Capture the ASR engine name from current settings + let engineName = settings?.transcriptionModel.rawValue + let index = SessionIndex( id: sessionID, startedAt: transcriptStore.utterances.first?.timestamp ?? Date(), @@ -263,13 +274,26 @@ final class AppCoordinator { templateSnapshot: sessionTemplateSnapshot, title: title, utteranceCount: utteranceCount, - hasNotes: false + hasNotes: false, + meetingApp: meetingAppName, + engine: engineName ) let sidecar = SessionSidecar(index: index, notes: nil) // 4. Write sidecar await sessionStore.writeSidecar(sidecar) + // 4b. Generate structured Markdown file from JSONL (has refined text after backfill) + let jsonlRecords = await sessionStore.loadTranscript(sessionID: sessionID) + if !jsonlRecords.isEmpty, let settings { + let outputDir = URL(fileURLWithPath: settings.notesFolderPath) + MarkdownMeetingWriter.write( + metadata: .init(from: index), + records: jsonlRecords, + outputDirectory: outputDir + ) + } + // 5. Close JSONL file await sessionStore.endSession() diff --git a/OpenOats/Sources/OpenOats/Intelligence/MarkdownMeetingWriter.swift b/OpenOats/Sources/OpenOats/Intelligence/MarkdownMeetingWriter.swift new file mode 100644 index 00000000..1051a6e3 --- /dev/null +++ b/OpenOats/Sources/OpenOats/Intelligence/MarkdownMeetingWriter.swift @@ -0,0 +1,583 @@ +import Foundation +import os + +private let writerLogger = Logger(subsystem: "com.openoats.app", category: "MarkdownMeetingWriter") + +/// Produces spec-compliant openoats/v1 Markdown files from session data. +/// +/// The writer is stateless: call `write(...)` with session metadata and transcript records, +/// and it returns the URL of the generated `.md` file. All I/O is synchronous and runs +/// on the caller's context (designed for `nonisolated static` or actor-isolated use). +enum MarkdownMeetingWriter { + + // MARK: - Public API + + /// Metadata needed to produce the Markdown file, extracted from SessionIndex + sidecar. + struct Metadata: Sendable { + let sessionID: String + let title: String? + let startedAt: Date + let endedAt: Date? + let meetingApp: String? + let engine: String? + + init(from index: SessionIndex) { + self.sessionID = index.id + self.title = index.title + self.startedAt = index.startedAt + self.endedAt = index.endedAt + self.meetingApp = index.meetingApp + self.engine = index.engine + } + } + + /// Write a spec-compliant `.md` file to the output directory. + /// + /// - Parameters: + /// - metadata: Session metadata (title, dates, app, engine). + /// - records: The transcript records from the JSONL session store. + /// - outputDirectory: The directory to write into (e.g. `~/Documents/OpenOats/`). + /// - Returns: The URL of the written file, or `nil` on failure. + @discardableResult + static func write( + metadata: Metadata, + records: [SessionRecord], + outputDirectory: URL + ) -> URL? { + guard !records.isEmpty else { + writerLogger.warning("MarkdownMeetingWriter: no records, skipping write") + return nil + } + + let fm = FileManager.default + try? fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + + // Build the Markdown content + let content = buildMarkdown(metadata: metadata, records: records) + + // Generate filename with collision handling + let fileURL = resolveFilename( + title: metadata.title, + startedAt: metadata.startedAt, + directory: outputDirectory + ) + + // Write with restricted permissions + do { + try content.write(to: fileURL, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + writerLogger.info("Wrote meeting markdown: \(fileURL.lastPathComponent, privacy: .public)") + return fileURL + } catch { + writerLogger.error("Failed to write markdown: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + // MARK: - Markdown Assembly + + static func buildMarkdown(metadata: Metadata, records: [SessionRecord]) -> String { + let resolvedTitle = metadata.title?.isEmpty == false ? metadata.title! : "Meeting" + let frontmatter = buildFrontmatter(metadata: metadata, records: records, title: resolvedTitle) + let body = buildBody(title: resolvedTitle, records: records, startedAt: metadata.startedAt) + return frontmatter + "\n" + body + } + + // MARK: - YAML Frontmatter + + static func buildFrontmatter( + metadata: Metadata, + records: [SessionRecord], + title: String + ) -> String { + var lines: [String] = ["---"] + + lines.append("schema: openoats/v1") + lines.append("title: \(yamlQuote(title))") + lines.append("date: \(formatISO8601(metadata.startedAt))") + lines.append("duration: \(computeDuration(records: records, metadata: metadata))") + + // Participants - always You/Them for now + lines.append("participants:") + lines.append(" - You") + lines.append(" - Them") + + // Recorder (system user's full name) + let recorderName = NSFullUserName() + if !recorderName.isEmpty { + lines.append("recorder: \(yamlQuote(recorderName))") + } + + // Engine + if let engine = metadata.engine, !engine.isEmpty { + lines.append("engine: \(engine)") + } + + // Meeting app (lowercase per spec) + if let app = metadata.meetingApp, !app.isEmpty { + lines.append("app: \(normalizeAppName(app))") + } + + // Extension: link back to session ID + lines.append("x_openoats_session: \(yamlQuote(metadata.sessionID))") + + lines.append("---") + return lines.joined(separator: "\n") + } + + // MARK: - Body + + static func buildBody(title: String, records: [SessionRecord], startedAt: Date) -> String { + var parts: [String] = [] + + // H1 title + parts.append("# \(title)") + parts.append("") + + // Transcript section + parts.append("## Transcript") + parts.append("") + + let transcriptLines = formatTranscriptLines(records: records, startedAt: startedAt) + parts.append(transcriptLines) + + return parts.joined(separator: "\n") + } + + // MARK: - Transcript Formatting + + static func formatTranscriptLines(records: [SessionRecord], startedAt: Date) -> String { + var lines: [String] = [] + + for record in records { + let relativeTimestamp = formatRelativeTimestamp( + record.timestamp, + relativeTo: startedAt + ) + let speaker = speakerLabel(record.speaker) + let text = record.refinedText ?? record.text + lines.append("[\(relativeTimestamp)] **\(speaker):** \(text)") + lines.append("") + } + + return lines.joined(separator: "\n") + } + + // MARK: - Timestamp Helpers + + /// Format a date as a relative timestamp `HH:MM:SS` from the meeting start. + static func formatRelativeTimestamp(_ timestamp: Date, relativeTo start: Date) -> String { + let interval = max(0, timestamp.timeIntervalSince(start)) + let totalSeconds = Int(interval.rounded()) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + + /// Format a date as ISO 8601 with timezone offset. + static func formatISO8601(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone.current + return formatter.string(from: date) + } + + // MARK: - Duration + + /// Compute meeting duration in minutes from transcript records, rounded to nearest minute. + /// Minimum is 1 minute. + static func computeDuration(records: [SessionRecord], metadata: Metadata) -> Int { + // Prefer endedAt from metadata if available + if let endedAt = metadata.endedAt { + let seconds = endedAt.timeIntervalSince(metadata.startedAt) + return max(1, Int((seconds / 60.0).rounded())) + } + + // Fallback: difference between first and last record timestamps + guard let first = records.first, let last = records.last else { return 1 } + let seconds = last.timestamp.timeIntervalSince(first.timestamp) + return max(1, Int((seconds / 60.0).rounded())) + } + + // MARK: - Speaker Label + + static func speakerLabel(_ speaker: Speaker) -> String { + switch speaker { + case .you: return "You" + case .them: return "Them" + } + } + + // MARK: - YAML Quoting + + /// Quote a YAML string value. Per spec, title MUST always be quoted. + /// Wraps in double quotes and escapes internal double quotes and backslashes. + static func yamlQuote(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\t", with: "\\t") + return "\"\(escaped)\"" + } + + // MARK: - App Name Normalization + + /// Normalize meeting app display name to a lowercase slug for the `app` frontmatter field. + /// Maps known display names to standard short names per spec. + static func normalizeAppName(_ name: String) -> String { + let lower = name.lowercased() + // Map well-known display names to their spec identifiers + if lower.contains("zoom") { return "zoom" } + if lower.contains("teams") { return "teams" } + if lower.contains("meet") && lower.contains("google") { return "meet" } + if lower.contains("facetime") { return "facetime" } + if lower.contains("slack") { return "slack" } + if lower.contains("discord") { return "discord" } + if lower.contains("webex") { return "webex" } + if lower.contains("whatsapp") { return "whatsapp" } + if lower.contains("tuple") { return "tuple" } + if lower.contains("around") { return "around" } + // Fallback: kebab-case the name + return toKebabCase(lower) + } + + // MARK: - Kebab Case + + /// Convert a string to kebab-case: lowercase, ASCII-only, hyphens for separators. + /// Non-ASCII characters are stripped. Multiple hyphens are collapsed. + /// Leading/trailing hyphens are trimmed. + static func toKebabCase(_ input: String) -> String { + let lowered = input.lowercased() + + // Replace non-alphanumeric ASCII with hyphens, strip non-ASCII + var result = "" + for scalar in lowered.unicodeScalars { + if scalar.isASCII { + let char = Character(scalar) + if char.isLetter || char.isNumber { + result.append(char) + } else { + result.append("-") + } + } + // Non-ASCII characters are silently dropped + } + + // Collapse multiple hyphens + while result.contains("--") { + result = result.replacingOccurrences(of: "--", with: "-") + } + + // Trim leading/trailing hyphens + result = result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + + // Truncate to 60 characters per spec + if result.count > 60 { + result = String(result.prefix(60)) + // Don't end on a hyphen after truncation + result = result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } + + // If nothing remains, use fallback + return result.isEmpty ? "meeting" : result + } + + // MARK: - Filename Generation + + /// Generate the filename: `YYYY-MM-DD-HHMM-kebab-title.md` + /// Handles collisions by appending -2, -3, etc. + static func resolveFilename(title: String?, startedAt: Date, directory: URL) -> URL { + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyy-MM-dd-HHmm" + dateFmt.timeZone = TimeZone.current + let datePrefix = dateFmt.string(from: startedAt) + + let titleSlug = toKebabCase(title ?? "meeting") + let baseName = "\(datePrefix)-\(titleSlug)" + + let fm = FileManager.default + var candidate = directory.appendingPathComponent("\(baseName).md") + var counter = 2 + + while fm.fileExists(atPath: candidate.path) { + candidate = directory.appendingPathComponent("\(baseName)-\(counter).md") + counter += 1 + } + + return candidate + } + + // MARK: - Stage 3: Insert LLM Sections + + /// Insert LLM-generated sections (Summary, Action Items, Decisions) into an existing + /// Stage 1+2 Markdown file. Updates frontmatter title and tags if provided. + /// + /// - Parameters: + /// - fileURL: The existing `.md` file to update. + /// - llmMarkdown: The raw LLM-generated markdown (may contain ## Summary, ## Action Items, ## Decisions). + /// - newTitle: An optional new title from the LLM. + /// - tags: Optional tags array from the LLM. + /// - Returns: The (possibly renamed) URL of the updated file, or `nil` on failure. + @discardableResult + static func insertLLMSections( + fileURL: URL, + llmMarkdown: String, + newTitle: String? = nil, + tags: [String]? = nil + ) -> URL? { + guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { + writerLogger.error("Failed to read file for LLM insertion: \(fileURL.lastPathComponent, privacy: .public)") + return nil + } + + // Parse frontmatter and body + let parts = content.components(separatedBy: "---") + guard parts.count >= 3 else { + writerLogger.error("No valid frontmatter in file: \(fileURL.lastPathComponent, privacy: .public)") + return nil + } + + let bodyContent = parts.dropFirst(2).joined(separator: "---") + let originalFrontmatterLines = parts[1].components(separatedBy: "\n") + .filter { !$0.isEmpty } + + let resolvedTitle = newTitle ?? extractTitle(from: originalFrontmatterLines) + var updatedFrontmatter = rebuildFrontmatterWithUpdates( + originalLines: originalFrontmatterLines, + newTitle: newTitle, + tags: tags + ) + + // Parse body to find ## Transcript + let bodyLines = bodyContent.components(separatedBy: "\n") + var transcriptStartIndex: Int? + for (i, line) in bodyLines.enumerated() { + if line.trimmingCharacters(in: .whitespaces) == "## Transcript" { + transcriptStartIndex = i + break + } + } + + // Build new body: # Title + LLM sections + ## Transcript + var newBody: [String] = [] + newBody.append("# \(resolvedTitle ?? "Meeting")") + newBody.append("") + + // Insert LLM-generated sections + let llmSections = extractLLMSections(from: llmMarkdown) + if !llmSections.isEmpty { + newBody.append(llmSections) + newBody.append("") + } + + // Append transcript section (everything from ## Transcript onwards) + if let transcriptStart = transcriptStartIndex { + let transcriptContent = bodyLines[transcriptStart...].joined(separator: "\n") + newBody.append(transcriptContent) + } + + let finalContent = "---\n\(updatedFrontmatter)\n---\n\n\(newBody.joined(separator: "\n"))" + + // Write updated content + do { + try finalContent.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + writerLogger.error("Failed to write LLM sections: \(error.localizedDescription, privacy: .public)") + return nil + } + + // Rename file if title changed + if let newTitle, !newTitle.isEmpty { + let directory = fileURL.deletingLastPathComponent() + // Extract date from existing filename + let existingName = fileURL.deletingPathExtension().lastPathComponent + let datePrefix: String + if existingName.count >= 15 { + datePrefix = String(existingName.prefix(15)) // YYYY-MM-DD-HHMM + } else { + return fileURL + } + + let newSlug = toKebabCase(newTitle) + let newBaseName = "\(datePrefix)-\(newSlug)" + var newURL = directory.appendingPathComponent("\(newBaseName).md") + + // Don't rename to self + if newURL.lastPathComponent == fileURL.lastPathComponent { + return fileURL + } + + // Handle collision + var counter = 2 + while FileManager.default.fileExists(atPath: newURL.path) { + newURL = directory.appendingPathComponent("\(newBaseName)-\(counter).md") + counter += 1 + } + + do { + try FileManager.default.moveItem(at: fileURL, to: newURL) + writerLogger.info("Renamed meeting file to: \(newURL.lastPathComponent, privacy: .public)") + return newURL + } catch { + writerLogger.warning("Failed to rename file: \(error.localizedDescription, privacy: .public)") + return fileURL + } + } + + return fileURL + } + + // MARK: - Stage 3 Helpers + + /// Extract the title from frontmatter lines. + private static func extractTitle(from lines: [String]) -> String? { + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("title:") { + var value = String(trimmed.dropFirst("title:".count)).trimmingCharacters(in: .whitespaces) + // Remove quotes + if value.hasPrefix("\"") && value.hasSuffix("\"") { + value = String(value.dropFirst().dropLast()) + value = value.replacingOccurrences(of: "\\\\", with: "\u{0000}") + value = value.replacingOccurrences(of: "\\\"", with: "\"") + value = value.replacingOccurrences(of: "\u{0000}", with: "\\") + } + return value + } + } + return nil + } + + /// Rebuild frontmatter with optional title and tags updates. + private static func rebuildFrontmatterWithUpdates( + originalLines: [String], + newTitle: String?, + tags: [String]? + ) -> String { + var result: [String] = [] + var insideParticipants = false + var insideTags = false + // Tags are re-inserted at the end after stripping originals + + for line in originalLines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Track multi-line YAML arrays + if trimmed.hasPrefix("participants:") { insideParticipants = true; insideTags = false } + else if trimmed.hasPrefix("tags:") { insideTags = true; insideParticipants = false } + else if !trimmed.hasPrefix("- ") && !trimmed.isEmpty { + insideParticipants = false + insideTags = false + } + + // Skip existing tags (we'll re-add them) + if tags != nil && (trimmed.hasPrefix("tags:") || (insideTags && trimmed.hasPrefix("- "))) { + continue + } + + // Update title + if let newTitle, trimmed.hasPrefix("title:") { + result.append("title: \(yamlQuote(newTitle))") + continue + } + + result.append(line) + } + + // Insert tags before the end + if let tags, !tags.isEmpty { + // Find a good insertion point (after recorder or engine, before closing ---) + var insertIndex = result.count + for (i, line) in result.enumerated().reversed() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { + insertIndex = i + 1 + break + } + } + var tagLines = ["tags:"] + for tag in tags { + tagLines.append(" - \(tag)") + } + result.insert(contentsOf: tagLines, at: insertIndex) + } + + return result.joined(separator: "\n") + } + + /// Extract ## Summary, ## Action Items, ## Decisions sections from LLM markdown. + /// Returns the sections as a single string block ready for insertion. + static func extractLLMSections(from markdown: String) -> String { + // The LLM output might contain these sections mixed with other content. + // We extract them in order: Summary, Action Items, Decisions. + let lines = markdown.components(separatedBy: "\n") + var sections: [String] = [] + var currentSection: [String]? + var currentHeader: String? + + let knownHeaders = ["## Summary", "## Action Items", "## Decisions"] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if knownHeaders.contains(where: { trimmed.hasPrefix($0) }) { + // Save previous section + if let section = currentSection { + let content = section.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !content.isEmpty { + sections.append(content) + } + } + currentSection = [line] + currentHeader = trimmed + } else if trimmed.hasPrefix("## ") || trimmed.hasPrefix("# ") { + // End of a known section, hit an unknown heading + if let section = currentSection { + let content = section.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !content.isEmpty { + sections.append(content) + } + } + currentSection = nil + currentHeader = nil + } else if currentSection != nil { + currentSection?.append(line) + } + } + + // Flush last section + if let section = currentSection { + let content = section.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !content.isEmpty { + sections.append(content) + } + } + + return sections.joined(separator: "\n\n") + } + + // MARK: - Find Markdown File for Session + + /// Find the `.md` file for a given session ID in the output directory. + /// Searches by the `x_openoats_session` frontmatter field. + static func findMarkdownFile(sessionID: String, in directory: URL) -> URL? { + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { + return nil + } + + for file in files where file.pathExtension == "md" { + guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue } + if content.contains("x_openoats_session: \"\(sessionID)\"") { + return file + } + } + + return nil + } +} diff --git a/OpenOats/Sources/OpenOats/Models/Models.swift b/OpenOats/Sources/OpenOats/Models/Models.swift index a63ed93a..e050500e 100644 --- a/OpenOats/Sources/OpenOats/Models/Models.swift +++ b/OpenOats/Sources/OpenOats/Models/Models.swift @@ -257,6 +257,10 @@ struct SessionIndex: Identifiable, Codable, Sendable { var title: String? var utteranceCount: Int var hasNotes: Bool + /// The detected meeting application name (e.g. "Zoom", "Microsoft Teams"). + var meetingApp: String? + /// The ASR engine used for transcription (e.g. "parakeetV2"). + var engine: String? } struct SessionSidecar: Codable, Sendable { diff --git a/OpenOats/Sources/OpenOats/Storage/SessionStore.swift b/OpenOats/Sources/OpenOats/Storage/SessionStore.swift index 799fce93..66ad0de0 100644 --- a/OpenOats/Sources/OpenOats/Storage/SessionStore.swift +++ b/OpenOats/Sources/OpenOats/Storage/SessionStore.swift @@ -359,7 +359,9 @@ actor SessionStore { templateSnapshot: idx.templateSnapshot, title: idx.title, utteranceCount: idx.utteranceCount, - hasNotes: true + hasNotes: true, + meetingApp: idx.meetingApp, + engine: idx.engine ), notes: notes ) @@ -390,7 +392,9 @@ actor SessionStore { templateSnapshot: idx.templateSnapshot, title: newTitle.isEmpty ? nil : newTitle, utteranceCount: idx.utteranceCount, - hasNotes: idx.hasNotes + hasNotes: idx.hasNotes, + meetingApp: idx.meetingApp, + engine: idx.engine ), notes: sidecar.notes ) diff --git a/OpenOats/Sources/OpenOats/Views/NotesView.swift b/OpenOats/Sources/OpenOats/Views/NotesView.swift index b9e6adbd..13f085fa 100644 --- a/OpenOats/Sources/OpenOats/Views/NotesView.swift +++ b/OpenOats/Sources/OpenOats/Views/NotesView.swift @@ -591,6 +591,18 @@ struct NotesView: View { await coordinator.sessionStore.saveNotes(sessionID: sessionID, notes: notes) loadedNotes = notes + // Update the structured Markdown file with LLM-generated sections + let outputDir = URL(fileURLWithPath: settings.notesFolderPath) + if let mdFile = MarkdownMeetingWriter.findMarkdownFile( + sessionID: sessionID, + in: outputDir + ) { + MarkdownMeetingWriter.insertLLMSections( + fileURL: mdFile, + llmMarkdown: coordinator.notesEngine.generatedMarkdown + ) + } + await coordinator.loadHistory() } } diff --git a/OpenOats/Tests/OpenOatsTests/MarkdownMeetingWriterTests.swift b/OpenOats/Tests/OpenOatsTests/MarkdownMeetingWriterTests.swift new file mode 100644 index 00000000..1e518e59 --- /dev/null +++ b/OpenOats/Tests/OpenOatsTests/MarkdownMeetingWriterTests.swift @@ -0,0 +1,554 @@ +import XCTest +@testable import OpenOatsKit + +final class MarkdownMeetingWriterTests: XCTestCase { + + // MARK: - Kebab Case Conversion + + func testKebabCaseBasic() { + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase("Weekly Product Sync"), "weekly-product-sync") + } + + func testKebabCaseWithSpecialCharacters() { + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase("Q1 Launch: Planning!"), "q1-launch-planning") + } + + func testKebabCaseCollapsesMultipleHyphens() { + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase("hello---world"), "hello-world") + } + + func testKebabCaseTrimsEdgeHyphens() { + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase("--hello--"), "hello") + } + + func testKebabCaseEmptyInputReturnsMeeting() { + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase(""), "meeting") + } + + func testKebabCaseNonASCIIStripped() { + // Non-ASCII characters are stripped; if only non-ASCII remains, returns "meeting" + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase("spotkanie"), "spotkanie") + // Pure non-ASCII (e.g., Chinese) should fallback + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase("\u{4F1A}\u{8BAE}"), "meeting") + } + + func testKebabCaseTruncatesLongStrings() { + let longTitle = String(repeating: "a", count: 100) + let result = MarkdownMeetingWriter.toKebabCase(longTitle) + XCTAssertLessThanOrEqual(result.count, 60) + } + + func testKebabCaseMixedASCIIAndNonASCII() { + XCTAssertEqual(MarkdownMeetingWriter.toKebabCase("Meeting z klientem"), "meeting-z-klientem") + } + + // MARK: - YAML Quoting + + func testYamlQuoteSimpleString() { + XCTAssertEqual(MarkdownMeetingWriter.yamlQuote("Meeting"), "\"Meeting\"") + } + + func testYamlQuoteStringWithColons() { + XCTAssertEqual( + MarkdownMeetingWriter.yamlQuote("Feature Flag Rollout: New Editor"), + "\"Feature Flag Rollout: New Editor\"" + ) + } + + func testYamlQuoteStringWithDoubleQuotes() { + XCTAssertEqual( + MarkdownMeetingWriter.yamlQuote("The \"big\" meeting"), + "\"The \\\"big\\\" meeting\"" + ) + } + + func testYamlQuoteStringWithBackslashes() { + XCTAssertEqual( + MarkdownMeetingWriter.yamlQuote("path\\to\\file"), + "\"path\\\\to\\\\file\"" + ) + } + + func testYamlQuoteYesNoBooleanSafety() { + // "yes" unquoted would be parsed as boolean true in YAML + let result = MarkdownMeetingWriter.yamlQuote("yes") + XCTAssertEqual(result, "\"yes\"") + } + + // MARK: - Relative Timestamp Conversion + + func testRelativeTimestampZero() { + let start = Date() + XCTAssertEqual( + MarkdownMeetingWriter.formatRelativeTimestamp(start, relativeTo: start), + "00:00:00" + ) + } + + func testRelativeTimestampMinutesAndSeconds() { + let start = Date() + let later = start.addingTimeInterval(65) // 1 min 5 sec + XCTAssertEqual( + MarkdownMeetingWriter.formatRelativeTimestamp(later, relativeTo: start), + "00:01:05" + ) + } + + func testRelativeTimestampOverOneHour() { + let start = Date() + let later = start.addingTimeInterval(3661) // 1 hour, 1 min, 1 sec + XCTAssertEqual( + MarkdownMeetingWriter.formatRelativeTimestamp(later, relativeTo: start), + "01:01:01" + ) + } + + func testRelativeTimestampNegativeClampedToZero() { + let start = Date() + let earlier = start.addingTimeInterval(-30) // 30 sec before start + XCTAssertEqual( + MarkdownMeetingWriter.formatRelativeTimestamp(earlier, relativeTo: start), + "00:00:00" + ) + } + + // MARK: - Transcript Line Formatting + + func testTranscriptLineFormat() { + let start = Date() + let records = [ + SessionRecord(speaker: .you, text: "Hello", timestamp: start), + SessionRecord(speaker: .them, text: "Hi there", timestamp: start.addingTimeInterval(5)), + ] + + let output = MarkdownMeetingWriter.formatTranscriptLines(records: records, startedAt: start) + + XCTAssertTrue(output.contains("[00:00:00] **You:** Hello")) + XCTAssertTrue(output.contains("[00:00:05] **Them:** Hi there")) + } + + func testTranscriptLinePrefersRefinedText() { + let start = Date() + let record = SessionRecord( + speaker: .them, + text: "um uh like hello", + timestamp: start, + refinedText: "Hello." + ) + + let output = MarkdownMeetingWriter.formatTranscriptLines(records: [record], startedAt: start) + XCTAssertTrue(output.contains("**Them:** Hello.")) + XCTAssertFalse(output.contains("um uh like hello")) + } + + func testTranscriptLineBlankLineSeparation() { + let start = Date() + let records = [ + SessionRecord(speaker: .you, text: "One", timestamp: start), + SessionRecord(speaker: .them, text: "Two", timestamp: start.addingTimeInterval(3)), + ] + + let output = MarkdownMeetingWriter.formatTranscriptLines(records: records, startedAt: start) + let lines = output.components(separatedBy: "\n") + + // Should be: line1, blank, line2, blank (trailing) + XCTAssertTrue(lines.count >= 4) + XCTAssertTrue(lines[0].hasPrefix("[00:00:00]")) + XCTAssertEqual(lines[1], "") + XCTAssertTrue(lines[2].hasPrefix("[00:00:03]")) + } + + // MARK: - Frontmatter Generation + + func testFrontmatterContainsRequiredFields() { + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "session_2026-03-20_14-00-06", + startedAt: start, + endedAt: start.addingTimeInterval(1920), // 32 minutes + utteranceCount: 10, + hasNotes: false, + engine: "parakeetV2" + ) + ) + + let records = [ + SessionRecord(speaker: .you, text: "Hello", timestamp: start), + SessionRecord(speaker: .them, text: "Hi", timestamp: start.addingTimeInterval(1920)), + ] + + let frontmatter = MarkdownMeetingWriter.buildFrontmatter( + metadata: metadata, records: records, title: "Meeting" + ) + + XCTAssertTrue(frontmatter.hasPrefix("---")) + XCTAssertTrue(frontmatter.hasSuffix("---")) + XCTAssertTrue(frontmatter.contains("schema: openoats/v1")) + XCTAssertTrue(frontmatter.contains("title: \"Meeting\"")) + XCTAssertTrue(frontmatter.contains("duration: 32")) + XCTAssertTrue(frontmatter.contains("participants:")) + XCTAssertTrue(frontmatter.contains(" - You")) + XCTAssertTrue(frontmatter.contains(" - Them")) + XCTAssertTrue(frontmatter.contains("engine: parakeetV2")) + } + + func testFrontmatterIncludesMeetingApp() { + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "test", + startedAt: start, + endedAt: start.addingTimeInterval(60), + utteranceCount: 1, + hasNotes: false, + meetingApp: "Zoom", + engine: "parakeetV2" + ) + ) + + let records = [SessionRecord(speaker: .you, text: "Hi", timestamp: start)] + let frontmatter = MarkdownMeetingWriter.buildFrontmatter( + metadata: metadata, records: records, title: "Meeting" + ) + + XCTAssertTrue(frontmatter.contains("app: zoom")) + } + + func testFrontmatterIncludesSessionExtension() { + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "session_2026-03-20_14-00-06", + startedAt: start, + utteranceCount: 1, + hasNotes: false + ) + ) + + let records = [SessionRecord(speaker: .you, text: "Hi", timestamp: start)] + let frontmatter = MarkdownMeetingWriter.buildFrontmatter( + metadata: metadata, records: records, title: "Meeting" + ) + + XCTAssertTrue(frontmatter.contains("x_openoats_session: \"session_2026-03-20_14-00-06\"")) + } + + // MARK: - Duration Computation + + func testDurationFromEndedAt() { + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "test", + startedAt: start, + endedAt: start.addingTimeInterval(1920), // 32 minutes + utteranceCount: 2, + hasNotes: false + ) + ) + + let records = [ + SessionRecord(speaker: .you, text: "a", timestamp: start), + SessionRecord(speaker: .them, text: "b", timestamp: start.addingTimeInterval(60)), + ] + + XCTAssertEqual(MarkdownMeetingWriter.computeDuration(records: records, metadata: metadata), 32) + } + + func testDurationMinimumIsOne() { + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "test", + startedAt: start, + endedAt: start.addingTimeInterval(10), // 10 seconds + utteranceCount: 1, + hasNotes: false + ) + ) + + let records = [SessionRecord(speaker: .you, text: "a", timestamp: start)] + XCTAssertEqual(MarkdownMeetingWriter.computeDuration(records: records, metadata: metadata), 1) + } + + // MARK: - App Name Normalization + + func testNormalizeAppNameZoom() { + XCTAssertEqual(MarkdownMeetingWriter.normalizeAppName("Zoom"), "zoom") + } + + func testNormalizeAppNameMicrosoftTeams() { + XCTAssertEqual(MarkdownMeetingWriter.normalizeAppName("Microsoft Teams"), "teams") + } + + func testNormalizeAppNameFaceTime() { + XCTAssertEqual(MarkdownMeetingWriter.normalizeAppName("FaceTime"), "facetime") + } + + func testNormalizeAppNameGoogleMeetPWA() { + XCTAssertEqual(MarkdownMeetingWriter.normalizeAppName("Google Meet (PWA)"), "meet") + } + + func testNormalizeAppNameUnknown() { + XCTAssertEqual(MarkdownMeetingWriter.normalizeAppName("MyVideoApp"), "myvideoapp") + } + + // MARK: - Full Markdown Output + + func testBuildMarkdownProducesCompleteFile() { + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "session_2026-03-20_14-00-06", + startedAt: start, + endedAt: start.addingTimeInterval(120), + utteranceCount: 2, + hasNotes: false, + meetingApp: "Zoom", + engine: "parakeetV2" + ) + ) + + let records = [ + SessionRecord(speaker: .you, text: "Hello world", timestamp: start), + SessionRecord( + speaker: .them, text: "raw text", + timestamp: start.addingTimeInterval(5), + refinedText: "Refined text here." + ), + ] + + let markdown = MarkdownMeetingWriter.buildMarkdown(metadata: metadata, records: records) + + // Verify structure + XCTAssertTrue(markdown.hasPrefix("---\n")) + XCTAssertTrue(markdown.contains("schema: openoats/v1")) + XCTAssertTrue(markdown.contains("# Meeting")) + XCTAssertTrue(markdown.contains("## Transcript")) + XCTAssertTrue(markdown.contains("[00:00:00] **You:** Hello world")) + XCTAssertTrue(markdown.contains("[00:00:05] **Them:** Refined text here.")) + // Refined text should be used, not raw + XCTAssertFalse(markdown.contains("raw text")) + } + + // MARK: - File Writing + + func testWriteCreatesFileOnDisk() { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenOatsTest-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "test_session", + startedAt: start, + endedAt: start.addingTimeInterval(60), + utteranceCount: 1, + hasNotes: false, + engine: "parakeetV2" + ) + ) + + let records = [SessionRecord(speaker: .you, text: "Test", timestamp: start)] + + let fileURL = MarkdownMeetingWriter.write( + metadata: metadata, + records: records, + outputDirectory: tmpDir + ) + + XCTAssertNotNil(fileURL) + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL!.path)) + XCTAssertTrue(fileURL!.lastPathComponent.hasSuffix(".md")) + + // Verify content + let content = try! String(contentsOf: fileURL!, encoding: .utf8) + XCTAssertTrue(content.contains("schema: openoats/v1")) + } + + func testWriteHandlesFilenameCollision() { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenOatsTest-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let start = Date() + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "test_session", + startedAt: start, + endedAt: start.addingTimeInterval(60), + utteranceCount: 1, + hasNotes: false + ) + ) + + let records = [SessionRecord(speaker: .you, text: "Test", timestamp: start)] + + // Write first file + let first = MarkdownMeetingWriter.write( + metadata: metadata, records: records, outputDirectory: tmpDir + )! + + // Write second file with same metadata (collision) + let second = MarkdownMeetingWriter.write( + metadata: metadata, records: records, outputDirectory: tmpDir + )! + + XCTAssertNotEqual(first.lastPathComponent, second.lastPathComponent) + XCTAssertTrue(second.lastPathComponent.contains("-2")) + } + + func testWriteReturnsNilForEmptyRecords() { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenOatsTest-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: "test", startedAt: Date(), utteranceCount: 0, hasNotes: false + ) + ) + + let result = MarkdownMeetingWriter.write( + metadata: metadata, records: [], outputDirectory: tmpDir + ) + + XCTAssertNil(result) + } + + // MARK: - LLM Section Extraction + + func testExtractLLMSectionsAllPresent() { + let llmOutput = """ + ## Summary + + The team discussed the launch timeline and decided to move it up. + + ## Action Items + + - [ ] Update the timeline [owner:: You] [due:: 2026-03-25] + - [ ] Run load testing [owner:: Them] + + ## Decisions + + - Launch date set to April 15 + - Collaborative editing deferred to v1.1 + """ + + let sections = MarkdownMeetingWriter.extractLLMSections(from: llmOutput) + + XCTAssertTrue(sections.contains("## Summary")) + XCTAssertTrue(sections.contains("## Action Items")) + XCTAssertTrue(sections.contains("## Decisions")) + XCTAssertTrue(sections.contains("[owner:: You]")) + } + + func testExtractLLMSectionsOnlySummary() { + let llmOutput = """ + ## Summary + + A brief discussion about the product roadmap. + + ## Key Points + + - Point one + - Point two + """ + + let sections = MarkdownMeetingWriter.extractLLMSections(from: llmOutput) + + XCTAssertTrue(sections.contains("## Summary")) + // Key Points is not a recognized section, should not be included + XCTAssertFalse(sections.contains("## Key Points")) + } + + // MARK: - Find Markdown File + + func testFindMarkdownFileBySessionID() { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenOatsTest-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let start = Date() + let sessionID = "session_2026-03-20_14-00-06" + let metadata = MarkdownMeetingWriter.Metadata( + from: SessionIndex( + id: sessionID, + startedAt: start, + endedAt: start.addingTimeInterval(60), + utteranceCount: 1, + hasNotes: false + ) + ) + + let records = [SessionRecord(speaker: .you, text: "Test", timestamp: start)] + MarkdownMeetingWriter.write( + metadata: metadata, records: records, outputDirectory: tmpDir + ) + + let found = MarkdownMeetingWriter.findMarkdownFile(sessionID: sessionID, in: tmpDir) + XCTAssertNotNil(found) + } + + // MARK: - Filename Format + + func testFilenameFormatMatchesSpec() { + let calendar = Calendar.current + var components = DateComponents() + components.year = 2026 + components.month = 3 + components.day = 20 + components.hour = 14 + components.minute = 0 + let date = calendar.date(from: components)! + + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenOatsTest-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let url = MarkdownMeetingWriter.resolveFilename( + title: "Weekly Product Sync", + startedAt: date, + directory: tmpDir + ) + + XCTAssertEqual(url.lastPathComponent, "2026-03-20-1400-weekly-product-sync.md") + } + + func testFilenameWithNilTitleUsesMeeting() { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenOatsTest-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let url = MarkdownMeetingWriter.resolveFilename( + title: nil, + startedAt: Date(), + directory: tmpDir + ) + + XCTAssertTrue(url.lastPathComponent.contains("-meeting.md")) + } + + // MARK: - Speaker Label + + func testSpeakerLabelYou() { + XCTAssertEqual(MarkdownMeetingWriter.speakerLabel(.you), "You") + } + + func testSpeakerLabelThem() { + XCTAssertEqual(MarkdownMeetingWriter.speakerLabel(.them), "Them") + } + + // MARK: - ISO 8601 Formatting + + func testISO8601IncludesTimezone() { + let result = MarkdownMeetingWriter.formatISO8601(Date()) + // Should contain a timezone offset like +01:00 or -05:00 or Z + let hasTimezone = result.contains("+") || result.contains("Z") || result.hasSuffix("00") + XCTAssertTrue(hasTimezone, "ISO 8601 date should include timezone: \(result)") + } +} diff --git a/docs/example-transcript.md b/docs/example-transcript.md new file mode 100644 index 00000000..27e881d4 --- /dev/null +++ b/docs/example-transcript.md @@ -0,0 +1,110 @@ +--- +schema: openoats/v1 +title: "Notification System: Scope and Launch Plan" +date: 2026-03-18T10:30:00+01:00 +duration: 11 +participants: + - You + - Them +recorder: Szymon Sypniewicz +tags: + - product + - notifications + - launch +language: en +engine: parakeet-tdt-v2 +app: meet +--- + +# Notification System: Scope and Launch Plan + +## Summary + +Discussed the scope and timeline for shipping the in-app notification system. The original plan included real-time push notifications, email digests, and in-app alerts, but the team decided to cut email digests from v1 to avoid the deliverability rabbit hole (SPF, DKIM, reputation management). The notification system will ship with in-app alerts and optional browser push notifications only. Target launch is March 28. A soft rollout to beta users will happen on March 25, with three days of monitoring before the public release. The backend will use a simple polling architecture rather than WebSockets to keep infrastructure costs flat. + +## Action Items + +- [ ] Write the notification preferences UI component [owner:: You] [due:: 2026-03-21] +- [ ] Set up the notifications database table and API endpoints [owner:: Them] [due:: 2026-03-22] +- [ ] Deploy notification service to staging [owner:: Them] [due:: 2026-03-24] +- [ ] Draft the changelog entry for the notification feature [owner:: You] [due:: 2026-03-25] +- [ ] Run load test simulating 500 concurrent users polling for notifications [owner:: Them] [due:: 2026-03-25] +- [ ] Coordinate with beta users for soft rollout [owner:: You] [due:: 2026-03-25] + +## Decisions + +- Email digests cut from v1, will revisit in v1.1 +- Polling architecture instead of WebSockets for notifications +- 30-second polling interval as default, configurable per user +- Soft rollout to beta users on March 25, public launch March 28 +- Notifications auto-expire after 30 days + +## Transcript + +[00:00:00] **You:** Morning. I wanted to nail down the notification system scope before the weekend so we can start building Monday. + +[00:00:06] **Them:** Good timing. I was actually sketching out the data model last night. I think we are overcomplicating this. + +[00:00:12] **You:** How so? + +[00:00:14] **Them:** The original spec has three channels: in-app alerts, browser push, and email digests. The first two are straightforward. Email digests are a completely different beast. We need a transactional email provider, SPF records, DKIM signing, domain reputation management. It is a whole project on its own. + +[00:00:32] **You:** Yeah, I had that thought too. The email setup alone could take a week if we hit deliverability issues. + +[00:00:38] **Them:** Exactly. And honestly, who reads email digests? Our users live in the app. If we ship in-app alerts and browser push, that covers 95% of the use case. + +[00:00:48] **You:** I agree. Let's cut email digests from v1. We can revisit it in v1.1 if users actually ask for it. + +[00:00:55] **Them:** Good. Now, for the delivery mechanism. I know WebSockets are the trendy choice, but I think simple polling is better for us right now. + +[00:01:04] **You:** Because of infrastructure cost? + +[00:01:07] **Them:** Partly. WebSocket connections are persistent. If we have a thousand users online, that is a thousand open connections our server is maintaining. Polling lets us stay on a basic HTTP setup. No special infrastructure, no connection management, no reconnection logic on the client. + +[00:01:22] **You:** What polling interval are you thinking? + +[00:01:25] **Them:** 30 seconds default. Fast enough that notifications feel responsive, infrequent enough that we are not hammering the server. We can let power users configure it down to 10 seconds if they want. + +[00:01:37] **You:** That sounds reasonable. At 30 seconds, even with a few thousand active users, the load is trivial. + +[00:01:44] **Them:** Right. And if we ever need real-time, we can swap polling for WebSockets later without changing the notification data model. The upgrade path is clean. + +[00:01:53] **You:** Perfect. Let's talk timeline. We said end of March originally. Is that still realistic with the reduced scope? + +[00:02:00] **Them:** More than realistic. Without email digests, I think we can have the backend done by the 22nd. That gives us time to test and do a soft rollout. + +[00:02:09] **You:** I want to do a soft rollout to our beta users before the public launch. Maybe three days of monitoring. + +[00:02:16] **Them:** So beta on the 25th, public on the 28th? + +[00:02:19] **You:** Exactly. That gives us the weekend as a buffer too. If something breaks during beta, we have Monday and Tuesday to fix it. + +[00:02:27] **Them:** Works for me. One thing I want to decide now: notification expiry. Do they stay forever or auto-delete? + +[00:02:34] **You:** Auto-expire. Stale notifications are worse than no notifications. What is a reasonable window? + +[00:02:40] **Them:** 30 days. Long enough that people do not miss things on vacation, short enough that the database does not grow forever. + +[00:02:48] **You:** 30 days. Done. Let's split the work. I will take the frontend: the notification bell, the preferences panel, the dropdown list. You take the backend: database schema, API endpoints, the polling service. + +[00:03:01] **Them:** Agreed. I will have the database table and endpoints ready by the 22nd so you can start integrating the frontend against real data. + +[00:03:10] **You:** Good. And I need to write the changelog entry for this feature. I will do that on the 25th once we have the final build. + +[00:03:18] **Them:** One more thing. We should run a load test before the public launch. I want to simulate 500 concurrent users polling at 30-second intervals and make sure response times stay under 200ms. + +[00:03:30] **You:** Absolutely. Can you set that up as part of the staging deploy? + +[00:03:34] **Them:** Yeah. I will deploy to staging on the 24th and run the load test on the 25th, same day as the beta rollout. + +[00:03:42] **You:** Great. I will reach out to the beta group today and give them a heads up about the March 25th date. + +[00:03:49] **Them:** Sounds good. I think we are in good shape. + +[00:03:52] **You:** Agreed. Nice call on cutting the email digests. That would have derailed the whole timeline. + +[00:03:58] **Them:** Every feature you do not build is a feature that cannot break. + +[00:04:02] **You:** Words to live by. Talk Monday. + +[00:04:05] **Them:** See you then. diff --git a/docs/meeting-format-spec.md b/docs/meeting-format-spec.md new file mode 100644 index 00000000..3105770c --- /dev/null +++ b/docs/meeting-format-spec.md @@ -0,0 +1,796 @@ +# OpenOats Meeting Format Specification + +**Version:** 1.0 +**Status:** Draft +**License:** MIT + +The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in RFC 2119. + +--- + +## Table of Contents + +- [Overview](#overview) +- [File Naming](#file-naming) +- [YAML Frontmatter](#yaml-frontmatter) +- [Processing Stages](#processing-stages) +- [Body Structure](#body-structure) +- [Transcript Line Format](#transcript-line-format) +- [Speaker Model](#speaker-model) +- [Extensibility](#extensibility) +- [Parsing Guide](#parsing-guide) +- [Versioning](#versioning) +- [Complete Example: Stage 1+2 File (No LLM)](#complete-example-stage-12-file-no-llm) +- [Complete Example: Stage 1+2+3 File (After LLM Processing)](#complete-example-stage-123-file-after-llm-processing) +- [Conformance](#conformance) +- [Security Considerations](#security-considerations) +- [Design Rationale (Non-normative)](#design-rationale-non-normative) +- [Acknowledgments](#acknowledgments) + +--- + +## Overview + +The OpenOats Meeting Format (`.md`) is a structured Markdown format for meeting transcripts. It replaces OpenOats' plain `.txt` output with a file that is simultaneously human-readable, grep-friendly, Obsidian-native, and parseable by LLM agents. + +### Goals + +1. **Human-readable** in any text editor, Obsidian, or GitHub preview +2. **Agent-ready** for LLM consumption (Claude Code, Cursor, RAG pipelines) +3. **CLI-friendly** with predictable patterns for grep/ripgrep +4. **Obsidian-native** with Dataview-queryable frontmatter and Dataview TASK-queryable action items +5. **Adoptable** by other tools as a shared standard for meeting transcripts +6. **Incrementally structured** so files are useful at every processing stage + +### Non-goals + +- Replacing the JSONL session store (that stays for word-level data, RAG hits, etc.) +- Encoding audio playback offsets with sub-second precision (use JSONL for that) +- Handling real-time streaming (this format describes the finished artifact) + +--- + +## File Naming + +Filenames MUST be valid UTF-8. The kebab-case title portion MUST contain only ASCII characters `[a-z0-9-]`. If the title contains non-ASCII characters that produce an empty slug after conversion, use the fallback title `meeting`. The title portion SHOULD NOT exceed 60 characters. + +``` +YYYY-MM-DD-HHMM-kebab-case-title.md +``` + +Examples: +``` +2026-03-20-1400-weekly-product-sync.md +2026-03-20-0930-investor-update.md +2026-03-21-1600-onboarding-call.md +``` + +Rules: +- Date and time MUST be the meeting start time in local time +- Time MUST be 24-hour, no separator between hours and minutes +- Title MUST be kebab-case, lowercase, ASCII only +- Filenames MUST NOT contain spaces (CLI-friendly) +- Lexical sort = chronological sort +- The filename is the file's unique identifier. No UUID field needed. +- If a file with the generated name already exists, implementations SHOULD append `-2`, `-3`, etc. before `.md`. + +When the app cannot determine a title (no LLM post-processing, no calendar integration), use a fallback: +``` +2026-03-20-1400-meeting.md +``` + +--- + +## YAML Frontmatter + +Every file starts with a YAML frontmatter block. + +### Field Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `schema` | string | Yes | Format identifier. Always `openoats/v1` for this version. | +| `title` | string | Yes | Meeting title. Auto-generated from conversation topic, calendar event, or user edit. The H1 heading in the body MUST be identical to the `title` frontmatter value (after YAML string parsing). | +| `date` | ISO 8601 datetime | Yes | Meeting start time. Include timezone offset when available (e.g., `2026-03-20T14:00:00+01:00`). Omit timezone only if unknown. | +| `duration` | integer | Yes | Meeting duration in minutes, rounded to nearest minute. MUST be a positive integer (>= 1). | +| `participants` | string array | Yes | List of participant names. Default: `["You", "Them"]`. MUST contain at least one entry. See Speaker Model below. | +| `recorder` | string | No | Name of the person who recorded the meeting. Maps the speaker `You` to a real identity. The app SHOULD set this automatically from the system user name. | +| `tags` | string array | No | Topic tags. Auto-generated by LLM or user-assigned. Plain strings, no `#` prefix. | +| `language` | string | No | BCP 47 language code (e.g., `en`, `pl`, `de`). Defaults to `en` if omitted. | +| `engine` | string | No | ASR backend used for transcription (e.g., `parakeet-tdt-v2`, `qwen3-asr`, `whisper-large-v3`, `11labs-scribe-2`). | +| `app` | string | No | Detected meeting application, lowercase (e.g., `zoom`, `teams`, `meet`, `slack`, `facetime`). Omit if not detected. | +| `x_*` | any | No | Extension namespace. Any field prefixed with `x_` is valid. See Extensibility. | + +### Frontmatter Rules + +1. **UTF-8 without BOM, LF line endings.** Files MUST be encoded as UTF-8 without BOM. Lines MUST use LF (`\n`) line endings. +2. **YAML arrays, never comma-separated strings.** Array fields MUST use YAML array syntax (either inline `tags: [product, roadmap]` or block list with `- item` entries), not a comma-separated string like `tags: "product, roadmap"`. +3. **Plain text in frontmatter, wikilinks in body.** Frontmatter fields MUST NOT contain wikilink syntax (`[[Person Name]]`). +4. **Flat structure, no nesting.** Frontmatter MUST NOT use nested YAML objects. Dataview cannot query nested YAML objects without DataviewJS. +5. **Keep under 20 lines.** Frontmatter SHOULD be scannable and SHOULD NOT exceed 20 lines. +6. **The `title` field MUST always be quoted in YAML.** Unquoted titles risk silent coercion by YAML parsers (`yes` becomes boolean, `null` becomes empty, `#` starts a comment that truncates the value). +7. **Consistent types across files.** If `participants` is an array in one file, it MUST be an array in all files. + +### Minimal Frontmatter (Stage 1+2, no LLM) + +```yaml +--- +schema: openoats/v1 +title: "Meeting" +date: 2026-03-20T14:00:00+01:00 +duration: 32 +participants: + - You + - Them +engine: parakeet-tdt-v2 +--- +``` + +### Full Frontmatter (Stage 3, after LLM processing) + +```yaml +--- +schema: openoats/v1 +title: "Q1 Launch Planning" +date: 2026-03-20T14:00:00+01:00 +duration: 47 +participants: + - You + - Them +recorder: Szymon Sypniewicz +tags: + - product + - launch + - roadmap +language: en +engine: parakeet-tdt-v2 +app: zoom +--- +``` + +--- + +## Processing Stages + +OpenOats produces this file in stages. Stage 2 refines Stage 1 output in-place (filler removal, punctuation, speaker correction). Stage 3 inserts new sections without modifying what Stage 1+2 wrote. + +### Stage 1: Transcription + +Raw ASR output. Produces a file with: +- Frontmatter: `schema`, `title` (fallback), `date`, `duration`, `participants` (`You`/`Them`), `engine` +- Body: `# Title` and `## Transcript` only + +### Stage 2: Post-processing + +Cleanup applied to Stage 1 output: +- Filler word removal (uh, um, like, you know) +- Punctuation and capitalization correction +- Speaker attribution corrections (echo cancellation artifacts) +- Enrichment from available context (meeting app name from detection, title from conversation state) + +Stage 2 modifies the transcript text in-place and may update frontmatter fields (`title`, `app`). The file structure is identical to Stage 1. + +### Stage 3: Intelligence (optional, LLM) + +User triggers LLM post-processing. The LLM reads the Stage 1+2 file and generates: +- `## Summary` section +- `## Action Items` section +- `## Decisions` section (if applicable) +- `tags` array in frontmatter + +These sections are inserted between `# Title` and `## Transcript`. The transcript itself is not modified. + +--- + +## Body Structure + +The body follows the title-first, transcript-last principle. Synthesized content (summary, action items, decisions) goes at the top because LLMs weight the beginning and end of documents more heavily, and humans scanning the file want the high-signal content first. + +### Section Order + +``` +# Title + +## Summary <- Stage 3 only (LLM-generated) + +## Action Items <- Stage 3 only (LLM-generated) + +## Decisions <- Stage 3 only (LLM-generated, optional) + +## Transcript <- Stage 1+2 (always present) +``` + +### Required vs Optional Sections + +| Section | Required | Added by | +|---------|----------|----------| +| `# Title` | Yes | Stage 1 | +| `## Transcript` | Yes | Stage 1+2 | +| `## Summary` | No | Stage 3 (LLM) | +| `## Action Items` | No | Stage 3 (LLM) | +| `## Decisions` | No | Stage 3 (LLM) | + +A Stage 1+2 file (no LLM post-processing) contains only the title heading and the transcript section. This is a complete, valid file. + +A Stage 3 file has the Summary, Action Items, and Decisions sections inserted between the title and the transcript. + +Custom `## ` sections are permitted. They MUST appear between `## Decisions` (or `# Title` if no Stage 3 sections exist) and `## Transcript`. Parsers MUST ignore sections they do not recognize. + +### Section Details + +#### `# Title` + +The H1 heading matches the `title` frontmatter field. It appears once, at the top of the body. + +```markdown +# Q1 Launch Planning +``` + +#### `## Summary` + +One to three paragraphs. No bullet points in the summary. Written in past tense, describing what happened and what was decided. + +```markdown +## Summary + +The team discussed moving the v1.0 launch from May 1 to April 15 to stay ahead of +TranscriptPro's desktop release. Engineering confirmed the encryption module is +production-ready but recommended deferring collaborative editing to v1.1 due to CRDT +complexity. The marketing site will lead with privacy-first messaging. +``` + +#### `## Action Items` + +Standard Markdown checkboxes with Obsidian Dataview inline fields for `owner` and `due`. + +```markdown +## Action Items + +- [ ] Finalize launch announcement blog post [owner:: You] [due:: 2026-03-25] +- [ ] Run load testing on SQLite concurrency [owner:: Them] [due:: 2026-03-28] +- [ ] Send revised timeline to stakeholders [owner:: You] [due:: 2026-03-22] +``` + +Format per line: +``` +- [ ] {task description} [owner:: {name}] [due:: {YYYY-MM-DD}] +``` + +Rules: +- `owner` MUST use the same name that appears in the `participants` array +- `due` MUST be an ISO 8601 date. Omit the `[due:: ...]` field entirely if no due date was discussed +- Completed items MUST use `[x]` or `[X]` instead of `[ ]` +- Each item MUST be a single line (no multi-line tasks) +- When both `owner` and `due` are present, `owner` MUST precede `due` +- Task descriptions MUST NOT contain Dataview inline field syntax (`[field:: value]`). All metadata goes in the trailing inline fields. + +#### `## Decisions` + +A flat bullet list of decisions made during the meeting. No IDs, no sub-structure. + +```markdown +## Decisions + +- Launch date set to April 15, moved up from May 1 +- Collaborative editing deferred to v1.1 +- Marketing hero copy leads with privacy, not open source +``` + +This section is optional even in Stage 3 output. If the LLM determines no decisions were made, omit the section entirely. + +#### `## Transcript` + +The raw transcript. Every utterance is a single line following the transcript line format described below. + +--- + +## Transcript Line Format + +Each line in the `## Transcript` section follows this pattern: + +``` +[HH:MM:SS] **Speaker Name:** Utterance text here. +``` + +Each utterance MUST be a single line, regardless of length. Line wrapping within an utterance is not supported. + +### Formal Pattern + +``` +[{timestamp}] **{speaker}:** {text} +``` + +| Component | Format | Example | +|-----------|--------|---------| +| Timestamp | `HH:MM:SS` - hours, minutes, seconds, zero-padded | `[00:05:23]` | +| Speaker | Bold Markdown, followed by colon | `**You:**` | +| Text | Free text, single line | `I think we should launch earlier.` | + +### Regex for Parsing + +```regex +^\[(\d{2}:\d{2}:\d{2})\] \*\*(.+?):\*\* (.*)$ +``` + +Capture groups: +1. Timestamp (`00:05:23`) +2. Speaker name (`You`) +3. Utterance text (`I think we should launch earlier.`), may be empty + +> **Parser note:** Parsers SHOULD normalize speaker names by stripping Markdown bold markers (`**`) before comparison. + +### Timestamp Rules + +- Timestamps MUST be relative to meeting start, not wall-clock time +- Wall-clock time is in the frontmatter `date` field +- Format MUST always be `HH:MM:SS`, zero-padded (e.g., `00:01:05`, not `0:1:5`) +- For meetings over 24 hours (unlikely), hours MAY exceed 23: `[25:00:00]` + +### Blank Lines + +Utterances are separated by blank lines for readability: + +```markdown +[00:00:00] **You:** Let's get started. The main topic is the launch timeline. + +[00:00:08] **Them:** Sure. I looked at the latest metrics and usage is up significantly. + +[00:00:15] **You:** That matches what I'm hearing from users too. +``` + +Parsers SHOULD treat blank lines between transcript entries as cosmetic. They carry no semantic meaning. + +--- + +## Speaker Model + +### Current State: You/Them + +OpenOats captures two audio streams: +- **Microphone** (your voice) mapped to speaker `You` +- **System audio** (remote participants) mapped to speaker `Them` + +There is no diarization between multiple remote speakers. All remote audio is attributed to `Them`. + +In Stage 1+2 output, `participants` is always `["You", "Them"]` and the transcript uses `**You:**` and `**Them:**`. + +### Future State: Named Participants + +When OpenOats gains participant identification (via calendar integration, manual labeling, or diarization), the format supports named speakers with no structural changes: + +```yaml +participants: + - Alice Chen + - Bob Martinez + - Carol Wu +``` + +```markdown +[00:00:00] **Alice Chen:** Let's get started. + +[00:00:08] **Bob Martinez:** Sure, I've got the slides ready. +``` + +The transition is seamless: `You`/`Them` are just speaker names. Named participants are also just speaker names. No format change required. + +### Speaker Rules + +1. Speaker names in transcript lines MUST match an entry in the `participants` array +2. Speaker names MUST be compared case-sensitively +3. Speaker names MUST NOT contain `*`, `:`, `[`, or `]` characters +4. When the app cannot identify individual remote speakers, all remote audio MUST use `Them` +5. The app SHOULD NOT invent names. Use `You`/`Them` until reliable identification exists + +--- + +## Extensibility + +Any field prefixed with `x_` in the frontmatter is a valid extension field. This namespace is reserved for tool-specific or user-specific metadata that is outside the core schema. + +### Examples + +```yaml +x_openoats_session: "session_2026-03-20_14-00-06" +x_openoats_template: "customer-discovery" +x_calendar_event_id: "abc123def456" +x_project: "OpenOats v1.0" +x_confidence: 0.92 +``` + +### Extension Rules + +1. Extension fields MUST start with `x_` +2. Extension fields are always optional. Implementations MUST NOT require any `x_` field for conformance. +3. Parsers MUST ignore extension fields they do not recognize +4. Tools SHOULD namespace their extensions: `x_toolname_field` (e.g., `x_openoats_session`) +5. Extension fields MUST follow all other frontmatter rules (flat structure, consistent types) + +--- + +## Parsing Guide + +### Error Handling + +- If the `schema` field is missing or unrecognized, parsers SHOULD emit a warning and attempt best-effort parsing. +- Transcript lines that do not match the regex SHOULD be preserved as-is but excluded from structured output. +- Parsers MUST NOT reject a file due to unknown `x_` extension fields or unknown `## ` sections. +- Parsers SHOULD emit a warning if a `[due:: ...]` field appears before `[owner:: ...]` in an action item line, as this violates the ordering rule and will not be captured correctly by the reference regex. + +### Reading Frontmatter (Python) + +```python +import yaml + +def parse_openoats(filepath): + with open(filepath) as f: + content = f.read() + + # Split frontmatter from body + parts = content.split("---", 2) + if len(parts) < 3: + raise ValueError("No YAML frontmatter found") + + meta = yaml.safe_load(parts[1]) + body = parts[2].strip() + + if meta.get("schema") != "openoats/v1": + raise ValueError(f"Unsupported schema: {meta.get('schema')}") + return meta, body +``` + +### Extracting Transcript Lines (Python) + +```python +import re + +TRANSCRIPT_RE = re.compile( + r"^\[(\d{2}:\d{2}:\d{2})\] \*\*(.+?):\*\* (.*)$" +) + +def extract_transcript(body): + lines = [] + for line in body.splitlines(): + match = TRANSCRIPT_RE.match(line) + if match: + lines.append({ + "timestamp": match.group(1), + "speaker": match.group(2), + "text": match.group(3), + }) + return lines +``` + +### Extracting Action Items (Python) + +```python +import re + +ACTION_RE = re.compile( + r"^- \[([ xX])\] (.+?)(?:\s*\[owner:: ([^\]]+)\])?(?:\s*\[due:: ([^\]]+)\])?\s*$" +) + +def extract_actions(body): + items = [] + for line in body.splitlines(): + match = ACTION_RE.match(line) + if match: + items.append({ + "completed": match.group(1) in ("x", "X"), + "task": match.group(2).strip(), + "owner": match.group(3), + "due": match.group(4), + }) + return items +``` + +### Extracting Transcript Lines (JavaScript/Node) + +```javascript +const TRANSCRIPT_RE = /^\[(\d{2}:\d{2}:\d{2})\] \*\*(.+?):\*\* (.*)$/; + +function extractTranscript(body) { + return body.split("\n") + .map(line => TRANSCRIPT_RE.exec(line)) + .filter(Boolean) + .map(m => ({ timestamp: m[1], speaker: m[2], text: m[3] })); +} +``` + +### Extracting Action Items (JavaScript/Node) + +```javascript +const ACTION_RE = /^- \[([ xX])\] (.+?)(?:\s*\[owner:: ([^\]]+)\])?(?:\s*\[due:: ([^\]]+)\])?\s*$/; + +function extractActions(body) { + return body.split("\n") + .map(line => ACTION_RE.exec(line)) + .filter(Boolean) + .map(m => ({ + completed: m[1] === "x" || m[1] === "X", + task: m[2].trim(), + owner: m[3] || null, + due: m[4] || null, + })); +} +``` + +### Grep/Ripgrep Recipes + +```bash +# All meetings with a specific speaker +rg '\*\*Alice Chen\*\*:' ~/Documents/OpenOats/ + +# Everything "Them" said in a specific meeting +rg '\*\*Them\*\*:' ~/Documents/OpenOats/2026-03-20-1400-meeting.md + +# All open action items across all meetings +rg '^\- \[ \]' ~/Documents/OpenOats/ + +# Open action items assigned to You +rg '\[ \].*\[owner:: You\]' ~/Documents/OpenOats/ + +# Decisions (approximate - also grabs action items starting with "- ") +# For precise extraction, use the Python parser +rg '## Decisions' -A 10 ~/Documents/OpenOats/ + +# Meetings tagged with a specific topic (works with both YAML array styles) +rg 'tags:.*product|^\s+- product$' ~/Documents/OpenOats/ + +# Meetings that used Zoom +rg '^app: zoom' ~/Documents/OpenOats/ + +# Meetings 60 minutes or longer +rg '^duration: [6-9][0-9]$|^duration: [1-9][0-9]{2,}$' ~/Documents/OpenOats/ + +# Find what was said about a topic +rg -i 'launch date' ~/Documents/OpenOats/ + +# List all meeting files chronologically (filenames sort naturally) +ls ~/Documents/OpenOats/*.md +``` + +### Obsidian Dataview Queries + +List all meetings: +```dataview +TABLE date, duration, participants +FROM "OpenOats" +WHERE schema = "openoats/v1" +SORT date DESC +``` + +Meetings tagged with a specific topic: +```dataview +TABLE date, title, duration +FROM "OpenOats" +WHERE contains(tags, "product") +SORT date DESC +``` + +All open action items assigned to You: +```dataview +TASK +FROM "OpenOats" +WHERE !completed AND contains(text, "owner:: You") +``` + +All action items due this week: +```dataview +TASK +FROM "OpenOats" +WHERE !completed AND date(due) >= date(today) AND date(due) <= date(today) + dur(7 days) +SORT due ASC +``` + +Meetings using a specific ASR engine: +```dataview +TABLE date, title, duration +FROM "OpenOats" +WHERE engine = "parakeet-tdt-v2" +SORT date DESC +``` + +Today's meetings (for daily notes embeds): +```dataview +TABLE title, duration +FROM "OpenOats" +WHERE dateformat(date(date), "yyyy-MM-dd") = dateformat(date(today), "yyyy-MM-dd") +SORT date ASC +``` + +> **Note:** The `date` frontmatter field includes time and timezone. Use `dateformat()` to compare date-only portions. + +### Graph View + +Meeting files connect to other vault notes through wikilinks in the body. The Stage 3 Summary section MAY contain wikilinks to people notes (e.g., `[[Alice Chen]]`) and project notes. Parsers MUST preserve wikilink syntax when present. + +--- + +## Versioning + +The `schema` field identifies the format version. The current version is `openoats/v1`. + +### Compatibility Promise + +- **Patch changes** (bug fixes, clarifications) do not change the schema identifier +- **Minor additions** (new optional fields, new optional sections) do not change the schema identifier. Parsers built for `openoats/v1` will continue to work. +- **Breaking changes** (removing fields, changing required fields, changing the transcript line format) increment the version: `openoats/v2` + +### Migration + +When a new version is released, the specification will include migration notes describing what changed and how to update existing files. + +--- + +## Complete Example: Stage 1+2 File (No LLM) + +This is what the app outputs immediately after a meeting, before any LLM post-processing. It is a complete, valid file. + +```markdown +--- +schema: openoats/v1 +title: "Meeting" +date: 2026-03-20T14:00:00+01:00 +duration: 2 +participants: + - You + - Them +engine: parakeet-tdt-v2 +app: zoom +--- + +# Meeting + +## Transcript + +[00:00:00] **You:** Hey, thanks for jumping on. I wanted to talk through the feature flag rollout before we commit to the timeline. + +[00:00:07] **Them:** Sure. I spent the morning looking at the current implementation and I have some concerns about the gradual rollout approach. + +[00:00:14] **You:** What kind of concerns? + +[00:00:16] **Them:** The percentage-based rollout is fine for stateless features, but for the new editor it creates a split-brain problem. Users who got the new editor early will have documents in the new format. If we roll them back, those documents break. + +[00:00:31] **You:** Right. So we need a migration path regardless. + +[00:00:35] **Them:** Exactly. My suggestion is we skip the gradual rollout entirely for this one. Ship it behind a manual opt-in toggle, let power users find the bugs, then flip it to default once we are confident. + +[00:00:48] **You:** That makes sense. How long do you think the opt-in period needs to be? + +[00:00:53] **Them:** Two weeks minimum. We need at least one full sprint cycle of feedback before we go default. + +[00:01:02] **You:** Okay. Let's do that. I will update the rollout plan and send it around by end of day. + +[00:01:09] **Them:** Sounds good. One more thing. Can we add a telemetry event for when someone toggles the feature on? I want to track adoption rate during the opt-in phase. + +[00:01:19] **You:** Yeah, that's easy. I will add it to the ticket. + +[00:01:24] **Them:** Great. I think that covers it. + +[00:01:27] **You:** Agreed. Thanks for flagging the split-brain issue, that would have bitten us. + +[00:01:33] **Them:** No problem. Talk soon. +``` + +## Complete Example: Stage 1+2+3 File (After LLM Processing) + +This is the same meeting after the user runs LLM post-processing. The Summary, Action Items, and Decisions sections have been inserted. The transcript is unchanged. + +```markdown +--- +schema: openoats/v1 +title: "Feature Flag Rollout: New Editor" +date: 2026-03-20T14:00:00+01:00 +duration: 2 +participants: + - You + - Them +recorder: Szymon Sypniewicz +tags: + - engineering + - feature-flags + - editor +engine: parakeet-tdt-v2 +app: zoom +--- + +# Feature Flag Rollout: New Editor + +## Summary + +Discussed the rollout strategy for the new editor feature. The original plan for a percentage-based gradual rollout was rejected because documents created in the new format would break if users were rolled back, creating a split-brain problem. The team decided to use a manual opt-in toggle instead, with a minimum two-week opt-in period before switching to default. A telemetry event will be added to track adoption during the opt-in phase. + +## Action Items + +- [ ] Update rollout plan and circulate by end of day [owner:: You] [due:: 2026-03-20] +- [ ] Add telemetry event for feature toggle [owner:: You] +- [ ] Review opt-in feedback after two-week period [owner:: Them] [due:: 2026-04-03] + +## Decisions + +- Skip percentage-based gradual rollout for the new editor +- Use manual opt-in toggle instead +- Minimum two-week opt-in period before switching to default + +## Transcript + +[00:00:00] **You:** Hey, thanks for jumping on. I wanted to talk through the feature flag rollout before we commit to the timeline. + +[00:00:07] **Them:** Sure. I spent the morning looking at the current implementation and I have some concerns about the gradual rollout approach. + +[00:00:14] **You:** What kind of concerns? + +[00:00:16] **Them:** The percentage-based rollout is fine for stateless features, but for the new editor it creates a split-brain problem. Users who got the new editor early will have documents in the new format. If we roll them back, those documents break. + +[00:00:31] **You:** Right. So we need a migration path regardless. + +[00:00:35] **Them:** Exactly. My suggestion is we skip the gradual rollout entirely for this one. Ship it behind a manual opt-in toggle, let power users find the bugs, then flip it to default once we are confident. + +[00:00:48] **You:** That makes sense. How long do you think the opt-in period needs to be? + +[00:00:53] **Them:** Two weeks minimum. We need at least one full sprint cycle of feedback before we go default. + +[00:01:02] **You:** Okay. Let's do that. I will update the rollout plan and send it around by end of day. + +[00:01:09] **Them:** Sounds good. One more thing. Can we add a telemetry event for when someone toggles the feature on? I want to track adoption rate during the opt-in phase. + +[00:01:19] **You:** Yeah, that's easy. I will add it to the ticket. + +[00:01:24] **Them:** Great. I think that covers it. + +[00:01:27] **You:** Agreed. Thanks for flagging the split-brain issue, that would have bitten us. + +[00:01:33] **Them:** No problem. Talk soon. +``` + +--- + +## Conformance + +A conformant file MUST include all required frontmatter fields, a `# Title` heading, and a `## Transcript` section with at least one valid transcript line. + +A conformant parser MUST be able to extract frontmatter, transcript lines, and action items from any conformant file. Parsers MUST ignore unknown frontmatter fields and unknown `## ` sections. + +--- + +## Security Considerations + +Meeting transcript files may contain confidential business information, PII, or privileged communications. Implementations SHOULD NOT expose transcript files to untrusted parties without explicit user consent. + +--- + +## Design Rationale (Non-normative) + +### Why full names in transcript, not speaker IDs + +Speaker IDs (`AC`, `BM`, `S1`) save a few bytes per line but require a mental lookup table. Full bold names are self-documenting, grep-friendly without consulting frontmatter, and LLMs parse them without the indirection layer. + +### Why summary at top, transcript at bottom + +LLMs weight the beginning and end of context windows more heavily than the middle ("lost in the middle" effect). Putting synthesized insights first means agents get high-signal content before raw transcript. Humans scanning the file also want the summary first. + +### Why relative timestamps, not wall-clock + +`[00:01:24]` (1 minute 24 seconds into the meeting) is more useful than `[14:01:24]` because audio playback tools use relative offsets, the absolute start time is already in the `date` frontmatter field, and relative times are shorter and scan faster. + +### Why simple participant arrays, not structured objects + +`participants: [You, Them]` vs `participants: [{name: You, role: host}]`. The simple array keeps frontmatter under 20 lines, works with basic Dataview queries without DataviewJS, and does not force OpenOats to know information it does not have (email, role). Rich participant data can live in `x_` extension fields. + +### Why Dataview inline fields for action items + +`[owner:: You] [due:: 2026-03-25]` looks slightly unusual in raw Markdown, but it renders cleanly (brackets are unobtrusive), Obsidian Dataview queries work natively (`TASK WHERE owner = "You"`), the Obsidian Tasks plugin aggregates them across the vault, and grep works (`rg 'owner:: You' meetings/`). Any non-Obsidian user just sees mildly-decorated checkboxes. + +### Why no UUID + +The filename is the identifier. `2026-03-20-1400-weekly-product-sync.md` is unique, human-readable, and does not require a generator. If cross-system referencing is needed later, use `x_uuid` as an extension field. + +### Why `schema: openoats/v1` not `schema_version: "1.0"` + +A namespaced identifier (`openoats/v1`) is more specific than a bare version number. If another tool adopts this format, it can use `openoats/v1` to signal compatibility. Future versions (`openoats/v2`) can include migration notes. + +--- + +## Acknowledgments + +This specification was informed by analysis of output formats from whisper.cpp, WhisperX, AssemblyAI, Deepgram, Granola, Meetily, and Screenpipe, as well as Obsidian community conventions for meeting notes and PKM frontmatter patterns. From 224584c68e687941ac98662d8ca4e804eae795f0 Mon Sep 17 00:00:00 2001 From: Newarr <22638839+Newarr@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:21:13 +0000 Subject: [PATCH 5/9] Derive isRecording from coordinator state machine (#90) Derive isRecording from coordinator state machine Replace writable stored `_isRecording` property with a computed property that reads directly from the coordinator's authoritative `MeetingState`. Eliminates stale-value risk from the 100ms polling loop. Also adds `MeetingMetadata.manual()` static factory to deduplicate inline metadata construction across call sites. --- .../Sources/OpenOats/App/AppCoordinator.swift | 6 ++---- .../Sources/OpenOats/Meeting/MeetingTypes.swift | 12 ++++++++++++ OpenOats/Sources/OpenOats/Views/ContentView.swift | 15 +-------------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/OpenOats/Sources/OpenOats/App/AppCoordinator.swift b/OpenOats/Sources/OpenOats/App/AppCoordinator.swift index 7dadd6ce..1b96f5bb 100644 --- a/OpenOats/Sources/OpenOats/App/AppCoordinator.swift +++ b/OpenOats/Sources/OpenOats/App/AppCoordinator.swift @@ -67,11 +67,9 @@ final class AppCoordinator { set { withMutation(keyPath: \.requestedSessionSelectionID) { _requestedSessionSelectionID = newValue } } } - /// Reflects whether a transcription session is currently active (set by ContentView). - @ObservationIgnored nonisolated(unsafe) private var _isRecording = false var isRecording: Bool { - get { access(keyPath: \.isRecording); return _isRecording } - set { withMutation(keyPath: \.isRecording) { _isRecording = newValue } } + if case .recording = state { return true } + return false } @ObservationIgnored nonisolated(unsafe) private var _sessionHistory: [SessionIndex] = [] diff --git a/OpenOats/Sources/OpenOats/Meeting/MeetingTypes.swift b/OpenOats/Sources/OpenOats/Meeting/MeetingTypes.swift index dc4b2ba5..fff4f50c 100644 --- a/OpenOats/Sources/OpenOats/Meeting/MeetingTypes.swift +++ b/OpenOats/Sources/OpenOats/Meeting/MeetingTypes.swift @@ -67,4 +67,16 @@ struct MeetingMetadata: Sendable, Equatable, Codable { let title: String? let startedAt: Date var endedAt: Date? + + static func manual() -> MeetingMetadata { + let now = Date() + return MeetingMetadata( + detectionContext: DetectionContext( + signal: .manual, detectedAt: now, + meetingApp: nil, calendarEvent: nil + ), + calendarEvent: nil, title: nil, + startedAt: now, endedAt: nil + ) + } } diff --git a/OpenOats/Sources/OpenOats/Views/ContentView.swift b/OpenOats/Sources/OpenOats/Views/ContentView.swift index 5874fb95..5b9a4e45 100644 --- a/OpenOats/Sources/OpenOats/Views/ContentView.swift +++ b/OpenOats/Sources/OpenOats/Views/ContentView.swift @@ -328,19 +328,7 @@ struct ContentView: View { } suggestionEngine?.clear() - let metadata = MeetingMetadata( - detectionContext: DetectionContext( - signal: .manual, - detectedAt: Date(), - meetingApp: nil, - calendarEvent: nil - ), - calendarEvent: nil, - title: nil, - startedAt: Date(), - endedAt: nil - ) - coordinator.handle(.userStarted(metadata), settings: settings) + coordinator.handle(.userStarted(.manual()), settings: settings) } private func stopSession() { @@ -548,7 +536,6 @@ struct ContentView: View { if currentViewState.isRunning != observedIsRunning { observedIsRunning = currentViewState.isRunning - coordinator.isRecording = currentViewState.isRunning } let pendingExternalCommandID = coordinator.pendingExternalCommand?.id From 566829a61231d1d34944a96190500f856ceab5f1 Mon Sep 17 00:00:00 2001 From: Newarr <22638839+Newarr@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:26:47 +0000 Subject: [PATCH 6/9] Extract service initialization for headless operation (#91) Extract service initialization for headless operation Add ensureServicesInitialized() on AppRuntime with an idempotency guard. Coordinator-owned services (TranscriptionEngine, TranscriptLogger, RefinementEngine, AudioRecorder) are created on first call from wherever runs first. ContentView.task now only creates view-local services (KnowledgeBase, SuggestionEngine) that don't need to outlive the window. --- OpenOats/Sources/OpenOats/App/AppRuntime.swift | 12 ++++++++++++ .../Sources/OpenOats/Views/ContentView.swift | 16 +++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/OpenOats/Sources/OpenOats/App/AppRuntime.swift b/OpenOats/Sources/OpenOats/App/AppRuntime.swift index fdbf1d8b..801f731b 100644 --- a/OpenOats/Sources/OpenOats/App/AppRuntime.swift +++ b/OpenOats/Sources/OpenOats/App/AppRuntime.swift @@ -39,6 +39,7 @@ final class AppRuntime { let notesDirectory: URL private var didSeedInitialData = false + private var didInitializeServices = false init( mode: AppRuntimeMode, @@ -168,6 +169,17 @@ final class AppRuntime { ) } + func ensureServicesInitialized(settings: AppSettings, coordinator: AppCoordinator) { + guard !didInitializeServices else { return } + didInitializeServices = true + + let services = makeServices(settings: settings, coordinator: coordinator) + coordinator.transcriptionEngine = services.transcriptionEngine + coordinator.transcriptLogger = services.transcriptLogger + coordinator.refinementEngine = services.refinementEngine + coordinator.audioRecorder = services.audioRecorder + } + func seedIfNeeded(coordinator: AppCoordinator) async { guard !didSeedInitialData else { return } didSeedInitialData = true diff --git a/OpenOats/Sources/OpenOats/Views/ContentView.swift b/OpenOats/Sources/OpenOats/Views/ContentView.swift index 5b9a4e45..b0990f44 100644 --- a/OpenOats/Sources/OpenOats/Views/ContentView.swift +++ b/OpenOats/Sources/OpenOats/Views/ContentView.swift @@ -263,13 +263,15 @@ struct ContentView: View { showOnboarding = true } if knowledgeBase == nil { - let services = runtime.makeServices(settings: settings, coordinator: coordinator) - knowledgeBase = services.knowledgeBase - suggestionEngine = services.suggestionEngine - coordinator.transcriptionEngine = services.transcriptionEngine - coordinator.transcriptLogger = services.transcriptLogger - coordinator.refinementEngine = services.refinementEngine - coordinator.audioRecorder = services.audioRecorder + runtime.ensureServicesInitialized(settings: settings, coordinator: coordinator) + let kb = KnowledgeBase(settings: settings) + let se = SuggestionEngine( + transcriptStore: coordinator.transcriptStore, + knowledgeBase: kb, + settings: settings + ) + knowledgeBase = kb + suggestionEngine = se } overlayManager.defaults = runtime.defaults await runtime.seedIfNeeded(coordinator: coordinator) From 7943047c6b3ab73decb50b267795c232d3887807 Mon Sep 17 00:00:00 2001 From: Newarr <22638839+Newarr@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:05:27 +0000 Subject: [PATCH 7/9] Fix model download retry when cached files are corrupt (#100) Add clearModelCache() to TranscriptionBackend protocol so corrupt model files are removed on load failure. Each backend (Parakeet, Qwen3, WhisperKit) implements targeted cache deletion. TranscriptionEngine now clears cache and resets download state on failure, so "Download Now" triggers a fresh download. --- .../OpenOats/Transcription/ParakeetBackend.swift | 5 +++++ .../OpenOats/Transcription/Qwen3Backend.swift | 5 +++++ .../Transcription/TranscriptionBackend.swift | 7 +++++++ .../Transcription/TranscriptionEngine.swift | 5 +++++ .../OpenOats/Transcription/WhisperKitBackend.swift | 14 ++++++++++++++ 5 files changed, 36 insertions(+) diff --git a/OpenOats/Sources/OpenOats/Transcription/ParakeetBackend.swift b/OpenOats/Sources/OpenOats/Transcription/ParakeetBackend.swift index e7c86b99..537f5a5e 100644 --- a/OpenOats/Sources/OpenOats/Transcription/ParakeetBackend.swift +++ b/OpenOats/Sources/OpenOats/Transcription/ParakeetBackend.swift @@ -23,6 +23,11 @@ final class ParakeetBackend: TranscriptionBackend, @unchecked Sendable { return exists ? .ready : .needsDownload(prompt: "Transcription requires a one-time model download.") } + func clearModelCache() { + let cacheDir = AsrModels.defaultCacheDirectory(for: version) + try? FileManager.default.removeItem(at: cacheDir) + } + func prepare(onStatus: @Sendable (String) -> Void) async throws { onStatus("Downloading \(displayName)...") let models = try await AsrModels.downloadAndLoad(version: version) diff --git a/OpenOats/Sources/OpenOats/Transcription/Qwen3Backend.swift b/OpenOats/Sources/OpenOats/Transcription/Qwen3Backend.swift index a8db2da8..6e365550 100644 --- a/OpenOats/Sources/OpenOats/Transcription/Qwen3Backend.swift +++ b/OpenOats/Sources/OpenOats/Transcription/Qwen3Backend.swift @@ -12,6 +12,11 @@ final class Qwen3Backend: TranscriptionBackend, @unchecked Sendable { return exists ? .ready : .needsDownload(prompt: "Qwen3 ASR requires a one-time model download.") } + func clearModelCache() { + let cacheDir = Qwen3AsrModels.defaultCacheDirectory() + try? FileManager.default.removeItem(at: cacheDir) + } + func prepare(onStatus: @Sendable (String) -> Void) async throws { onStatus("Downloading \(displayName)...") let modelsDirectory = try await Qwen3AsrModels.download() diff --git a/OpenOats/Sources/OpenOats/Transcription/TranscriptionBackend.swift b/OpenOats/Sources/OpenOats/Transcription/TranscriptionBackend.swift index 6ce3bc67..863816b1 100644 --- a/OpenOats/Sources/OpenOats/Transcription/TranscriptionBackend.swift +++ b/OpenOats/Sources/OpenOats/Transcription/TranscriptionBackend.swift @@ -25,6 +25,13 @@ protocol TranscriptionBackend: Sendable { /// Transcribe a segment of Float32 audio samples at 16kHz mono. /// Returns the transcribed text, or empty string if no speech detected. func transcribe(_ samples: [Float], locale: Locale) async throws -> String + + /// Remove cached model files so the next prepare() triggers a fresh download. + func clearModelCache() +} + +extension TranscriptionBackend { + func clearModelCache() {} } enum TranscriptionBackendError: Error { diff --git a/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift b/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift index bd3b3524..3f41e3f1 100644 --- a/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift +++ b/OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift @@ -212,6 +212,11 @@ final class TranscriptionEngine { lastError = msg assetStatus = "Ready" isRunning = false + // Clear corrupt cache so the next attempt triggers a fresh download + settings.transcriptionModel.makeBackend().clearModelCache() + diagLog("[ENGINE-2-FAIL] cleared model cache for \(settings.transcriptionModel.rawValue)") + needsModelDownload = true + downloadConfirmed = false return } diff --git a/OpenOats/Sources/OpenOats/Transcription/WhisperKitBackend.swift b/OpenOats/Sources/OpenOats/Transcription/WhisperKitBackend.swift index f872404f..2add2611 100644 --- a/OpenOats/Sources/OpenOats/Transcription/WhisperKitBackend.swift +++ b/OpenOats/Sources/OpenOats/Transcription/WhisperKitBackend.swift @@ -19,6 +19,20 @@ final class WhisperKitBackend: TranscriptionBackend, @unchecked Sendable { ) } + func clearModelCache() { + let fm = FileManager.default + guard let documentsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + let hfCacheDir = documentsDir + .appendingPathComponent("huggingface") + .appendingPathComponent("models") + .appendingPathComponent("argmaxinc") + .appendingPathComponent("whisperkit-coreml") + guard let contents = try? fm.contentsOfDirectory(atPath: hfCacheDir.path) else { return } + for entry in contents where entry.contains("whisper-\(variant.rawValue)") { + try? fm.removeItem(at: hfCacheDir.appendingPathComponent(entry)) + } + } + func prepare(onStatus: @Sendable (String) -> Void) async throws { onStatus("Downloading \(displayName)...") let manager = WhisperKitManager(variant: variant) From e1e4ed0b75555a92920d004f7e78d445d014e8e1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Mar 2026 21:09:05 -0400 Subject: [PATCH 8/9] Add horizontal wordmark logo for marketing materials (#99) Add horizontal wordmark logo (icon + text) for marketing and documentation use. Contributed by @Alex-Wengg. --- assets/openoats-wordmark.png | Bin 0 -> 24454 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/openoats-wordmark.png diff --git a/assets/openoats-wordmark.png b/assets/openoats-wordmark.png new file mode 100644 index 0000000000000000000000000000000000000000..81e1783d14d1890a228212283727bf804ae22027 GIT binary patch literal 24454 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 Date: Sat, 21 Mar 2026 06:11:26 +0300 Subject: [PATCH 9/9] Fix mic tap format failure and AEC conflict with system audio capture (#95) (#103) Fix mic tap format failure and AEC conflict with system audio capture. Based on analysis from @brandonbloom in #95. --- .../OpenOats/Audio/AudioRecorder.swift | 77 ++++++++++++++++--- .../Sources/OpenOats/Audio/MicCapture.swift | 22 +++--- .../Transcription/TranscriptionEngine.swift | 40 ++++++++-- .../Sources/OpenOats/Views/SettingsView.swift | 2 +- 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/OpenOats/Sources/OpenOats/Audio/AudioRecorder.swift b/OpenOats/Sources/OpenOats/Audio/AudioRecorder.swift index f8ccfa92..6c652e41 100644 --- a/OpenOats/Sources/OpenOats/Audio/AudioRecorder.swift +++ b/OpenOats/Sources/OpenOats/Audio/AudioRecorder.swift @@ -40,11 +40,11 @@ final class AudioRecorder: @unchecked Sendable { func writeMicBuffer(_ buffer: AVAudioPCMBuffer) { lock.withLock { - guard buffer.frameLength > 0, let src = buffer.floatChannelData else { return } + guard buffer.frameLength > 0 else { return } let frames = Int(buffer.frameLength) let channels = Int(buffer.format.channelCount) - // Lazily create file as mono 48kHz (avoids deinterleaved format issues) + // Lazily create file as mono at the source sample rate if micFile == nil, let url = micTempURL { let monoFormat = AVAudioFormat( standardFormatWithSampleRate: buffer.format.sampleRate, channels: 1 @@ -53,7 +53,7 @@ final class AudioRecorder: @unchecked Sendable { diagLog("[RECORDER] mic file created: \(url.lastPathComponent) mono at \(buffer.format.sampleRate)Hz") } - // Downmix to mono inline + // Downmix to mono inline — handle float32, int16, and int32 formats guard let monoFormat = AVAudioFormat( standardFormatWithSampleRate: buffer.format.sampleRate, channels: 1 ), @@ -61,15 +61,70 @@ final class AudioRecorder: @unchecked Sendable { let dst = monoBuf.floatChannelData?[0] else { return } monoBuf.frameLength = buffer.frameLength - if channels == 1 { - memcpy(dst, src[0], frames * MemoryLayout.size) - } else { - let scale = 1.0 / Float(channels) - for i in 0...size) + } else { + memcpy(dst, src[0], frames * MemoryLayout.size) + } + } else { + let scale = 1.0 / Float(channels) + if buffer.format.isInterleaved { + for i in 0..