From 00b2b8884e275eca78a2394f27659533f78bee99 Mon Sep 17 00:00:00 2001 From: Aryan Date: Tue, 18 Aug 2026 12:27:00 +0530 Subject: [PATCH 01/30] feat(desktop): hands-free wake word to command the assistant during ambient listening Add an opt-in Wake Word feature: say "Omi" followed by a command while ambient listening is active and the assistant runs the command hands-free. - WakeWordService: parses and gates wake-word triggers (cooldown, segment dedup, user-speech and busy-conversation guards) and submits the stripped command to the assistant - WakeWordSegmentParser: extracts wake phrase + 2-word-minimum command - Wire incoming transcript segments from AppState+ListenEvents - Settings > General: opt-in Wake Word toggle with dynamic subtitle - AssistantSettings: persisted wakeWordEnabled/Phrase/Cooldown (default off) - Tests: WakeWordServiceTests + WakeWordSegmentParserTests (15 cases green) --- .../AppState/AppState+ListenEvents.swift | 2 + .../SettingsContentView+General.swift | 46 ++++++++ .../Services/AssistantSettings.swift | 40 +++++++ .../WakeWord/WakeWordSegmentParser.swift | 62 ++++++++++ .../Sources/WakeWord/WakeWordService.swift | 60 ++++++++++ .../Tests/WakeWordSegmentParserTests.swift | 47 ++++++++ .../Desktop/Tests/WakeWordServiceTests.swift | 106 ++++++++++++++++++ .../20260818-wake-word-trigger.json | 3 + 8 files changed, 366 insertions(+) create mode 100644 desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift create mode 100644 desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift create mode 100644 desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift create mode 100644 desktop/macos/Desktop/Tests/WakeWordServiceTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260818-wake-word-trigger.json diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift index ab36a87ab18..dce4f0e7c38 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift @@ -29,6 +29,8 @@ extension AppState { translations: translations ) + WakeWordService.shared.observe(newSeg) + // Upsert: if we already have a segment with this ID, update it; otherwise append if let segId = segment.id, let existingIdx = speakerSegments.firstIndex(where: { $0.segmentId == segId }) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+General.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+General.swift index b242bbcf82d..857e46eb9c5 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+General.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+General.swift @@ -91,6 +91,35 @@ extension SettingsContentView { } } + // Wake word trigger (opt-in; needs ambient listening active to hear it) + settingsCard(settingId: "general.wakeword") { + VStack(alignment: .leading, spacing: OmiSpacing.md) { + HStack(spacing: OmiSpacing.lg) { + SettingsIconTile(symbol: "waveform.and.mic") + + VStack(alignment: .leading, spacing: OmiSpacing.xxs) { + Text("Wake Word") + .scaledFont(size: OmiType.subheading, weight: .semibold) + .foregroundColor(Ink.primary) + + Text(wakeWordSubtitle) + .scaledFont(size: OmiType.body) + .foregroundColor(Ink.secondary) + } + + Spacer() + + Toggle( + "", + isOn: wakeWordEnabledBinding + ) + .toggleStyle(OmiToggleStyle()) + .labelsHidden() + .frame(width: 36, height: 20) + } + } + } + // Notifications toggle settingsCard(settingId: "general.notifications") { VStack(spacing: OmiSpacing.md) { @@ -263,6 +292,23 @@ extension SettingsContentView { audioRecordingMode != .off && !appState.hasMicrophonePermission } + private var wakeWordEnabledBinding: Binding { + Binding( + get: { AssistantSettings.shared.wakeWordEnabled }, + set: { AssistantSettings.shared.wakeWordEnabled = $0 } + ) + } + + private var wakeWordSubtitle: String { + let requiresListeningText: String + switch audioRecordingMode { + case .off: requiresListeningText = "Requires Audio Recording to be on" + case .onlyMeetings: requiresListeningText = "Listens during meetings and calls" + case .always: requiresListeningText = "Listens in the background" + } + return "Say \"\(AssistantSettings.shared.wakeWordPhrase)\" then a command, hands-free. \(requiresListeningText)." + } + private var audioRecordingStatusText: String { if audioRecordingNeedsAttention { return "Microphone permission required" } switch audioRecordingMode { diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift index b9e4d368b28..1ae34133e75 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift @@ -30,6 +30,9 @@ class AssistantSettings { private let batchTranscriptionEnabledKey = "batchTranscriptionEnabled" private let legacyTranscriptionEnabledKey = "transcriptionEnabled" private let legacySystemAudioCaptureModeKey = "systemAudioCaptureMode" + private let wakeWordEnabledKey = "wakeWordEnabled" + private let wakeWordPhraseKey = "wakeWordPhrase" + private let wakeWordCooldownKey = "wakeWordCooldown" // MARK: - Default Values @@ -44,6 +47,9 @@ class AssistantSettings { private let defaultBatchTranscriptionEnabled = false private let defaultAudioRecordingMode: AudioRecordingMode = .onlyMeetings private(set) var transcriptionVocabularyRevision: UInt64 = 0 + private let defaultWakeWordEnabled = false + private let defaultWakeWordPhrase = "Omi" + private let defaultWakeWordCooldown: TimeInterval = 30 private init() { migrateLegacyAudioRecordingSettings() @@ -59,6 +65,9 @@ class AssistantSettings { transcriptionVocabularyKey: defaultTranscriptionVocabulary, vadGateEnabledKey: defaultVadGateEnabled, batchTranscriptionEnabledKey: defaultBatchTranscriptionEnabled, + wakeWordEnabledKey: defaultWakeWordEnabled, + wakeWordPhraseKey: defaultWakeWordPhrase, + wakeWordCooldownKey: defaultWakeWordCooldown, ]) } @@ -165,6 +174,37 @@ class AssistantSettings { } } + var wakeWordEnabled: Bool { + get { UserDefaults.standard.bool(forKey: wakeWordEnabledKey) } + set { + UserDefaults.standard.set(newValue, forKey: wakeWordEnabledKey) + NotificationCenter.default.post(name: .assistantSettingsDidChange, object: nil) + } + } + + var wakeWordPhrase: String { + get { + let stored = UserDefaults.standard.string(forKey: wakeWordPhraseKey) ?? defaultWakeWordPhrase + let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? defaultWakeWordPhrase : trimmed + } + set { + UserDefaults.standard.set(newValue, forKey: wakeWordPhraseKey) + NotificationCenter.default.post(name: .assistantSettingsDidChange, object: nil) + } + } + + var wakeWordCooldown: TimeInterval { + get { + let stored = UserDefaults.standard.double(forKey: wakeWordCooldownKey) + return stored > 0 ? stored : defaultWakeWordCooldown + } + set { + UserDefaults.standard.set(newValue, forKey: wakeWordCooldownKey) + NotificationCenter.default.post(name: .assistantSettingsDidChange, object: nil) + } + } + /// The language code for transcription (e.g., "en", "uk", "ru") var transcriptionLanguage: String { get { diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift new file mode 100644 index 00000000000..9270e8e72b7 --- /dev/null +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -0,0 +1,62 @@ +import Foundation + +enum WakeWordSegmentParser { + static func command(after segmentText: String, wakePhrase: String) -> String? { + let phrase = configuredPhrase(wakePhrase) + guard !phrase.isEmpty else { return nil } + let raw = dropLeadingPunctuationAndWhitespace(segmentText) + let normalized = normalize(raw) + for candidate in candidatePhrases(for: phrase) where normalized.hasPrefix(candidate) { + guard hasWordBoundary(after: candidate, in: normalized) else { continue } + guard + let commandEnd = raw.index( + raw.startIndex, offsetBy: candidate.count, limitedBy: raw.endIndex) + else { continue } + let remainder = String(raw[commandEnd...]) + let command = dropLeadingPunctuationAndWhitespace(remainder) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { continue } + return command + } + return nil + } + + static func configuredPhrase(_ raw: String) -> String { + normalize(raw).trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) + } + + static func candidatePhrases(for phrase: String) -> [String] { + var phrases = [phrase] + for greeting in ["hey", "ok", "okay"] { + phrases.append("\(greeting) \(phrase)") + } + return phrases + } + + private static func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func dropLeadingPunctuationAndWhitespace(_ value: String) -> String { + var index = value.startIndex + while index < value.endIndex { + guard let scalar = value[index].unicodeScalars.first else { break } + guard + CharacterSet.punctuationCharacters.contains(scalar) + || CharacterSet.whitespacesAndNewlines.contains(scalar) + else { break } + index = value.index(after: index) + } + return String(value[index...]) + } + + private static func hasWordBoundary(after prefix: String, in text: String) -> Bool { + guard + let boundaryIndex = text.index( + text.startIndex, offsetBy: prefix.count, limitedBy: text.endIndex) + else { return false } + guard boundaryIndex < text.endIndex else { return true } + let next = text[boundaryIndex] + return !next.isLetter && !next.isNumber + } +} \ No newline at end of file diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift new file mode 100644 index 00000000000..02fccd9aa0a --- /dev/null +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -0,0 +1,60 @@ +import Foundation + +@MainActor +final class WakeWordService { + static let shared = WakeWordService() + + private var lastTriggeredAt: Date? + private var firedSegmentIDs: [String] = [] + private let maxRememberedSegmentIDs = 100 + private let minimumCommandWords = 2 + + var now: @MainActor () -> Date = { Date() } + var onTrigger: @MainActor (String) -> Void = { command in + log("WakeWord: submitting '\(command)' to the assistant") + FloatingControlBarManager.shared.openAIInputWithQuery(command, fromVoice: false) + } + private(set) var lastTriggeredCommand: String? + + init() {} + + func observe( + _ segment: SpeakerSegment, + isConversationActive: Bool = WakeWordService.defaultIsConversationActive() + ) { + guard AssistantSettings.shared.wakeWordEnabled else { return } + guard !isConversationActive else { return } + guard segment.isUser else { return } + if let id = segment.segmentId, firedSegmentIDs.contains(id) { return } + guard + let command = WakeWordSegmentParser.command( + after: segment.text, + wakePhrase: AssistantSettings.shared.wakeWordPhrase) + else { return } + guard Self.wordCount(command) >= minimumCommandWords else { return } + let current = now() + if let last = lastTriggeredAt { + let interval = current.timeIntervalSince(last) + if interval >= 0 && interval < AssistantSettings.shared.wakeWordCooldown { return } + } + if let id = segment.segmentId { + firedSegmentIDs.append(id) + if firedSegmentIDs.count > maxRememberedSegmentIDs { + firedSegmentIDs.removeFirst(firedSegmentIDs.count - maxRememberedSegmentIDs) + } + } + lastTriggeredAt = current + lastTriggeredCommand = command + onTrigger(command) + } + + static func wordCount(_ text: String) -> Int { + text.split(whereSeparator: \.isWhitespace).count + } + + static func defaultIsConversationActive() -> Bool { + if let provider = ChatProvider.mainInstance, provider.isSending { return true } + if VoiceTurnCoordinator.shared.activeTurnID != nil { return true } + return false + } +} \ No newline at end of file diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift new file mode 100644 index 00000000000..d20c63aa3c6 --- /dev/null +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -0,0 +1,47 @@ +import XCTest + +@testable import Omi_Computer + +final class WakeWordSegmentParserTests: XCTestCase { + func testExtractsCommandAfterWakeWord() { + XCTAssertEqual( + WakeWordSegmentParser.command( + after: "Omi, let's order Maya some food", wakePhrase: "Omi"), + "let's order Maya some food") + } + + func testAcceptsCaseAndPunctuationVariants() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "omi, let's order pizza", wakePhrase: "Omi"), + "let's order pizza") + XCTAssertEqual( + WakeWordSegmentParser.command(after: "Omi let's order pizza", wakePhrase: "omi"), + "let's order pizza") + } + + func testBareWakeWordReturnsNil() { + XCTAssertNil(WakeWordSegmentParser.command(after: "Omi", wakePhrase: "Omi")) + XCTAssertNil(WakeWordSegmentParser.command(after: "Omi.", wakePhrase: "Omi")) + } + + func testIgnoresSegmentsWithoutWakeWord() { + XCTAssertNil(WakeWordSegmentParser.command(after: "We should order pizza", wakePhrase: "Omi")) + XCTAssertNil(WakeWordSegmentParser.command(after: "Omiway to the store", wakePhrase: "Omi")) + } + + func testAcceptsGreetingVariant() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "Hey Omi, order pizza", wakePhrase: "Omi"), + "order pizza") + } + + func testAcceptsConfiguredGreetingPhrase() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "Hey Omi order pizza", wakePhrase: "hey omi"), + "order pizza") + } + + func testEmptyPhraseReturnsNil() { + XCTAssertNil(WakeWordSegmentParser.command(after: "Omi order pizza", wakePhrase: " ")) + } +} \ No newline at end of file diff --git a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift new file mode 100644 index 00000000000..04b462a4ef5 --- /dev/null +++ b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift @@ -0,0 +1,106 @@ +import XCTest + +@testable import Omi_Computer + +@MainActor +final class WakeWordServiceTests: XCTestCase { + private let enabledKey = "wakeWordEnabled" + private let phraseKey = "wakeWordPhrase" + private let cooldownKey = "wakeWordCooldown" + + private var service = WakeWordService() + private var triggered: [String] = [] + private var clock: Double = 0 + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: enabledKey) + UserDefaults.standard.removeObject(forKey: phraseKey) + UserDefaults.standard.removeObject(forKey: cooldownKey) + UserDefaults.standard.set(true, forKey: enabledKey) + UserDefaults.standard.set("Omi", forKey: phraseKey) + UserDefaults.standard.set(30.0, forKey: cooldownKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: enabledKey) + UserDefaults.standard.removeObject(forKey: phraseKey) + UserDefaults.standard.removeObject(forKey: cooldownKey) + super.tearDown() + } + + @MainActor + private func configureService() { + service = WakeWordService() + triggered = [] + clock = 0 + service.now = { [weak self] in + Date(timeIntervalSince1970: self?.clock ?? 0) + } + service.onTrigger = { [weak self] command in + self?.triggered.append(command) + } + } + + private func userSegment(_ text: String, id: String? = nil) -> SpeakerSegment { + SpeakerSegment(segmentId: id, speaker: 0, text: text, start: 0, end: 1, isUser: true) + } + + func testDisabledSettingNeverTriggers() { + configureService() + UserDefaults.standard.set(false, forKey: enabledKey) + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) + XCTAssertTrue(triggered.isEmpty) + } + + func testTriggersCommandWithoutWakeWord() { + configureService() + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) + XCTAssertEqual(triggered, ["let's order food"]) + } + + func testBareWakeWordIgnored() { + configureService() + service.observe(userSegment("Omi", id: "a"), isConversationActive: false) + XCTAssertTrue(triggered.isEmpty) + } + + func testNonUserSegmentIgnored() { + configureService() + let segment = SpeakerSegment( + segmentId: "a", speaker: 1, text: "Omi, let's order food", start: 0, end: 1, isUser: false) + service.observe(segment, isConversationActive: false) + XCTAssertTrue(triggered.isEmpty) + } + + func testBusyConversationSuppresses() { + configureService() + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: true) + XCTAssertTrue(triggered.isEmpty) + } + + func testDeduplicatesBySegmentID() { + configureService() + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) + XCTAssertEqual(triggered.count, 1) + } + + func testCooldownSuppressesRapidRepeats() { + configureService() + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) + XCTAssertEqual(triggered.count, 1) + clock = 10 + service.observe(userSegment("Omi, let's order tea", id: "b"), isConversationActive: false) + XCTAssertEqual(triggered.count, 1) + clock = 31 + service.observe(userSegment("Omi, let's order tea", id: "c"), isConversationActive: false) + XCTAssertEqual(triggered.count, 2) + } + + func testLastTriggeredCommandRecorded() { + configureService() + service.observe(userSegment("Omi, order pizza", id: "a"), isConversationActive: false) + XCTAssertEqual(service.lastTriggeredCommand, "order pizza") + } +} \ No newline at end of file diff --git a/desktop/macos/changelog/unreleased/20260818-wake-word-trigger.json b/desktop/macos/changelog/unreleased/20260818-wake-word-trigger.json new file mode 100644 index 00000000000..8ca416da253 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260818-wake-word-trigger.json @@ -0,0 +1,3 @@ +{ + "change": "Added an opt-in wake word — say it during ambient listening to command the assistant hands-free" +} \ No newline at end of file From 0eca6df848b64182efc343a4e69ffacbe3d52042 Mon Sep 17 00:00:00 2001 From: Aryan Date: Tue, 18 Aug 2026 14:13:22 +0530 Subject: [PATCH 02/30] style(desktop): format WakeWord sources with pinned swift-format; add e2e flow coverage Fix the two Desktop Swift CI failures: - desktop-e2e-flow-coverage: cover WakeWordService.swift and WakeWordSegmentParser.swift under capture-lifecycle.yaml (the capture_test_transcript seam drives the same AppState+ListenEvents funnel the wake word observes). - desktop-swift-format-lint: add missing trailing newlines to the four WakeWord source/test files per pinned swift-format 602.0.0. Verified: run_checks.py macos lane passes e2e-flow-coverage, swift-format-lint, and swiftlint; WakeWord 15 tests pass (WakeWordSegmentParserTests 7 + WakeWordServiceTests 8). --- .../macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift | 2 +- desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift | 2 +- desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift | 2 +- desktop/macos/Desktop/Tests/WakeWordServiceTests.swift | 2 +- desktop/macos/e2e/flows/capture-lifecycle.yaml | 2 ++ 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index 9270e8e72b7..c5ed867b2a5 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -59,4 +59,4 @@ enum WakeWordSegmentParser { let next = text[boundaryIndex] return !next.isLetter && !next.isNumber } -} \ No newline at end of file +} diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index 02fccd9aa0a..19f855039ee 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -57,4 +57,4 @@ final class WakeWordService { if VoiceTurnCoordinator.shared.activeTurnID != nil { return true } return false } -} \ No newline at end of file +} diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift index d20c63aa3c6..e66e1a762f7 100644 --- a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -44,4 +44,4 @@ final class WakeWordSegmentParserTests: XCTestCase { func testEmptyPhraseReturnsNil() { XCTAssertNil(WakeWordSegmentParser.command(after: "Omi order pizza", wakePhrase: " ")) } -} \ No newline at end of file +} diff --git a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift index 04b462a4ef5..fbed74d81b7 100644 --- a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift @@ -103,4 +103,4 @@ final class WakeWordServiceTests: XCTestCase { service.observe(userSegment("Omi, order pizza", id: "a"), isConversationActive: false) XCTAssertEqual(service.lastTriggeredCommand, "order pizza") } -} \ No newline at end of file +} diff --git a/desktop/macos/e2e/flows/capture-lifecycle.yaml b/desktop/macos/e2e/flows/capture-lifecycle.yaml index 6b19b00134e..36ff6334467 100644 --- a/desktop/macos/e2e/flows/capture-lifecycle.yaml +++ b/desktop/macos/e2e/flows/capture-lifecycle.yaml @@ -9,6 +9,8 @@ covers: - desktop/macos/Desktop/Sources/Audio/PreferredMicrophoneReconnect.swift - desktop/macos/Desktop/Sources/AppState/SharedCaptureSilentMicRecoveryPolicy.swift - desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift + - desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift + - desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift - desktop/macos/Desktop/Sources/AppState/LocalTranscriptionDuplicatePolicy.swift - desktop/macos/Desktop/Sources/AppState/STTSessionState.swift - desktop/macos/Desktop/Sources/LocalTranscriptionService.swift From e42ee6fbff2069cbc0a42812f6372f5b5beb85f1 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 10:47:13 +0530 Subject: [PATCH 03/30] fix(desktop): match speech-to-text renderings of the wake phrase The wake word only matched the literal string "omi", but speech-to-text spells the phrase by sound. A live mic session transcribed "Omi, how are you?" as "Oh me, how are you?" (Parakeet v3, conf=0.92) and the wake word silently never fired -- the recognizer heard the user correctly and the parser rejected it. Accept the renderings recognizers actually emit ("oh me", "omni", "ohmi", "oh mi", "omee", "o me", "oh-me") as the same phrase, expanded through the existing greeting prefixes so "hey oh me, ..." works too. The downstream guards (user speech only, 2+ word command, cooldown, segment dedup) still bound the false-positive cost of the wider match. The existing unit tests passed before and after this change because they fed the parser the string "Omi" -- which is exactly what the microphone never produces. Verification - swift test --filter WakeWordSegmentParserTests -> 10 passed - Added the exact failing string from the live session as a regression test, plus negatives proving the wider match does not swallow ordinary speech ("Omnibus schedule changed" and a bare "Oh me" still do not fire). --- .../WakeWord/WakeWordSegmentParser.swift | 19 +++++++++++-- .../Tests/WakeWordSegmentParserTests.swift | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index c5ed867b2a5..eef5283c197 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -25,10 +25,23 @@ enum WakeWordSegmentParser { normalize(raw).trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) } + /// Speech-to-text spells the wake word by sound, not by brand. "Omi" is + /// acoustically "oh-mee", so recognizers routinely emit "oh me", "omni", or + /// "ohmi" instead. Matching only the literal spelling makes the wake word fail + /// for reasons the user cannot see or correct, so known renderings are accepted + /// as the same phrase. Downstream guards (user speech only, 2+ word command, + /// cooldown, dedup) still bound the false-positive cost of the wider match. + static let sttHomophones: [String: [String]] = [ + "omi": ["oh me", "ohmi", "omni", "oh mi", "omee", "o me", "oh-me"] + ] + static func candidatePhrases(for phrase: String) -> [String] { - var phrases = [phrase] - for greeting in ["hey", "ok", "okay"] { - phrases.append("\(greeting) \(phrase)") + var phrases: [String] = [] + for base in [phrase] + (sttHomophones[phrase] ?? []) { + phrases.append(base) + for greeting in ["hey", "ok", "okay"] { + phrases.append("\(greeting) \(base)") + } } return phrases } diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift index e66e1a762f7..cd96fa4f5a4 100644 --- a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -44,4 +44,32 @@ final class WakeWordSegmentParserTests: XCTestCase { func testEmptyPhraseReturnsNil() { XCTAssertNil(WakeWordSegmentParser.command(after: "Omi order pizza", wakePhrase: " ")) } + + /// Regression: a live mic session transcribed "Omi, how are you?" as + /// "Oh me, how are you?" (Parakeet, conf=0.92) and the wake word silently + /// never fired. STT spells the phrase by sound, so homophones must match. + func testAcceptsSpeechToTextHomophones() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "Oh me, how are you?", wakePhrase: "Omi"), + "how are you?") + for rendering in ["Omni", "Ohmi", "Oh mi", "Omee", "O me"] { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "\(rendering), order pizza", wakePhrase: "Omi"), + "order pizza", + "expected \(rendering) to be treated as the wake word") + } + } + + func testAcceptsGreetingBeforeHomophone() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "Hey oh me, order pizza", wakePhrase: "Omi"), + "order pizza") + } + + /// The wider match must not swallow ordinary speech: a homophone still needs a + /// word boundary and a real command behind it. + func testHomophoneWithoutBoundaryOrCommandIsIgnored() { + XCTAssertNil(WakeWordSegmentParser.command(after: "Omnibus schedule changed", wakePhrase: "Omi")) + XCTAssertNil(WakeWordSegmentParser.command(after: "Oh me", wakePhrase: "Omi")) + } } From 01cb0fc45f3601df17588e45a900bdd7910619ff Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 10:49:14 +0530 Subject: [PATCH 04/30] fix(desktop): make the wake word survive the real ambient transcript Three defects found by running the feature against a live ambient session rather than constructed segments. Each failed silently, so a wake word that never fired was indistinguishable from one that was never spoken. 1. Diarization attribution. The trigger required `segment.isUser`, but the backend only sets `is_user` once a speech profile is enrolled. A live session logged `Speaker 0: "Omi, what's the weather?"` with is_user=false, so the wake word was structurally dead for every user without an enrolled profile. VoiceBargeInPolicy already gates on `isUser || speaker == 0` and documents speaker 0 as the primary user; the two entry points disagreed about what "the user" means. Align on the sibling's contract. 2. Segment dedup. The backend re-delivers one growing segment under a single id (observed: [206.0s-217.1s] -> [206.0s-228.9s]). Deduping on the id alone dropped every later command that landed inside an id that had already fired. Key on the id plus the extracted command so a re-sent segment is still suppressed but a new instruction inside it runs. 3. Cooldown. The cooldown exists to swallow a rapid repeat of the same utterance, but it gated on elapsed time alone. The ambient transcript lane runs ~35s behind live speech (measured over 11 segments, 34.3-36.6s), so several genuinely distinct commands routinely arrive inside one 30s window and were discarded as "repeats" -- two consecutive "what time is it" attempts were both lost this way. Gate on a repeat of the same command. Also name the reason a segment was ignored. Every guard returned silently; the diagnostics are what located defects 1 and 2, and the demo runbook depends on the log explaining a beat that did not fire. Only segments that actually carry the wake phrase are reported, so ordinary speech stays quiet. Verification - swift test --filter WakeWordServiceTests -> 11 passed - Regression tests added for each defect, carrying the live values that exposed them (speaker 0 with is_user=false, a reused segment id, a distinct command inside the cooldown). - Confirmed live after the fix: `WakeWord: submitting 'can you order food for me?' to the assistant`, and the re-delivered segment correctly suppressed. Honest gaps - The ~35s ambient latency is server-side (client ships audio every 100ms via audioBufferSize=3200); it is not addressed here and needs the realtime lane. - Not verified against an account with an enrolled speech profile. --- .../Sources/WakeWord/WakeWordService.swift | 57 ++++++++++++++----- .../Desktop/Tests/WakeWordServiceTests.swift | 48 ++++++++++++++-- 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index 19f855039ee..28a9b50be8f 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -22,23 +22,52 @@ final class WakeWordService { _ segment: SpeakerSegment, isConversationActive: Bool = WakeWordService.defaultIsConversationActive() ) { - guard AssistantSettings.shared.wakeWordEnabled else { return } - guard !isConversationActive else { return } - guard segment.isUser else { return } - if let id = segment.segmentId, firedSegmentIDs.contains(id) { return } - guard - let command = WakeWordSegmentParser.command( - after: segment.text, - wakePhrase: AssistantSettings.shared.wakeWordPhrase) - else { return } - guard Self.wordCount(command) >= minimumCommandWords else { return } + let parsed = WakeWordSegmentParser.command( + after: segment.text, + wakePhrase: AssistantSettings.shared.wakeWordPhrase) + + // Every rejection below used to be silent, so a wake word that never fired + // was indistinguishable from one that was never spoken. Only segments that + // actually carry the wake phrase are reported, so ordinary speech stays quiet. + func ignore(_ reason: String) { + if parsed != nil { log("WakeWord: ignored — \(reason)") } + } + + guard AssistantSettings.shared.wakeWordEnabled else { return ignore("disabled in settings") } + guard !isConversationActive else { return ignore("assistant already busy") } + // Diarization only sets `isUser` once a speech profile is enrolled, so requiring + // it alone makes the wake word silently dead for every user who has not enrolled + // one. Speaker 0 is the primary user everywhere else in this feature set — + // `VoiceBargeInPolicy` gates on `isUser || speaker == 0` for exactly this reason. + guard segment.isUser || segment.speaker == 0 else { + return ignore("segment not attributed to the user (speaker \(segment.speaker))") + } + guard let command = parsed else { return } + // The backend re-delivers a growing segment under one id (observed live: + // [206.0s-217.1s] → [206.0s-228.9s]), so deduping on the id alone silently + // drops every later command that lands inside an id that already fired. + // Key on the extracted command so a re-sent segment is suppressed but a new + // instruction inside the same segment still runs. + let dedupKey = segment.segmentId.map { "\($0)|\(command)" } + if let key = dedupKey, firedSegmentIDs.contains(key) { + return ignore("segment \(segment.segmentId ?? "?") already fired for '\(command)'") + } + guard Self.wordCount(command) >= minimumCommandWords else { + return ignore("command '\(command)' is shorter than \(minimumCommandWords) words") + } let current = now() - if let last = lastTriggeredAt { + // The cooldown exists to swallow a rapid repeat of the same utterance, not to + // ration distinct instructions. The ambient transcript lane runs ~35s behind + // live speech (measured), so several genuinely different commands routinely + // arrive inside one 30s window; gating on time alone silently discarded them. + if let last = lastTriggeredAt, command == lastTriggeredCommand { let interval = current.timeIntervalSince(last) - if interval >= 0 && interval < AssistantSettings.shared.wakeWordCooldown { return } + if interval >= 0 && interval < AssistantSettings.shared.wakeWordCooldown { + return ignore("cooldown — repeat of '\(command)' \(Int(interval))s after the last trigger") + } } - if let id = segment.segmentId { - firedSegmentIDs.append(id) + if let key = dedupKey { + firedSegmentIDs.append(key) if firedSegmentIDs.count > maxRememberedSegmentIDs { firedSegmentIDs.removeFirst(firedSegmentIDs.count - maxRememberedSegmentIDs) } diff --git a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift index fbed74d81b7..0d50f7d8423 100644 --- a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift @@ -73,6 +73,19 @@ final class WakeWordServiceTests: XCTestCase { XCTAssertTrue(triggered.isEmpty) } + /// Regression: a live ambient session transcribed "Omi, what's the weather?" as + /// speaker 0 with `is_user=false` (diarization only sets `is_user` once a speech + /// profile is enrolled) and the wake word never fired. Speaker 0 is the primary + /// user, matching `VoiceBargeInPolicy.shouldInterrupt`. + func testSpeakerZeroTriggersWithoutDiarizedUserFlag() { + configureService() + let segment = SpeakerSegment( + segmentId: "a", speaker: 0, text: "Omi, what's the weather?", start: 0, end: 1, + isUser: false) + service.observe(segment, isConversationActive: false) + XCTAssertEqual(triggered, ["what's the weather?"]) + } + func testBusyConversationSuppresses() { configureService() service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: true) @@ -86,16 +99,41 @@ final class WakeWordServiceTests: XCTestCase { XCTAssertEqual(triggered.count, 1) } - func testCooldownSuppressesRapidRepeats() { + /// Regression: the backend re-delivers one growing segment under a single id + /// (observed live: [206.0s-217.1s] → [206.0s-228.9s]). Deduping on the id alone + /// dropped every later command that arrived inside an id that had already fired. + func testNewCommandInReusedSegmentIDStillFires() { configureService() service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) XCTAssertEqual(triggered.count, 1) - clock = 10 - service.observe(userSegment("Omi, let's order tea", id: "b"), isConversationActive: false) + clock = 31 // clear the cooldown so this asserts dedup, not pacing + service.observe(userSegment("Omi, open my tasks", id: "a"), isConversationActive: false) + XCTAssertEqual(triggered, ["let's order food", "open my tasks"]) + } + + /// The cooldown swallows a rapid repeat of the *same* utterance. It used to gate + /// on elapsed time alone, which discarded distinct instructions: the ambient + /// transcript lane runs ~35s behind live speech (measured over 11 segments, + /// 34.3–36.6s), so several different commands routinely land inside one 30s + /// window and were silently dropped as "repeats". + func testCooldownSuppressesRepeatsOfTheSameCommand() { + configureService() + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) XCTAssertEqual(triggered.count, 1) + clock = 10 + service.observe(userSegment("Omi, let's order food", id: "b"), isConversationActive: false) + XCTAssertEqual(triggered.count, 1, "same command inside the cooldown must be suppressed") clock = 31 - service.observe(userSegment("Omi, let's order tea", id: "c"), isConversationActive: false) - XCTAssertEqual(triggered.count, 2) + service.observe(userSegment("Omi, let's order food", id: "c"), isConversationActive: false) + XCTAssertEqual(triggered.count, 2, "same command after the cooldown runs again") + } + + func testDistinctCommandInsideCooldownStillRuns() { + configureService() + service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) + clock = 10 + service.observe(userSegment("Omi, what time is it", id: "b"), isConversationActive: false) + XCTAssertEqual(triggered, ["let's order food", "what time is it"]) } func testLastTriggeredCommandRecorded() { From bbe9d4db6cdd9f42f3d0309f379b0f478568c4cc Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 10:51:23 +0530 Subject: [PATCH 05/30] fix(desktop): speak the assistant's reply to a wake word The wake word dispatched with `fromVoice: false`, which is the flag that decides whether the assistant speaks its answer or only renders text. A wake word is a hands-free entry point by definition -- the user's hands and eyes are elsewhere -- so a silent reply strands the interaction the feature exists to enable. Observed live: the trigger fired and answered correctly, and the user heard nothing. PushToTalkManager already passes `fromVoice: true` for the same reason. Verification - swift build -> clean - swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' -> 21 passed Honest gaps - Voice follow-up is still unavailable from the wake word. `sendFollowUpQuery` exists and PushToTalkManager uses it with a voiceTurnID, but the wake word always opens a fresh query, and `guard !isConversationActive` suppresses the trigger while a turn is live. Wiring multi-turn to the wake word needs the realtime lane, not the ~35s ambient lane; tracked separately. --- .../macos/Desktop/Sources/WakeWord/WakeWordService.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index 28a9b50be8f..58c10beeaf6 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -12,7 +12,11 @@ final class WakeWordService { var now: @MainActor () -> Date = { Date() } var onTrigger: @MainActor (String) -> Void = { command in log("WakeWord: submitting '\(command)' to the assistant") - FloatingControlBarManager.shared.openAIInputWithQuery(command, fromVoice: false) + // `fromVoice` is what makes the assistant speak its answer instead of only + // rendering text. A wake word is a hands-free entry point by definition — the + // user's hands and eyes are elsewhere — so a silent reply strands the whole + // interaction. Push-to-talk already passes true for the same reason. + FloatingControlBarManager.shared.openAIInputWithQuery(command, fromVoice: true) } private(set) var lastTriggeredCommand: String? From 8b3bb10af197e3dfbc29c3eb3fb8731e6dd69703 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 12:50:46 +0530 Subject: [PATCH 06/30] fix(desktop): accept aspirated renderings of the wake phrase On-device recognition has no keyword list, so it fronts the vowel with an aspirate. Observed live from one speaker in a single session: "Homi what's the weather? outworking." "Homie, can you order food for me? Street drive to children dance." Adds "homi" and "hommi". Deliberately excludes "homie": it is an ordinary English word, and accepting it as the wake phrase would fire on real speech. Scope note: this list is a safety net, not the fix. `TranscriptionService` already seeds ["Omi", "OMI"] into the STT keyword boost, and on the cloud lane the phrase transcribes exactly every time. These misses only occur on the on-device lane, which takes no keyword list -- the durable fix is keyword boosting on the recognizer, not a longer list here. Verification - swift test --filter WakeWordSegmentParserTests -> 10 passed --- .../Sources/WakeWord/WakeWordSegmentParser.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index eef5283c197..4f97277b83c 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -32,7 +32,15 @@ enum WakeWordSegmentParser { /// as the same phrase. Downstream guards (user speech only, 2+ word command, /// cooldown, dedup) still bound the false-positive cost of the wider match. static let sttHomophones: [String: [String]] = [ - "omi": ["oh me", "ohmi", "omni", "oh mi", "omee", "o me", "oh-me"] + "omi": [ + "oh me", "ohmi", "omni", "oh mi", "omee", "o me", "oh-me", + // On-device recognition has no keyword list, so it also fronts the vowel + // with an aspirate ("Homi what's the weather?", observed live). Deliberately + // excludes "homie": it is an ordinary English word, and accepting it as the + // wake phrase would fire on real speech. The fix for on-device misses is + // keyword boosting on the recognizer, not a longer list here. + "homi", "hommi", + ] ] static func candidatePhrases(for phrase: String) -> [String] { From fb13867fb93c3b21f91fa2e0f62d1cdce39d1b28 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 12:52:27 +0530 Subject: [PATCH 07/30] fix(desktop): stop a growing transcript segment re-firing the wake word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backend segment is re-delivered as it grows, in place and under one id. The previous dedup keyed on the exact extracted command, so every growth counted as a new instruction and fired again with a longer string. Worse, the assistant's own spoken reply is captured by the microphone and appended to the same segment, so each re-fire submitted a more polluted command. Observed live: 12:41:32 submitting 'what time it is? You speak English. Got it.' 12:41:33 submitting 'what time it is? You speak English. Got it. Handed you this' 12:41:39 Transcript [ADD] Speaker 1: Handed you this An agent is getting started on that. The query actually dispatched was the polluted string, which is not what the user asked and cannot be answered usefully. Deduping on the id alone is also wrong -- it drops a genuinely new instruction that lands in a reused id. Treat a command that extends one already fired for that segment as the same instruction, and anything else as new. Verification - swift test --filter WakeWordServiceTests -> 12 passed - Regression test carries the live growth case. - Confirmed live: one clean `submitting 'what time it is?'`, and the following re-delivery correctly `ignored — already fired`. Honest gaps - The microphone capturing the assistant's own speech is a separate defect and is not addressed here; this change only stops it corrupting the command. --- .../Sources/WakeWord/WakeWordService.swift | 28 +++++++++++++------ .../Desktop/Tests/WakeWordServiceTests.swift | 15 ++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index 58c10beeaf6..537ce3b7d87 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -4,6 +4,10 @@ import Foundation final class WakeWordService { static let shared = WakeWordService() + /// Separates a segment id from the command it fired, in `firedSegmentIDs`. + /// A control character so it cannot occur inside either half. + private static let dedupSeparator: Character = "\u{1}" + private var lastTriggeredAt: Date? private var firedSegmentIDs: [String] = [] private let maxRememberedSegmentIDs = 100 @@ -47,15 +51,23 @@ final class WakeWordService { return ignore("segment not attributed to the user (speaker \(segment.speaker))") } guard let command = parsed else { return } - // The backend re-delivers a growing segment under one id (observed live: - // [206.0s-217.1s] → [206.0s-228.9s]), so deduping on the id alone silently - // drops every later command that lands inside an id that already fired. - // Key on the extracted command so a re-sent segment is suppressed but a new - // instruction inside the same segment still runs. - let dedupKey = segment.segmentId.map { "\($0)|\(command)" } - if let key = dedupKey, firedSegmentIDs.contains(key) { - return ignore("segment \(segment.segmentId ?? "?") already fired for '\(command)'") + // A backend segment is re-delivered as it grows, in place and under one id + // (observed live: "what time it is?" → "what time it is? You speak English. + // Got it."). Deduping on the id alone drops a genuinely new instruction that + // lands in a reused id; deduping on the exact command re-fires on every growth + // with a longer, more polluted string. Treat a command that extends one already + // fired for this segment as the same instruction, and anything else as new. + if let id = segment.segmentId { + let priorCommands = firedSegmentIDs.compactMap { entry -> String? in + let parts = entry.split(separator: Self.dedupSeparator, maxSplits: 1) + guard parts.count == 2, parts[0] == id else { return nil } + return String(parts[1]) + } + if let prior = priorCommands.first(where: { command.hasPrefix($0) || $0.hasPrefix(command) }) { + return ignore("segment \(id) already fired for '\(prior)'") + } } + let dedupKey = segment.segmentId.map { "\($0)\(Self.dedupSeparator)\(command)" } guard Self.wordCount(command) >= minimumCommandWords else { return ignore("command '\(command)' is shorter than \(minimumCommandWords) words") } diff --git a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift index 0d50f7d8423..ca9cdcd509c 100644 --- a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift @@ -116,6 +116,21 @@ final class WakeWordServiceTests: XCTestCase { /// transcript lane runs ~35s behind live speech (measured over 11 segments, /// 34.3–36.6s), so several different commands routinely land inside one 30s /// window and were silently dropped as "repeats". + /// Regression: a segment grows in place under one id, and the assistant's own + /// spoken reply is captured by the mic and appended to it. Observed live: + /// "what time it is?" then "what time it is? You speak English. Got it." fired + /// twice, the second time submitting the polluted string as the command. + func testGrowingSegmentDoesNotRefire() { + configureService() + service.observe(userSegment("Omi, what time is it", id: "a"), isConversationActive: false) + XCTAssertEqual(triggered.count, 1) + clock = 31 // clear the cooldown so this asserts dedup, not pacing + service.observe( + userSegment("Omi, what time is it and an agent is getting started on that", id: "a"), + isConversationActive: false) + XCTAssertEqual(triggered, ["what time is it"], "growth of a fired command is not a new command") + } + func testCooldownSuppressesRepeatsOfTheSameCommand() { configureService() service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) From ddaf0e85f8a35aba1567ece90af2d7815b1c1794 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 14:50:45 +0530 Subject: [PATCH 08/30] fix(desktop): restore wake word dispatch broken by fromVoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier commit on this branch switched the wake word to `openAIInputWithQuery(command, fromVoice: true)` so the assistant would speak its answer. That silently disabled the feature end to end. `openAIInputWithQuery` gates the voice path on a turn it does not mint: if fromVoice { guard let voiceTurnID, VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil else { return } `fromVoice: true` with no `voiceTurnID` fails that guard and returns with no log and no user-visible effect. Every wake word therefore logged `submitting` and dispatched nothing -- the trigger looked healthy in the log while the assistant was never invoked. `fromVoice: true` belongs to callers that own a voice-turn lifecycle: PushToTalkManager begins a turn and passes both arguments. The wake word submits an already-transcribed command and owns no turn, exactly like DesktopAutomationBridge, which passes `fromVoice: false` for the same reason. Verification - swift build -> clean - swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' -> 22 passed - Confirmed live: `WakeWord: submitting 'what time is it?'` followed 1.3s later by `WakeWord: ignored — assistant already busy`, i.e. the turn actually engaged. With `fromVoice: true` that second line never appeared on any attempt. Honest gaps - The reply is no longer spoken aloud; it renders in the floating bar. Restoring spoken replies requires minting a VoiceTurnID via `VoiceTurnCoordinator.begin(intent:)` before dispatch and threading it through, which is a separate change and is not attempted here. --- .../Desktop/Sources/WakeWord/WakeWordService.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index 537ce3b7d87..0eaea01df80 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -16,11 +16,13 @@ final class WakeWordService { var now: @MainActor () -> Date = { Date() } var onTrigger: @MainActor (String) -> Void = { command in log("WakeWord: submitting '\(command)' to the assistant") - // `fromVoice` is what makes the assistant speak its answer instead of only - // rendering text. A wake word is a hands-free entry point by definition — the - // user's hands and eyes are elsewhere — so a silent reply strands the whole - // interaction. Push-to-talk already passes true for the same reason. - FloatingControlBarManager.shared.openAIInputWithQuery(command, fromVoice: true) + // `fromVoice: true` is reserved for callers that own a voice-turn lifecycle: + // `openAIInputWithQuery` guards it behind `voiceTurnID` + + // `VoiceTurnCoordinator.requireCurrentOwner` and returns silently when either is + // missing, so passing it without a turn drops the query with no diagnostic. + // Push-to-talk owns a turn and passes both; the wake word, like the automation + // bridge, submits an already-transcribed command and owns no turn. + FloatingControlBarManager.shared.openAIInputWithQuery(command, fromVoice: false) } private(set) var lastTriggeredCommand: String? From cfb0b61f99f43192d87f6d18c50650fb460ed8e7 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 20:47:05 +0530 Subject: [PATCH 09/30] fix(desktop): require a pause after a homophone wake phrase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on this PR: with "oh me" accepted as the wake phrase, an ordinary sentence like "oh me and my friend went hiking" parses to the 2-word command "and my friend went hiking" and auto-sends it. That false-positive surface was introduced by the homophone table earlier in this branch. The homophones and the literal spelling are not the same kind of evidence. Saying "Omi" is deliberate -- nobody produces it mid-sentence by accident -- so "Omi order food" needs no corroboration. A homophone is the recognizer guessing, and its guesses are ordinary English, so it needs something more. A bare homophone now has to be followed by a punctuation break: the recognizer's own signal that the speaker addressed something and then paused. Every homophone hit observed live carried one ("Oh me, how are you?"). A greeting prefix is corroboration in its own right -- "hey oh me" is not said by accident -- so those forms keep the ordinary word boundary. "Omi order food" fires (literal, unchanged) "Oh me, how are you?" fires (homophone + break) "hey oh me order pizza" fires (greeting corroborates) "oh me and my friend went hiking" ignored (bare homophone, no break) "o me it has been a long day" ignored This only ever makes the wake word fire less, so the risk it carries is a missed trigger, never a spurious one. Verification - swift test --filter 'WakeWordSegmentParserTests|WakeWordServiceTests' -> 25 passed (13 parser + 12 service; 3 new, every prior case still green) - New cases use the reviewer's example sentence and the exact strings observed live. - Confirmed live on the dev serving plane after the change, both forms in one session: 20:44:35 Transcript: "Hey Omi what's the time" WakeWord: submitting 'what's the time' to the assistant 20:44:37 Transcript: "Omi, what is the time?" WakeWord: ignored — assistant already busy The second line is the trigger being correctly suppressed while the turn opened by the first was still live, which is also proof the dispatch engaged. Honest gaps - Punctuation is a proxy for a spoken pause and depends on the recognizer emitting it. A homophone rendered without punctuation will now be missed rather than misfire, which is the safer direction but is still a miss. - Does not address a wake phrase transcribed in a non-Latin script (the socket runs language=multi); matching remains ASCII-only. --- .../WakeWord/WakeWordSegmentParser.swift | 54 +++++++++++++++---- .../Tests/WakeWordSegmentParserTests.swift | 34 ++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index 4f97277b83c..5b71cfda389 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -6,11 +6,16 @@ enum WakeWordSegmentParser { guard !phrase.isEmpty else { return nil } let raw = dropLeadingPunctuationAndWhitespace(segmentText) let normalized = normalize(raw) - for candidate in candidatePhrases(for: phrase) where normalized.hasPrefix(candidate) { - guard hasWordBoundary(after: candidate, in: normalized) else { continue } + for candidate in candidates(for: phrase) where normalized.hasPrefix(candidate.text) { + guard + hasBoundary( + after: candidate.text, + in: normalized, + requiringPunctuation: candidate.requiresPunctuationBreak) + else { continue } guard let commandEnd = raw.index( - raw.startIndex, offsetBy: candidate.count, limitedBy: raw.endIndex) + raw.startIndex, offsetBy: candidate.text.count, limitedBy: raw.endIndex) else { continue } let remainder = String(raw[commandEnd...]) let command = dropLeadingPunctuationAndWhitespace(remainder) @@ -43,15 +48,36 @@ enum WakeWordSegmentParser { ] ] - static func candidatePhrases(for phrase: String) -> [String] { - var phrases: [String] = [] - for base in [phrase] + (sttHomophones[phrase] ?? []) { - phrases.append(base) + /// A phrase that may open a wake-word utterance, and how much corroboration it needs. + struct Candidate: Equatable { + let text: String + /// Whether a punctuation break must follow the phrase for it to count. + let requiresPunctuationBreak: Bool + } + + /// The literal spelling is a deliberate act: nobody says "Omi" mid-sentence by accident, + /// so `"Omi order food"` needs no further evidence. A homophone is the recognizer + /// guessing, and the guesses are ordinary English — `"oh me and my friend went hiking"` + /// would otherwise parse to the command "and my friend went hiking" and auto-send it. + /// + /// A bare homophone therefore has to be followed by a punctuation break, which is the + /// recognizer's own signal that the speaker addressed something and then paused. Every + /// homophone hit observed live carried one ("Oh me, how are you?"). A greeting prefix is + /// corroboration in its own right — "hey oh me" is not something a person says by + /// accident — so those forms keep the ordinary word boundary. + static func candidates(for phrase: String) -> [Candidate] { + var result: [Candidate] = [Candidate(text: phrase, requiresPunctuationBreak: false)] + for greeting in ["hey", "ok", "okay"] { + result.append(Candidate(text: "\(greeting) \(phrase)", requiresPunctuationBreak: false)) + } + for homophone in sttHomophones[phrase] ?? [] { + result.append(Candidate(text: homophone, requiresPunctuationBreak: true)) for greeting in ["hey", "ok", "okay"] { - phrases.append("\(greeting) \(base)") + result.append( + Candidate(text: "\(greeting) \(homophone)", requiresPunctuationBreak: false)) } } - return phrases + return result } private static func normalize(_ value: String) -> String { @@ -71,13 +97,21 @@ enum WakeWordSegmentParser { return String(value[index...]) } - private static func hasWordBoundary(after prefix: String, in text: String) -> Bool { + private static func hasBoundary( + after prefix: String, + in text: String, + requiringPunctuation: Bool + ) -> Bool { guard let boundaryIndex = text.index( text.startIndex, offsetBy: prefix.count, limitedBy: text.endIndex) else { return false } + // A phrase at the very end carries no command, so the caller rejects it either way. guard boundaryIndex < text.endIndex else { return true } let next = text[boundaryIndex] + if requiringPunctuation { + return next.isPunctuation + } return !next.isLetter && !next.isNumber } } diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift index cd96fa4f5a4..bd880e95fe5 100644 --- a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -60,6 +60,40 @@ final class WakeWordSegmentParserTests: XCTestCase { } } + /// Review feedback on #11801: with "oh me" accepted as the wake phrase, an ordinary + /// sentence like "oh me and my friend went hiking" parsed to the 2-word command + /// "and my friend went hiking" and auto-sent it. The homophones are the recognizer + /// guessing, and its guesses are ordinary English, so a bare homophone now needs a + /// punctuation break — the recognizer's own signal that the speaker addressed something + /// and paused. Every homophone hit observed live carried one. + func testBareHomophoneInOrdinarySpeechDoesNotFire() { + for sentence in [ + "oh me and my friend went hiking", + "o me it has been a long day", + "oh me I forgot to reply", + ] { + XCTAssertNil( + WakeWordSegmentParser.command(after: sentence, wakePhrase: "Omi"), + "expected ordinary speech not to trigger: \(sentence)") + } + } + + /// The literal spelling is deliberate — nobody says "Omi" mid-sentence by accident — so + /// it keeps working without a separator. + func testLiteralPhraseStillNeedsNoPunctuation() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "Omi order food", wakePhrase: "Omi"), + "order food") + } + + /// A greeting is corroboration in its own right, so those forms keep the ordinary + /// word boundary. + func testGreetingBeforeHomophoneNeedsNoPunctuation() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "hey oh me order pizza", wakePhrase: "Omi"), + "order pizza") + } + func testAcceptsGreetingBeforeHomophone() { XCTAssertEqual( WakeWordSegmentParser.command(after: "Hey oh me, order pizza", wakePhrase: "Omi"), From 377556361ba3a9aa01ecabad800c860076da4b8d Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 13:55:04 +0530 Subject: [PATCH 10/30] fix(ci): bypass stalled Azure apt mirror The hosted runner's Azure Ubuntu mirror repeatedly stalled Redis setup until the 20-minute gauntlet deadline. Use the existing archive.ubuntu.com fallback directly for each Redis-dependent gauntlet.\n\nVerification:\n- actionlint -config-file .github/actionlint.yaml .github/workflows/backend-hermetic-e2e.yml\n- python3 backend/scripts/check_workflow_contracts.py --changed-files .github/workflows/backend-hermetic-e2e.yml\n- git diff --check\n\nFailure-Class: none --- .github/workflows/backend-hermetic-e2e.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/backend-hermetic-e2e.yml b/.github/workflows/backend-hermetic-e2e.yml index 8091cc0d736..a5e7d8c1be5 100644 --- a/.github/workflows/backend-hermetic-e2e.yml +++ b/.github/workflows/backend-hermetic-e2e.yml @@ -164,6 +164,7 @@ jobs: - name: Install Redis server run: | + sudo sed -i '/azure\.archive\.ubuntu\.com/d' /etc/apt/apt-mirrors.txt sudo apt-get update sudo apt-get install --yes redis-server @@ -239,6 +240,7 @@ jobs: - name: Install Redis server run: | + sudo sed -i '/azure\.archive\.ubuntu\.com/d' /etc/apt/apt-mirrors.txt sudo apt-get update sudo apt-get install --yes redis-server @@ -291,6 +293,7 @@ jobs: - name: Install Redis server run: | + sudo sed -i '/azure\.archive\.ubuntu\.com/d' /etc/apt/apt-mirrors.txt sudo apt-get update sudo apt-get install --yes redis-server From d3d1544f34e908d9a668627f346b644ff4a43d60 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 19 Aug 2026 20:26:39 +0530 Subject: [PATCH 11/30] fix(ci): bypass stalled Azure apt mirror in Linux package helper smoke Same stall as backend-hermetic-e2e's Redis install (69e5a9e): the hosted runner's Azure Ubuntu mirror hangs InRelease fetches, this time in desktop-windows-ci's Linux runtime dependency install, until the job's 6-hour default timeout cancels it. Drop the mirror the same way, falling back to archive.ubuntu.com directly. Verification: - actionlint -config-file .github/actionlint.yaml .github/workflows/desktop-windows-ci.yml - git diff --check Failure-Class: none --- .github/workflows/desktop-windows-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/desktop-windows-ci.yml b/.github/workflows/desktop-windows-ci.yml index 8d3db6a51ca..3920d5a1b3b 100644 --- a/.github/workflows/desktop-windows-ci.yml +++ b/.github/workflows/desktop-windows-ci.yml @@ -106,6 +106,7 @@ jobs: cache-dependency-path: desktop/windows/pnpm-lock.yaml - name: Install Linux runtime dependencies run: | + sudo sed -i '/azure\.archive\.ubuntu\.com/d' /etc/apt/apt-mirrors.txt sudo apt-get update sudo apt-get install -y imagemagick fonts-dejavu-core tesseract-ocr tesseract-ocr-eng xvfb - name: Provision .env From 9f250300b84d4d14001c0648f7b057a9cc86610a Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 20 Aug 2026 13:30:18 +0530 Subject: [PATCH 12/30] chore(ci): retrigger hermetic suite after a scope-detect runner cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect Hermetic Backend Scope was cancelled mid-run by a runner race, so Backend Hermetic Merge Gate read SCOPE_RESULT: cancelled and failed closed. Distinct from the apt-mirror stall in #11848 — this one never reaches a package fetch. No code change; the branch has no admin rights to rerun the workflow directly. From 5733ca9d8cdddbbace6e1f6ed331acb6aa780636 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 21 Aug 2026 23:14:19 +0530 Subject: [PATCH 13/30] feat(desktop): recognize evidence-backed Omie wake-word transcripts --- .../WakeWord/WakeWordSegmentParser.swift | 18 +++++++++++------- .../Tests/WakeWordSegmentParserTests.swift | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index 5b71cfda389..fe26c1bdd84 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -30,15 +30,19 @@ enum WakeWordSegmentParser { normalize(raw).trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) } - /// Speech-to-text spells the wake word by sound, not by brand. "Omi" is - /// acoustically "oh-mee", so recognizers routinely emit "oh me", "omni", or - /// "ohmi" instead. Matching only the literal spelling makes the wake word fail - /// for reasons the user cannot see or correct, so known renderings are accepted - /// as the same phrase. Downstream guards (user speech only, 2+ word command, - /// cooldown, dedup) still bound the false-positive cost of the wider match. + /// Speech-to-text spells the wake word by sound, not by brand. The backend's + /// read-only scan of 25,329 real transcript segments found "omie" and "omni" + /// alongside "omi"; desktop live runs also observed split and aspirated forms. + /// Matching only the literal spelling makes the wake word fail for reasons the + /// user cannot see or correct, so measured renderings are accepted as the same + /// phrase. Downstream guards (user speech only, 2+ word command, cooldown, + /// dedup) still bound the false-positive cost of the wider match. static let sttHomophones: [String: [String]] = [ "omi": [ - "oh me", "ohmi", "omni", "oh mi", "omee", "o me", "oh-me", + // Shared with backend EVIDENCE_BACKED_WAKE_WORD_VARIANTS. + "omie", "omni", + // Additional renderings observed during desktop microphone testing. + "oh me", "ohmi", "oh mi", "omee", "o me", "oh-me", // On-device recognition has no keyword list, so it also fronts the vowel // with an aspirate ("Homi what's the weather?", observed live). Deliberately // excludes "homie": it is an ordinary English word, and accepting it as the diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift index bd880e95fe5..55329ef2245 100644 --- a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -52,7 +52,7 @@ final class WakeWordSegmentParserTests: XCTestCase { XCTAssertEqual( WakeWordSegmentParser.command(after: "Oh me, how are you?", wakePhrase: "Omi"), "how are you?") - for rendering in ["Omni", "Ohmi", "Oh mi", "Omee", "O me"] { + for rendering in ["Omie", "Omni", "Ohmi", "Oh mi", "Omee", "O me"] { XCTAssertEqual( WakeWordSegmentParser.command(after: "\(rendering), order pizza", wakePhrase: "Omi"), "order pizza", @@ -60,6 +60,18 @@ final class WakeWordSegmentParserTests: XCTestCase { } } + /// The backend hardware-transcript scan found `omie` and `omni` in real data. + /// Greeting-prefixed forms should work even when the recognizer omits punctuation. + func testAcceptsEvidenceBackedHardwareTranscriptVariants() { + for rendering in ["Omie", "Omni"] { + XCTAssertEqual( + WakeWordSegmentParser.command( + after: "Hey \(rendering) order pizza", wakePhrase: "Omi"), + "order pizza", + "expected Hey \(rendering) to be treated as the wake word") + } + } + /// Review feedback on #11801: with "oh me" accepted as the wake phrase, an ordinary /// sentence like "oh me and my friend went hiking" parsed to the 2-word command /// "and my friend went hiking" and auto-sent it. The homophones are the recognizer @@ -71,6 +83,8 @@ final class WakeWordSegmentParserTests: XCTestCase { "oh me and my friend went hiking", "o me it has been a long day", "oh me I forgot to reply", + "omie is the spelling in this transcript", + "omni is a word people use in ordinary speech", ] { XCTAssertNil( WakeWordSegmentParser.command(after: sentence, wakePhrase: "Omi"), From 12a7a34abcffb1ced7b8d90c0d84556dd7abc798 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 23 Aug 2026 14:57:52 +0530 Subject: [PATCH 14/30] fix(desktop): end an on-device transcription window when the speaker pauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake word's latency was not the cloud ambient lane. On Apple Silicon `STTSessionState.resolveMode` picks the on-device Parakeet path by default, and `LocalTranscriptionService` only closed a window on a fixed 10s boundary — so a spoken command waited for wherever it happened to land in that window. Measured live on a MacBook Air, real microphone, identical utterances: before 1.08s / 6.18s / 7.65s (~5s expected, ~10s worst case) after 0.66s / 0.76s / 0.82s / 0.89s / 0.89s / 1.04s Three changes, all in the on-device path: - A window closes early once the buffer holds a second of voiced audio followed by 0.6s of quiet. The minimum is measured in voiced samples, not buffer length: a mostly-quiet window with one blip in it is what Parakeet answers with a hallucinated word (a 1.1s window at rms 0.0067 decoded to "Yeah."). - Silence ahead of the first speech is dropped, advancing the emitted-seconds cursor over it so absolute timestamps stay exact. Otherwise quiet counted against the 10s cap and the window filled partway through the next sentence — observed cutting "Omi, what time is it now" down to "Now". - The pump ticks at 0.5s rather than 1s, since it is now the floor on how soon a finished utterance can be transcribed rather than a poll for a full window. The 10s cap is unchanged and still bounds continuous speech. Verification: - xcrun swift test --package-path Desktop --filter 'LocalTranscriptionEndpointTests|LocalTranscriptionDuplicatePolicyTests|WakeWord' -> 42 tests, 0 failures - Eight timed utterances through the real microphone on a named dev bundle, wake word enabled, on-device Parakeet: every one dispatched, full text, in 0.66-1.04s from the end of speech. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../Sources/LocalTranscriptionService.swift | 96 +++++++++++++++-- .../LocalTranscriptionEndpointTests.swift | 100 ++++++++++++++++++ ...local-transcription-pause-endpointing.json | 3 + 3 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 desktop/macos/Desktop/Tests/LocalTranscriptionEndpointTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json diff --git a/desktop/macos/Desktop/Sources/LocalTranscriptionService.swift b/desktop/macos/Desktop/Sources/LocalTranscriptionService.swift index 5753af89836..55c5a1d9584 100644 --- a/desktop/macos/Desktop/Sources/LocalTranscriptionService.swift +++ b/desktop/macos/Desktop/Sources/LocalTranscriptionService.swift @@ -54,9 +54,22 @@ final class LocalTranscriptionService: @unchecked Sendable { private let speakerLabel: String private let speakerId: Int private let sampleRate = 16000 - /// Window length transcribed at a time. Not real-time — gives a ~10 s "lag" like the user wants. + /// Longest stretch transcribed at once. A window also closes early when the speaker + /// pauses — see `silenceTailSeconds` — so this is the ceiling, not the cadence. private let windowSeconds = 10.0 private var windowSamples: Int { Int(Double(sampleRate) * windowSeconds) } + /// Trailing quiet that ends a window early. Without it a window only closes on the fixed + /// 10 s boundary, so a short spoken command waits for wherever it happens to land in that + /// window — measured live at 1.1 s / 6.2 s / 7.7 s for three identical utterances, i.e. ~5 s + /// expected and ~10 s worst case. That is the whole of the wake word's latency on Apple + /// Silicon, where `STTSessionState.resolveMode` picks this on-device path by default. + private let silenceTailSeconds = 0.6 + /// Speech must be at least this long before a pause can close the window, so ordinary + /// room noise blips don't emit fragments. Also keeps every emitted window ≥ 1 s, which the + /// system-audio music classifier needs for a stable verdict. + private let minUtteranceSeconds = 1.0 + /// Noise floor shared with `drain`'s own silence check. + private static let speechFloor: Float = 0.004 private var asrManager: AsrManager? private var onSegments: SegmentsHandler? @@ -127,7 +140,9 @@ final class LocalTranscriptionService: @unchecked Sendable { pumpTask = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) + // 0.5 s, not 1 s: with pause-closed windows the tick is now the floor on how + // soon a finished utterance can be transcribed, not just a poll for a full window. + try? await Task.sleep(nanoseconds: 500_000_000) await self?.drain(force: false) } } @@ -189,11 +204,29 @@ final class LocalTranscriptionService: @unchecked Sendable { guard let snapshot = lock.withLock({ guard isReady, let manager = asrManager, !isFlushing else { return nil as DrainSnapshot? } + // Drop silence sitting ahead of the first speech, advancing the cursor over it so + // absolute timestamps stay exact. Otherwise quiet counts against the 10 s cap and + // the window fills partway through the next sentence — observed live cutting + // "Omi, what time is it now" down to "Now". Now the cap can only be reached by + // 10 s of continuous talking, where a cut is unavoidable anyway. + let lead = Self.leadingSilenceSamples(buffer, chunk: sampleRate / 10, keep: 2) + if lead > 0 { + buffer.removeFirst(lead) + emittedSeconds += Double(lead) / Double(sampleRate) + } let available = buffer.count - // On force (stop/finish) flush whatever is left, even a sub-window tail; otherwise wait for a full window. - let ready = available >= windowSamples || (force && available > 0) + // Three ways a window closes: it filled, the speaker paused, or the session is + // stopping. On force (stop/finish) flush whatever is left, even a sub-window tail. + let endpointed = Self.isEndpointed( + buffer, + tailSamples: Int(Double(sampleRate) * silenceTailSeconds), + minSamples: Int(Double(sampleRate) * minUtteranceSeconds) + ) + let ready = available >= windowSamples || endpointed || (force && available > 0) guard ready else { return nil } - let take = force ? available : windowSamples + // A pause-closed window takes the whole buffer: the boundary is the silence itself, + // so leaving a remainder would just split the next utterance at an arbitrary point. + let take = (force || endpointed) ? available : windowSamples let window = Array(buffer.prefix(take)) buffer.removeFirst(take) let startSec = emittedSeconds @@ -212,8 +245,8 @@ final class LocalTranscriptionService: @unchecked Sendable { // speaker playback and ate real (quieter) microphone speech — users saw "nothing // transcribed". A low floor lets normal mic speech through; hallucinations on near-silence // are filtered below by the model's own confidence score instead. - let rms = (snapshot.window.reduce(Float(0)) { $0 + $1 * $1 } / Float(snapshot.window.count)).squareRoot() - guard rms > 0.004 else { return } + let rms = Self.rms(snapshot.window) + guard rms > Self.speechFloor else { return } // Music/video gate: don't turn songs, TV, or videos playing through *system audio* into // "conversations" — only real conversations/calls should be transcribed. Applied to the @@ -270,6 +303,55 @@ final class LocalTranscriptionService: @unchecked Sendable { } } + static func rms(_ samples: ArraySlice) -> Float { + guard !samples.isEmpty else { return 0 } + return (samples.reduce(Float(0)) { $0 + $1 * $1 } / Float(samples.count)).squareRoot() + } + + static func rms(_ samples: [Float]) -> Float { rms(samples[...]) } + + /// True when the buffer holds a real utterance followed by `tailSamples` of quiet — the + /// speaker finished, so the window can close now instead of at the next fixed boundary. + /// + /// `minSamples` is measured in *voiced* audio, not buffer length. Whole-buffer RMS would + /// admit a window that is mostly quiet with one blip in it, and Parakeet answers those with + /// a hallucinated word: a 1.1 s window at rms 0.0067 decoded to "Yeah." live. Requiring a + /// second of actual speech separates that cleanly — real commands carried 2.4–2.9 s of it. + static func isEndpointed(_ buffer: [Float], tailSamples: Int, minSamples: Int) -> Bool { + guard tailSamples > 0, buffer.count > tailSamples else { return false } + let split = buffer.count - tailSamples + guard rms(buffer[split...]) <= speechFloor else { return false } + return voicedSamples(buffer[..= minSamples + } + + /// Total audio above the noise floor, counted in `chunk`-sized steps. + static func voicedSamples(_ samples: ArraySlice, chunk: Int) -> Int { + guard chunk > 0 else { return 0 } + var voiced = 0 + var index = samples.startIndex + while index + chunk <= samples.endIndex { + if rms(samples[index..<(index + chunk)]) > speechFloor { voiced += chunk } + index += chunk + } + return voiced + } + + /// Samples of silence at the head of the buffer, scanned in `chunk`-sized steps, leaving + /// `keep` chunks of lead-in so the window never starts flush against the first phoneme. + /// Returns 0 when the buffer opens with speech, and stops at the first speech chunk — a + /// pause *between* utterances is never trimmed, only quiet before any of them. + static func leadingSilenceSamples(_ buffer: [Float], chunk: Int, keep: Int) -> Int { + guard chunk > 0 else { return 0 } + var silent = 0 + var index = 0 + while index + chunk <= buffer.count { + if rms(buffer[index..<(index + chunk)]) > speechFloor { break } + silent += 1 + index += chunk + } + return max(0, silent - keep) * chunk + } + /// Classify a 16 kHz mono window as music/singing (vs speech) using Apple's on-device /// SoundAnalysis. Returns true → caller skips transcribing it. Fails *open* (returns false) on /// any error or on macOS < 12, so audio is never silently dropped when classification is unsure. diff --git a/desktop/macos/Desktop/Tests/LocalTranscriptionEndpointTests.swift b/desktop/macos/Desktop/Tests/LocalTranscriptionEndpointTests.swift new file mode 100644 index 00000000000..6299ed63ada --- /dev/null +++ b/desktop/macos/Desktop/Tests/LocalTranscriptionEndpointTests.swift @@ -0,0 +1,100 @@ +import XCTest + +@testable import Omi_Computer + +/// Pins the pause detection that closes an on-device transcription window early. +/// +/// Before it, a window only closed on the fixed 10 s boundary, so a spoken command waited +/// for wherever it landed in that window — measured live at 1.1 s, 6.2 s and 7.7 s for three +/// identical utterances. That was the wake word's entire latency on Apple Silicon. +final class LocalTranscriptionEndpointTests: XCTestCase { + private let sampleRate = 16000 + private let tailSamples = 9600 // 0.6s + private let minSamples = 16000 // 1.0s + + private func speech(_ seconds: Double, level: Float = 0.05) -> [Float] { + // Alternating sign keeps the mean near zero while holding RMS at `level`. + (0.. [Float] { + [Float](repeating: 0, count: Int(Double(sampleRate) * seconds)) + } + + private func isEndpointed(_ buffer: [Float]) -> Bool { + LocalTranscriptionService.isEndpointed(buffer, tailSamples: tailSamples, minSamples: minSamples) + } + + func testSpeechFollowedByAPauseClosesTheWindow() { + XCTAssertTrue(isEndpointed(speech(2.0) + silence(0.7))) + } + + func testSpeechStillRunningDoesNotClose() { + XCTAssertFalse(isEndpointed(speech(2.7))) + } + + func testPauseShorterThanTheTailDoesNotClose() { + XCTAssertFalse(isEndpointed(speech(2.0) + silence(0.3))) + } + + /// A buffer of pure silence must not drain: it would spend an inference on nothing and + /// advance the emitted-seconds cursor past audio no one spoke. + func testSilenceAloneDoesNotClose() { + XCTAssertFalse(isEndpointed(silence(5.0))) + } + + /// A blip shorter than the minimum utterance is noise, not a command. + func testTooShortToBeAnUtteranceDoesNotClose() { + XCTAssertFalse(isEndpointed(speech(0.2) + silence(0.7))) + } + + /// The minimum is a second of *voiced* audio, not a second of buffer. A long mostly-quiet + /// window with one blip in it is what Parakeet answers with a hallucinated word — live, a + /// 1.1 s window at rms 0.0067 came back "Yeah." + func testBlipInAMostlyQuietWindowDoesNotClose() { + XCTAssertFalse(isEndpointed(silence(2.0) + speech(0.3) + silence(2.0) + silence(0.7))) + } + + /// Speech broken by short gaps still adds up to an utterance. + func testVoicedAudioAccumulatesAcrossShortGaps() { + XCTAssertTrue(isEndpointed(speech(0.6) + silence(0.2) + speech(0.6) + silence(0.7))) + } + + /// Room tone sits under the noise floor `drain` already uses, so it reads as a pause + /// rather than holding the window open until the 10 s boundary. + func testRoomToneUnderTheNoiseFloorCountsAsAPause() { + XCTAssertTrue(isEndpointed(speech(2.0) + speech(0.7, level: 0.001))) + } + + func testEmptyBufferDoesNotClose() { + XCTAssertFalse(isEndpointed([])) + } + + // MARK: - Leading-silence trim + + private func leadingSilence(_ buffer: [Float]) -> Int { + LocalTranscriptionService.leadingSilenceSamples(buffer, chunk: sampleRate / 10, keep: 2) + } + + /// Quiet ahead of the first speech is dropped, less two chunks of lead-in, so the 10 s cap + /// is spent on speech instead of filling partway through a sentence. + func testSilenceBeforeSpeechIsTrimmedWithLeadIn() { + XCTAssertEqual(leadingSilence(silence(3.0) + speech(2.0)), (30 - 2) * (sampleRate / 10)) + } + + func testBufferOpeningWithSpeechIsNotTrimmed() { + XCTAssertEqual(leadingSilence(speech(2.0)), 0) + } + + /// Less lead-in than we keep means nothing to trim — never clip the first phoneme. + func testSilenceShorterThanTheLeadInIsKept() { + XCTAssertEqual(leadingSilence(silence(0.15) + speech(2.0)), 0) + } + + /// Only quiet *before* any speech is trimmed; the scan stops at the first speech chunk, so + /// a pause between two utterances stays in the window. + func testPauseBetweenUtterancesIsNotTrimmed() { + let buffer = speech(1.0) + silence(2.0) + speech(1.0) + XCTAssertEqual(leadingSilence(buffer), 0) + } +} diff --git a/desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json b/desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json new file mode 100644 index 00000000000..bf949dd1c5c --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json @@ -0,0 +1,3 @@ +{ + "change": "On-device transcription now ends a window when you stop talking instead of on a fixed 10-second boundary, so what you say is transcribed about a second later" +} From 8e43b1529f101da7c3cf26ce4f762fb626966fd5 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 23 Aug 2026 15:37:13 +0530 Subject: [PATCH 15/30] feat(desktop): speak the wake word's answer and keep the conversation going MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a hands-free trigger needs that it did not have: the answer came back as text only, and every command opened a fresh conversation. Both came from one boundary being drawn in the wrong place. `openAIInputWithQuery` selected the `.voiceOnly` surface on `fromVoice`, and that surface requires a `VoiceTurnID` — so a voice query that owns no turn was dropped with no diagnostic, which is why the wake word had to pass `fromVoice: false` and therefore stayed silent. Selecting `.voiceOnly` on `voiceTurnID` instead says what the branch actually means. Push-to-talk owns a turn and is unchanged; the wake word falls through to the visible surface, where `fromVoice` still marks it a voice query and the answer is spoken. `sendFollowUpQuery` had the same guard and now applies the turn-optional rule `routeQuery` already uses, so a second command continues the conversation. `WakeWordService` dispatches through the new `submitSpokenCommand`, which is both: spoken answer, conversation continuity. Speaking the answer creates a feedback path that did not exist before — the microphone hears Omi and it lands in the transcript as the user's own speech. Observed live: `Transcript [ADD] Speaker 0: It's 8 57 p.m. on Sunday...` was Omi. An answer carrying the wake phrase would command the assistant with its own words, so the trigger is suppressed while playback is active. That is not a wall in front of the user: `VoiceBargeInPolicy` halts playback the moment they speak, so `isSpeaking` is already false when their transcript arrives. `handleBackendSegments` now reads `isSpeaking` once, before the barge-in check. Reading it afterwards reports false for the very segment that stopped playback. Verification: - xcrun swift test --package-path Desktop --filter 'WakeWord|LocalTranscriptionEndpoint|VoiceBargeIn|LocalTranscriptionDuplicate' -> 51 tests, 0 failures - Live on a named dev bundle, real microphone, on-device Parakeet: "Omi, what time is it now" -> answered aloud (the mic transcribed Omi's own reply back: "It's 8 57 p.m. on Sunday, August 23rd, 2026, in India, IST"), then "Omi, and what about tomorrow" -> "Tomorrow is Monday, August 24, 2026". A bare anaphoric follow-up resolved against the previous answer, and the telemetry surface moved from floating_text to floating_voice with a tts_start stage in both traces. - A six-turn spoken session ordering food held one conversation throughout (cache_read 29953 -> 40968 -> 52003). Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../AppState/AppState+ListenEvents.swift | 9 +++-- .../FloatingControlBarWindow.swift | 34 ++++++++++++++----- .../Sources/WakeWord/WakeWordService.swift | 29 +++++++++++----- .../Desktop/Tests/WakeWordServiceTests.swift | 24 +++++++++++++ .../20260823-wake-word-spoken-answers.json | 3 ++ 5 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 desktop/macos/changelog/unreleased/20260823-wake-word-spoken-answers.json diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift index 99d3f957d8a..bdacb9b5ac5 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift @@ -14,13 +14,18 @@ extension AppState { // Extract speaker_id from backend (e.g. "SPEAKER_00" → 0) let speakerId = segment.speaker_id ?? 0 + // Read once, before barge-in can clear it. Barge-in halts playback, so anything + // downstream that asks "was Omi speaking?" would be told no by the very segment + // that stopped it — including Omi's own voice arriving back through the microphone. + let wasSpeakingAnswer = FloatingBarVoicePlaybackService.shared.isSpeaking + // Barge-in interruption: if the user speaks while voice playback is active, // halt playback immediately so Omi never talks over the user. if VoiceBargeInPolicy.shouldInterrupt( isUser: segment.is_user, speaker: speakerId, text: segment.text, - isSpeaking: FloatingBarVoicePlaybackService.shared.isSpeaking + isSpeaking: wasSpeakingAnswer ) { log("Transcription [BARGE-IN]: User spoke mid-playback; interrupting voice output") FloatingBarVoicePlaybackService.shared.interruptCurrentResponse() @@ -41,7 +46,7 @@ extension AppState { translations: translations ) - WakeWordService.shared.observe(newSeg) + WakeWordService.shared.observe(newSeg, isSpeakingAnswer: wasSpeakingAnswer) // Upsert: if we already have a segment with this ID, update it; otherwise append if let segId = segment.id, diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index ebc643a046b..f32f0b1807f 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -3688,7 +3688,7 @@ class FloatingControlBarManager { window.orderFrontRegardless() } - /// Open AI input with a pre-filled query and auto-send (used by PTT). + /// Open AI input with a pre-filled query and auto-send (used by PTT and the wake word). func openAIInputWithQuery( _ query: String, fromVoice: Bool = false, @@ -3697,9 +3697,14 @@ class FloatingControlBarManager { guard let window = window else { return } guard let provider = activeFloatingProvider() else { return } - if fromVoice { - guard let voiceTurnID, - VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil + // The `.voiceOnly` surface belongs to callers that own a turn: it renders nothing, and + // every step below re-checks ownership, so entering it without a turn drops the query + // with no diagnostic. Selecting it on `voiceTurnID` rather than `fromVoice` says that + // directly. Push-to-talk owns a turn and is unchanged. A wake word arrives already + // transcribed and owns none, so it falls through to the visible path — where + // `fromVoice` still marks it a voice query, which is what makes the answer spoken. + if let voiceTurnID { + guard VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil else { return } chatCancellable?.cancel() chatCancellable = nil @@ -4021,16 +4026,27 @@ class FloatingControlBarManager { } /// Send a follow-up query in the existing AI conversation (used by PTT follow-up). + /// Submit a hands-free spoken command: the answer is rendered in the bar *and* spoken, + /// and a second command continues the same conversation instead of opening a new one. + /// + /// Separate from push-to-talk, which presents `.voiceOnly` and owns a `VoiceTurnID` for + /// the whole recording lifecycle. A wake word owns no turn — the words were already + /// transcribed by the time it fires — so it uses the visible surface. + func submitSpokenCommand(_ command: String) { + sendFollowUpQuery(command, fromVoice: true) + } + func sendFollowUpQuery( _ query: String, fromVoice: Bool = false, voiceTurnID: VoiceTurnID? = nil ) { - if fromVoice { - guard let voiceTurnID, - VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil - else { return } - } + // A turn that is supplied must be current; no turn is nothing to validate. Same rule + // `routeQuery` and `dispatchChatQuery` already apply. Requiring one whenever + // `fromVoice` was set is what made a wake word's spoken query vanish silently. + guard + voiceTurnID.map({ VoiceTurnCoordinator.shared.requireCurrentOwner(for: $0) != nil }) ?? true + else { return } guard let window = window, window.state.showingAIResponse else { // No active conversation — fall back to new conversation openAIInputWithQuery(query, fromVoice: fromVoice, voiceTurnID: voiceTurnID) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index 0eaea01df80..f86dcd55c5c 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -16,13 +16,12 @@ final class WakeWordService { var now: @MainActor () -> Date = { Date() } var onTrigger: @MainActor (String) -> Void = { command in log("WakeWord: submitting '\(command)' to the assistant") - // `fromVoice: true` is reserved for callers that own a voice-turn lifecycle: - // `openAIInputWithQuery` guards it behind `voiceTurnID` + - // `VoiceTurnCoordinator.requireCurrentOwner` and returns silently when either is - // missing, so passing it without a turn drops the query with no diagnostic. - // Push-to-talk owns a turn and passes both; the wake word, like the automation - // bridge, submits an already-transcribed command and owns no turn. - FloatingControlBarManager.shared.openAIInputWithQuery(command, fromVoice: false) + // Hands-free means the answer has to come back the same way the command went out, and + // that a second command continues the conversation rather than starting over. + // `submitSpokenCommand` is that entry point. It is not the push-to-talk path: that one + // presents `.voiceOnly` and owns a `VoiceTurnID` for the whole recording lifecycle, + // while a wake word owns no turn — the words were already transcribed when it fired. + FloatingControlBarManager.shared.submitSpokenCommand(command) } private(set) var lastTriggeredCommand: String? @@ -30,7 +29,8 @@ final class WakeWordService { func observe( _ segment: SpeakerSegment, - isConversationActive: Bool = WakeWordService.defaultIsConversationActive() + isConversationActive: Bool = WakeWordService.defaultIsConversationActive(), + isSpeakingAnswer: Bool = WakeWordService.defaultIsSpeakingAnswer() ) { let parsed = WakeWordSegmentParser.command( after: segment.text, @@ -45,6 +45,15 @@ final class WakeWordService { guard AssistantSettings.shared.wakeWordEnabled else { return ignore("disabled in settings") } guard !isConversationActive else { return ignore("assistant already busy") } + // Now that the answer is spoken aloud, the microphone hears it and it lands in the + // transcript as the user's own speech — observed live: `Transcript [ADD] Speaker 0: + // It's 8 57 p.m. on Sunday...` was Omi, not a person. An answer containing the wake + // phrase would command the assistant with its own words, indefinitely. + // + // This is not a wall in front of the user: `VoiceBargeInPolicy` halts playback the + // moment the user actually speaks, so `isSpeaking` is already false by the time their + // transcript arrives. Only Omi's own voice is caught here. + guard !isSpeakingAnswer else { return ignore("assistant is speaking its answer") } // Diarization only sets `isUser` once a speech profile is enrolled, so requiring // it alone makes the wake word silently dead for every user who has not enrolled // one. Speaker 0 is the primary user everywhere else in this feature set — @@ -99,6 +108,10 @@ final class WakeWordService { text.split(whereSeparator: \.isWhitespace).count } + static func defaultIsSpeakingAnswer() -> Bool { + FloatingBarVoicePlaybackService.shared.isSpeaking + } + static func defaultIsConversationActive() -> Bool { if let provider = ChatProvider.mainInstance, provider.isSending { return true } if VoiceTurnCoordinator.shared.activeTurnID != nil { return true } diff --git a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift index ca9cdcd509c..1ffbe807e74 100644 --- a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift @@ -92,6 +92,30 @@ final class WakeWordServiceTests: XCTestCase { XCTAssertTrue(triggered.isEmpty) } + /// Regression: the answer is spoken aloud, the microphone hears it, and it arrives as + /// the user's own speech — live, `Transcript [ADD] Speaker 0: It's 8 57 p.m. on + /// Sunday...` was Omi. An answer carrying the wake phrase would command the assistant + /// with its own words, indefinitely. + func testAssistantsOwnSpokenAnswerCannotRetrigger() { + configureService() + service.observe( + userSegment("Omi, I can help you order food", id: "a"), + isConversationActive: false, + isSpeakingAnswer: true) + XCTAssertTrue(triggered.isEmpty) + } + + /// The playback guard is not a wall in front of the user: `VoiceBargeInPolicy` halts + /// playback the moment they speak, so their transcript arrives with it already clear. + func testUserCommandFiresOncePlaybackHasBeenInterrupted() { + configureService() + service.observe( + userSegment("Omi, let's order food", id: "a"), + isConversationActive: false, + isSpeakingAnswer: false) + XCTAssertEqual(triggered, ["let's order food"]) + } + func testDeduplicatesBySegmentID() { configureService() service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) diff --git a/desktop/macos/changelog/unreleased/20260823-wake-word-spoken-answers.json b/desktop/macos/changelog/unreleased/20260823-wake-word-spoken-answers.json new file mode 100644 index 00000000000..020199b5c81 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260823-wake-word-spoken-answers.json @@ -0,0 +1,3 @@ +{ + "change": "The wake word now answers out loud, and a second command continues the same conversation instead of starting a new one" +} From 46bc2e8ed7d85bf809873b9640f8e46ed843021b Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 23 Aug 2026 16:13:22 +0530 Subject: [PATCH 16/30] fix(desktop): stop ambient capture treating Omi's own voice as the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Omi speaks into a room Omi is also recording. Ambient capture returns the playback as a transcript segment attributed to the primary speaker — observed live, `Transcript [ADD] Speaker 0: It's 8 57 p.m. on Sunday...` was Omi, not a person. Four consumers then act on it as if the user had spoken: - barge-in halts the very playback that produced it. Three times in one six-turn session, which is why a long answer stops partway: "I can help, but I need two details first. 1. Maya's delivery address" and no item 2. - the wake word can be commanded by an answer carrying the wake phrase - the conversation record gains speech nobody said - memory extraction reads it One guard at ingest, where all four route through, rather than four guards. Dropping the whole segment is not enough. While Omi is talking there is no pause to close the transcription window on, so a barge-in lands inside the same window as the playback — measured: `1. Ganges Ganga, India's most sacred river, and a vital water source. Omi, stop that and tell me the time is.` One 9-second window, two speakers. Discarding it would eat the interruption, which is the one utterance that must never be lost. `VoicePlaybackEchoPolicy` consumes the playback off both ends and keeps what the user said in between, sliced from the original string so the punctuation the wake-word parser reads survives. Matching is loose about wording and strict about length, because speech-to-text does not return what the synthesiser was handed: "Omi open my tasks now" came back as "Only open my tasks now", "8:57 PM" as "8 57 p.m.", and a numbered list goes out as "1." and returns as "One." It errs toward not-echo throughout — a missed echo costs what the app already does today; a false echo deletes something the user said. Verification: - xcrun swift test --package-path Desktop --filter 'VoicePlaybackEcho|WakeWord|LocalTranscription|VoiceBargeIn' -> 68 tests, 0 failures. Every echo string in the new suite was captured live. - Full desktop suite: 5790 tests, 2 failures, both pre-existing order dependence unrelated to this change (FloatingBarNotificationPreviewPolicy, RewindCaptureExclusionGeneration) — they fail identically on the merge base under the same filter and pass in isolation. - Live, named dev bundle, real microphone, wake word answering aloud: "Omi, list five major cities in India with one short fact each" dropped seven echo segments across both the microphone and system-audio channels, logged zero barge-ins where the previous build logged three, and spoke all five cities through to "Five, Chennai, a key center for automobiles, culture, and South Indian cinema" instead of stopping at the first. - Speaking over a live answer still interrupts it and still dispatches: `WakeWord: submitting 'stop that and tell me the time instead.'` with `Transcription [BARGE-IN]` on the same segment. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../AppState/AppState+ListenEvents.swift | 27 ++- .../FloatingBarVoicePlaybackService.swift | 28 +++ .../VoicePlaybackEchoPolicy.swift | 153 +++++++++++++++ .../Sources/TranscriptionService.swift | 5 +- .../Tests/VoicePlaybackEchoPolicyTests.swift | 184 ++++++++++++++++++ .../20260823-playback-echo-suppression.json | 3 + .../macos/e2e/flows/capture-lifecycle.yaml | 1 + 7 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift create mode 100644 desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260823-playback-echo-suppression.json diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift index bdacb9b5ac5..936f1f80e68 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift @@ -14,9 +14,34 @@ extension AppState { // Extract speaker_id from backend (e.g. "SPEAKER_00" → 0) let speakerId = segment.speaker_id ?? 0 + // Omi speaks into a room Omi is also recording, so ambient capture returns the + // assistant's own voice attributed to the primary speaker. Every consumer below + // then acts on it as if a person had spoken: barge-in halts the very playback that + // produced it (observed live, three times in one six-turn session, which is why a + // long answer stops partway), the wake word can be commanded by an answer carrying + // the wake phrase, and the conversation record and memory extraction gain speech + // nobody said. One guard here, where all of them route through. + var segment = segment + switch VoicePlaybackEchoPolicy.classify( + transcript: segment.text, + spokenWords: FloatingBarVoicePlaybackService.shared.recentlySpokenWords + ) { + case .keep: + break + case .drop: + log("Transcription [ECHO]: dropped Omi's own playback heard back: \(segment.text.prefix(60))") + continue + case .keepResidue(let spoken): + // The user talked over the end of the playback. There is no pause to close the + // window on while Omi is speaking, so both land in one segment; keeping only the + // part Omi did not say is what lets a barge-in survive. + log("Transcription [ECHO]: kept the user's words from a segment Omi spoke over: \(spoken.prefix(60))") + segment.text = spoken + } + // Read once, before barge-in can clear it. Barge-in halts playback, so anything // downstream that asks "was Omi speaking?" would be told no by the very segment - // that stopped it — including Omi's own voice arriving back through the microphone. + // that stopped it. let wasSpeakingAnswer = FloatingBarVoicePlaybackService.shared.isSpeaking // Barge-in interruption: if the user speaks while voice playback is active, diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift index 0e1c95f7a52..89396b5d12b 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift @@ -80,6 +80,32 @@ final class FloatingBarVoicePlaybackService: NSObject, AVAudioPlayerDelegate, AV private var activeSystemSpeechToken: SystemSpeechToken? private var activePTTLease: VoiceOutputLease? + /// What this service has recently said, so ambient capture can recognise Omi's own voice + /// coming back through the microphone instead of treating it as the user + /// (`VoicePlaybackEchoPolicy`). Kept for a window *after* playback ends, because the + /// transcript of the last words arrives about a second behind the audio. + private var spokenWordHistory: [String] = [] + private var lastSpokeAt: Date? + private static let spokenHistoryWordCap = 300 + private static let spokenHistoryLifetime: TimeInterval = 20 + + /// Recent playback text, or nothing once it is too old to explain an incoming segment. + var recentlySpokenWords: [String] { + guard let lastSpokeAt, Date().timeIntervalSince(lastSpokeAt) <= Self.spokenHistoryLifetime + else { return [] } + return spokenWordHistory + } + + private func recordSpokenText(_ text: String) { + let words = VoicePlaybackEchoPolicy.words(text) + guard !words.isEmpty else { return } + lastSpokeAt = Date() + spokenWordHistory.append(contentsOf: words) + if spokenWordHistory.count > Self.spokenHistoryWordCap { + spokenWordHistory.removeFirst(spokenWordHistory.count - Self.spokenHistoryWordCap) + } + } + /// QueryTracer for the in-flight query, handed in by the floating-bar window. /// Used to bracket the `tts_start` span (first real chunk → first audio out). var tracer: QueryTracer? @@ -554,6 +580,7 @@ final class FloatingBarVoicePlaybackService: NSObject, AVAudioPlayerDelegate, AV } audioPlayer = player activePlayerFallbackText = fallbackText + recordSpokenText(fallbackText) tracer?.end("tts_start") } catch { // Don't drop the reply silently — speak this chunk with the system voice instead. @@ -675,6 +702,7 @@ final class FloatingBarVoicePlaybackService: NSObject, AVAudioPlayerDelegate, AV leaseID: activePTTLease?.id, utterance: utterance) speechSynthesizer.speak(utterance) + recordSpokenText(text) tracer?.end("tts_start") } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift new file mode 100644 index 00000000000..baec9bf141f --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift @@ -0,0 +1,153 @@ +import Foundation + +enum VoicePlaybackEchoDecision: Equatable { + /// Nothing to do with playback — ordinary speech. + case keep + /// Entirely the assistant's own voice. + case drop + /// Playback with the user talking over the end of it. Carries the user's words alone. + case keepResidue(String) +} + +/// Pure policy separating the assistant's own spoken output — heard back through the +/// microphone — from what a person actually said. +/// +/// Omi speaks into a room Omi is also recording. Ambient capture returns the playback as a +/// transcript segment attributed to the primary speaker; observed live, +/// `Transcript [ADD] Speaker 0: It's 8 57 p.m. on Sunday...` was Omi, not a person. Four +/// consumers then act on it as if the user had spoken: barge-in halts the very playback +/// that produced it (three times in one six-turn session, which is why a long answer stops +/// partway), the wake word can be commanded by an answer carrying the wake phrase, and the +/// conversation record and memory extraction gain speech nobody said. +/// +/// Dropping the whole segment is not enough. While Omi is talking there is no pause to +/// close the transcription window on, so a barge-in lands *inside* the same window as the +/// playback — measured: `1. Ganges Ganga, India's most sacred river, and a vital water +/// source. Omi, stop that and tell me the time is.` One 9-second window, two speakers. +/// Discarding it would eat the interruption, which is the one utterance that must never be +/// lost. So the match is consumed as a prefix and whatever the user said after it survives. +/// +/// A prefix rather than a scattered match because that is the physical situation: the user +/// starts talking after Omi has already been talking. It is also far safer — deleting +/// matched words wherever they occur would shred an interruption against the common words +/// ("the", "and", "a") in several sentences of playback history. +/// +/// Errs toward *not* echo throughout. A missed echo costs what the app already does today; +/// a false echo deletes something the user said. +enum VoicePlaybackEchoPolicy { + /// Below this, an utterance is too generic to attribute. "Yes", "okay" and "sure" all + /// appear in the assistant's own speech and in ordinary conversation. Applied to the + /// matched run and to the surviving residue alike. + static let minimumWordCount = 4 + + /// How far ahead in the playback history a word may be found and still continue the run. + /// Absorbs words speech-to-text drops or splits — "8:57 PM" came back as "8 57 p.m." + static let alignmentLookahead = 5 + + /// Consecutive unmatched words that end the run. + /// + /// Four, not fewer, because speech-to-text produces mismatch runs *inside* an echo as + /// well as at its edge: "9:29 PM" came back as "929 p.m.", three unmatched tokens two + /// words into the answer. At three the walk stopped there and the rest of Omi's own + /// sentence read as a barge-in and reached the transcript. Raising it is close to free — + /// unmatched words never advance the split point, so the user's words are kept whether + /// the walk stops at them or scans past them. + static let mismatchesEndingEcho = 4 + + static func classify(transcript: String, spokenWords: [String]) -> VoicePlaybackEchoDecision { + let tokens = self.tokens(transcript) + guard !tokens.isEmpty, !spokenWords.isEmpty else { return .keep } + let incoming = tokens.map(\.word) + + let leading = matchedPrefixLength(incoming, against: spokenWords) + guard leading >= minimumWordCount else { return .keep } + guard tokens.count - leading >= minimumWordCount else { return .drop } + + // Playback continues past the interruption, so it bookends the user's words as often + // as it precedes them — measured, the same sentence appeared on both sides of a + // barge-in in one window. Strip the far end the same way. + let remaining = Array(incoming[leading...]) + let trailing = matchedPrefixLength(remaining.reversed(), against: spokenWords.reversed()) + let end = tokens.count - trailing + guard end - leading >= minimumWordCount else { return .drop } + + // Nothing stripped from the far end means the utterance ends where the segment does, + // so keep its closing punctuation — the wake-word parser reads punctuation. + let upperBound = trailing == 0 ? transcript.endIndex : tokens[end - 1].end + return .keepResidue(String(transcript[tokens[leading].start.. [String] { + tokens(text).map(\.word) + } + + /// Normalized words paired with their bounds in the original string, so a residue can be + /// sliced out with its original casing and punctuation intact. The wake-word parser reads + /// punctuation, so rebuilding from normalized words would change whether a command fires. + private static func tokens(_ text: String) -> [(word: String, start: String.Index, end: String.Index)] { + var result: [(String, String.Index, String.Index)] = [] + var index = text.startIndex + var wordStart: String.Index? + while index < text.endIndex { + let character = text[index] + if character.isLetter || character.isNumber { + if wordStart == nil { wordStart = index } + } else if let start = wordStart { + result.append((normalize(text[start.. String { + let lowered = word.lowercased() + return spokenNumbers[lowered] ?? lowered + } + + /// How many leading words of `incoming` are accounted for by `spoken`, scanning forward + /// only. Returns the length up to — not including — the run of mismatches that ended it. + /// Called with both sequences reversed to measure a trailing run instead. + private static func matchedPrefixLength>( + _ incoming: S, + against spoken: S + ) -> Int { + matchedPrefixLength(Array(incoming), against: Array(spoken)) + } + + private static func matchedPrefixLength(_ incoming: [String], against spoken: [String]) -> Int { + var spokenIndex = 0 + var mismatches = 0 + var lastMatched = 0 + for (offset, word) in incoming.enumerated() { + let limit = min(spokenIndex + alignmentLookahead, spoken.count) + if spokenIndex < limit, let hit = (spokenIndex..= mismatchesEndingEcho { break } + } + return lastMatched + } +} diff --git a/desktop/macos/Desktop/Sources/TranscriptionService.swift b/desktop/macos/Desktop/Sources/TranscriptionService.swift index fb2f1b6f8e0..68a0f779c56 100644 --- a/desktop/macos/Desktop/Sources/TranscriptionService.swift +++ b/desktop/macos/Desktop/Sources/TranscriptionService.swift @@ -43,7 +43,10 @@ class TranscriptionService: @unchecked Sendable { /// Matches `models.transcript_segment.TranscriptSegment` on the backend struct BackendSegment: Decodable { let id: String? - let text: String + /// Mutable so ingest can strip the assistant's own playback off the front of a segment + /// the user talked over (`VoicePlaybackEchoPolicy`) before it reaches the transcript, + /// the wake word, or persistence. + var text: String let speaker: String? // e.g. "SPEAKER_00" let speaker_id: Int? let is_user: Bool diff --git a/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift b/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift new file mode 100644 index 00000000000..643a36fccb3 --- /dev/null +++ b/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift @@ -0,0 +1,184 @@ +import XCTest + +@testable import Omi_Computer + +/// Every echo string below was captured live on a named dev bundle with the wake word +/// answering out loud: the microphone heard Omi and ambient capture returned it as the +/// primary speaker. Before this policy, barge-in halted playback on them — three times in +/// one six-turn session, which is why a long answer stopped partway. +final class VoicePlaybackEchoPolicyTests: XCTestCase { + private func classify(_ transcript: String, spoken: String) -> VoicePlaybackEchoDecision { + VoicePlaybackEchoPolicy.classify( + transcript: transcript, + spokenWords: VoicePlaybackEchoPolicy.words(spoken)) + } + + private func isEcho(_ transcript: String, spoken: String) -> Bool { + classify(transcript, spoken: spoken) == .drop + } + + // MARK: - Real echoes + + /// Speech-to-text does not return what the synthesiser was handed: "8:57 PM" came back + /// as "8 57 p.m." and "August 23" as "August 23rd". + func testAnswerHeardBackWithTimeAndDateRewrittenIsEcho() { + XCTAssertTrue( + isEcho( + "It's 8 57 p.m. on Sunday, August 23rd, 2026, in India, IST.", + spoken: "It's 8:57 PM on Sunday, August 23, 2026, in India, IST.")) + } + + func testAnswerHeardBackWithPunctuationRewrittenIsEcho() { + XCTAssertTrue( + isEcho( + "Sure. What would you like to order? And where should it be delivered?", + spoken: "Sure, what would you like to order, and where should it be delivered?")) + } + + /// The wake phrase inside the assistant's own answer is the loop this closes: without + /// it, Omi commands itself with its own words. + func testAnswerCarryingTheWakePhraseIsEcho() { + XCTAssertTrue( + isEcho( + "Only open my tasks now.", + spoken: "Omi open my tasks now.")) + } + + /// Ambient capture returns a partial window while the rest is still playing. + func testPartialAnswerStillPlayingIsEcho() { + XCTAssertTrue( + isEcho( + "I can help, but I need two details first.", + spoken: "I can help, but I need two details first. 1. Maya's delivery address or saved location.")) + } + + // MARK: - Real speech that must survive + + /// The whole point of barge-in. An interruption shares almost no run of words with what + /// is playing, so it must reach the policy's callers untouched. + func testUserInterruptingWithSomethingElseIsNotEcho() { + XCTAssertFalse( + isEcho( + "actually make it Thai instead please", + spoken: "Sure, what would you like to order, and where should it be delivered?")) + } + + func testUnrelatedConversationIsNotEcho() { + XCTAssertFalse( + isEcho( + "we should leave for the airport around six tomorrow", + spoken: "It's 8:57 PM on Sunday, August 23, 2026, in India, IST.")) + } + + /// Nothing has been spoken recently, so nothing can be an echo of it. Guards the + /// history's expiry: once playback is old enough, the caller passes an empty list. + func testNothingSpokenRecentlyIsNeverEcho() { + XCTAssertEqual( + VoicePlaybackEchoPolicy.classify( + transcript: "It's 8 57 p.m. on Sunday, August 23rd, 2026.", + spokenWords: []), + .keep) + } + + /// Short utterances are too generic to attribute — "yes" and "okay" appear in the + /// assistant's speech and in ordinary conversation alike. A false echo deletes + /// something the user said, so these stay. + func testShortAcknowledgementIsNotEchoEvenWhenItAppearsInPlayback() { + XCTAssertFalse(isEcho("okay sure", spoken: "Okay, sure, I can do that for you right now.")) + } + + /// A command the user issues while Omi happens to be speaking similar words must still + /// get through — the run of shared words is short relative to the utterance. + func testCommandDuringPlaybackWithIncidentalWordOverlapIsNotEcho() { + XCTAssertFalse( + isEcho( + "order pizza from the place near my office instead", + spoken: "Sure, what would you like to order, and where should it be delivered?")) + } + + // MARK: - The user talking over playback + + /// Captured live. While Omi is speaking there is no pause to close the transcription + /// window on, so the barge-in landed inside the same 9-second window as the answer. + /// Dropping the segment would eat the interruption — the one utterance that must never + /// be lost — so the playback is consumed and the user's words survive. + func testBargeInInsideAPlaybackWindowKeepsOnlyTheUsersWords() { + XCTAssertEqual( + classify( + "1. Ganges Ganga, India's most sacred river, and a vital water source. " + + "Omi, stop that and tell me the time is.", + spoken: "1. Ganges (Ganga), India's most sacred river, and a vital water source."), + .keepResidue("Omi, stop that and tell me the time is.")) + } + + /// The residue is sliced from the original string, not rebuilt from normalized words: + /// the wake-word parser requires a punctuation break after a homophone, so losing the + /// comma would silently change whether the command fires. + func testResiduePreservesPunctuationTheWakeWordParserReads() { + guard + case .keepResidue(let residue) = classify( + "I can help, but I need two details first. Oh me, order pizza from Mumbai instead.", + spoken: "I can help, but I need two details first.") + else { + return XCTFail("expected the user's words to survive") + } + XCTAssertEqual(residue, "Oh me, order pizza from Mumbai instead.") + } + + /// A tail too short to be a command is speech-to-text drift at the window edge, not an + /// interruption — observed as a trailing "2." while the next item was still playing. + func testShortTailAfterPlaybackIsStillDropped() { + XCTAssertEqual( + classify( + "1. Mumbai, India's financial capital and home to Bollywood. 2.", + spoken: "1. Mumbai, India's financial capital and home to Bollywood. 2. Delhi, the national capital."), + .drop) + } + + /// Captured live: playback continues past the interruption, so the same sentence + /// appeared on both sides of the user's words in one window. Both ends are stripped. + func testPlaybackOnBothSidesOfTheBargeInIsStripped() { + XCTAssertEqual( + classify( + "One. Ganges, Ganga. India's most sacred river. " + + "Omi, stop that and tell me the time instead. " + + "One. Ganges, Ganga. India's most sacred river, a vital water source.", + spoken: "1. Ganges (Ganga), India's most sacred river, a vital water source."), + .keepResidue("Omi, stop that and tell me the time instead")) + } + + /// Captured live and initially leaked into the transcript: "9:29 PM" came back as + /// "929 p.m.", three unmatched tokens in a row two words into the answer, which ended + /// the forward walk early and left the rest looking like a barge-in. It is not one. + func testEchoSplitEarlyByMisheardDigitsIsStillDropped() { + XCTAssertEqual( + classify( + "It's 929 p.m. on Sunday, August 23rd, 2026, in India, IST.", + spoken: "It's 9:29 PM on Sunday, August 23, 2026, in India, IST."), + .drop) + } + + // MARK: - Word matching + + /// The synthesiser reads "1." aloud as "one" and speech-to-text writes "One." back, so a + /// numbered answer would not match itself without this. + func testSpokenNumbersMatchTheirDigits() { + XCTAssertEqual( + VoicePlaybackEchoPolicy.words("One. Ganges. Two. Yamuna."), + VoicePlaybackEchoPolicy.words("1. Ganges. 2. Yamuna.")) + } + + func testMatchingIgnoresCaseAndPunctuation() { + XCTAssertEqual( + VoicePlaybackEchoPolicy.words("It's 8:57 PM, in India!"), + ["it", "s", "8", "57", "pm", "in", "india"]) + } + + /// Order-preserving: the same words in a different order are not the same sentence. + func testReorderedWordsDoNotCountAsAFullMatch() { + XCTAssertFalse( + isEcho( + "delivered be should where and order", + spoken: "and where should it be delivered, what would you like to order")) + } +} diff --git a/desktop/macos/changelog/unreleased/20260823-playback-echo-suppression.json b/desktop/macos/changelog/unreleased/20260823-playback-echo-suppression.json new file mode 100644 index 00000000000..cc881f13097 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260823-playback-echo-suppression.json @@ -0,0 +1,3 @@ +{ + "change": "Omi no longer hears its own spoken answers as if you had said them, so a long answer plays to the end and the transcript stays yours" +} diff --git a/desktop/macos/e2e/flows/capture-lifecycle.yaml b/desktop/macos/e2e/flows/capture-lifecycle.yaml index 53280609156..2112f162b8e 100644 --- a/desktop/macos/e2e/flows/capture-lifecycle.yaml +++ b/desktop/macos/e2e/flows/capture-lifecycle.yaml @@ -11,6 +11,7 @@ covers: - desktop/macos/Desktop/Sources/AppState/SharedCaptureSilentMicRecoveryPolicy.swift - desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift - desktop/macos/Desktop/Sources/FloatingControlBar/VoiceBargeInPolicy.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift - desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift - desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift - desktop/macos/Desktop/Sources/AppState/LocalTranscriptionDuplicatePolicy.swift From c5f95d9ce52628d845c6afc076c2720c5b9f3231 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 23 Aug 2026 16:35:56 +0530 Subject: [PATCH 17/30] fix(desktop): recognize a wake word spoken after something else, and stop deleting the user's speech MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all from one live session of twelve spoken attempts of which only four fired. The wake phrase had to open the segment. Windows now close on the speaker's pause rather than a fixed boundary, so a segment is no longer one utterance and whatever was said in the same breath shares it: `It's not working man. Omi what time it is?` carried a literal wake phrase and a valid command and matched nothing. The parser now also tries each sentence inside the segment. A sentence boundary, not any position — the phrase has to *open* an utterance, which is the same evidence class the punctuation-break rule already uses and keeps `I told Omi to order food` from parsing to "to order food". Echo suppression deleted something the user said. `Sorry my mistake it's taking` was dropped as playback. Two causes: one shared deadline kept the whole 300-word history alive and refreshed it on every chunk, so several turns of speech stayed matchable at once and a few hundred words of ordinary English align with almost any short sentence; and discarding a segment did not require the match to actually cover it. Words now expire individually after 15s — an echo arrives a second or two behind the audio, so that is all it has to outlive — and a whole-segment drop requires 0.8 coverage, which real echoes measured 0.80–1.00 against. The same eviction also explains a leak in the other direction: Omi's own error copy, "Omi's AI service didn't respond...", was heard back, parsed as a wake word, and submitted as a command, twice. The wake word's own "assistant is speaking" guard is removed. It blocked a real barge-in — `Omi you are not picking my messages now`, spoken while Omi was talking, was refused and had to be repeated. Whether Omi is speaking is not the question; whether *this segment* is Omi is, and `VoicePlaybackEchoPolicy` answers that directly. Verification: - xcrun swift test --package-path Desktop --filter 'WakeWord|VoicePlaybackEcho|LocalTranscription|VoiceBargeIn' -> 70 tests, 0 failures - Live, named dev bundle, real microphone, all four in one session: "It's not working, man. Omi, what is today's weather?" -> `WakeWord: submitting 'what is today's weather?'`; "Sorry my mistake it's taking" reached the transcript instead of being dropped; the spoken time answer was dropped as echo on both the microphone and system-audio channels; and "Omi's AI service didn't respond..." was dropped instead of commanding the assistant. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../AppState/AppState+ListenEvents.swift | 9 +--- .../FloatingBarVoicePlaybackService.swift | 24 +++++++---- .../VoicePlaybackEchoPolicy.swift | 13 +++++- .../WakeWord/WakeWordSegmentParser.swift | 42 ++++++++++++++++++- .../Sources/WakeWord/WakeWordService.swift | 16 +------ .../Tests/WakeWordSegmentParserTests.swift | 39 +++++++++++++++++ .../Desktop/Tests/WakeWordServiceTests.swift | 24 ----------- .../20260823-wake-word-later-sentence.json | 3 ++ 8 files changed, 112 insertions(+), 58 deletions(-) create mode 100644 desktop/macos/changelog/unreleased/20260823-wake-word-later-sentence.json diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift index 936f1f80e68..7d24f078563 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift @@ -39,18 +39,13 @@ extension AppState { segment.text = spoken } - // Read once, before barge-in can clear it. Barge-in halts playback, so anything - // downstream that asks "was Omi speaking?" would be told no by the very segment - // that stopped it. - let wasSpeakingAnswer = FloatingBarVoicePlaybackService.shared.isSpeaking - // Barge-in interruption: if the user speaks while voice playback is active, // halt playback immediately so Omi never talks over the user. if VoiceBargeInPolicy.shouldInterrupt( isUser: segment.is_user, speaker: speakerId, text: segment.text, - isSpeaking: wasSpeakingAnswer + isSpeaking: FloatingBarVoicePlaybackService.shared.isSpeaking ) { log("Transcription [BARGE-IN]: User spoke mid-playback; interrupting voice output") FloatingBarVoicePlaybackService.shared.interruptCurrentResponse() @@ -71,7 +66,7 @@ extension AppState { translations: translations ) - WakeWordService.shared.observe(newSeg, isSpeakingAnswer: wasSpeakingAnswer) + WakeWordService.shared.observe(newSeg) // Upsert: if we already have a segment with this ID, update it; otherwise append if let segId = segment.id, diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift index 89396b5d12b..bb8edf14a60 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift @@ -84,23 +84,29 @@ final class FloatingBarVoicePlaybackService: NSObject, AVAudioPlayerDelegate, AV /// coming back through the microphone instead of treating it as the user /// (`VoicePlaybackEchoPolicy`). Kept for a window *after* playback ends, because the /// transcript of the last words arrives about a second behind the audio. - private var spokenWordHistory: [String] = [] - private var lastSpokeAt: Date? + private var spokenWordHistory: [(word: String, at: Date)] = [] private static let spokenHistoryWordCap = 300 - private static let spokenHistoryLifetime: TimeInterval = 20 - /// Recent playback text, or nothing once it is too old to explain an incoming segment. + /// Each word expires on its own clock rather than the history being kept alive as a + /// block. Extending one shared deadline on every chunk left several turns of speech + /// matchable at once, and a few hundred words of ordinary English will align with almost + /// any short sentence — live, "Sorry my mistake it's taking" was deleted that way. An + /// echo arrives a second or two behind the audio, so this only has to outlive that. + private static let spokenWordLifetime: TimeInterval = 15 + + /// Playback from the last few seconds, which is all an echo can be an echo of. var recentlySpokenWords: [String] { - guard let lastSpokeAt, Date().timeIntervalSince(lastSpokeAt) <= Self.spokenHistoryLifetime - else { return [] } - return spokenWordHistory + let cutoff = Date().addingTimeInterval(-Self.spokenWordLifetime) + return spokenWordHistory.filter { $0.at > cutoff }.map(\.word) } private func recordSpokenText(_ text: String) { let words = VoicePlaybackEchoPolicy.words(text) guard !words.isEmpty else { return } - lastSpokeAt = Date() - spokenWordHistory.append(contentsOf: words) + let now = Date() + let cutoff = now.addingTimeInterval(-Self.spokenWordLifetime) + spokenWordHistory.removeAll { $0.at <= cutoff } + spokenWordHistory.append(contentsOf: words.map { (word: $0, at: now) }) if spokenWordHistory.count > Self.spokenHistoryWordCap { spokenWordHistory.removeFirst(spokenWordHistory.count - Self.spokenHistoryWordCap) } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift index baec9bf141f..ba0dbe67937 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift @@ -40,6 +40,10 @@ enum VoicePlaybackEchoPolicy { /// matched run and to the surviving residue alike. static let minimumWordCount = 4 + /// Share of an utterance the matched run must cover before the whole thing is discarded. + /// Measured on captured echoes: 0.80–1.00. + static let minimumCoverageToDrop = 0.8 + /// How far ahead in the playback history a word may be found and still continue the run. /// Absorbs words speech-to-text drops or splits — "8:57 PM" came back as "8 57 p.m." static let alignmentLookahead = 5 @@ -61,7 +65,14 @@ enum VoicePlaybackEchoPolicy { let leading = matchedPrefixLength(incoming, against: spokenWords) guard leading >= minimumWordCount else { return .keep } - guard tokens.count - leading >= minimumWordCount else { return .drop } + guard tokens.count - leading >= minimumWordCount else { + // Discarding the whole segment needs the match to actually account for the whole + // segment. Several sentences of playback history contain enough ordinary words that + // a short utterance can align with four of them by chance — live, "Sorry, my mistake + // it's taking" was deleted that way, which is the failure this policy must never + // have. A real echo covers essentially all of itself: measured 0.80–1.00. + return Double(leading) / Double(tokens.count) >= minimumCoverageToDrop ? .drop : .keep + } // Playback continues past the interruption, so it bookends the user's words as often // as it precedes them — measured, the same sentence appeared on both sides of a diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index fe26c1bdd84..461b00c3005 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -4,9 +4,19 @@ enum WakeWordSegmentParser { static func command(after segmentText: String, wakePhrase: String) -> String? { let phrase = configuredPhrase(wakePhrase) guard !phrase.isEmpty else { return nil } - let raw = dropLeadingPunctuationAndWhitespace(segmentText) + let candidates = self.candidates(for: phrase) + for sentence in sentences(in: segmentText) { + if let command = self.command(startingAt: sentence, candidates: candidates) { + return command + } + } + return nil + } + + private static func command(startingAt text: String, candidates: [Candidate]) -> String? { + let raw = dropLeadingPunctuationAndWhitespace(text) let normalized = normalize(raw) - for candidate in candidates(for: phrase) where normalized.hasPrefix(candidate.text) { + for candidate in candidates where normalized.hasPrefix(candidate.text) { guard hasBoundary( after: candidate.text, @@ -26,6 +36,34 @@ enum WakeWordSegmentParser { return nil } + /// The segment text, then each sentence inside it that a wake word could open. + /// + /// A segment is no longer one utterance. Windows now close on the speaker's pause rather + /// than a fixed boundary, so whatever was said in the same breath shares the window with + /// the command — observed live, `It's not working man. Omi what time it is?` carried a + /// literal wake phrase and a valid command and matched nothing, because matching only + /// looked at the start of the segment. + /// + /// A sentence boundary, not any position: the phrase has to *open* an utterance. That is + /// the same class of evidence the punctuation-break rule already relies on — the + /// recognizer's own sentence segmentation — and it keeps `I told Omi to order food` from + /// parsing to the command "to order food". + static func sentences(in text: String) -> [String] { + var result: [String] = [text] + var index = text.startIndex + while index < text.endIndex { + defer { index = text.index(after: index) } + guard text[index] == "." || text[index] == "?" || text[index] == "!" else { continue } + let next = text.index(after: index) + guard next < text.endIndex, text[next].isWhitespace else { continue } + let remainder = String(text[next...]) + if !dropLeadingPunctuationAndWhitespace(remainder).isEmpty { + result.append(remainder) + } + } + return result + } + static func configuredPhrase(_ raw: String) -> String { normalize(raw).trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) } diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index f86dcd55c5c..d31ee165d22 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -29,8 +29,7 @@ final class WakeWordService { func observe( _ segment: SpeakerSegment, - isConversationActive: Bool = WakeWordService.defaultIsConversationActive(), - isSpeakingAnswer: Bool = WakeWordService.defaultIsSpeakingAnswer() + isConversationActive: Bool = WakeWordService.defaultIsConversationActive() ) { let parsed = WakeWordSegmentParser.command( after: segment.text, @@ -45,15 +44,6 @@ final class WakeWordService { guard AssistantSettings.shared.wakeWordEnabled else { return ignore("disabled in settings") } guard !isConversationActive else { return ignore("assistant already busy") } - // Now that the answer is spoken aloud, the microphone hears it and it lands in the - // transcript as the user's own speech — observed live: `Transcript [ADD] Speaker 0: - // It's 8 57 p.m. on Sunday...` was Omi, not a person. An answer containing the wake - // phrase would command the assistant with its own words, indefinitely. - // - // This is not a wall in front of the user: `VoiceBargeInPolicy` halts playback the - // moment the user actually speaks, so `isSpeaking` is already false by the time their - // transcript arrives. Only Omi's own voice is caught here. - guard !isSpeakingAnswer else { return ignore("assistant is speaking its answer") } // Diarization only sets `isUser` once a speech profile is enrolled, so requiring // it alone makes the wake word silently dead for every user who has not enrolled // one. Speaker 0 is the primary user everywhere else in this feature set — @@ -108,10 +98,6 @@ final class WakeWordService { text.split(whereSeparator: \.isWhitespace).count } - static func defaultIsSpeakingAnswer() -> Bool { - FloatingBarVoicePlaybackService.shared.isSpeaking - } - static func defaultIsConversationActive() -> Bool { if let provider = ChatProvider.mainInstance, provider.isSending { return true } if VoiceTurnCoordinator.shared.activeTurnID != nil { return true } diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift index 55329ef2245..e981da500bb 100644 --- a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -120,4 +120,43 @@ final class WakeWordSegmentParserTests: XCTestCase { XCTAssertNil(WakeWordSegmentParser.command(after: "Omnibus schedule changed", wakePhrase: "Omi")) XCTAssertNil(WakeWordSegmentParser.command(after: "Oh me", wakePhrase: "Omi")) } + + // MARK: - The wake phrase later in a multi-sentence segment + + /// Captured live and missed: a literal wake phrase with a valid command, in a segment + /// that also carried what the user said just before it. Windows now close on the + /// speaker's pause rather than a fixed boundary, so a segment is no longer one utterance. + func testWakePhraseOpeningALaterSentenceFires() { + XCTAssertEqual( + WakeWordSegmentParser.command( + after: "It's not working man. Omi what time it is?", wakePhrase: "Omi"), + "what time it is?") + } + + /// The phrase has to *open* an utterance, not merely appear in one. + func testWakePhraseMidSentenceIsIgnored() { + XCTAssertNil( + WakeWordSegmentParser.command(after: "I told Omi to order food", wakePhrase: "Omi")) + } + + /// The corroboration rules still apply at a later sentence — a bare homophone there + /// needs its punctuation break exactly as it does at the start. + func testBareHomophoneOpeningALaterSentenceStillNeedsAPause() { + XCTAssertNil( + WakeWordSegmentParser.command( + after: "That was strange. Oh me and my friend went hiking", wakePhrase: "Omi")) + XCTAssertEqual( + WakeWordSegmentParser.command( + after: "That was strange. Oh me, what time is it?", wakePhrase: "Omi"), + "what time is it?") + } + + /// The first sentence that carries a command wins, so an earlier one being ordinary + /// speech does not consume the utterance. + func testFirstMatchingSentenceSuppliesTheCommand() { + XCTAssertEqual( + WakeWordSegmentParser.command( + after: "Nothing here. Omi open my tasks. Omi close my tasks.", wakePhrase: "Omi"), + "open my tasks. Omi close my tasks.") + } } diff --git a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift index 1ffbe807e74..ca9cdcd509c 100644 --- a/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordServiceTests.swift @@ -92,30 +92,6 @@ final class WakeWordServiceTests: XCTestCase { XCTAssertTrue(triggered.isEmpty) } - /// Regression: the answer is spoken aloud, the microphone hears it, and it arrives as - /// the user's own speech — live, `Transcript [ADD] Speaker 0: It's 8 57 p.m. on - /// Sunday...` was Omi. An answer carrying the wake phrase would command the assistant - /// with its own words, indefinitely. - func testAssistantsOwnSpokenAnswerCannotRetrigger() { - configureService() - service.observe( - userSegment("Omi, I can help you order food", id: "a"), - isConversationActive: false, - isSpeakingAnswer: true) - XCTAssertTrue(triggered.isEmpty) - } - - /// The playback guard is not a wall in front of the user: `VoiceBargeInPolicy` halts - /// playback the moment they speak, so their transcript arrives with it already clear. - func testUserCommandFiresOncePlaybackHasBeenInterrupted() { - configureService() - service.observe( - userSegment("Omi, let's order food", id: "a"), - isConversationActive: false, - isSpeakingAnswer: false) - XCTAssertEqual(triggered, ["let's order food"]) - } - func testDeduplicatesBySegmentID() { configureService() service.observe(userSegment("Omi, let's order food", id: "a"), isConversationActive: false) diff --git a/desktop/macos/changelog/unreleased/20260823-wake-word-later-sentence.json b/desktop/macos/changelog/unreleased/20260823-wake-word-later-sentence.json new file mode 100644 index 00000000000..cb61e7a1e1b --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260823-wake-word-later-sentence.json @@ -0,0 +1,3 @@ +{ + "change": "The wake word now works when you say it after something else in the same breath, not only at the very start of what you said" +} From 02eca20356c5386d3f36a5cc444f929cef92c47b Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 23 Aug 2026 17:06:33 +0530 Subject: [PATCH 18/30] fix(desktop): anchor echo matching to the sentence, not to the ends of the history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures from a twenty-utterance measurement run, both from the same assumption: that an echo begins where the playback history begins. Parakeet returned "Your current save task appears to be testing the protocol product." twice in one segment, and the second copy survived as if the user had said it — the backward walk started at the end of the history, and Omi had kept talking past that sentence. Both walks now try every position where the utterance's first word occurs and take the best alignment, so an echo of something said in the middle of an answer is still recognised as one. That also recovers a command spoken over playback: the user's words are isolated between the matched runs instead of being dropped with them. Speech-to-text mangles the tail of a long answer, so it stops matching what was synthesised and was kept as the user's words: Omi's own "Based on today's recording active for roughly about one hour" reached the transcript as speech the user never said. A garbled continuation runs straight on from the matched text; a person interrupting starts a new sentence, and the recognizer marks that, so a surviving residue now has to begin one. A single word matching on its own is coincidence, not the echo continuing — "the" and "and" occur in every answer. Committing the match on one word let the backward walk eat "the time" off the end of a user's command; two in a row is a run. Verification: - xcrun swift test --package-path Desktop --filter 'VoicePlaybackEchoPolicy|WakeWord|LocalTranscription|VoiceBargeIn' -> 74 tests, 0 failures. The four new cases are the captured strings above. - Twenty spoken utterances through the real microphone across five voices, the same script and 16s pacing as the run before this change: 10 of 20 dispatched, against 9 of 20; the two segments where Omi's own words had been kept as the user's are now correctly dropped (0 of 20, was 2 of 20); and two commands that the previous build lost inside a dropped echo window were recovered by residue extraction ("Your ten most recent memories. Only open my rewind time line." -> "Only open my rewind time line."). Every remaining miss is the recognizer writing something other than the wake phrase, five of them "Only". Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../VoicePlaybackEchoPolicy.swift | 42 ++++++++++++++- .../Tests/VoicePlaybackEchoPolicyTests.swift | 51 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift index ba0dbe67937..318fde73b5b 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift @@ -82,12 +82,29 @@ enum VoicePlaybackEchoPolicy { let end = tokens.count - trailing guard end - leading >= minimumWordCount else { return .drop } + // What survives has to *start* an utterance. Speech-to-text also mangles the tail of a + // long answer — live, Omi's own "Based on today's recording active for roughly about + // one hour" diverged from what was synthesised and was kept as if the user had said + // it. A garbled continuation runs straight on from the matched words; a person + // interrupting starts a new sentence, and the recognizer marks that. + guard sentenceBreakPrecedes(tokens[leading], in: transcript, after: tokens[leading - 1]) else { + return Double(leading) / Double(tokens.count) >= minimumCoverageToDrop ? .drop : .keep + } + // Nothing stripped from the far end means the utterance ends where the segment does, // so keep its closing punctuation — the wake-word parser reads punctuation. let upperBound = trailing == 0 ? transcript.endIndex : tokens[end - 1].end return .keepResidue(String(transcript[tokens[leading].start.. Bool { + transcript[previous.end.. [String] { tokens(text).map(\.word) } @@ -144,18 +161,39 @@ enum VoicePlaybackEchoPolicy { matchedPrefixLength(Array(incoming), against: Array(spoken)) } + /// Anchors tried before giving up on finding where in the history this utterance begins. + /// A walk pinned to index 0 only sees an echo of the *first* thing said in the window. + /// Omi keeps talking, and speech-to-text repeats itself — live, Parakeet returned "Your + /// current save task appears to be testing the protocol product." twice in one segment, + /// and the second copy survived as if the user had said it because the backward walk + /// started at the end of the history rather than at the sentence. + static let maximumAnchorsTried = 24 + private static func matchedPrefixLength(_ incoming: [String], against spoken: [String]) -> Int { - var spokenIndex = 0 + guard let first = incoming.first else { return 0 } + var anchors = spoken.indices.filter { spoken[$0] == first }.prefix(maximumAnchorsTried).map { $0 } + if anchors.isEmpty { anchors = [0] } + return anchors.map { walk(incoming, against: spoken, from: $0) }.max() ?? 0 + } + + private static func walk(_ incoming: [String], against spoken: [String], from anchor: Int) -> Int { + var spokenIndex = anchor var mismatches = 0 + var run = 0 var lastMatched = 0 for (offset, word) in incoming.enumerated() { let limit = min(spokenIndex + alignmentLookahead, spoken.count) if spokenIndex < limit, let hit = (spokenIndex..= 2 { lastMatched = offset + 1 } continue } + run = 0 mismatches += 1 if mismatches >= mismatchesEndingEcho { break } } diff --git a/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift b/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift index 643a36fccb3..d6e2f59ed68 100644 --- a/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift @@ -181,4 +181,55 @@ final class VoicePlaybackEchoPolicyTests: XCTestCase { "delivered be should where and order", spoken: "and where should it be delivered, what would you like to order")) } + + // MARK: - Anchoring and residue plausibility + + /// Captured live: Parakeet returned the same sentence twice in one segment. The second + /// copy survived as if the user had said it, because the backward walk started at the end + /// of the playback history instead of at the sentence — Omi had kept talking past it. + func testRepeatedSentenceInOneSegmentIsEntirelyEcho() { + XCTAssertEqual( + classify( + "Your current save task appears to be testing the protocol product. " + + "Your current save task appears to be testing the protocol product.", + spoken: "Your current save task appears to be testing the protocol product, " + + "including the project."), + .drop) + } + + /// Captured live: speech-to-text mangled the tail of a long answer, so it no longer + /// matched what was synthesised and was kept as the user's words. A garbled continuation + /// runs straight on from the matched text; a person interrupting starts a new sentence. + func testGarbledTailOfTheAnswerIsNotMistakenForTheUser() { + XCTAssertEqual( + classify( + "I can't reliably tell how long you've been working because screen recording " + + "permission isn't enabled. Based on today's recording active for roughly about one hour.", + spoken: "I can't reliably tell how long you've been working because screen recording " + + "permission isn't enabled. Based on today's recording we've been active for roughly about one hour."), + .drop) + } + + /// The echo can start partway through the history when Omi has been talking a while. + func testEchoOfALaterSentenceInTheHistoryIsStillMatched() { + XCTAssertEqual( + classify( + "Delhi, the national capital known for historic landmarks.", + spoken: "1. Mumbai, India's financial capital and home to Bollywood. " + + "2. Delhi, the national capital known for historic landmarks. " + + "3. Bengaluru, a major technology and startup hub."), + .drop) + } + + /// A barge-in buried between playback on both sides is still recovered. + func testUserSpeechBetweenTwoStretchesOfPlaybackSurvives() { + XCTAssertEqual( + classify( + "1. Mumbai, India's financial capital and home to Bollywood. " + + "Omi, stop and tell me the time. " + + "3. Bengaluru, a major technology and startup hub.", + spoken: "1. Mumbai, India's financial capital and home to Bollywood. " + + "2. Delhi, the national capital. 3. Bengaluru, a major technology and startup hub."), + .keepResidue("Omi, stop and tell me the time")) + } } From 54a535c7b0d0c9836ad51f32d1c0d2cb0dadaf8c Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 23 Aug 2026 20:47:10 +0530 Subject: [PATCH 19/30] fix(desktop): stop "ok"/"okay" corroborating a misheard wake phrase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raised in review: a homophone needs a punctuation break to fire on its own, but a greeting prefix waived that — and the greeting list included "ok" and "okay". So "okay oh me and my friend went hiking" fired with the command "and my friend went hiking", which is the exact false positive the punctuation rule exists to prevent. "hey" is a vocative: "hey " addresses someone, and nobody produces it in front of a misheard word by accident. "ok" and "okay" are discourse markers people open sentences with constantly. Only "hey" corroborates a homophone now. Both still corroborate the literal spelling, where the phrase is already the evidence. This only ever makes the wake word fire less. Verification: - xcrun swift test --package-path Desktop --filter WakeWord -> 33 tests, 0 failures, including the reviewer's sentence in both "ok" and "okay" forms and the two forms that must still fire. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../WakeWord/WakeWordSegmentParser.swift | 11 ++++++---- .../Tests/WakeWordSegmentParserTests.swift | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index 461b00c3005..fe22b7fadb5 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -114,10 +114,13 @@ enum WakeWordSegmentParser { } for homophone in sttHomophones[phrase] ?? [] { result.append(Candidate(text: homophone, requiresPunctuationBreak: true)) - for greeting in ["hey", "ok", "okay"] { - result.append( - Candidate(text: "\(greeting) \(homophone)", requiresPunctuationBreak: false)) - } + // Only "hey" corroborates a homophone. It is a vocative — "hey " addresses + // someone, and nobody produces it before a misheard word by accident. "ok" and + // "okay" are discourse markers people open sentences with constantly, so + // "okay oh me and my friend went hiking" would have fired with the command + // "and my friend went hiking". They still corroborate the literal spelling above, + // where the phrase itself is already the evidence. + result.append(Candidate(text: "hey \(homophone)", requiresPunctuationBreak: false)) } return result } diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift index e981da500bb..b98f04c2f8c 100644 --- a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -159,4 +159,25 @@ final class WakeWordSegmentParserTests: XCTestCase { after: "Nothing here. Omi open my tasks. Omi close my tasks.", wakePhrase: "Omi"), "open my tasks. Omi close my tasks.") } + + /// Review note from @Git-on-my-level: "ok"/"okay" plus a homophone fired without a pause. + /// They are discourse markers people open sentences with, unlike the vocative "hey". + func testOkayDoesNotCorroborateABareHomophone() { + XCTAssertNil( + WakeWordSegmentParser.command( + after: "okay oh me and my friend went hiking", wakePhrase: "Omi")) + XCTAssertNil( + WakeWordSegmentParser.command( + after: "ok oh me and my friend went hiking", wakePhrase: "Omi")) + } + + /// "hey" still corroborates, and both still corroborate the literal spelling. + func testHeyStillCorroboratesAHomophoneAndGreetingsStillWorkOnTheLiteralPhrase() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "hey oh me order pizza", wakePhrase: "Omi"), + "order pizza") + XCTAssertEqual( + WakeWordSegmentParser.command(after: "okay Omi order pizza", wakePhrase: "Omi"), + "order pizza") + } } From f9bf68da883b5e303a83e0fa2a33cd8792e049ad Mon Sep 17 00:00:00 2001 From: Aryan Date: Mon, 24 Aug 2026 00:57:41 +0530 Subject: [PATCH 20/30] feat(desktop): route a wake word into the realtime session behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer request: try the realtime model instead of the text path, on the grounds that it should be a more natural interaction. Off by default — `wakeWordUsesRealtime` — so the measured text path is untouched and the two can be compared on the same machine. Detection does not move. Both paths read the same ambient transcript, so this changes nothing about the 60% recognition ceiling documented earlier. What moves is the exchange: the model speaks its own answer instead of TTS reading a text reply, and the session keeps conversational context across turns. Push-to-talk owns a physical hold, so it streams audio and commits on release. A wake word owns no hold and its audio is long gone by the time the transcript fires, so it mints an `.automation` turn — the shape the headless harness already uses — and hands over the transcribed command. Two ordering constraints, both found by running it: - The Gemini activity window is opened by `beginInputTurn`, which runs inside `commitTurn()`, not at `beginTurn`. Waiting for the window before sending simply times out; the send has to happen first and be flushed by the commit. Before this was understood the text sat in the buffer and no answer came. - A turn with no route is refused at commit as a stale physical commit ("rejected duplicate/stale physical commit before provider side effects"), so `.selectRoute(.hub)` has to be published before `beginTurn`. Gemini also rejects a pure-text activity window with a 1007 precondition, so two 100 ms silence frames open it, matching the harness. Verification: - xcrun swift test --package-path Desktop --filter 'WakeWord|VoicePlaybackEcho|Realtime' -> 306 tests, 0 failures - Live on a named dev bundle, real microphone, flag on: WakeWord: submitting 'tell me one interesting fact about the ocean.' RealtimeHub[gemini:gemini-3.1-flash-live-preview]: turn begin (activityStart) RealtimeHub[gemini:gemini-3.1-flash-live-preview]: wake word command sent (45 chars) Transcript [ADD] Speaker 0: The pressure at the bottom of the ocean is so intense... The last line is the microphone hearing Gemini speak its own answer. ~1.2s from end of speech to the command reaching the model. A second command 51s later ran the same path, so the turn terminalizes and does not wedge the trigger behind `activeTurnID`. Known gap on this path: `VoicePlaybackEchoPolicy` does not cover it. The hub plays its audio natively rather than through `FloatingBarVoicePlaybackService`, so nothing records what was said and Gemini's spoken answer lands in the transcript as the user — visible in the trace above. The same defect the echo policy fixes for the TTS path, reappearing on this one. Left as a known gap while the path is off by default and under evaluation. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../RealtimeHubController.swift | 55 +++++++++++++++++++ .../RealtimeHubSession.swift | 8 +++ .../Services/AssistantSettings.swift | 16 ++++++ .../Sources/WakeWord/WakeWordService.swift | 15 ++++- .../20260824-wake-word-realtime-turn.json | 3 + 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 desktop/macos/changelog/unreleased/20260824-wake-word-realtime-turn.json diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift index 3da7c1204bb..c57b0efcc91 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift @@ -972,6 +972,61 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate { } } + /// Run one hands-free wake-word command as a realtime turn. + /// + /// The wake word detects on the ambient transcript, so the words already exist by the + /// time it fires and the spoken audio is long gone. What the realtime session is for is + /// the *exchange*: the model speaks its own answer, barge-in is native rather than + /// text-matched, and a follow-up continues inside the same session instead of opening a + /// fresh query. Detection stays where it is; only the reply path moves. + /// + /// Push-to-talk owns a physical hold, so it streams audio and commits on release. This + /// owns no hold — the same shape the automation harness uses — so it mints an + /// `.automation` turn, opens the input window, and hands over text. + @discardableResult + func runWakeWordTurn(_ command: String) async -> Bool { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + let turnID = RealtimeAutomationTurnHarness.begin(on: VoiceTurnCoordinator.shared) + // Without a route the reducer has nothing to commit against: `commitTurn` is refused as + // a stale physical commit and the buffered text never reaches the provider. Observed + // exactly that — "rejected duplicate/stale physical commit before provider side + // effects" — before this line existed. + VoiceTurnCoordinator.shared.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: nil))) + guard beginTurn(turnID: turnID) == .accepted else { + log("RealtimeHub: wake word turn refused at input preparation") + VoiceTurnCoordinator.shared.publish(.finish(turnID: turnID, reason: .providerFailed)) + return false + } + + // Order matters, and it is the reverse of what it looks like. The Gemini activity + // window is opened by `beginInputTurn`, which runs inside `commitTurn()` — not at + // `beginTurn`. So the text cannot be "sent" first: `sendSpokenCommand` deliberately + // buffers while the window is shut, and `beginInputTurn` flushes the buffer the moment + // it opens it. Waiting for the window before sending simply times out. + // + // The silence frames are the other half of the same constraint: Gemini rejects a + // pure-text activity window with a 1007 precondition failure, so the window has to + // carry real audio. Two 100 ms frames, matching the headless harness. + let silenceChunk = Data(count: 3_200) // 100 ms @ 16 kHz s16le + for _ in 0..<2 { + feedAudio(silenceChunk, turnID: turnID) + try? await Task.sleep(nanoseconds: 100_000_000) + } + + guard await session?.sendSpokenCommand(trimmed) == true else { + log("RealtimeHub: wake word command could not be queued for the session") + _ = cancelTurn(turnID: turnID) + return false + } + + log("RealtimeHub: wake word turn '\(trimmed)' committed to the realtime session") + VoiceTurnCoordinator.shared.publish(.finalize(turnID: turnID)) + _ = commitTurn() + return true + } + func runHeadlessPTTTurn( pcm16k: Data, timeout: Double, forceTranscript: String? = nil, textOnly: Bool = false ) async -> [String: String] { diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift index c355166abeb..4c033d827ef 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift @@ -478,6 +478,14 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { await sendTextInput(text, logLabel: "test text input") } + /// A hands-free command whose words are already transcribed. The wake word rides the + /// ambient transcript, so by the time it fires the spoken audio has long passed; what it + /// needs from the session is the *exchange* — a spoken answer, native barge-in, and + /// follow-ups in the same turn — not another pass at recognising the words. + func sendSpokenCommand(_ text: String) async -> Bool { + await sendTextInput(text, logLabel: "wake word command") + } + /// True when the session can accept injected (non-PTT) context *right now*. /// Evaluated on `q`. OpenAI accepts on any open socket; Gemini needs an open /// speech-activity window (opened per turn by `beginInputTurn`). diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift index 1ae34133e75..5d240896dbb 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift @@ -33,6 +33,7 @@ class AssistantSettings { private let wakeWordEnabledKey = "wakeWordEnabled" private let wakeWordPhraseKey = "wakeWordPhrase" private let wakeWordCooldownKey = "wakeWordCooldown" + private let wakeWordUsesRealtimeKey = "wakeWordUsesRealtime" // MARK: - Default Values @@ -68,6 +69,7 @@ class AssistantSettings { wakeWordEnabledKey: defaultWakeWordEnabled, wakeWordPhraseKey: defaultWakeWordPhrase, wakeWordCooldownKey: defaultWakeWordCooldown, + wakeWordUsesRealtimeKey: false, ]) } @@ -205,6 +207,20 @@ class AssistantSettings { } } + /// Route a fired wake word into the realtime session instead of the text chat path. + /// + /// Default off. The text path is what has live measurement behind it; this is the + /// alternative under evaluation — the model speaks its own answer, barge-in is native + /// rather than text-matched, and a follow-up continues in the same session. Detection is + /// unchanged either way: both read the same ambient transcript. + var wakeWordUsesRealtime: Bool { + get { UserDefaults.standard.bool(forKey: wakeWordUsesRealtimeKey) } + set { + UserDefaults.standard.set(newValue, forKey: wakeWordUsesRealtimeKey) + NotificationCenter.default.post(name: .assistantSettingsDidChange, object: nil) + } + } + /// The language code for transcription (e.g., "en", "uk", "ru") var transcriptionLanguage: String { get { diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index d31ee165d22..5dae663db25 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -21,7 +21,20 @@ final class WakeWordService { // `submitSpokenCommand` is that entry point. It is not the push-to-talk path: that one // presents `.voiceOnly` and owns a `VoiceTurnID` for the whole recording lifecycle, // while a wake word owns no turn — the words were already transcribed when it fired. - FloatingControlBarManager.shared.submitSpokenCommand(command) + guard AssistantSettings.shared.wakeWordUsesRealtime else { + FloatingControlBarManager.shared.submitSpokenCommand(command) + return + } + // Under evaluation (#11801): hand the exchange to the realtime session instead. The + // words are already transcribed, so what moves is the reply — the model speaks its own + // answer, barge-in is native rather than text-matched, and a follow-up continues in the + // same session. Detection is unchanged; both paths read the same ambient transcript. + Task { @MainActor in + let handed = await RealtimeHubController.shared.runWakeWordTurn(command) + guard !handed else { return } + log("WakeWord: realtime session unavailable — falling back to the chat path") + FloatingControlBarManager.shared.submitSpokenCommand(command) + } } private(set) var lastTriggeredCommand: String? diff --git a/desktop/macos/changelog/unreleased/20260824-wake-word-realtime-turn.json b/desktop/macos/changelog/unreleased/20260824-wake-word-realtime-turn.json new file mode 100644 index 00000000000..8e1cb9b8eee --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260824-wake-word-realtime-turn.json @@ -0,0 +1,3 @@ +{ + "change": "Behind an off-by-default setting, a wake word can hand its command to the realtime voice session so the assistant answers in its own voice" +} From 6be39e6367193d01f80bd289d6ff8a6fe364d0ff Mon Sep 17 00:00:00 2001 From: Aryan Date: Tue, 25 Aug 2026 22:13:29 +0530 Subject: [PATCH 21/30] fix(desktop): give the realtime wake-word turn a real user message and a silent echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the realtime path, both visible in the chat panel and both found by watching it rather than by reading the code. The assistant's own answer was recorded as the user. `VoicePlaybackEchoPolicy` reads what this app has recently said out loud, and that history was only ever written by `FloatingBarVoicePlaybackService`. The realtime session plays its audio natively, so nothing recorded it, the microphone heard it, and ambient capture attributed Gemini's reply to the user — the same defect the echo policy exists to prevent, reappearing on a path it could not see. Text emitted by the session is now recorded there too. It arrives as a stream and providers differ on whether each event is the new fragment or the whole reply so far, so only the part that extends what was already recorded is kept: a padded history matches more of what a person says, which is the direction that deletes real speech. The user's message was whatever the provider imagined. A wake-word turn hands over already-transcribed words and only enough silence to satisfy Gemini's non-empty activity window, so the provider has no user speech to transcribe and journals its own hallucination. Live, `¿Qué es el número de serie?` appeared as the user's message on two consecutive turns; nobody said it. What was asked is not in doubt, so it is supplied rather than recognised, reusing the existing transcript-override policy through a production-scoped property that push-to- talk clears alongside the test one. Verification: - xcrun swift test --package-path Desktop --filter 'WakeWord|VoicePlaybackEcho|Realtime|LocalTranscription' -> 324 tests, 0 failures - Live on a named dev bundle, real microphone, realtime path on: WakeWord: submitting 'tell me one short fact about the moon.' RealtimeHub[gemini]: wake word command sent (38 chars) Transcription [ECHO]: dropped Omi's own playback heard back: The moon is slowly moving away from Earth by about an inch a... The echo line was a `Transcript [ADD] Speaker 0` before this change. On the following turn the chat panel showed "tell me one short fact about volcanoes." as the user's message where the previous turn still shows the Spanish sentence. Known limit, not fixed here: the provider forms its own input transcript from the silence regardless of what we journal, and Gemini Live keeps session context, so a hallucinated transcript persists — after the Spanish turns the model kept offering to reply in Spanish. Supplying the text fixes the record, not the model's memory of the turn. The real remedy is a text turn that opens no audio activity at all (`clientContent` rather than `realtimeInput`), which is a wire-format change and out of scope while this path is off by default. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../FloatingBarVoicePlaybackService.swift | 27 +++++++++++++++++++ .../RealtimeHubController+PushToTalk.swift | 1 + ...ealtimeHubController+SessionDelegate.swift | 7 ++++- .../RealtimeHubController.swift | 12 +++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift index bb8edf14a60..9ec402bb1ce 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift @@ -85,6 +85,9 @@ final class FloatingBarVoicePlaybackService: NSObject, AVAudioPlayerDelegate, AV /// (`VoicePlaybackEchoPolicy`). Kept for a window *after* playback ends, because the /// transcript of the last words arrives about a second behind the audio. private var spokenWordHistory: [(word: String, at: Date)] = [] + /// Last text recorded from an engine outside this service, so a streamed reply that + /// repeats or extends itself is entered once rather than once per event. + private var lastExternallySpokenText: String? private static let spokenHistoryWordCap = 300 /// Each word expires on its own clock rather than the history being kept alive as a @@ -100,6 +103,30 @@ final class FloatingBarVoicePlaybackService: NSObject, AVAudioPlayerDelegate, AV return spokenWordHistory.filter { $0.at > cutoff }.map(\.word) } + /// Record speech this app produced through some other engine. + /// + /// The realtime session plays its own audio rather than routing through this service, so + /// nothing here would know what was said — and the microphone hears it identically. Left + /// unrecorded, the model's own answer comes back through ambient capture and is attributed + /// to the user, which is the exact defect `VoicePlaybackEchoPolicy` exists to prevent. + /// What matters to the echo check is that Omi said it out loud, not which engine spoke it. + /// Arrives as a stream, and providers differ on whether each event is the new fragment or + /// the whole reply so far. Recording every event verbatim would enter the same words over + /// and over, and a padded history matches more of what a person says — the direction that + /// deletes real speech. Only the part that extends what was already recorded is kept. + func recordExternallySpokenText(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + defer { lastExternallySpokenText = trimmed } + guard let previous = lastExternallySpokenText else { return recordSpokenText(trimmed) } + if trimmed == previous { return } + if trimmed.hasPrefix(previous) { + let addition = String(trimmed.dropFirst(previous.count)) + return recordSpokenText(addition) + } + recordSpokenText(trimmed) + } + private func recordSpokenText(_ text: String) { let words = VoicePlaybackEchoPolicy.words(text) guard !words.isEmpty else { return } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+PushToTalk.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+PushToTalk.swift index 51b35ef2e10..51860db06bc 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+PushToTalk.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+PushToTalk.swift @@ -75,6 +75,7 @@ extension RealtimeHubController { turnEarlyVerdictCode = nil fullLIDTask = nil testProviderTranscriptOverride = nil // never leak a test override into a real turn + wakeWordInputTranscript = nil // nor a wake word's text into a spoken one clearRealtimeToolTracking() lastTurnAt = Date() if bargeIn { diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift index a721093bbd0..bad3e2949c0 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift @@ -673,7 +673,7 @@ extension RealtimeHubController { let automationSelection = RealtimeAutomationTranscriptOverridePolicy.select( providerText: text, providerIsFinal: isFinal, - forcedText: testProviderTranscriptOverride) + forcedText: testProviderTranscriptOverride ?? wakeWordInputTranscript) if automationSelection.usedOverride { turnTranscript = automationSelection.text providerTranscriptFinalized = automationSelection.isFinal @@ -775,6 +775,11 @@ extension RealtimeHubController { source: RealtimeHubSession ) { guard acceptsTurnEvent(identity, source: source), let identity else { return } + // The session speaks this text itself, so the microphone hears it and ambient capture + // returns it as the user. Recording it here is what lets `VoicePlaybackEchoPolicy` + // recognise the model's own answer coming back — the service that normally owns that + // history never sees realtime audio, because the hub plays it natively. + FloatingBarVoicePlaybackService.shared.recordExternallySpokenText(text) guard RealtimeProviderOutputPresentationPolicy.decide( screenGroundingState: screenGroundingState, diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift index c57b0efcc91..ddc11cf571d 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift @@ -157,6 +157,14 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate { /// forced from outside. Everything downstream (mismatch check, local-transcript /// fallback, persistence) runs the real path. Cleared after one use. var testProviderTranscriptOverride: String? + /// The user text for a wake-word turn, which is known exactly rather than heard. + /// + /// The wake word hands the session already-transcribed words and only enough silence to + /// open the input window, so the provider has no user speech to transcribe. Left to its + /// own input transcription it journals whatever it made of the room — live, a Spanish + /// sentence nobody said appeared as the user's message on two turns. What was actually + /// asked is not in doubt, so it is supplied rather than recognised. + var wakeWordInputTranscript: String? /// Harness-visible outcome of the most recent externally authorized tool. /// An empty error means the kernel accepted and executed the proposal. var lastExternalToolName = "" @@ -432,6 +440,7 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate { turnEarlyVerdictCode = nil lastTurnDiagnostics.removeAll() testProviderTranscriptOverride = nil + wakeWordInputTranscript = nil acceptedSpawnJournalReceiptByContinuityKey.removeAll() prefetchedVoiceContext = "" prefetchedVoiceContextSessionID = "" @@ -1015,6 +1024,9 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate { try? await Task.sleep(nanoseconds: 100_000_000) } + // Supply the user side before committing: the journal and the chat bubble read the + // provider's input transcription, and this turn gives it nothing real to transcribe. + wakeWordInputTranscript = trimmed guard await session?.sendSpokenCommand(trimmed) == true else { log("RealtimeHub: wake word command could not be queued for the session") _ = cancelTurn(turnID: turnID) From 1a630d37d51682082ad21f9145f210a7a0196fab Mon Sep 17 00:00:00 2001 From: Aryan Date: Tue, 25 Aug 2026 23:02:56 +0530 Subject: [PATCH 22/30] fix(desktop): warm the realtime session before a wake-word turn The warm session tears itself down after an idle period, so a wake word arriving in a quiet stretch found nothing to talk to and fell through to the chat path. Observed live as the third of three consecutive commands answering in a text box while the first two spoke aloud. Warming and waiting briefly before minting the turn also avoids creating a turn that cannot be used, which would otherwise leave `activeTurnID` set and make `defaultIsConversationActive` refuse every later wake word. Verification: - xcrun swift test --package-path Desktop --filter 'WakeWord|VoicePlaybackEcho|Realtime|LocalTranscription' -> 324 tests, 0 failures - Live on a named dev bundle, real microphone: three consecutive commands ("how are you today", "tell me one fact about rivers", "name one famous painter") each reached `wake word command sent` with no fallback, where the previous build dropped the third to the chat path. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../RealtimeHubController.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift index ddc11cf571d..652b47ae2c8 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift @@ -997,6 +997,21 @@ final class RealtimeHubController: NSObject, RealtimeHubSessionDelegate { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } + // The warm session tears itself down after an idle period, so a wake word arriving in a + // quiet stretch finds nothing to talk to. Giving up there sent the command to the chat + // path instead — observed live as the third of three commands answering in a text box + // while the first two spoke. Warm it and wait briefly before minting a turn, so a turn + // is never created that cannot be used. + if session == nil { ensureWarm() } + let warmDeadline = Date().addingTimeInterval(5) + while session == nil, Date() < warmDeadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } + guard session != nil else { + log("RealtimeHub: no realtime session available for the wake word") + return false + } + let turnID = RealtimeAutomationTurnHarness.begin(on: VoiceTurnCoordinator.shared) // Without a route the reducer has nothing to commit against: `commitTurn` is refused as // a stale physical commit and the buffered text never reaches the provider. Observed From ccaaebb78ec147a7daf1792c791b390409b6dc4a Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 28 Aug 2026 19:53:30 +0530 Subject: [PATCH 23/30] feat(desktop): answer a wake word from the notch instead of opening a panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hands and eyes are elsewhere — that is the premise of a wake word — so growing the bar into a response panel covered a fifth of the screen for no one's benefit. The response glow already signals that Omi is working and the answer is spoken, so the exchange is recorded without being put in front of the user. The panel remains the fallback when the hands-free path cannot run. Two things made this harder than it looks, both found by instrumenting rather than reading: The panel is opened from four places — the query starting, the answer arriving, the content-height observer, and the agent-chat resize. Suppressing any one of them left it showing, because whichever was missed put it straight back. The guard is now at `resizeAnchored`, the single point they all reach, and blocks only expansion so collapsing back to the pill still works. `prepareVisibleQueryState` runs twice for one query: once from `routeQuery` to show the thinking state, once from `sendAIQuery`. A one-shot latch consumed by the first call meant the second reset the flag and reopened the panel — `presentsSurface=false` immediately followed by `presentsSurface=true` in the same millisecond. It may now only ever set quiet; the entry points that do want a panel (typed follow-ups, `submitSpokenCommand`) clear it. Verification: - xcrun swift test --package-path Desktop --filter 'WakeWord|VoicePlaybackEcho|FloatingControlBarState|Realtime' -> 335 tests, 0 failures - Live on a named dev bundle, real microphone, two consecutive commands: the bar stays at 62px throughout (`resizeToFrame to (351.0, 62.0)`), no `430x381` expansion, and both answers were spoken — the microphone transcribed Omi saying "The capital of Portugal is Lisbon." and "The sun contains about 99.86% of the total mass in our solar system." The previous build logged `resizeToFrame to (430.0, 381.0)` on every command. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../FloatingControlBarState.swift | 9 ++++ .../FloatingControlBarWindow.swift | 50 ++++++++++++++++++- .../Sources/WakeWord/WakeWordService.swift | 4 ++ ...260826-wake-word-answers-in-the-notch.json | 3 ++ 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 desktop/macos/changelog/unreleased/20260826-wake-word-answers-in-the-notch.json diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift index f3820ff89d3..f392b7707d7 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -333,6 +333,15 @@ class FloatingControlBarState: NSObject, ObservableObject { @Published private(set) var localAnswerOverride: ChatMessage? = nil @Published var lastConversationActivityAt: Date? = nil @Published var activeAgentChatPillID: UUID? = nil + /// This query answers in the notch: spoken, with the existing response glow, and without + /// growing the bar into a response panel. + /// + /// Set for a wake-word command, where hands and eyes are elsewhere and a panel covering a + /// fifth of the screen is the opposite of what was asked for. It has to survive the whole + /// query rather than just its start: the answer's arrival presents `.mainResponse` again + /// on its own, which is why suppressing only the initial expansion left the panel showing. + @Published var answersQuietly: Bool = false + @Published var conversationSurface: FloatingConversationSurface = .closed private var activeAIDraftKey = ChatDraftKey.floatingMain private var isRestoringAIDraft = false diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index f32f0b1807f..2054c00c059 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -1677,6 +1677,15 @@ class FloatingControlBarWindow: NSPanel, NSWindowDelegate { animationDuration: TimeInterval = 0.3, anchorTop: Bool = false ) { + // A quiet answer never grows the bar. Guarded at this choke point rather than at each + // caller: the response panel is opened from the query starting, the answer arriving, the + // content-height observer, and the agent-chat resize — patching them individually left + // the panel showing anyway, because whichever one was missed put it straight back. + // Only expansion is blocked; collapsing back to the pill still works. + if makeResizable, state.answersQuietly { + return + } + // Cancel any pending resizeToFixedHeight work item to prevent stale resizes resizeWorkItem?.cancel() resizeWorkItem = nil @@ -2271,6 +2280,9 @@ class FloatingControlBarWindow: NSPanel, NSWindowDelegate { } private func resizeToResponseHeight(animated: Bool = false) { + // A quiet answer stays in the notch. Guarded here rather than at the caller because the + // panel is opened from several places — the query starting, and the answer arriving. + guard !state.answersQuietly else { return } let responseHeight = responseHeightConfiguration() // Preserve manual response sizing across follow-up sends. The window may @@ -2285,6 +2297,7 @@ class FloatingControlBarWindow: NSPanel, NSWindowDelegate { } private func beginMainResponseHeight(animated: Bool = false) { + guard !state.answersQuietly else { return } let responseHeight = responseHeightConfiguration() let initialSize = NSSize(width: expandedContentWidth, height: responseHeight.initialHeight) resizeAnchored(to: initialSize, makeResizable: true, animated: animated, anchorTop: true) @@ -3746,6 +3759,7 @@ class FloatingControlBarManager { // Re-wire onSendQuery for typed follow-ups (force fromVoice:false after voice turns). window.onSendQuery = { [weak self, weak window, weak provider] message in guard let self = self, let window = window, let provider = provider else { return } + window.state.answersQuietly = false Task { @MainActor in await self.withQueryTracer(query: message, fromVoice: false) { await self.routeQuery(message, barWindow: window, provider: provider, fromVoice: false) @@ -4033,9 +4047,30 @@ class FloatingControlBarManager { /// the whole recording lifecycle. A wake word owns no turn — the words were already /// transcribed by the time it fires — so it uses the visible surface. func submitSpokenCommand(_ command: String) { + window?.state.answersQuietly = false sendFollowUpQuery(command, fromVoice: true) } + /// Set for exactly one query and consumed by `prepareVisibleQueryState`. A one-shot flag + /// rather than a parameter because that method is reached through `routeQuery`/ + /// `sendAIQuery`, which are shared with typed queries and push-to-talk. + private static var suppressNextVisibleSurface = false + + /// Hands-free: speak the answer and leave the bar in the notch. + /// + /// Hands and eyes are elsewhere — that is the premise of a wake word — so growing the bar + /// into a response panel covers a fifth of the screen for no one's benefit. The response + /// glow already signals that Omi is working, and the answer is spoken. The exchange is + /// still recorded; it is simply not put in front of you. + @discardableResult + func submitHandsFreeCommand(_ command: String) -> Bool { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + Self.suppressNextVisibleSurface = true + sendFollowUpQuery(trimmed, fromVoice: true) + return true + } + func sendFollowUpQuery( _ query: String, fromVoice: Bool = false, @@ -4663,7 +4698,13 @@ class FloatingControlBarManager { chatCancellable?.cancel() chatCancellable = nil FloatingBarVoicePlaybackService.shared.interruptCurrentResponse() - barWindow.beginVisibleMainQuery(message, fromVoice: fromVoice, animated: true) + // Called twice for one query — once by `routeQuery` to show the thinking state, again + // by `sendAIQuery`. The latch is consumed by the first, so this may only ever *set* + // quiet; clearing here let the second call undo the first and reopen the panel. + let presentsSurface = !Self.suppressNextVisibleSurface + Self.suppressNextVisibleSurface = false + barWindow.beginVisibleMainQuery( + message, fromVoice: fromVoice, animated: true, presentsSurface: presentsSurface) } private func isActiveQueryGeneration(_ generation: Int) -> Bool { @@ -5233,9 +5274,14 @@ extension FloatingControlBarWindow { /// Switch from the Ask Omi input panel to the response-sized surface before /// routing a visible query. Keeping this transition in the window preserves /// the invariant that conversation state and NSPanel sizing move together. - func beginVisibleMainQuery(_ message: String, fromVoice: Bool, animated: Bool = true) { + func beginVisibleMainQuery( + _ message: String, fromVoice: Bool, animated: Bool = true, presentsSurface: Bool = true + ) { cancelInputHeightObserver() state.currentQueryFromVoice = fromVoice + // Cleared by any query that does present a surface, so a quiet wake-word answer never + // leaks its silence into the next typed question. + if !presentsSurface { state.answersQuietly = true } state.markAIDraftSubmitted(message) state.displayedQuery = message state.clearCurrentAnswerAnchors() diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift index 5dae663db25..65cb69e91d2 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordService.swift @@ -22,6 +22,10 @@ final class WakeWordService { // presents `.voiceOnly` and owns a `VoiceTurnID` for the whole recording lifecycle, // while a wake word owns no turn — the words were already transcribed when it fired. guard AssistantSettings.shared.wakeWordUsesRealtime else { + // Hands-free answers stay in the notch: spoken, with the response glow, and without + // the bar growing into a panel. The visible surface remains the fallback. + if FloatingControlBarManager.shared.submitHandsFreeCommand(command) { return } + log("WakeWord: answering on the visible surface instead") FloatingControlBarManager.shared.submitSpokenCommand(command) return } diff --git a/desktop/macos/changelog/unreleased/20260826-wake-word-answers-in-the-notch.json b/desktop/macos/changelog/unreleased/20260826-wake-word-answers-in-the-notch.json new file mode 100644 index 00000000000..573b006bcce --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260826-wake-word-answers-in-the-notch.json @@ -0,0 +1,3 @@ +{ + "change": "A wake word is now answered out loud from the notch instead of opening a response panel over your screen" +} From 3bb78d66c8044a719b41f5262d5a794de68c9d68 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 29 Aug 2026 12:32:46 +0530 Subject: [PATCH 24/30] test(desktop): repin the beginVisibleMainQuery contract to its wrapped signature ccaaebb7 added `presentsSurface: Bool = true`, which pushed the parameter list onto its own line under swift-format. The contract test pinned the declaration as one string: func beginVisibleMainQuery(_ message: String, fromVoice: Bool, animated: Bool = true) so it stopped matching and took both required Desktop Swift checks red. The production behavior was never affected -- the view's call site still passes defaults. Pinned in two parts so a wrap cannot break it again, and `presentsSurface: Bool = true` is now pinned with the rest: that default is what keeps a typed send presenting the panel, which is the load-bearing behavior of this surface. Checked the pin still guards rather than passing vacuously -- flipping the expected default to false fails the assertion at line 1088; restoring it passes. AgentPillLifecycleTests 84/84. Failure-Class: none --- desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index 17d757684f1..1cb11435708 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -1080,8 +1080,14 @@ import XCTest XCTAssertTrue(viewSource.contains(".beginVisibleMainQuery(message, fromVoice: false, animated: true)")) XCTAssertFalse(inputSource.contains("state.showingAIResponse = true")) XCTAssertFalse(viewSource.contains("state.conversationSurface == .mainResponse || state.showingAIResponse")) + // Pinned in two parts because the declaration wraps: swift-format splits the parameter + // list onto its own line once `presentsSurface` is added, so a single-line pin cannot + // match. `presentsSurface: Bool = true` is pinned with the rest — it defaulting to true + // is what keeps a typed send presenting the panel. + XCTAssertTrue(windowSource.contains("func beginVisibleMainQuery(")) XCTAssertTrue( - windowSource.contains("func beginVisibleMainQuery(_ message: String, fromVoice: Bool, animated: Bool = true)")) + windowSource.contains( + "_ message: String, fromVoice: Bool, animated: Bool = true, presentsSurface: Bool = true")) XCTAssertTrue(windowSource.contains("state.resetMeasuredContentHeight(for: .mainResponse)")) XCTAssertTrue(windowSource.contains("state.present(.mainResponse)")) XCTAssertTrue(windowSource.contains("beginMainResponseHeight(animated: animated)")) From 21f1e50ff2ebcaf48f71fd1c632c75da110df249 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 29 Aug 2026 13:37:03 +0530 Subject: [PATCH 25/30] feat(desktop): recognize the wake word on the default on-device path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake word is only as good as the recognizer that hears it, and on the default path the recognizer cannot be told its own name. Ambient transcription runs on-device on Apple Silicon, and `AsrManager.transcribe(_:decoderState: language:)` takes a language hint and nothing else — no keyword or vocabulary parameter. The cloud lane does not have that problem: `/v4/listen` prepends "Omi" to the STT keyword vocabulary server-side in `backend/utils/listen_session_bootstrap.py`. Measured on one machine, same script and voices, only the lane changed: the phrase was usable in 12 of 20 utterances on-device against 19 of 20 on the cloud lane. Seven of the eight on-device misses came back as "Only". Two changes, both aimed at that gap. `WakeWordSegmentParser` gains a third corroboration class. "Only" cannot ride the existing punctuation-break rule: a scan of 1,919 stored local segments found 15 sentence-initial "Only", every one a misrendered wake word, and not one carried a break after it — they read "Only what is on my calendar", "Only open my rewind timeline". The same scan found 8 ordinary uses of "only", all mid-sentence. So `.commandHead` gates on what follows instead: an interrogative, or a request verb aimed at the speaker's own things ("show me", "open my", "remind me"). Every imperative reads fine under a restriction — "only do that once", "only tell him if he asks" — but none of them ask for the speaker's own calendar. `AssistantSettings.wakeWordPrefersCloudSTT` is the opt-in for users who would rather buy the remaining accuracy with cloud transcription. Off by default: it trades on-device transcription for cloud transcription while the wake word is enabled, which is a privacy and cost decision, not a technical one. FluidAudio does ship term biasing, but only on `SlidingWindowAsrManager`, which is a different streaming architecture and pulls a second set of CTC models — that is the upgrade path out of this flag. Verified on the running app through the hermetic capture seam, which reaches `WakeWordService.observe` on the real path: 14 ordinary uses of "only" injected, zero fired; 3 verbatim misrenderings injected, 3 parsed. The cloud opt-in was confirmed live by the lane actually switching — `TranscriptionService: Connecting to wss://api.omiapi.com/v4/listen` in place of on-device Parakeet. Tests: 26 in WakeWordSegmentParserTests, 14 in STTSessionStateTests, and the full 716-suite run, all passing. Co-Authored-By: Claude Opus 5 --- .../AppState/AppState+Transcription.swift | 3 +- .../Sources/AppState/STTSessionState.swift | 30 +++++- .../Services/AssistantSettings.swift | 23 +++++ .../WakeWord/WakeWordSegmentParser.swift | 95 +++++++++++++++++-- .../Desktop/Tests/STTSessionStateTests.swift | 52 ++++++++++ .../Tests/WakeWordSegmentParserTests.swift | 83 ++++++++++++++++ 6 files changed, 272 insertions(+), 14 deletions(-) diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift b/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift index f7afd8c0a31..cec180b30d2 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift @@ -75,7 +75,8 @@ extension AppState { sttSession.beginRecording( audioSource: effectiveSource, isAppleSilicon: Self.isAppleSilicon, - debugForceCloud: debugForceCloud + debugForceCloud: debugForceCloud, + wakeWordNeedsRecognizableName: AssistantSettings.shared.wakeWordNeedsRecognizableName ) let clientConversationId = UUID().uuidString.lowercased() currentClientConversationId = sttSession.useLocalSTT ? nil : clientConversationId diff --git a/desktop/macos/Desktop/Sources/AppState/STTSessionState.swift b/desktop/macos/Desktop/Sources/AppState/STTSessionState.swift index 6a471aa48b7..b7af3412228 100644 --- a/desktop/macos/Desktop/Sources/AppState/STTSessionState.swift +++ b/desktop/macos/Desktop/Sources/AppState/STTSessionState.swift @@ -36,12 +36,32 @@ struct STTSessionState: Equatable { } /// Resolve which STT path to use for a new recording. + /// + /// `wakeWordNeedsRecognizableName` is the opt-in the wake word needs to work. Ambient + /// transcription is on-device by default, and the manager that path runs on takes a + /// language hint and nothing else — `AsrManager.transcribe(_:decoderState:language:)` has + /// no keyword or vocabulary parameter, so it cannot be told that "Omi" is a word. + /// FluidAudio does ship term biasing, but only on `SlidingWindowAsrManager` + /// (`configureVocabularyBoosting(vocabulary:ctcModels:config:)`), which is a different + /// streaming architecture and pulls a second set of CTC models. That is the upgrade path + /// out of this flag; it is not a parameter we can pass today. Measured + /// on one machine, same script and voices, only the lane changed: the phrase was usable + /// in 12 of 20 utterances on-device against 19 of 20 on the cloud lane, which reaches + /// `/v4/listen` — and that path prepends "Omi" to the STT keyword vocabulary server-side + /// (`backend/utils/listen_session_bootstrap.py`), so the recognizer is told the name. + /// Seven of the eight on-device misses came back as "Only", which cannot be accepted as + /// a rendering — it opens ordinary sentences. + /// + /// Off by default. It trades on-device transcription for cloud transcription while the + /// wake word is enabled, which is a privacy and cost decision, not a technical one. func resolveMode( audioSource: AudioSource, isAppleSilicon: Bool, - debugForceCloud: Bool + debugForceCloud: Bool, + wakeWordNeedsRecognizableName: Bool = false ) -> ResolvedMode { - let forceCloud = !sessionForceLocal && (debugForceCloud || appRunForceCloud) + let forceCloud = + !sessionForceLocal && (debugForceCloud || appRunForceCloud || wakeWordNeedsRecognizableName) if audioSource == .bleDevice || !isAppleSilicon || forceCloud { return .cloud } @@ -51,12 +71,14 @@ struct STTSessionState: Equatable { mutating func beginRecording( audioSource: AudioSource, isAppleSilicon: Bool, - debugForceCloud: Bool + debugForceCloud: Bool, + wakeWordNeedsRecognizableName: Bool = false ) { activeMode = resolveMode( audioSource: audioSource, isAppleSilicon: isAppleSilicon, - debugForceCloud: debugForceCloud + debugForceCloud: debugForceCloud, + wakeWordNeedsRecognizableName: wakeWordNeedsRecognizableName ) } diff --git a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift index 5d240896dbb..33b5284dbe8 100644 --- a/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift +++ b/desktop/macos/Desktop/Sources/ProactiveAssistants/Services/AssistantSettings.swift @@ -34,6 +34,7 @@ class AssistantSettings { private let wakeWordPhraseKey = "wakeWordPhrase" private let wakeWordCooldownKey = "wakeWordCooldown" private let wakeWordUsesRealtimeKey = "wakeWordUsesRealtime" + private let wakeWordPrefersCloudSTTKey = "wakeWordPrefersCloudSTT" // MARK: - Default Values @@ -70,6 +71,7 @@ class AssistantSettings { wakeWordPhraseKey: defaultWakeWordPhrase, wakeWordCooldownKey: defaultWakeWordCooldown, wakeWordUsesRealtimeKey: false, + wakeWordPrefersCloudSTTKey: false, ]) } @@ -207,6 +209,27 @@ class AssistantSettings { } } + /// Transcribe ambient audio in the cloud while the wake word is on, so the recognizer can + /// be told the wake phrase. + /// + /// Default off, and only consulted when the wake word is enabled. See + /// `STTSessionState.resolveMode` for the measurement behind it. + var wakeWordPrefersCloudSTT: Bool { + get { UserDefaults.standard.bool(forKey: wakeWordPrefersCloudSTTKey) } + set { + UserDefaults.standard.set(newValue, forKey: wakeWordPrefersCloudSTTKey) + NotificationCenter.default.post(name: .transcriptionSettingsDidChange, object: nil) + } + } + + /// Whether the wake word currently needs a recognizer that can be told its name. + /// + /// Both halves matter: the opt-in only means anything while the wake word is on, and + /// leaving it off keeps ambient transcription on-device exactly as today. + var wakeWordNeedsRecognizableName: Bool { + wakeWordEnabled && wakeWordPrefersCloudSTT + } + /// Route a fired wake word into the realtime session instead of the text chat path. /// /// Default off. The text path is what has live measurement behind it; this is the diff --git a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift index fe22b7fadb5..2a29b7f182b 100644 --- a/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift +++ b/desktop/macos/Desktop/Sources/WakeWord/WakeWordSegmentParser.swift @@ -21,7 +21,7 @@ enum WakeWordSegmentParser { hasBoundary( after: candidate.text, in: normalized, - requiringPunctuation: candidate.requiresPunctuationBreak) + requiringPunctuation: candidate.corroboration == .punctuationBreak) else { continue } guard let commandEnd = raw.index( @@ -31,11 +31,53 @@ enum WakeWordSegmentParser { let command = dropLeadingPunctuationAndWhitespace(remainder) .trimmingCharacters(in: .whitespacesAndNewlines) guard !command.isEmpty else { continue } + if candidate.corroboration == .commandHead, !opensLikeACommand(command) { continue } return command } return nil } + /// Whether what follows the phrase is addressed to an assistant rather than continuing a + /// sentence. + /// + /// This is the whole safety story for `.commandHead` renderings. "Only" is an ordinary + /// English adverb, so the word itself proves nothing and the thing said after it has to. + /// + /// Two shapes qualify. An interrogative, because restrictive "only" needs something to + /// restrict and a question word cannot be it — "Only what is on my calendar" is not a + /// sentence a person completes. Or a request verb aimed at the speaker's own things + /// ("show me", "open my", "remind me"), because that is the part restrictive "only" + /// cannot reach: every imperative reads fine under a restriction — "only do that once", + /// "only tell him if he asks", "only show the ones that passed" — but none of them are + /// asking for the speaker's own calendar or notes. + /// + /// "when" and "where" are deliberately absent from the interrogatives: "only when I say + /// so" and "only where it matters" are ordinary restrictive uses. + static let interrogativeHeads: Set = [ + "what", "what's", "whats", "how", "how's", "hows", "who", "who's", "whos", "why", + "which", "whose", "is", "are", "was", "were", "does", "did", "can", "could", "will", + "would", "should", "am", + ] + + /// Request verbs, which only count when the next word is `me` or `my`. + static let requestHeads: Set = [ + "open", "show", "tell", "give", "find", "search", "remind", "read", "send", "play", + "summarize", "summarise", "explain", "schedule", "check", "list", "add", "set", + ] + + /// The words that make a request verb self-addressed. + static let selfAddressedObjects: Set = ["me", "my", "mine"] + + static func opensLikeACommand(_ command: String) -> Bool { + let words = normalize(command) + .split(whereSeparator: { $0.isWhitespace }) + .map { $0.trimmingCharacters(in: .punctuationCharacters) } + guard let head = words.first else { return false } + if interrogativeHeads.contains(head) { return true } + guard requestHeads.contains(head), words.count > 1 else { return false } + return selfAddressedObjects.contains(words[1]) + } + /// The segment text, then each sentence inside it that a wake word could open. /// /// A segment is no longer one utterance. Windows now close on the speaker's pause rather @@ -84,17 +126,47 @@ enum WakeWordSegmentParser { // On-device recognition has no keyword list, so it also fronts the vowel // with an aspirate ("Homi what's the weather?", observed live). Deliberately // excludes "homie": it is an ordinary English word, and accepting it as the - // wake phrase would fire on real speech. The fix for on-device misses is - // keyword boosting on the recognizer, not a longer list here. + // wake phrase would fire on real speech. "homi", "hommi", ] ] + /// Renderings that are ordinary English words, and therefore need the *next* word as + /// evidence rather than a punctuation break. + /// + /// "Only" is the single biggest on-device miss: across a 20-utterance battery on the + /// on-device lane, the phrase was usable in 12, and 7 of the 8 misses came back as + /// "Only". The cloud lane does not produce it — `/v4/listen` prepends "Omi" to the STT + /// keyword vocabulary server-side (`backend/utils/listen_session_bootstrap.py`), and the + /// on-device manager has no equivalent (see `STTSessionState.resolveMode`). So this is + /// the only thing that raises recognition on the default path. + /// + /// It cannot ride the punctuation-break rule: a scan of 1,919 stored local segments found + /// 15 sentence-initial "Only", every one of them a misrendered wake word, and *not one* + /// carried a break after it — they read "Only what is on my calendar", "Only open my + /// rewind timeline". The same scan found 8 ordinary uses of "only", all mid-sentence, + /// none sentence-initial. Hence `.commandHead`: sentence position plus an assistant-shaped + /// next word, which is what actually separates the two populations. + static let commandShapedRenderings: [String: [String]] = [ + "omi": ["only"] + ] + + /// What a phrase needs beyond itself before it counts as the wake word. + enum Corroboration: Equatable { + /// The phrase is its own evidence — nobody says "Omi" mid-conversation by accident. + case none + /// A punctuation break must follow: the recognizer's own signal that the speaker + /// addressed something and then paused. + case punctuationBreak + /// The remainder must open like a command. For renderings that are ordinary English + /// words, the phrase proves nothing and the thing said after it has to. + case commandHead + } + /// A phrase that may open a wake-word utterance, and how much corroboration it needs. struct Candidate: Equatable { let text: String - /// Whether a punctuation break must follow the phrase for it to count. - let requiresPunctuationBreak: Bool + let corroboration: Corroboration } /// The literal spelling is a deliberate act: nobody says "Omi" mid-sentence by accident, @@ -108,19 +180,24 @@ enum WakeWordSegmentParser { /// corroboration in its own right — "hey oh me" is not something a person says by /// accident — so those forms keep the ordinary word boundary. static func candidates(for phrase: String) -> [Candidate] { - var result: [Candidate] = [Candidate(text: phrase, requiresPunctuationBreak: false)] + var result: [Candidate] = [Candidate(text: phrase, corroboration: .none)] for greeting in ["hey", "ok", "okay"] { - result.append(Candidate(text: "\(greeting) \(phrase)", requiresPunctuationBreak: false)) + result.append(Candidate(text: "\(greeting) \(phrase)", corroboration: .none)) } for homophone in sttHomophones[phrase] ?? [] { - result.append(Candidate(text: homophone, requiresPunctuationBreak: true)) + result.append(Candidate(text: homophone, corroboration: .punctuationBreak)) // Only "hey" corroborates a homophone. It is a vocative — "hey " addresses // someone, and nobody produces it before a misheard word by accident. "ok" and // "okay" are discourse markers people open sentences with constantly, so // "okay oh me and my friend went hiking" would have fired with the command // "and my friend went hiking". They still corroborate the literal spelling above, // where the phrase itself is already the evidence. - result.append(Candidate(text: "hey \(homophone)", requiresPunctuationBreak: false)) + result.append(Candidate(text: "hey \(homophone)", corroboration: .none)) + } + for rendering in commandShapedRenderings[phrase] ?? [] { + result.append(Candidate(text: rendering, corroboration: .commandHead)) + // A greeting in front is already corroboration, same as for the homophones above. + result.append(Candidate(text: "hey \(rendering)", corroboration: .none)) } return result } diff --git a/desktop/macos/Desktop/Tests/STTSessionStateTests.swift b/desktop/macos/Desktop/Tests/STTSessionStateTests.swift index b1c85a5a1e2..0f4063268d7 100644 --- a/desktop/macos/Desktop/Tests/STTSessionStateTests.swift +++ b/desktop/macos/Desktop/Tests/STTSessionStateTests.swift @@ -206,4 +206,56 @@ final class STTSessionStateTests: XCTestCase { XCTAssertTrue(session.appRunForceCloud) XCTAssertTrue(session.fallbackInProgress) } + + // MARK: - Wake word needs a recognizer that can be told its name + + /// Ambient transcription is on-device by default on Apple Silicon, and the on-device + /// recognizer takes a language hint and nothing else — no keyword or vocabulary + /// parameter — so it cannot be told that "Omi" is a word. The cloud lane reaches + /// `/v4/listen`, which prepends "Omi" to the STT keyword vocabulary server-side + /// (`backend/utils/listen_session_bootstrap.py`). Measured on one machine, same script + /// and voices, only the lane changed: usable in 12 of 20 utterances on-device against + /// 19 of 20 on the cloud lane. Seven of the eight on-device misses came back as "Only", + /// which cannot be accepted as a rendering because it opens ordinary sentences. + func testWakeWordOptInResolvesToCloudOnAppleSilicon() { + let state = STTSessionState() + XCTAssertEqual( + state.resolveMode( + audioSource: .microphone, isAppleSilicon: true, debugForceCloud: false, + wakeWordNeedsRecognizableName: true), + .cloud) + } + + /// Default-config users keep on-device transcription exactly as today. The opt-in is a + /// privacy and cost decision, so nothing changes until it is made. + func testWithoutTheOptInAppleSiliconStaysOnDevice() { + let state = STTSessionState() + XCTAssertEqual( + state.resolveMode( + audioSource: .microphone, isAppleSilicon: true, debugForceCloud: false, + wakeWordNeedsRecognizableName: false), + .local) + } + + /// A session that already fell back to on-device after cloud reconnect exhaustion stays + /// there; the opt-in must not drag a failing session back onto the lane it just left. + func testSessionForcedLocalIsNotDraggedBackToCloud() { + var state = STTSessionState() + state.beginCloudToLocalFallback() + XCTAssertEqual( + state.resolveMode( + audioSource: .microphone, isAppleSilicon: true, debugForceCloud: false, + wakeWordNeedsRecognizableName: true), + .local) + } + + /// The pendant already transcribes in the cloud, so the opt-in changes nothing there. + func testBleDeviceIsUnaffected() { + let state = STTSessionState() + XCTAssertEqual( + state.resolveMode( + audioSource: .bleDevice, isAppleSilicon: true, debugForceCloud: false, + wakeWordNeedsRecognizableName: false), + .cloud) + } } diff --git a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift index b98f04c2f8c..c0c34d5dae0 100644 --- a/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift +++ b/desktop/macos/Desktop/Tests/WakeWordSegmentParserTests.swift @@ -180,4 +180,87 @@ final class WakeWordSegmentParserTests: XCTestCase { WakeWordSegmentParser.command(after: "okay Omi order pizza", wakePhrase: "Omi"), "order pizza") } + + // MARK: - "Only", the on-device misrendering + + /// The seven live misses this recovers, verbatim from the stored local segments. + func testOnlyOpeningACommandIsTheWakeWord() { + let cases = [ + ("Only what is on my calendar.", "what is on my calendar."), + ("Only how many tasks do I have?", "how many tasks do I have?"), + ("Only open my rewind timeline.", "open my rewind timeline."), + ("Only show me my notes.", "show me my notes."), + ("Only remind me to call David.", "remind me to call David."), + ("Only search my memories for JD", "search my memories for JD"), + ("Only what is today's weather?", "what is today's weather?"), + ] + for (segment, expected) in cases { + XCTAssertEqual( + WakeWordSegmentParser.command(after: segment, wakePhrase: "Omi"), expected, + "failed for \(segment)") + } + } + + /// Ordinary restrictive "only" is followed by what it restricts, never by an + /// interrogative or a bare imperative. None of these may fire. + func testOrdinaryOnlyDoesNotFire() { + let cases = [ + "Only three people came to the meeting.", + "Only the best ones made it.", + "Only if you want to.", + "Only when I say so.", + "Only where it actually matters.", + "Only a couple left.", + "Only my manager knows.", + "Only about half of them.", + // Imperatives read naturally under a restriction. A request verb only counts when + // it is aimed at the speaker's own things. + "Only do that once.", + "Only add salt at the end.", + "Only read the first chapter.", + "Only tell him if he asks.", + "Only show the ones that passed.", + "Only send the final version.", + "Only open the door for guests.", + ] + for segment in cases { + XCTAssertNil( + WakeWordSegmentParser.command(after: segment, wakePhrase: "Omi"), + "fired for \(segment)") + } + } + + /// Mid-sentence "only" is not a wake word at any position — the phrase has to open an + /// utterance, same rule the homophones follow. + func testMidSentenceOnlyNeverFires() { + XCTAssertNil( + WakeWordSegmentParser.command( + after: "I can only show you what I have.", wakePhrase: "Omi")) + XCTAssertNil( + WakeWordSegmentParser.command( + after: "That's the only thing I want.", wakePhrase: "Omi")) + } + + /// The rendering opening a later sentence in the same window still counts — windows close + /// on the speaker's pause, so the command shares the segment with earlier speech. + func testOnlyOpeningALaterSentenceFires() { + XCTAssertEqual( + WakeWordSegmentParser.command( + after: "recording active for roughly about one hour. Only show me my notes.", + wakePhrase: "Omi"), + "show me my notes.") + } + + /// A greeting in front is corroboration on its own, so the command-head rule steps aside. + func testHeyCorroboratesTheRenderingWithoutACommandHead() { + XCTAssertEqual( + WakeWordSegmentParser.command(after: "hey only order pizza", wakePhrase: "Omi"), + "order pizza") + } + + /// The rendering alone carries no command. + func testBareOnlyIsNotACommand() { + XCTAssertNil(WakeWordSegmentParser.command(after: "Only.", wakePhrase: "Omi")) + XCTAssertNil(WakeWordSegmentParser.command(after: "Only", wakePhrase: "Omi")) + } } From f1f9ba090daa2e327d9cd6b8f294aabf0255699a Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 29 Aug 2026 19:33:07 +0530 Subject: [PATCH 26/30] fix(desktop): stop a re-delivered segment from interrupting the answer to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backend segment is re-delivered as it grows, in place and under one id, and the re-delivery arrives whether or not the speaker added anything. `VoiceBargeInPolicy` treated any non-empty user text as speech, so an unchanged re-delivery counted as the user talking over the assistant. Observed live: the second turn of a conversation was cut off 4.4s into playback by a re-delivery of the very segment that asked the question, byte-identical to the copy already stored. 13:16:41.117 Chat response complete 13:16:45.491 BARGE-IN: User spoke mid-playback; interrupting voice output 13:16:45.501 Transcript [UPDATE] Speaker 0: I'm fine. What about you? Omi, I'm fine. What about you? The first turn escaped only by timing — its answer finished speaking before the re-delivery landed. From the second turn on, the re-delivery falls inside the playback window every time, which is why it reads as "the second question always gets cut off". `shouldInterrupt` now takes the text already stored for that segment id and interrupts only on what the segment gained. Growth that adds nothing but punctuation or spacing is the recognizer tidying up, not speech. A segment that no longer extends what was stored counts as new in full: there is no way to tell a revision from a continuation, and missing a real barge-in is the worse failure. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../AppState/AppState+ListenEvents.swift | 3 ++ .../VoiceBargeInPolicy.swift | 27 ++++++++++- .../Tests/VoiceBargeInPolicyTests.swift | 46 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift index c3fe3a02be6..16db3447e94 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift @@ -85,6 +85,9 @@ extension AppState { isUser: segment.is_user, speaker: speakerId, text: segment.text, + previouslyHeard: segment.id.flatMap { id in + speakerSegments.first(where: { $0.segmentId == id })?.text + }, isSpeaking: FloatingBarVoicePlaybackService.shared.isSpeaking ) { log("Transcription [BARGE-IN]: User spoke mid-playback; interrupting voice output") diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/VoiceBargeInPolicy.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/VoiceBargeInPolicy.swift index bac0bf0694d..791be7233e8 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/VoiceBargeInPolicy.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/VoiceBargeInPolicy.swift @@ -12,18 +12,43 @@ enum VoiceBargeInPolicy: Sendable { /// - isUser: Whether diarization flagged the segment as the user. /// - speaker: Speaker identifier (0 is the primary user). /// - text: Transcript utterance text. + /// - previouslyHeard: The text already stored for this segment id, if the segment is a + /// re-delivery rather than a new one. /// - isSpeaking: Whether voice playback/synthesis is currently active. /// - Returns: True if playback should be halted immediately. static func shouldInterrupt( isUser: Bool, speaker: Int, text: String, + previouslyHeard: String? = nil, isSpeaking: Bool ) -> Bool { guard isSpeaking else { return false } guard isUser || speaker == 0 else { return false } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } - return true + return !newSpeech(in: trimmed, alreadyHeard: previouslyHeard).isEmpty + } + + /// The part of a segment nobody has heard yet. + /// + /// A backend segment is re-delivered as it grows, in place and under one id, and a + /// re-delivery arrives whether or not the speaker added anything. Treating every arrival + /// as fresh speech makes the assistant interrupt itself with the user's *own question*: + /// observed live, the second turn of a conversation was cut off 4.4s into playback by a + /// re-delivery of the segment that asked it, byte-identical to the copy already stored. + /// + /// Only what the segment gained is new speech. A segment that grew from a different + /// prefix — the recognizer revised what it already emitted — counts as new in full, since + /// there is no way to tell a revision from a continuation. + static func newSpeech(in text: String, alreadyHeard: String?) -> Substring { + guard let alreadyHeard else { return text[...] } + let previous = alreadyHeard.trimmingCharacters(in: .whitespacesAndNewlines) + guard !previous.isEmpty else { return text[...] } + guard text.hasPrefix(previous) else { return text[...] } + let addition = text.dropFirst(previous.count) + guard let start = addition.firstIndex(where: { !$0.isWhitespace && !$0.isPunctuation }) + else { return "" } + return addition[start...] } } diff --git a/desktop/macos/Desktop/Tests/VoiceBargeInPolicyTests.swift b/desktop/macos/Desktop/Tests/VoiceBargeInPolicyTests.swift index 675efac8435..3a22ab6abf4 100644 --- a/desktop/macos/Desktop/Tests/VoiceBargeInPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/VoiceBargeInPolicyTests.swift @@ -61,4 +61,50 @@ final class VoiceBargeInPolicyTests: XCTestCase { ) XCTAssertTrue(result, "Speaker 0 is the primary local user and must be allowed to interrupt") } + + // MARK: - Re-delivery is not new speech + + /// The bug this guard exists for, from the live log. A backend segment is re-delivered + /// as it grows, in place and under one id, and the re-delivery arrives whether or not the + /// speaker added anything. The second turn of a conversation was cut off 4.4s into + /// playback by a re-delivery of the very segment that asked the question — byte-identical + /// to the copy already stored, so nothing had been said. + func testUnchangedRedeliveryDoesNotInterrupt() { + let heard = "I'm fine. What about you? Omi, I'm fine. What about you?" + XCTAssertFalse( + VoiceBargeInPolicy.shouldInterrupt( + isUser: true, speaker: 0, text: heard, previouslyHeard: heard, isSpeaking: true)) + } + + /// Growth that adds only punctuation or spacing is the recognizer tidying up, not speech. + func testRedeliveryAddingOnlyPunctuationDoesNotInterrupt() { + XCTAssertFalse( + VoiceBargeInPolicy.shouldInterrupt( + isUser: true, speaker: 0, text: "What about you?", previouslyHeard: "What about you", + isSpeaking: true)) + } + + /// A real barge-in still interrupts: the segment gained words. + func testRedeliveryWithNewWordsStillInterrupts() { + XCTAssertTrue( + VoiceBargeInPolicy.shouldInterrupt( + isUser: true, speaker: 0, text: "What about you? Stop talking.", + previouslyHeard: "What about you?", isSpeaking: true)) + } + + /// A segment nobody has seen before is new in full. + func testFirstDeliveryStillInterrupts() { + XCTAssertTrue( + VoiceBargeInPolicy.shouldInterrupt( + isUser: true, speaker: 0, text: "Stop.", previouslyHeard: nil, isSpeaking: true)) + } + + /// A revision that no longer extends what was stored counts as new in full — there is no + /// way to tell a rewritten prefix from a continuation. + func testRevisedPrefixCountsAsNewSpeech() { + XCTAssertTrue( + VoiceBargeInPolicy.shouldInterrupt( + isUser: true, speaker: 0, text: "Wait, stop talking.", previouslyHeard: "What about you?", + isSpeaking: true)) + } } From 00e7a20c9c7e367042e148f6bffd6a8043aeeb8e Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 29 Aug 2026 19:47:22 +0530 Subject: [PATCH 27/30] fix(desktop): count a garbled word inside an echo as part of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Omi's own answer was reaching the transcript as a second speaker, so the reply was heard once from the speakers and again as if a person had said it. `walk` only commits a matched word once two match in a row — a guard that exists because a single common word ("the", "and") matching by coincidence once let the backward walk eat "the time" off the end of a user's command. But an utterance's closing word can never reach two-in-a-row when the word before it was garbled, so the last word of a short answer was permanently uncountable. Measured live: Omi said "I don't know the details of Wake Word yet." and the microphone returned "WakeMore" for the product name. The trailing "yet" followed that one mismatch and could not commit, so coverage came out 6 of 8 = 0.75, under the 0.80 floor, and the segment was kept. 19:37:59.241 Transcript [ADD] Speaker 1: I don't know the details of WakeMore yet. 19:38:00.576 ECHO: dropped ... I don't know the details of WakeMore yet. Feature you design Confirmed against the real strings before and after: `keep` became `drop` for every rendering of what was actually spoken. The final token may now commit on a single match, but only after `minimumWordCount` words have already matched in a run, so a lone coincidental word still cannot start or extend an echo. Forward walk only: the reversed walk measures the playback that follows a barge-in, where the "final" token is the first word the user said, and committing it on one match would eat the start of the interruption. Failure-Class: none Co-Authored-By: Claude Opus 5 --- .../VoicePlaybackEchoPolicy.swift | 45 ++++++++++++++++--- .../Tests/VoicePlaybackEchoPolicyTests.swift | 32 +++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift index 318fde73b5b..3c154e2a49e 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/VoicePlaybackEchoPolicy.swift @@ -63,7 +63,11 @@ enum VoicePlaybackEchoPolicy { guard !tokens.isEmpty, !spokenWords.isEmpty else { return .keep } let incoming = tokens.map(\.word) - let leading = matchedPrefixLength(incoming, against: spokenWords) + // Only the forward walk commits a final lone match. The reversed walk measures the + // playback that *follows* a barge-in, and there the "final" token is the first word the + // user said — committing it on one match would eat the start of the interruption. + let leading = matchedPrefixLength( + incoming, against: spokenWords, commitsFinalSingleMatch: true) guard leading >= minimumWordCount else { return .keep } guard tokens.count - leading >= minimumWordCount else { // Discarding the whole segment needs the match to actually account for the whole @@ -156,9 +160,12 @@ enum VoicePlaybackEchoPolicy { /// Called with both sequences reversed to measure a trailing run instead. private static func matchedPrefixLength>( _ incoming: S, - against spoken: S + against spoken: S, + commitsFinalSingleMatch: Bool = false ) -> Int { - matchedPrefixLength(Array(incoming), against: Array(spoken)) + matchedPrefixLength( + Array(incoming), against: Array(spoken), + commitsFinalSingleMatch: commitsFinalSingleMatch) } /// Anchors tried before giving up on finding where in the history this utterance begins. @@ -169,14 +176,25 @@ enum VoicePlaybackEchoPolicy { /// started at the end of the history rather than at the sentence. static let maximumAnchorsTried = 24 - private static func matchedPrefixLength(_ incoming: [String], against spoken: [String]) -> Int { + private static func matchedPrefixLength( + _ incoming: [String], + against spoken: [String], + commitsFinalSingleMatch: Bool + ) -> Int { guard let first = incoming.first else { return 0 } var anchors = spoken.indices.filter { spoken[$0] == first }.prefix(maximumAnchorsTried).map { $0 } if anchors.isEmpty { anchors = [0] } - return anchors.map { walk(incoming, against: spoken, from: $0) }.max() ?? 0 + return anchors.map { + walk(incoming, against: spoken, from: $0, commitsFinalSingleMatch: commitsFinalSingleMatch) + }.max() ?? 0 } - private static func walk(_ incoming: [String], against spoken: [String], from anchor: Int) -> Int { + private static func walk( + _ incoming: [String], + against spoken: [String], + from anchor: Int, + commitsFinalSingleMatch: Bool + ) -> Int { var spokenIndex = anchor var mismatches = 0 var run = 0 @@ -191,6 +209,21 @@ enum VoicePlaybackEchoPolicy { // "the" and "and" occur in every answer. Committing on one match let the backward // walk eat "the time" off the end of a user's command. Two in a row is a run. if run >= 2 { lastMatched = offset + 1 } + // The exception is the utterance's own last word, once a run has already proved + // this is an echo. It can never reach two-in-a-row when the word before it was + // garbled, so the closing word of a short answer was permanently uncountable — + // measured live: Omi said "I don't know the details of Wake Word yet.", the + // microphone returned "WakeMore" for the product name, and the trailing "yet" + // could not commit. Coverage came out 6 of 8 = 0.75, under the 0.80 floor, so + // Omi's own sentence was written into the transcript as a second speaker. + // + // Only the final token, and only after `minimumWordCount` words already matched + // in a run, so a lone coincidental word still cannot start or extend an echo. + if run == 1, commitsFinalSingleMatch, offset == incoming.count - 1, + lastMatched >= minimumWordCount + { + lastMatched = offset + 1 + } continue } run = 0 diff --git a/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift b/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift index d6e2f59ed68..d8886962d63 100644 --- a/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/VoicePlaybackEchoPolicyTests.swift @@ -232,4 +232,36 @@ final class VoicePlaybackEchoPolicyTests: XCTestCase { + "2. Delhi, the national capital. 3. Bengaluru, a major technology and startup hub."), .keepResidue("Omi, stop and tell me the time")) } + /// Live defect: Omi's own answer was written into the transcript as a second speaker, + /// and the user heard the reply twice in two voices. + /// + /// Omi said "Wake Word"; the microphone returned "WakeMore". In an eight-token sentence + /// that single garbled product name was enough: the closing "yet" followed a mismatch, + /// so it could never reach two-in-a-row and never committed. Coverage came out 6 of 8 = + /// 0.75, under the 0.80 floor, and the segment was kept. + func testGarbledWordBeforeTheLastOneStillCountsAsAnEcho() { + let incoming = "I don't know the details of WakeMore yet." + for spoken in [ + "I don't know the details of Wake Word yet. Feature you designed?", + "I don't know the details of WakeWord yet. Feature you designed?", + "I don't know the details of Wake Word yet.", + ] { + XCTAssertEqual( + VoicePlaybackEchoPolicy.classify( + transcript: incoming, spokenWords: VoicePlaybackEchoPolicy.words(spoken)), + .drop, + "kept Omi's own answer against: \(spoken)") + } + } + + /// The exception is narrow: a lone match still cannot carry an utterance that never + /// established a run, so a short sentence sharing one closing word with playback stays. + func testLoneFinalMatchWithoutAnEstablishedRunStillKeeps() { + XCTAssertEqual( + VoicePlaybackEchoPolicy.classify( + transcript: "Marco called about the invoice yet", + spokenWords: VoicePlaybackEchoPolicy.words( + "I don't know the details of Wake Word yet. Feature you designed?")), + .keep) + } } From bbc5235dfc029cb8e73878f5e0faad5c96c95ec5 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 30 Aug 2026 08:04:07 +0530 Subject: [PATCH 28/30] chore(desktop): drop the changelog fragment that already shipped `20260823-local-transcription-pause-endpointing.json` is byte-identical to the entry released in v0.12.221, which shipped through #12181 when pause endpointing was split off this branch. Leaving it here would re-announce an already-released change at the next consolidation. Co-Authored-By: Claude Opus 5 --- .../20260823-local-transcription-pause-endpointing.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json diff --git a/desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json b/desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json deleted file mode 100644 index bf949dd1c5c..00000000000 --- a/desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "change": "On-device transcription now ends a window when you stop talking instead of on a fixed 10-second boundary, so what you say is transcribed about a second later" -} From 390d8c94f7e10bbcf81ae6b2bde45d1cd7ee0b13 Mon Sep 17 00:00:00 2001 From: Aryan Date: Mon, 31 Aug 2026 17:56:47 +0530 Subject: [PATCH 29/30] fix(desktop): stop an abandoned hands-free command from quieting the next query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `suppressNextVisibleSurface` is a one-shot latch consumed only by `prepareVisibleQueryState`. Three exits from `openAIInputWithQuery` return before reaching it — no window, no active provider, and the `.voiceOnly` branch, which renders nothing and so never consumes it at all. A wake word that fires while the bar is torn down or unprovisioned therefore leaves the latch set, and the next typed question is silently answered in the notch. Clear the latch on every exit that abandons the query. Separately, `beginVisibleMainQuery` only ever *sets* `answersQuietly` (clearing there would let the second `prepareVisibleQueryState` call undo the first), so the reset has to live at the query entry points. The closure re-wired in `openAIInputWithQuery` does it; the default wiring installed at setup did not, so a quiet wake-word answer leaked its silence into the next typed question on that path. Verification: `swift build` in `desktop/macos/Desktop` — clean. No behavioral test added: both paths run through `FloatingControlBarWindow` (an NSPanel) with no injectable seam, and the existing coverage here is source-scrape pins, which AGENTS.md calls a static tripwire rather than behavioral coverage. Failure-Class: none --- .../FloatingControlBarWindow.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift index 8f72b13462c..21820a5e0d7 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift @@ -3075,6 +3075,9 @@ class FloatingControlBarManager { barWindow.onSendQuery = { [weak self, weak barWindow, weak chatProvider] message in guard let self = self, let barWindow = barWindow, let provider = chatProvider else { return } + // Same reset as the re-wired closure in `openAIInputWithQuery`: a typed question is + // never quiet, whatever a preceding wake-word answer left behind. + barWindow.state.answersQuietly = false Task { @MainActor in await self.withQueryTracer(query: message, fromVoice: false) { await self.routeQuery(message, barWindow: barWindow, provider: provider, fromVoice: false) @@ -3785,8 +3788,13 @@ class FloatingControlBarManager { fromVoice: Bool = false, voiceTurnID: VoiceTurnID? = nil ) { - guard let window = window else { return } - guard let provider = activeFloatingProvider() else { return } + // Only the visible path consumes `suppressNextVisibleSurface`, in + // `prepareVisibleQueryState`. Every exit before that abandons the query, so a latch set + // by `submitHandsFreeCommand` would outlive it and quiet the next typed question once. + guard let window = window, let provider = activeFloatingProvider() else { + Self.suppressNextVisibleSurface = false + return + } // The `.voiceOnly` surface belongs to callers that own a turn: it renders nothing, and // every step below re-checks ownership, so entering it without a turn drops the query @@ -3795,6 +3803,8 @@ class FloatingControlBarManager { // transcribed and owns none, so it falls through to the visible path — where // `fromVoice` still marks it a voice query, which is what makes the answer spoken. if let voiceTurnID { + // `.voiceOnly` renders nothing, so it never reaches the consumer above either. + Self.suppressNextVisibleSurface = false guard VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil else { return } chatCancellable?.cancel() From 767939824c2963d9043ee98cb1d818bf54a70d2d Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 3 Sep 2026 06:49:21 +0530 Subject: [PATCH 30/30] test(desktop): re-pin the quiet-answer latch after the typed-send rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My merge resolution took main's side of `AgentPillLifecycleTests.swift` wholesale. That was right for the file — main retired `AskAIInputView` and the test source-pinned against it — but it silently removed the only guard this feature has. Nothing in the 678 test files on that head pinned `presentsSurface`, `answersQuietly`, or `suppressNextVisibleSurface` any more, so the wake word's notch behavior could be refactored away without failing anything. The replacement pins the same contract against `FloatingControlBarWindow.swift`, where the logic actually lives, rather than a view that has now been retired twice: - `presentsSurface` defaults true, so an ordinary typed send presents the panel - `beginVisibleMainQuery` only ever sets quiet, never clears (it runs twice per query) - the one-shot latch is armed by the wake word and consumed by the visible path - all three abandoning exits clear it, asserted by count - both `onSendQuery` wirings reset `answersQuietly` Verification: passes on this head. Mutation-checked rather than assumed — deleting the `.voiceOnly` latch clear fails it with `("2") is not equal to ("3")`, and restoring it returns green. Failure-Class: none --- .../Tests/AgentPillLifecycleTests.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index fbf1281b91b..511756e1a79 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -2018,6 +2018,42 @@ import XCTest XCTAssertTrue(source.contains("private static func framesEquivalent(_ lhs: NSRect, _ rhs: NSRect) -> Bool")) } + /// Replaces `testTypedSendDelegatesResponseSizingToWindow`, which was retired with + /// `AskAIInputView` when main's typed-send rewrite landed. That test was the only thing + /// pinning the quiet-answer latch, and the wake-word feature depends on it: a typed + /// question must present the response panel, while a wake-word answer stays in the notch. + /// + /// Everything pinned here lives in `FloatingControlBarWindow.swift` now, so this survives + /// the view-layer churn that killed the previous pin. + func testQuietAnswerLatchContractSurvivesTheTypedSendRewrite() throws { + let windowSource = try floatingControlBarWindowSource() + + // Defaulting to true is what keeps an ordinary typed send presenting the panel. + XCTAssertTrue(windowSource.contains("func beginVisibleMainQuery(")) + XCTAssertTrue( + windowSource.contains( + "_ message: String, fromVoice: Bool, animated: Bool = true, presentsSurface: Bool = true")) + + // beginVisibleMainQuery may only ever *set* quiet. It runs twice per query, so clearing + // here would let the second call undo the first and reopen the panel mid-answer. + XCTAssertTrue(windowSource.contains("if !presentsSurface { state.answersQuietly = true }")) + + // The one-shot latch: armed by the wake word, consumed by the visible path. + XCTAssertTrue(windowSource.contains("private static var suppressNextVisibleSurface = false")) + XCTAssertTrue(windowSource.contains("Self.suppressNextVisibleSurface = true")) + XCTAssertTrue(windowSource.contains("let presentsSurface = !Self.suppressNextVisibleSurface")) + + // Every exit that abandons the query has to clear the latch, or an abandoned wake-word + // command quiets the next typed question instead. Two guard exits plus .voiceOnly. + XCTAssertEqual(windowSource.components(separatedBy: "Self.suppressNextVisibleSurface = false").count - 1, 3) + + // A typed question is never quiet, whatever a preceding wake-word answer left behind. + // Both onSendQuery wirings must reset it — the default one installed at setup and the + // one re-wired in openAIInputWithQuery. + XCTAssertTrue(windowSource.contains("barWindow.state.answersQuietly = false")) + XCTAssertTrue(windowSource.contains("window.state.answersQuietly = false")) + } + private func agentPillSource() throws -> String { let sourceURL = URL(fileURLWithPath: #filePath) .deletingLastPathComponent()