Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion OpenOats/Sources/OpenOats/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,20 +254,44 @@ 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(),
endedAt: Date(),
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()

Expand Down
86 changes: 86 additions & 0 deletions OpenOats/Sources/OpenOats/App/MenuBarController.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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
}
}
77 changes: 66 additions & 11 deletions OpenOats/Sources/OpenOats/Audio/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,23 +53,78 @@ 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
),
let monoBuf = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: buffer.frameCapacity),
let dst = monoBuf.floatChannelData?[0] else { return }
monoBuf.frameLength = buffer.frameLength

if channels == 1 {
memcpy(dst, src[0], frames * MemoryLayout<Float>.size)
} else {
let scale = 1.0 / Float(channels)
for i in 0..<frames {
var sum: Float = 0
for ch in 0..<channels { sum += src[ch][i] }
dst[i] = sum * scale
if let src = buffer.floatChannelData {
if channels == 1 {
if buffer.format.isInterleaved {
memcpy(dst, src[0], frames * MemoryLayout<Float>.size)
} else {
memcpy(dst, src[0], frames * MemoryLayout<Float>.size)
}
} else {
let scale = 1.0 / Float(channels)
if buffer.format.isInterleaved {
for i in 0..<frames {
var sum: Float = 0
for ch in 0..<channels { sum += src[0][(i * channels) + ch] }
dst[i] = sum * scale
}
} else {
for i in 0..<frames {
var sum: Float = 0
for ch in 0..<channels { sum += src[ch][i] }
dst[i] = sum * scale
}
}
}
} else if let src = buffer.int16ChannelData {
let scale = 1.0 / Float(Int16.max)
if channels == 1 {
for i in 0..<frames { dst[i] = Float(src[0][i]) * scale }
} else if buffer.format.isInterleaved {
let invCh = 1.0 / Float(channels)
for i in 0..<frames {
var sum: Float = 0
for ch in 0..<channels { sum += Float(src[0][(i * channels) + ch]) * scale }
dst[i] = sum * invCh
}
} else {
let invCh = 1.0 / Float(channels)
for i in 0..<frames {
var sum: Float = 0
for ch in 0..<channels { sum += Float(src[ch][i]) * scale }
dst[i] = sum * invCh
}
}
} else if let src = buffer.int32ChannelData {
let scale = 1.0 / Float(Int32.max)
if channels == 1 {
for i in 0..<frames { dst[i] = Float(src[0][i]) * scale }
} else if buffer.format.isInterleaved {
let invCh = 1.0 / Float(channels)
for i in 0..<frames {
var sum: Float = 0
for ch in 0..<channels { sum += Float(src[0][(i * channels) + ch]) * scale }
dst[i] = sum * invCh
}
} else {
let invCh = 1.0 / Float(channels)
for i in 0..<frames {
var sum: Float = 0
for ch in 0..<channels { sum += Float(src[ch][i]) * scale }
dst[i] = sum * invCh
}
}
} else {
diagLog("[RECORDER] mic write SKIP: unsupported buffer format \(buffer.format.commonFormat.rawValue)")
return
}

micWriteCount += 1
Expand Down
34 changes: 24 additions & 10 deletions OpenOats/Sources/OpenOats/Audio/MicCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ final class MicCapture: @unchecked Sendable {
)
}

func bufferStream(deviceID: AudioDeviceID? = nil) -> AsyncStream<AVAudioPCMBuffer> {
func bufferStream(deviceID: AudioDeviceID? = nil, echoCancellation: Bool = false) -> AsyncStream<AVAudioPCMBuffer> {
// Defensive cleanup of any prior state
_streamContinuation.withLock { $0?.finish(); $0 = nil }
engine.inputNode.removeTap(onBus: 0)
Expand All @@ -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 {
Expand Down Expand Up @@ -104,15 +114,19 @@ final class MicCapture: @unchecked Sendable {
return
}

guard let tapFormat = AVAudioFormat(
standardFormatWithSampleRate: sampleRate,
channels: format.channelCount
) else {
let msg = "Failed to build tap format from input format"
diagLog("[MIC-4-FAIL] \(msg)")
errorHolder.value = msg
continuation.finish()
return
// Try multiple tap formats — some devices report formats that don't
// round-trip through AVAudioFormat(standardFormat:). Fall back to the
// native input format as a last resort.
let tapFormat: AVAudioFormat
if let f = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: format.channelCount) {
tapFormat = f
} else if sampleRate != format.sampleRate,
let f = AVAudioFormat(standardFormatWithSampleRate: format.sampleRate, channels: format.channelCount) {
diagLog("[MIC-4] hardware-rate format failed, using node rate \(format.sampleRate)")
tapFormat = f
} else {
diagLog("[MIC-4] standard formats failed, using native input format")
tapFormat = format
}

diagLog("[MIC-4] tapFormat: sr=\(tapFormat.sampleRate) ch=\(tapFormat.channelCount)")
Expand Down
Loading
Loading