Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
00b2b88
feat(desktop): hands-free wake word to command the assistant during a…
aryanorastar Aug 18, 2026
0eca6df
style(desktop): format WakeWord sources with pinned swift-format; add…
aryanorastar Aug 18, 2026
e42ee6f
fix(desktop): match speech-to-text renderings of the wake phrase
aryanorastar Aug 19, 2026
01cb0fc
fix(desktop): make the wake word survive the real ambient transcript
aryanorastar Aug 19, 2026
bbe9d4d
fix(desktop): speak the assistant's reply to a wake word
aryanorastar Aug 19, 2026
8b3bb10
fix(desktop): accept aspirated renderings of the wake phrase
aryanorastar Aug 19, 2026
fb13867
fix(desktop): stop a growing transcript segment re-firing the wake word
aryanorastar Aug 19, 2026
ddaf0e8
fix(desktop): restore wake word dispatch broken by fromVoice
aryanorastar Aug 19, 2026
cfb0b61
fix(desktop): require a pause after a homophone wake phrase
aryanorastar Aug 19, 2026
3775563
fix(ci): bypass stalled Azure apt mirror
aryanorastar Aug 19, 2026
d3d1544
fix(ci): bypass stalled Azure apt mirror in Linux package helper smoke
aryanorastar Aug 19, 2026
9f25030
chore(ci): retrigger hermetic suite after a scope-detect runner cance…
aryanorastar Aug 20, 2026
2c24f5f
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 20, 2026
4f889ac
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 20, 2026
d54b858
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 21, 2026
c98fe84
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 21, 2026
348c989
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 21, 2026
328aef2
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 21, 2026
5733ca9
feat(desktop): recognize evidence-backed Omie wake-word transcripts
aryanorastar Aug 21, 2026
d9f63dd
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 23, 2026
12a7a34
fix(desktop): end an on-device transcription window when the speaker …
aryanorastar Aug 23, 2026
8e43b15
feat(desktop): speak the wake word's answer and keep the conversation…
aryanorastar Aug 23, 2026
46bc2e8
fix(desktop): stop ambient capture treating Omi's own voice as the user
aryanorastar Aug 23, 2026
c5f95d9
fix(desktop): recognize a wake word spoken after something else, and …
aryanorastar Aug 23, 2026
02eca20
fix(desktop): anchor echo matching to the sentence, not to the ends o…
aryanorastar Aug 23, 2026
54a535c
fix(desktop): stop "ok"/"okay" corroborating a misheard wake phrase
aryanorastar Aug 23, 2026
f9bf68d
feat(desktop): route a wake word into the realtime session behind a flag
aryanorastar Aug 23, 2026
6be39e6
fix(desktop): give the realtime wake-word turn a real user message an…
aryanorastar Aug 25, 2026
1a630d3
fix(desktop): warm the realtime session before a wake-word turn
aryanorastar Aug 25, 2026
ccaaebb
feat(desktop): answer a wake word from the notch instead of opening a…
aryanorastar Aug 28, 2026
7fd0e2d
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 28, 2026
f76cced
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Aug 29, 2026
3bb78d6
test(desktop): repin the beginVisibleMainQuery contract to its wrappe…
aryanorastar Aug 29, 2026
21f1e50
feat(desktop): recognize the wake word on the default on-device path
aryanorastar Aug 29, 2026
f1f9ba0
fix(desktop): stop a re-delivered segment from interrupting the answe…
aryanorastar Aug 29, 2026
00e7a20
fix(desktop): count a garbled word inside an echo as part of it
aryanorastar Aug 29, 2026
bbc5235
chore(desktop): drop the changelog fragment that already shipped
aryanorastar Aug 30, 2026
390d8c9
fix(desktop): stop an abandoned hands-free command from quieting the …
aryanorastar Aug 31, 2026
f3da12c
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Sep 1, 2026
dabf936
Merge remote-tracking branch 'origin/main' into feat/wake-word
aryanorastar Sep 2, 2026
7679398
test(desktop): re-pin the quiet-answer latch after the typed-send rew…
aryanorastar Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,40 @@
// 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
}

// 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,
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")
Expand All @@ -81,6 +109,8 @@
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 })
Expand Down Expand Up @@ -316,7 +346,7 @@
}

/// Handle message events from Python backend `/v4/listen`
func handleListenEvent(_ event: TranscriptionService.ListenEvent) {

Check warning on line 349 in desktop/macos/Desktop/Sources/AppState/AppState+ListenEvents.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

func handleListenEvent(_ event: TranscriptionService.ListenEvent) is 297 lines; consider extracting focused helpers over 150 lines.
switch event.type {
case "service_status":
let status = event.raw["status"] as? String ?? "unknown"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@preconcurrency import AVFoundation

Check warning on line 1 in desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift is 1745 lines; consider splitting files over 800 lines.
import Combine
import SwiftUI
@preconcurrency import UserNotifications
Expand Down Expand Up @@ -75,7 +75,8 @@
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
Expand Down
30 changes: 26 additions & 4 deletions desktop/macos/Desktop/Sources/AppState/STTSessionState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@preconcurrency import AVFoundation

Check warning on line 1 in desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift is 1461 lines; consider splitting files over 800 lines.
import CryptoKit
import Foundation
import OmiSupport
Expand Down Expand Up @@ -117,6 +117,65 @@
private var activeRealtimeSlowToolAcknowledgement: RealtimeSlowToolAcknowledgementKind?
private var activeRealtimeSlowToolAcknowledgementTransport: String?

/// 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: [(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
/// 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] {
let cutoff = Date().addingTimeInterval(-Self.spokenWordLifetime)
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 }
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)
}
}

/// 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?
Expand Down Expand Up @@ -689,6 +748,7 @@
}
audioPlayer = player
activePlayerFallbackText = fallbackText
recordSpokenText(fallbackText)
if let lease = activePTTLease {
_ = VoiceTurnCoordinator.shared.noteOutputProgress(lease)
}
Expand Down Expand Up @@ -823,6 +883,7 @@
leaseID: activePTTLease?.id,
utterance: utterance)
speechSynthesizer.speak(utterance)
recordSpokenText(text)
tracer?.end("tts_start")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Combine

Check warning on line 1 in desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift is 934 lines; consider splitting files over 800 lines.
import SwiftUI
import VoiceTurnDomain

Expand Down Expand Up @@ -361,6 +361,15 @@
@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
Expand Down
Loading
Loading