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
6 changes: 4 additions & 2 deletions OpenOats/Sources/OpenOats/Meeting/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
private static let notMeetingAction = "NOT_A_MEETING"
private static let ignoreAppAction = "IGNORE_APP"
private static let dismissAction = "DISMISS"
static let batchCompletedTitle = "Re-transcription Complete"
static let batchCompletedBody = "Re-transcription is complete. Your meeting transcript has been updated with higher-quality text."

override init() {
super.init()
Expand Down Expand Up @@ -144,8 +146,8 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
guard await ensurePermission() else { return }

let content = UNMutableNotificationContent()
content.title = "Re-transcription Complete"
content.body = "Batch transcription is complete. Your meeting transcript has been updated with higher-quality text."
content.title = Self.batchCompletedTitle
content.body = Self.batchCompletedBody
content.sound = .default

let request = UNNotificationRequest(
Expand Down
8 changes: 6 additions & 2 deletions OpenOats/Sources/OpenOats/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import Security
final class SettingsStore {
private let defaults: UserDefaults
private let secretStore: AppSecretStore
private static let enableLiveTranscriptCleanupLegacyKey = "enableTranscriptRefinement"
private static let enableBatchRetranscriptionLegacyKey = "enableBatchRefinement"

// MARK: - AI Settings

Expand Down Expand Up @@ -206,6 +208,7 @@ final class SettingsStore {
withMutation(keyPath: \.enableLiveTranscriptCleanup) {
_enableLiveTranscriptCleanup = newValue
defaults.set(newValue, forKey: "enableLiveTranscriptCleanup")
defaults.set(newValue, forKey: Self.enableLiveTranscriptCleanupLegacyKey)
}
}
}
Expand Down Expand Up @@ -373,6 +376,7 @@ final class SettingsStore {
withMutation(keyPath: \.enableBatchRetranscription) {
_enableBatchRetranscription = newValue
defaults.set(newValue, forKey: "enableBatchRetranscription")
defaults.set(newValue, forKey: Self.enableBatchRetranscriptionLegacyKey)
}
}
}
Expand Down Expand Up @@ -614,11 +618,11 @@ final class SettingsStore {

// Migrate renamed settings keys (old -> new)
if defaults.object(forKey: "enableLiveTranscriptCleanup") == nil,
let oldValue = defaults.object(forKey: "enableTranscriptRefinement") {
let oldValue = defaults.object(forKey: Self.enableLiveTranscriptCleanupLegacyKey) {
defaults.set(oldValue, forKey: "enableLiveTranscriptCleanup")
}
if defaults.object(forKey: "enableBatchRetranscription") == nil,
let oldValue = defaults.object(forKey: "enableBatchRefinement") {
let oldValue = defaults.object(forKey: Self.enableBatchRetranscriptionLegacyKey) {
defaults.set(oldValue, forKey: "enableBatchRetranscription")
}

Expand Down
110 changes: 89 additions & 21 deletions OpenOats/Sources/OpenOats/Transcription/TranscriptionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@ import FluidAudio
import Observation
import os

enum TranscriptionEngineError: LocalizedError {
case transcriberNotInitialized

var errorDescription: String? {
switch self {
case .transcriberNotInitialized:
"Transcription engine is not initialized. Please check your audio settings."
}
}
}

/// Enriched download progress info computed from fraction changes over time.
struct DownloadProgressDetail: Sendable {
let fraction: Double
Expand All @@ -26,6 +15,41 @@ struct DownloadProgressDetail: Sendable {
let etaText: String?
}

/// Session-scoped transcription settings captured at start time.
struct ActiveTranscriptionSession: Sendable, Equatable {
let transcriptionModel: TranscriptionModel

var flushIntervalSamples: Int {
transcriptionModel.flushIntervalSamples
}

func clearModelCache(
using makeBackend: (TranscriptionModel) -> any TranscriptionBackend = { $0.makeBackend() }
) {
makeBackend(transcriptionModel).clearModelCache()
}
}

/// Stops forwarding diarization samples after the first feed failure.
struct DiarizationFeedRelay: Sendable {
private(set) var hasFailed = false

mutating func feedAudio(
_ samples: [Float],
into feedAudio: @Sendable ([Float]) async throws -> Void,
onFailure: @Sendable (Error) async -> Void
) async {
guard !hasFailed else { return }

do {
try await feedAudio(samples)
} catch {
hasFailed = true
await onFailure(error)
}
}
}

/// Orchestrates dual StreamingTranscriber instances for mic (you) and system audio (them).
@Observable
@MainActor
Expand Down Expand Up @@ -134,6 +158,9 @@ final class TranscriptionEngine {
/// Speaker diarization manager for system audio (nil when diarization is disabled).
private var diarizationManager: DiarizationManager?

/// Active transcription model captured for the current session/startup.
@ObservationIgnored nonisolated(unsafe) var activeTranscriptionSession: ActiveTranscriptionSession?

/// Tracks the resolved mic device ID currently in use.
private var currentMicDeviceID: AudioDeviceID = 0

Expand Down Expand Up @@ -204,7 +231,14 @@ final class TranscriptionEngine {
return
}

guard await ensureMicrophonePermission() else { return }
activeTranscriptionSession = ActiveTranscriptionSession(
transcriptionModel: transcriptionModel
)

guard await ensureMicrophonePermission() else {
activeTranscriptionSession = nil
return
}

isRunning = true

Expand Down Expand Up @@ -276,7 +310,7 @@ final class TranscriptionEngine {
Log.transcription.info("Transcription model loaded")
} catch {
let msg = "Failed to load models: \(error.localizedDescription)"
Log.transcription.error("Failed to load models: \(msg, privacy: .public)")
Log.transcription.error("Failed to load models: \(error, privacy: .public)")
lastError = msg
assetStatus = "Ready"
isRunning = false
Expand All @@ -285,14 +319,20 @@ final class TranscriptionEngine {
downloadStartTime = nil
downloadTotalBytes = nil
// Clear corrupt cache so the next attempt triggers a fresh download
settings.transcriptionModel.makeBackend().clearModelCache()
Log.transcription.info("Cleared model cache for \(self.settings.transcriptionModel.rawValue, privacy: .public)")
activeTranscriptionSession?.clearModelCache()
Log.transcription.info(
"Cleared model cache for \(transcriptionModel.rawValue, privacy: .public)"
)
needsModelDownload = true
downloadConfirmed = false
activeTranscriptionSession = nil
return
}

guard let vadManager else { return }
guard let vadManager else {
activeTranscriptionSession = nil
return
}

// 2. Start mic capture
userSelectedDeviceID = inputDeviceID
Expand All @@ -302,6 +342,7 @@ final class TranscriptionEngine {
lastError = msg
assetStatus = "Ready"
isRunning = false
activeTranscriptionSession = nil
return
}
currentMicDeviceID = targetMicID
Expand Down Expand Up @@ -501,6 +542,7 @@ final class TranscriptionEngine {
assetStatus = "Ready"
transcriptStore.volatileYouText = ""
transcriptStore.volatileThemText = ""
activeTranscriptionSession = nil
return
}

Expand Down Expand Up @@ -536,8 +578,10 @@ final class TranscriptionEngine {

micBackend = nil
systemBackend = nil
vadManager = nil
transcriptStore.volatileYouText = ""
transcriptStore.volatileThemText = ""
activeTranscriptionSession = nil
isRunning = false
assetStatus = "Ready"
}
Expand Down Expand Up @@ -570,8 +614,10 @@ final class TranscriptionEngine {
currentMicDeviceID = 0
micBackend = nil
systemBackend = nil
vadManager = nil
transcriptStore.volatileYouText = ""
transcriptStore.volatileThemText = ""
activeTranscriptionSession = nil
isRunning = false
assetStatus = "Ready"
}
Expand Down Expand Up @@ -684,6 +730,7 @@ final class TranscriptionEngine {
lastError = "Failed to create transcriber. Try restarting."
isRunning = false
assetStatus = "Ready"
activeTranscriptionSession = nil
return
}
micTask = Task.detached {
Expand All @@ -704,7 +751,7 @@ final class TranscriptionEngine {
clearSystemAudioErrorIfPresent()
} catch {
let msg = "Failed to start system audio: \(error.localizedDescription)"
Log.transcription.error("Failed to start system audio: \(msg, privacy: .public)")
Log.transcription.error("Failed to start system audio: \(error, privacy: .public)")
lastError = msg
return
}
Expand All @@ -725,7 +772,8 @@ final class TranscriptionEngine {
let originalSysStream = sysStream
let (diarTapped, diarContinuation) = AsyncStream<AVAudioPCMBuffer>.makeStream()
Task {
nonisolated(unsafe) let safeDm = dm
let safeDm = dm
var diarizationRelay = DiarizationFeedRelay()
var diarBuf: [Float] = []
for await buffer in originalSysStream {
nonisolated(unsafe) let b = buffer
Expand All @@ -737,12 +785,28 @@ final class TranscriptionEngine {
if diarBuf.count >= diarFlushSize {
let batch = diarBuf
diarBuf.removeAll(keepingCapacity: true)
try? await safeDm.feedAudio(batch)
await diarizationRelay.feedAudio(
batch,
into: { samples in try await safeDm.feedAudio(samples) },
onFailure: { error in
Log.transcription.error(
"Diarization feed failed: \(error, privacy: .public)"
)
}
)
}
}
// Flush tail
if !diarBuf.isEmpty {
try? await safeDm.feedAudio(diarBuf)
await diarizationRelay.feedAudio(
diarBuf,
into: { samples in try await safeDm.feedAudio(samples) },
onFailure: { error in
Log.transcription.error(
"Diarization feed failed: \(error, privacy: .public)"
)
}
)
}
diarContinuation.finish()
}
Expand Down Expand Up @@ -799,12 +863,16 @@ final class TranscriptionEngine {
locale: locale,
vadManager: vadManager,
speaker: speaker,
flushInterval: settings.transcriptionModel.flushIntervalSamples,
flushInterval: currentTranscriptionModel().flushIntervalSamples,
onPartial: onPartial,
onFinal: onFinal
)
}

func currentTranscriptionModel() -> TranscriptionModel {
activeTranscriptionSession?.transcriptionModel ?? settings.transcriptionModel
}

private func resolvedMicDeviceID(for inputDeviceID: AudioDeviceID) -> AudioDeviceID? {
if inputDeviceID > 0 {
let availableDeviceIDs = Set(MicCapture.availableInputDevices().map(\.id))
Expand Down
14 changes: 14 additions & 0 deletions OpenOats/Tests/OpenOatsTests/NotificationServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import XCTest
@testable import OpenOatsKit

@MainActor
final class NotificationServiceTests: XCTestCase {

func testBatchCompletedNotificationCopyUsesReTranscriptionWording() {
XCTAssertEqual(NotificationService.batchCompletedTitle, "Re-transcription Complete")
XCTAssertEqual(
NotificationService.batchCompletedBody,
"Re-transcription is complete. Your meeting transcript has been updated with higher-quality text."
)
}
}
30 changes: 30 additions & 0 deletions OpenOats/Tests/OpenOatsTests/SettingsStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ final class SettingsStoreTests: XCTestCase {
XCTAssertTrue(store.enableLiveTranscriptCleanup)
}

func testEnableLiveTranscriptCleanupDualWritesLegacyKey() {
let suiteName = "com.openoats.test.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)

let store = makeStore(defaults: defaults)
store.enableLiveTranscriptCleanup = true

XCTAssertEqual(defaults.bool(forKey: "enableLiveTranscriptCleanup"), true)
XCTAssertEqual(defaults.bool(forKey: "enableTranscriptRefinement"), true)

let reopened = makeStore(defaults: defaults)
XCTAssertTrue(reopened.enableLiveTranscriptCleanup)
}

// MARK: - Capture Settings Group

func testDefaultInputDeviceID() {
Expand Down Expand Up @@ -175,6 +190,21 @@ final class SettingsStoreTests: XCTestCase {
XCTAssertFalse(store.enableBatchRetranscription)
}

func testEnableBatchRetranscriptionDualWritesLegacyKey() {
let suiteName = "com.openoats.test.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)

let store = makeStore(defaults: defaults)
store.enableBatchRetranscription = true

XCTAssertEqual(defaults.bool(forKey: "enableBatchRetranscription"), true)
XCTAssertEqual(defaults.bool(forKey: "enableBatchRefinement"), true)

let reopened = makeStore(defaults: defaults)
XCTAssertTrue(reopened.enableBatchRetranscription)
}

func testDefaultBatchTranscriptionModel() {
let store = makeStore()
XCTAssertEqual(store.batchTranscriptionModel, .whisperLargeV3Turbo)
Expand Down
Loading
Loading