Skip to content

feat(desktop): hands-free wake word to command the assistant during ambient listening - #11801

Open
aryanorastar wants to merge 31 commits into
BasedHardware:mainfrom
aryanorastar:feat/wake-word
Open

feat(desktop): hands-free wake word to command the assistant during ambient listening#11801
aryanorastar wants to merge 31 commits into
BasedHardware:mainfrom
aryanorastar:feat/wake-word

Conversation

@aryanorastar

@aryanorastar aryanorastar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Status note. #12181 (pause endpointing) has merged, so the first of the two always-on
surfaces is off this PR. Echo suppression stays, and is load-bearing rather than riding along —
c5f95d9 removes the wake word's own "assistant is speaking" guard precisely because
VoicePlaybackEchoPolicy.classify runs ahead of WakeWordService.observe.

Adds an opt-in Wake Word feature for macOS desktop: during ambient listening, say "Omi" followed by a command and the assistant runs it hands-free — no clicks, no Push-to-Talk.

What changed

  • Sources/WakeWord/WakeWordService.swift (new) — trigger engine: strips the wake phrase, extracts the command, and submits it to the assistant (openAIInputWithQuery). Guards: 30s cooldown against rapid repeats, per-segment-ID deduplication, user-speech only, and suppression while the assistant is already busy.
  • Sources/WakeWord/WakeWordSegmentParser.swift (new) — splits "Omi, do X" into wake phrase + command; requires a 2+ word command so a bare "Omi" never misfires.
  • AppState+ListenEvents.swift — feeds every incoming transcript segment into WakeWordService.observe(...), the single funnel for ambient speech.
  • AssistantSettings.swift — persisted wakeWordEnabled / wakeWordPhrase / wakeWordCooldown (default off so current users' behavior is unchanged).
  • SettingsContentView+General.swift — new Wake Word settings card with on/off toggle and a dynamic subtitle tied to the audio-recording mode ("Listens in the background during meetings and calls").
  • TestsWakeWordServiceTests + WakeWordSegmentParserTests (15 cases).

How it works

ambient transcript segment ("Omi, order asian food")
  → WakeWordService.observe
  → WakeWordSegmentParser extracts wake phrase + command
  → gated: enabled? busy? user speech? cooldown? dedup?
  → onTrigger("order asian food") → submitted to assistant

Verification

  • xcrun swift test --package-path Desktop --filter WakeWord15 tests, 0 failures.
  • Exercised end-to-end in a running named bundle via the hermetic capture seam (drives the real handleBackendSegmentsWakeWordService path):
WakeWord: submitting 'order asian food' to the assistant   ← wake phrase stripped
Transcript [ADD]: Omi, order asian food                    ← raw transcript line
Chat telemetry: chat_agent_query_started surface=floating_text
APIClient: POST https://api.omiapi.com/v2/desktop/messages ← real query submitted

Negative control: injecting "I was just saying the weather is nice today" produced no trigger (no wake phrase).

  • Changelog fragment added (changelog/unreleased/20260818-wake-word-trigger.json). Config rachets (check_desktop_test_quality.py, swift-format, SwiftLint) pass.

Follow-up (out of scope, separate issue)

While validating the demo bundle we hit a pre-existing macOS debug-build crash at startup in DesktopHomeView.restorePersistedCaptureServices → startTranscription (over-release, unaffected by this diff — reproduces with the wake word disabled and only manifests once mic permission is granted). Filed separately; does not block this feature.

Screenshots

New — wake word enabled Old — before (no wake word)
Wake word enabled Before

Product invariants affected

  • INV-CHAT-1
  • INV-VOICE-1

Failure class (fixes)

Failure-Class: none

Line-count exception

openAIInputWithQuery and sendFollowUpQuery gained the turn-optional guard that lets a
wake word — which owns no VoiceTurnID — reach the visible surface as a voice query, so its
answer is spoken and a second command continues the conversation. Both are existing
functions in this file; splitting it is unrelated refactoring for a 16-line change.

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift | 5395 -> 5457 | wake-word spoken answers and follow-up continuity route through the two existing dispatch entry points

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift | 1499 -> 1504 | Five lines recording session-spoken text into the playback history the echo policy reads; it belongs at the delegate that receives the text, and the surrounding turn-event guards are private to this file.

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift | 1540 -> 1622 | runWakeWordTurn sits beside runHeadlessPTTTurn because it shares that method's turn lifecycle exactly — mint an automation turn, select the hub route, open the input window, commit — and the two must stay in step; a sibling file would duplicate the ordering constraints rather than share them.

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift | 1661 -> 1669 | sendSpokenCommand is an eight-line sibling of sendTestTextInput sharing the same private buffering path; the wire form and buffer are private to this file.

…mbient 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)
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

… 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).
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Two CI failures fixed and pushed (0eca6df):

  1. Desktop Swift Static & Test Contracts — desktop-e2e-flow-coverage: the two new WakeWord sources had no covers: entry. Added WakeWordService.swift + WakeWordSegmentParser.swift to e2e/flows/capture-lifecycle.yaml, which is the correct home — the capture_test_transcript seam drives the same AppState+ListenEventshandleBackendSegments funnel the wake word observes (already covered that funnel file).

  2. Desktop Swift Static & Test Contracts — desktop-swift-format-lint (also surfaced during local pre-flight): four WakeWord files (2 sources + 2 tests) were missing the trailing newline per pinned swift-format 602.0.0. Formatted with the pinned wrapper — the only change is EOF newlines.

Local verification before push:

  • run_checks.py macos lane: e2e-flow-coverage PASS, swift-format-lint PASS, swiftlint PASS
  • check_desktop_test_quality.py: OK (no drift)
  • swift test --filter WakeWordSegmentParserTests|WakeWordServiceTests: 15/15 pass (7 parser + 8 service)

Waiting on CI to re-run — should flip green.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks @aryanorastar — nicely built feature: opt-in and default-off, clean parser/service split, injectable clock and trigger for testing, plus changelog and e2e contract coverage. I walked all nine files; notes below.

Code walkthrough

  • Sources/WakeWord/WakeWordSegmentParser.swift — prefix + word-boundary matching is done carefully, and testIgnoresSegmentsWithoutWakeWord rejecting "Omiway" is a good touch. Minor note: the command extraction (lines 11–15) offsets into the raw string by the normalized candidate's count; that's safe for ordinary casing since lowercased() preserves grapheme-cluster counts, just worth knowing if the phrase ever gains case-folding edge cases.
  • Sources/WakeWord/WakeWordService.swift — the guard stack (enabled → conversation-active → isUser → segmentId dedup → parse → 2-word minimum → cooldown) is in the right order, the 100-entry firedSegmentIDs cap bounds memory, and the injectable now/onTrigger let the service be tested without the floating bar.
  • AppState+ListenEvents.swift — the funnel at line 32 runs before the upsert, so both new and updated segments are observed; dedup by segmentId then correctly prevents double-firing when a segment grows.
  • AssistantSettings.swift — keys registered via register(defaults:) with sane values (off / "Omi" / 30s), phrase getter falls back to the default when blank, cooldown getter rejects non-positive stored values. Consistent with the surrounding settings style.
  • SettingsContentView+General.swift — the subtitle that adapts to audioRecordingMode is thoughtful copy; the toggle stays enabled even when recording is off (with the subtitle explaining), which reads as a deliberate choice.
  • WakeWordSegmentParserTests.swift / WakeWordServiceTests.swift — 15 cases covering the parser variants and every service guard, including the cooldown boundary at 31s.
  • changelog/unreleased/20260818-wake-word-trigger.json — matches shipped behavior (opt-in, during ambient listening).
  • e2e/flows/capture-lifecycle.yaml — adding both new files to covers keeps the capture-lifecycle contract honest.

Product questions before merge

  1. Transcript-based triggering: because detection runs on transcript segments, a user talking about the product ("Omi is a great product") parses to command "is a great product" and auto-sends a query. The 30s cooldown and 2-word minimum soften this but don't remove it. Is detection-on-transcript the intended v1 UX versus keyword spotting?
  2. onTrigger submits via openAIInputWithQuery(command, fromVoice: false), which auto-sends and presents the answer visually in the floating bar (brought to front). So "hands-free" covers issuing the command, not receiving the answer — is that the intended interaction, or should this ride the voice-turn path?
  3. minimumCommandWords = 2 means single-word commands ("Omi, stop") can't fire — deliberate?

No blocking code issues found; desktop CI is green including the new tests and e2e t0. Leaving the wake-word semantics and auto-send UX for human maintainer review before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added needs-maintainer-review Needs a human maintainer to sign off before merge positive-signal Good PR — positive signal, not a formal approval macOS labels Aug 18, 2026
@aryanorastar

aryanorastar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and kind words @Git-on-my-level!

Here is the context and rationale behind the three product questions:

  1. Transcript-based vs. Keyword Spotting (v1 approach):

    • Rationale: Running detection on the active transcript stream allows hands-free triggering with zero runtime overhead or binary bloat (no heavy neural KWS models/weights bundled).
    • Because ambient listening is already active, this gives instant v1 wake-word capability. For future iterations, we can layer on an acoustic KWS engine or verb-intent classifier if needed.
  2. Visual Floating Bar vs. Voice Output Delivery:

    • openAIInputWithQuery(command, fromVoice: false) was chosen to give immediate visual feedback and bring up the conversation card.
    • When paired with our companion PR feat(desktop): halt voice playback on user speech barge-in #11809 (feat(desktop): halt voice playback on user speech barge-in) and user TTS settings, speech output seamlessly speaks the answer for a fully eyes-free loop.
  3. Minimum Command Words (2 words vs single words like "stop"):

    • The 2-word minimum ("order food", "what's my schedule") prevents accidental single-word triggers like "Omi hey".
    • If desired, we can add a targeted whitelist for control commands (e.g., ["stop", "cancel", "mute", "quiet"]) that are permitted to fire with 1 word, while keeping the 2-word minimum for open queries.

Happy to follow the maintainers' preference on any follow-up adjustments! Ready for final merge.

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).
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.
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.
@aryanorastar

aryanorastar commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

I took this branch out of unit tests and ran it against a live ambient session on macOS. It did not work, and the reason it did not work was never visible: every guard in WakeWordService.observe returned silently, so a wake word that never fired looked identical to one that was never spoken.

Four defects, all found by running it. The unit tests passed before and after every one of them, because they construct segments the backend never actually sends.

1. Diarization attribution — the feature was dead on arrival

The trigger required segment.isUser. The backend only sets is_user once a speech profile is enrolled, so on an account without one it is false for every segment the user speaks:

Transcript [ADD] Speaker 0 [-0.0s-1.7s]: Omi, what's the weather?
WakeWord: ignored — segment not attributed to the user (speaker 0)

Speech-to-text heard the phrase perfectly. The wake word could not fire, and could not fire for any new user.

VoiceBargeInPolicy in the sibling PR already answers this — it gates on isUser || speaker == 0 and documents speaker 0 as the primary user. Two entry points in the same feature set disagreed about what "the user" means, and the stricter one silently won. Aligned the wake word on the sibling's contract.

2. Speech-to-text spells the wake phrase by sound

The parser matched the literal string omi only. "Omi" is acoustically "oh-mee", and recognizers render it accordingly:

LocalTranscriptionService[mic]: 10.0s rms=0.0129 conf=0.92 → Oh me, how are you?

The recognizer was confident and correct about what it heard. Added the renderings recognizers actually emit, expanded through the existing greeting prefixes. Negative tests cover the obvious risk — "Omnibus schedule changed" and a bare "Oh me" with no command still do not fire.

3. Segment dedup dropped later commands

The backend re-delivers one growing segment under a single id:

[206.0s-217.1s] → [206.0s-220.7s] → [206.0s-224.2s] → [206.0s-228.9s]

firedSegmentIDs deduped on the id for the life of the process, so any command that landed inside an id which had already fired was discarded. Keyed on the id plus the extracted command instead: a re-sent segment is still suppressed, a new instruction inside it runs.

4. The cooldown ate distinct commands

The cooldown is there to swallow a rapid repeat of the same utterance, but it gated on elapsed time alone. The ambient transcript lane runs well behind live speech, so several genuinely different commands arrive bunched inside one 30s window. Two consecutive "what time is it" attempts were both lost this way:

10:29:23  submitting 'how are you?'
10:29:23  ignored — cooldown — 0s since last trigger
10:29:34  ignored — cooldown — 11s since last trigger
10:29:47  ignored — cooldown — 24s since last trigger

Now gates on a repeat of the same command. A repeat inside 30s is still suppressed.

5. Replies were silent

Dispatch used fromVoice: false, which is the flag deciding whether the assistant speaks its answer or only renders text. A wake word is a hands-free entry point — hands and eyes are elsewhere — so a silent reply strands the interaction the feature exists to enable. PushToTalkManager already passes true. Confirmed live: the trigger fired, answered correctly, and I heard nothing.

Diagnostics

Every rejection now names itself. Only segments that actually carry the wake phrase are reported, so ordinary speech stays quiet. These log lines are what located defects 1 and 3, and they are the difference between "it doesn't work" and a diagnosis.

Verification

  • swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' → 21 passed
  • Regression tests carry the live values that exposed each defect: speaker 0 with is_user=false, the reused segment id, a distinct command inside the cooldown, and the exact "Oh me, how are you?" transcript.
  • Confirmed live after the fixes, against the dev serving plane: WakeWord: submitting 'can you order food for me?' to the assistant, with the re-delivered segment correctly suppressed on the following turn.

Honest gaps

  • Ambient latency is not addressed here and is the biggest remaining problem. Measured ~35s between speaking and the transcript arriving, near-constant across 11 segments (34.3–36.6s). The client ships audio every 100ms (audioBufferSize = 3200), so this is server-side: the socket opens with conversation_role=ambient, a lane that batches. Push-to-talk feels instant because it uses the realtime path instead. A wake word answering 35 seconds later is not usable command-and-control, and I do not think this branch can fix it — it needs the realtime lane. Happy to open that separately if you want it pursued.
  • Voice follow-up is still unavailable from the wake word. sendFollowUpQuery exists and push-to-talk 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. Answering a question by voice does not work today. Same dependency on the realtime lane.
  • Not verified on an account with an enrolled speech profile — I do not have one, which is how defect 1 surfaced.
  • No on-device/pendant verification; this is desktop mic + screen only.
  • Homophone coverage is a fixed list, not phonetic matching. It covers what I observed; a recognizer that renders the phrase some other way will still miss.

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
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.
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.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Update after taking this branch out of unit tests and running it as a user on macOS for an afternoon. Three more defects, one of which was mine from the previous round, plus two honest limits I could not resolve.

Correction to my previous comment

The commit I pushed to make the assistant speak its reply (fromVoice: true) silently disabled the feature end to end. openAIInputWithQuery gates the voice path on a turn the wake word does not mint:

if fromVoice {
  guard let voiceTurnID,
    VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil
  else { return }

With no voiceTurnID that guard returns with no log and no effect. Every wake word logged submitting and dispatched nothing — the trigger looked healthy while the assistant was never invoked. I spent hours diagnosing microphones, accents, agent VMs and the network before finding it in my own diff.

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. Reverted.

Live proof of the difference:

14:48:50  WakeWord: submitting 'what time is it?' to the assistant
14:48:52  WakeWord: ignored — assistant already busy

That second line means the turn actually engaged. It never appeared once on any attempt while fromVoice: true was in place.

Growing segments re-fired the wake word

A backend segment is re-delivered as it grows, in place, under one id. My earlier dedup keyed on the exact command, so every growth counted as a new instruction and fired again with a longer string. The assistant's own spoken reply is captured by the microphone and appended to that same segment, so each re-fire submitted a more polluted command:

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. Now a command that extends one already fired for that segment is treated as the same instruction; anything else is new.

Aspirated renderings

On-device recognition takes no keyword list and fronts the vowel with an aspirate — "Homi what's the weather?", "Homie, can you order food for me?". Added homi/hommi. Deliberately excluded homie: it is an ordinary English word and would fire on real speech.

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. The misses only occur on the on-device lane, which cannot take the boost.

Verification

  • swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' → 22 passed
  • Regression tests carry the live values that exposed each defect.
  • Confirmed working end to end on the dev serving plane: wake word fires with a clean command, dispatches, the assistant answers, and the re-delivered segment is correctly suppressed.

Honest gaps — the two that matter most

1. Ambient latency, ~15–35s. Measured across 11 segments at 34.3–36.6s in one session and ~25s in another. The client ships audio every 100ms (audioBufferSize = 3200), so this is server-side: the socket opens with conversation_role=ambient, a lane that batches. Push-to-talk feels instant because it uses the realtime path instead.

A wake word answering 25 seconds later is not usable command-and-control, and I do not believe this branch can fix it. I think this needs the realtime lane, or a dedicated on-device keyword spotter running on raw audio rather than riding the ambient transcript. Happy to pursue either if you want it — it is a design decision above my pay grade to make unilaterally.

2. "Works once, then not again." Reproducible in my hands but I have not isolated the mechanism, so I am reporting it rather than guessing. Candidates I could not separate:

  • guard !isConversationActive suppresses while a turn is live. Observed blocking once and clearing within 10s, so "permanently stuck" is not supported by my logs.
  • The 30s cooldown correctly suppresses a repeat of the same command, which is indistinguishable from a failure to a user who repeats themselves because nothing happened.
  • The latency above means the second answer may simply not have arrived yet.

All three would present identically to a user. I would rather flag this than close it with a plausible story.

Also unresolved:

  • The reply is no longer spoken aloud; it renders in the floating bar. Restoring speech means minting a VoiceTurnID via VoiceTurnCoordinator.begin(intent:) and threading it through dispatch — a real change, not attempted here.
  • Voice follow-up is unavailable. sendFollowUpQuery exists and push-to-talk 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. Same dependency on the realtime lane.
  • The microphone capturing the assistant's own speech back into the transcript is a separate defect. This branch only stops it corrupting the command.
  • Not verified on an account with an enrolled speech profile — I do not have one, which is how the is_user defect surfaced in the first place.
  • No on-device/pendant verification; desktop mic + screen only.

@aryanorastar

aryanorastar commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Verified working on device

Recorded a live session on macOS against the dev serving plane. Two hands-free wake word queries, both answered:

what time is it?   →  It's 2:48 PM on Wednesday, 19 August 2026.
what time it is?   →  It's 2:53 PM on Wednesday, 19 August 2026.

No clicks, no Push-to-Talk, no keyboard — spoken into an ambient session with the Wake Word toggle on. Matching log for the same session:

14:53:51  WakeWord: submitting 'what time it is?' to the assistant
14:54:05  WakeWord: ignored — segment a7ab87de… already fired for 'what time it is?'

The second line is the growing-segment dedup correctly suppressing the re-delivery of the same instruction, which is the defect fixed earlier in this branch.

Answers render in the chat panel.

Repeat invocations work — commands need spacing, not a restart

A longer session on the dev serving plane, three separate successful invocations in one process, no restart between them:

22:48:44  WakeWord: submitting 'what time it is?' to the assistant
22:50:20  WakeWord: submitting 'Vachita Vachita Vachita Today' to the assistant
22:50:51  WakeWord: submitting 'what's the weather today?' to the assistant
22:50:51  WakeWord: ignored — segment 449d3eb4… already fired for 'Vachita…'
22:51:43  WakeWord: ignored — segment 250378c6… already fired for 'what's the weather today?'

The two ignored lines are the growing-segment dedup correctly suppressing re-deliveries of instructions that had already run — the fix from earlier in this branch working, not a failure.

The middle trigger is worth noting for what it is: 'Vachita Vachita Vachita Today' is speech-to-text garbling non-English speech, and the wake word fired on it anyway. That is the auto-send surface the review raised, seen in the wild rather than in the abstract.

What the spacing actually is. Two independent constraints, not one:

  • the ambient transcript lane runs ~15–35 s behind live speech, so a second command spoken immediately is not visible to the trigger yet
  • the 30 s cooldown suppresses a repeat of the same command (distinct commands are not rationed, per the fix earlier in this branch)

In practice that means leaving roughly 15+ seconds between questions.

Still open, unchanged

  • Voice follow-up does not work. Answering a question by voice still needs sendFollowUpQuery and a voiceTurnID the wake word does not mint, and guard !isConversationActive suppresses the trigger while a turn is live.
  • The ~15–35 s latency remains the real constraint and I still do not think this branch can fix it — it needs the realtime lane or a dedicated keyword spotter.
  • Not verified on an account with an enrolled speech profile; no on-device/pendant verification.

Composition check

This branch was also exercised alongside #11864 (presence-aware notification suppression) in a single build, to confirm the two do not interfere: proactive notifications are withheld during a call while wake-word commands still dispatch and answer.

Edited: this comment originally reported the feature as working "once per session, then not again" and listed three candidate causes I could not separate. That was wrong and understated the feature — the longer session above shows three invocations in one process. What I had read as a hard limit was the transcript latency and the same-command cooldown stacking while I retried too quickly.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks @aryanorastar — this round of hardening is exactly what the feature needed: taking it out of unit tests and into live sessions caught a class of issues the suite couldn't see, and the current head reads much better for it.

What I verified on the current head:

  • Sources/WakeWord/WakeWordService.swift — the diarization fix (isUser || speaker == 0, matching VoiceBargeInPolicy) un-bricks the feature for users without an enrolled speech profile, and reverting to fromVoice: false with the comment explaining the voice-turn guard documents the silent-drop failure mode well. Growing-segment dedup via id + command-prefix matching (command.hasPrefix(prior) || prior.hasPrefix(command)) handles re-delivered-and-extended segments, and scoping the cooldown to command == lastTriggeredCommand stops distinct commands from being rationed by transcript latency. Logging rejections only when the segment actually carries the wake phrase keeps ordinary speech quiet while making failures visible.
  • Sources/WakeWord/WakeWordSegmentParser.swift — the homophone table with the documented homie exclusion is carefully reasoned.
  • Tests/WakeWordServiceTests.swifttestSpeakerZeroTriggersWithoutDiarizedUserFlag, testGrowingSegmentDoesNotRefire, and testNewCommandInReusedSegmentIDStillFires each pin a defect you observed live; that's the right way to encode them.
  • AppState+ListenEvents.swift, AssistantSettings.swift, SettingsContentView+General.swift, the changelog entry, and capture-lifecycle.yaml — unchanged in substance from the earlier walkthrough; still coherent, and the on-device verification log is strong evidence.

Two non-blocking observations for the record:

  1. The homophone widening grows the false-positive surface beyond "talking about Omi": with "oh me" accepted, an ordinary utterance like "oh me and my friend went hiking" parses to a 2+-word command and will auto-send. Opt-in + default-off + cooldown + dedup bound the cost, and you deliberately drew the line at "homie" — but "oh me" / "o me" sit close to that line. I'd leave the call to a maintainer rather than ask for a change.
  2. The cooldown keeps only the last fired command (lastTriggeredCommand), so X → Y → X within 30 s re-fires X. That matches the stated intent (swallowing rapid repeats), just noting the edge.

The open item from the earlier walkthrough is unchanged: whether transcript-based triggering with auto-send and a visual-only reply is the intended v1 interaction (vs. keyword spotting / voice-turn replies) is a product direction call, and your in-thread rationale for the v1 approach is a reasonable answer to it.

Human maintainer sign-off needed before merge: product decision on wake-word trigger semantics (transcript + homophone matching, auto-send UX).


by AI on behalf of David.

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.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Thanks @Git-on-my-level — the "oh me" false-positive is a fair hit and it was mine, introduced by the homophone table earlier in this branch. Fixed in cfb0b61.

The distinction the parser was missing

The literal spelling and a homophone 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, which is exactly why your example works: "oh me and my friend went hiking" parsed to the 2-word command "and my friend went hiking" and auto-sent it.

A bare homophone now requires a punctuation break after it — the recognizer's own signal that the speaker addressed something and then paused. Every homophone hit I 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). The new cases use your example sentence verbatim plus 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 correctly suppressed while the turn opened by the first was still live — which also confirms the dispatch engaged rather than silently dropping.

Honest gaps on this change: punctuation is a proxy for a spoken pause and depends on the recognizer emitting it, so a homophone rendered without punctuation is now missed rather than misfiring. And it does not address a wake phrase transcribed in a non-Latin script — the socket runs language=multi, and matching remains ASCII-only. I have seen my own speech come back in Devanagari on that lane, so that gap is real, not theoretical.

On your second note

The cooldown keeping only lastTriggeredCommand, so X → Y → X within 30s re-fires X — agreed that matches the stated intent, and I have left it alone rather than churn the semantics while the product question is open.

Still yours to call

The product decision on trigger semantics (transcript-based detection with auto-send, and a visual-only reply) is unchanged and still needs a human. My rationale is in the thread above; happy to take it whichever way you decide, including moving to a dedicated keyword spotter on the realtime lane if that is the direction — which would also address the ~15–35s ambient latency I documented, the one limitation I do not think this branch can fix.

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
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
@aryanorastar

aryanorastar commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Statistics — measured, not estimated

Same measurement pass as #11864, restricted to the wake word. Source is 78 real sessions, 32.6 h of logged runtime, Aug 18–20, on the dev serving plane with a real Firebase identity and real ambient transcription. No fixtures, no replay, no synthetic segments — which matters here specifically, because every defect this feature had was invisible to unit tests that constructed segments the backend never actually sends.

Triggers, before vs after

Before is exactly 0. There was no hands-free path to the assistant during ambient listening at all.

Counts split at fb13867fb9 ("stop a growing transcript segment re-firing the wake word", Aug 19 12:52), because that commit changes what a dispatch means:

dispatches distinct utterances redundant re-fires
before fb13867fb9 17 14 3
after fb13867fb9 (this PR as it stands) 10 10 0
total 27 24 3

Across the whole window: 62 raw wake-phrase matches, 27 dispatched, 35 correctly refused.

Refusal breakdown:

reason count
segment already fired (transcript revision, not a new utterance) 16
segment not attributed to the user (someone else in the room said it) 10
assistant already busy 5
cooldown — repeat of the same command 4

The 35 refusals are the number worth reading. Ambient transcripts arrive as revisions — the same utterance is re-sent as the backend refines it — so a matcher that fires on text alone triggers 2–4 times per phrase. Segment-scoped prefix dedup is what turns 62 raw matches into 27 dispatches. Speaker attribution accounts for 10 more: those are other people saying "Omi" near my machine, which must not command my assistant.

The 3 redundant re-fires are all pre-fix, and are exactly what fb13867fb9 was written to stop — the code comment in WakeWordService quotes that utterance because it was written from that trace. Post-fix the figure is 0.

False positives

0 false triggers reached the assistant. All 27 dispatches are real commands: "what time is it", "what's the weather", "tell me the latest news", "can you order food for me". Nothing fired on ambient conversation that merely contained a homophone. That is downstream of the punctuation-break rule in WakeWordSegmentParser — bare homophones ("oh me", "omni", "oh mi") require a punctuation break before the command, while a literal "Omi" or a greeting-prefixed form does not. That rule was added because the false-trigger class was real before it.

Latency

Not a defect and not fixable here: dispatch happens the moment the segment is delivered, and the ambient lane runs ~15–35 s behind live speech by design. In one trace, WakeWord: submitting at 12:41:28.162 precedes the Transcript [ADD] at 12:41:28.165 by 3 ms — the feature adds no measurable delay of its own. The wait is transcription, not this code. A realtime-lane trigger would be a different feature, not a tuning of this one.

Method and limits

  • 78 sessions, one user, one machine (MacBook Air, macOS 26.x), three days.
  • 10 post-fix dispatches is a small sample. "0 redundant" over 10 is weaker evidence than it looks, and I would not claim the class is closed on it.
  • 27 total dispatches is also small. "0 false triggers" is a real observation over 32.6 h of ambient listening, not a bounded false-positive rate.
  • The hasPrefix guard is scoped to one segment id by design. Whether ids stay stable across every revision is an open question — the id never appears in the transcript log lines, only in the ignore message, so these logs cannot answer it. Settling it needs the id logged on delivery, which is a one-line diagnostic change.
  • The 10 speaker-attribution refusals are evidence the multi-speaker path works, not proof of it.
  • Counts come from WakeWord: lines in /private/tmp/omi-dev-*.log; the redundant-dispatch figure groups submissions that prefix-extend an earlier one within 60 s.

Edited: an earlier revision of this comment reported the 3 pre-fix re-fires as a live defect and inferred that segment ids are unstable across revisions. Both were wrong — I had misread a pre-fix log line, since the old code logged the current command in the already fired for '…' message and fb13867fb9 changed it to log the prior one. Corrected in place rather than appended, and the split table above is the accurate reading.

…llation

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 BasedHardware#11848 — this one never
reaches a package fetch. No code change; the branch has no admin rights
to rerun the workflow directly.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@undivisible thanks for merging #11901 — and for saying what actually bugged you, that's the useful kind of bug report.

Two macOS PRs of mine are sitting on a human decision rather than on code, if you have a minute:

This one — hands-free wake word. Say "Omi, what's the weather" during ambient listening and it runs the command. @Git-on-my-level's passes found no blocking code issues; the one open item is a product call — transcript-based triggering with auto-send, vs a proper keyword spotter. My reasoning is in the thread and I'm happy either way, it just needs someone to pick.

#11864 — withhold proactive notifications while someone else is present. Screen share or a live call, including muted browser calls. Also adds the "Silence Notifications" control. The change the review asked for is fixed and pushed.

Both have live session evidence and measured numbers in the threads — presence suppression is 9/9 true positives cross-checked against an independent detector, 0 false positives.

The honest caveat on this one is already in the thread and worth reading before you try it: the ambient transcript lane runs ~15–35s behind live speech, so a wake-word answer arrives well after you speak. Functional, but not command-and-control. Fixing that needs the realtime lane or a dedicated keyword spotter, which is the same product call above.

No rush on either.

# Conflicts:
#	desktop/macos/e2e/flows/capture-lifecycle.yaml
@aryanorastar
aryanorastar marked this pull request as ready for review August 23, 2026 09:33
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

aryanorastar and others added 4 commits August 23, 2026 15:37
… going

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…stop deleting the user's speech

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 <noreply@anthropic.com>
…f the history

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 <noreply@anthropic.com>
@aryanorastar

aryanorastar commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up to the latency fix. The delay is resolved and out of the way, so this round is about whether the feature actually works once it is fast — measured, not asserted.

Short version: the wake word now answers out loud, keeps a conversation, and no longer commands itself. What it still does is mishear its own name, and I have the number for that and the reason.


1. The answer is spoken, and a second command continues the conversation

openAIInputWithQuery selected the .voiceOnly surface on fromVoice, and that surface requires a VoiceTurnID — so a voice query owning no turn was dropped with no diagnostic. That is the trap I hit earlier in this branch and reverted from. Selecting .voiceOnly on voiceTurnID says what the branch actually means: push-to-talk owns a turn and is unchanged, and the wake word falls through to the visible surface where fromVoice still marks it a voice query. sendFollowUpQuery had the same guard and now applies the turn-optional rule routeQuery already used.

Live, one session:

Omi, what time is it now      -> spoken: "It's 8:57 PM on Sunday, August 23, 2026, in India, IST."
Omi, and what about tomorrow  -> "Tomorrow is Monday, August 24, 2026."

The second is a bare anaphoric follow-up resolved against the first. Telemetry surface moved floating_text -> floating_voice, both traces carry a tts_start stage, and cache_read grew 29953 -> 40968 -> 52003 across a six-turn session instead of resetting.

2. Speaking the answer created a feedback loop, and closing it was most of the work

Omi speaks into a room Omi is also recording. Ambient capture returns the playback attributed to the primary speaker:

Transcript [ADD] Speaker 0 [43.1s-49.9s]: It's 8 57 p.m. on Sunday, August 23rd, 2026, in India, IST.

That was Omi. Four consumers acted on it as if a person had spoken. The worst was barge-in halting the very playback that produced it — three times in one six-turn session, which is why a long answer stopped partway ("I can help, but I need two details first. 1. Maya's delivery address" and no item 2). The wake word could also be commanded by an answer carrying the phrase, and it was: Omi's own error copy, "Omi's AI service didn't respond...", was heard back, parsed, and submitted as a command. Twice.

VoicePlaybackEchoPolicy is one guard at ingest where all four route through. It took four passes to get right, each driven by a live failure:

pass failure it fixed
initial drop a segment matching recent playback
+residue a barge-in lands inside the playback window (no pause to close the window on while Omi talks), so dropping it ate the interruption
+per-word expiry, coverage floor one shared 20s deadline kept 300 words alive across turns; "Sorry my mistake it's taking" — a real sentence — was deleted
+anchoring, sentence-break residue, 2-word runs Parakeet repeated a sentence and the second copy survived as the user's; a garbled answer tail was kept as the user's; a lone "the" match ate two words off a command

Errs toward not-echo throughout: a missed echo costs what the app already did; a false echo deletes something the user said. That failure happened once, to Aryan, and is what the coverage floor exists for.

3. The wake phrase no longer has to open the segment

Windows now close on the speaker's pause, 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 phrase and a valid command and matched nothing. The parser now also tries each sentence inside a segment — a sentence boundary, not any position, so "I told Omi to order food" still does not parse to "to order food".


Statistics

Real microphone, named dev bundle, signed in against the dev serving plane, on-device Parakeet unless stated. Each run is twenty scripted utterances, five synthesised voices (three Indian English, one British, one American), 16s apart, identical script across runs.

Triggers and correctness

human session, before this round run A run B run C (cloud STT)
attempts 12 20 20 20
dispatched 4 (33%) 9 (45%) 10 (50%) 8 (40%)
false triggers 2 0 0 0
Omi's words kept as the user's 2 0 0
user speech deleted as echo 1 0 0 0
commands lost inside a dropped echo window 4 2

The two false triggers in the first session were Omi commanding itself with its own error message. Zero across the 60 utterances since.

False negatives — why the misses happen

Run B's ten misses, by cause:

cause count
recognizer wrote something other than the wake phrase 8Only ×7, On me ×1
command lost inside a window Omi was talking over 2

Seven of eight recognizer misses wrote Only. That cannot be added to the homophone table — "only" is one of the commonest words in English, and accepting it would fire on "only what is on my calendar" said in ordinary conversation. There is no version of that table that catches this and stays safe.

The recognizer is the wall, and there are two recognizers

Same script, same voices, same build, only the STT lane changed:

on-device (Parakeet) cloud (/v4/listen)
utterances rendered as Only 7 of 20 1 of 20
utterances carrying a usable rendering of the phrase 12 of 20 19 of 20

TranscriptionService seeds ["Omi", "OMI"] into the cloud STT keyword boost. Parakeet takes no keyword list — the parser's comment already said the fix was keyword boosting rather than a longer table; this measures it.

The part that matters for the product decision: STTSessionState.resolveMode returns .local by default for microphone and system audio on Apple Silicon. Cloud is the fallback. So on Apple Silicon the wake word runs on the one recognizer that cannot be told its name.

Run C's own dispatch number (8/20) is not evidence against the cloud lane — eleven of its twelve misses transcribed the phrase perfectly and were refused by speaker == 1/2, because a synthesised voice comes out of the speakers and cloud diarization correctly decides it is not the enrolled user. That is my harness, not the feature. It is also why I report recognition separately from dispatch.

Cost

  • Each dispatch is one chat query against the user's plan quota (Quota plan=Operator unit=questions used=105.0 limit=500.0). A false trigger costs one question; measured false-trigger rate is 0 in 60.
  • Spoken answers add a TTS synthesis per answer that text-only replies did not incur. That is a real new per-answer cost of this change and I have not priced it.
  • Echo suppression is local string work on segments already in memory — no added network or model cost.
  • The latency fix reduced nothing and added nothing in cloud spend: it changed when an on-device window closes.

Verification

  • xcrun swift test --package-path Desktop --filter 'VoicePlaybackEchoPolicy|WakeWord|LocalTranscription|VoiceBargeIn' -> 74 tests, 0 failures. Every string in VoicePlaybackEchoPolicyTests was captured live.
  • Full desktop suite: 5790 tests, 2 failures — both pre-existing order dependence unrelated to this diff (FloatingBarNotificationPreviewPolicy, RewindCaptureExclusionGeneration); they fail identically on the merge base under the same filter and pass in isolation.
  • make preflight -> 22/22 manifest checks, on every commit in this round.
  • Three twenty-utterance microphone runs plus one twelve-attempt human session, all logged.

Honest gaps

  • 60% wake-phrase recognition on the default Apple Silicon path. That is the feature's real ceiling today and no amount of parser work moves it.
  • Two commands per twenty are still lost when spoken into the middle of an answer with no sentence break for the recognizer to mark. Separating two speakers from one microphone stream using text has a floor. The categorical fix is acoustic echo cancellation, which is not reachable here: mic capture uses a raw CoreAudio IOProc specifically to avoid AVAudioEngine's implicit aggregate device (a deliberate Bluetooth-quality decision in AudioCaptureService), and the platform's voice-processing unit lives on the AVAudioEngine node. Reversing that is its own change, not a line in this PR.
  • Every measurement above is one machine, one room, and synthesised voices for the scripted runs. The human session is twelve attempts. These are directional.
  • I did not change which STT lane the wake word uses. Forcing cloud STT when the wake word is enabled would raise recognition from 60% to ~95% on the evidence above, but it trades on-device privacy and cloud spend for it — that is your call, not mine.

What is still yours to decide

Unchanged from before, and now with a number attached: transcript-based triggering versus a dedicated keyword spotter. The latency argument against transcript triggering is gone (5s -> 0.7s). The recognition argument is the live one: 60% on the default path, and the failure mode is a word too common to whitelist.

Three ways forward, in the order I would rank them:

  1. Resolve to the cloud lane while the wake word is on. Smallest change, biggest measured gain, costs cloud STT and on-device privacy for users who opt in.
  2. A keyword spotter on raw audio. Correct, and it also removes the transcript-lane dependency entirely.
  3. Ship as-is, opt-in and default-off, and let the 60% be visible. Defensible given the guards, but I would not pick it.

Happy to build any of them.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Verifying the current head 02eca203 — this covers everything after the last verification pass (5733ca9d): the latency fix, playback echo suppression, spoken answers, and later-sentence wake-word matching. That delta is what answers the delay concern from upthread, so it got its own pass.

The delay fix is real and client-side. LocalTranscriptionService now closes a window on the speaker's pause instead of only the fixed 10 s boundary: isEndpointed requires 0.6 s of quiet under the shared noise floor after ≥1 s of voiced audio (not buffer length — the blip-in-a-quiet-window test pins exactly why), leadingSilenceSamples stops leading quiet from eating the window cap, and the pump tick halves to 0.5 s so the tick is no longer the latency floor. LocalTranscriptionEndpointTests pins each behavior, including the live hallucination case ("Yeah." at rms 0.0067). The mechanism sits in the client where the delay actually was.

The new code, walked:

  • VoicePlaybackEchoPolicy.swift — conservative and well-parameterized (4-word minimum, 0.8 coverage required to drop, 5-word lookahead, capped anchors), errs toward keep, and the residue path slices the original string so punctuation the wake-word parser reads survives. The tests are live-captured echoes, including both bookends of a barge-in.
  • AppState+ListenEvents.swift — one guard placed where all consumers route (barge-in, wake word, transcript upsert, memory), with residue substitution before WakeWordService.observe.
  • FloatingBarVoicePlaybackService.swift — per-word 15 s expiry with a 300-word cap; the comment documenting why block-expiry deleted live speech ("Sorry my mistake it's taking") is the right kind of war story.
  • FloatingControlBarWindow.swift — selecting .voiceOnly on voiceTurnID rather than fromVoice fixes the silent-drop trap at its source, and sendFollowUpQuery's turn-optional guard matches the rule routeQuery already used.
  • WakeWordSegmentParser.swift — sentence-scoped matching ("I told Omi to order food" still ignored) with the homophone corroboration rules carried into later sentences.
  • Settings, AssistantSettings, TranscriptionService (text now var, documented), changelog entries, and the capture-lifecycle.yaml covers: additions all check out; desktop CI green on this head.

Two things for the sign-off decision:

  1. This PR now changes behavior for users who never enable the wake word: pause endpointing applies to every on-device transcription session, and echo filtering applies whenever voice playback has been active. Both stand on their own (endpointing is the delay fix; echo suppression fixes the pre-existing self-barge-in bug), but they are separable, independently rollbackable surfaces riding behind an opt-in toggle. Worth a conscious call on whether they merge here or as standalone fixes.
  2. Residual echo-policy false positive: a user verbatim-repeating ≥4 words covering ≥80% of a just-spoken answer inside the 15 s window is dropped from the transcript. Bounded and conservative, but real. Also, ok/okay + homophone greeting forms fire without a punctuation break — contrived input, but it slightly widens the auto-send surface.

No blocking code issues on this delta. The remaining open item is still the product one from the earlier passes — transcript-based triggering with auto-send vs a dedicated keyword spotter — now alongside the bundling question above. needs-maintainer-review stays on.

Thanks @aryanorastar — the measurement discipline (every threshold traced to a live capture, every live defect pinned by a regression test) continues to be excellent.


by AI on behalf of David — human review needed for the product direction decision on transcript-based wake-word triggering/auto-send, and sign-off on the two pipeline-wide behavior changes now riding with the opt-in feature.

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 <name>" 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 <noreply@anthropic.com>
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Thanks @Git-on-my-level — three answers, in your order.

Bundling. Your call to make, and I think you're right that it's worth making consciously. Both are already isolated commits, so splitting is cheap: 12a7a34 (pause endpointing) and 46bc2e8+c5f95d9+02eca20 (echo suppression) can go out as two standalone PRs that merge first, leaving this one as just the wake word. I'd slightly prefer that — endpointing is a delay fix every on-device user benefits from and shouldn't wait on a product decision about a toggle, and self-barge-in is a pre-existing bug that predates this branch. Say the word and I'll split them; otherwise they stay here.

Residual echo false positive. Confirmed, and it's the one direction of the trade I don't like. Bound is narrow — a verbatim repeat of ≥4 words covering ≥80% of what was just spoken, inside 15s — but when it fires it deletes something the user said. I chose it over the alternative after the looser version deleted a real sentence live ("Sorry my mistake it's taking"), so the coverage floor exists because the failure already happened once. The clean fix isn't a better threshold, it's acoustic echo cancellation; I've written up why that isn't reachable from this PR in the gaps section.

"ok"/"okay" + homophone. Fair hit — fixed in 54a535c7b0. "hey" is a vocative, so "hey " addresses someone and isn't produced by accident in front of a misheard word. "ok" and "okay" are discourse markers people open sentences with constantly, and your point stands: okay oh me and my friend went hiking fired with the command "and my friend went hiking". Only "hey" corroborates a homophone now; both still corroborate the literal spelling, where the phrase is its own evidence. Your sentence is pinned as a test in both forms, plus the two forms that must still fire. 33 tests, make preflight 22/22.

One correction to my own comment above, folded in place rather than appended: option 3 read "let the 70% be visible" — the measured figure is 60%, as the table says.

aryanorastar added a commit to aryanorastar/omi that referenced this pull request Aug 23, 2026
…voice

Maintainer request: being on a call is often exactly when a nudge is worth
most — mid-call with someone is when "you owe them a task" is useful. The
guard suppressed on either presence signal, which treated the interruption as
the harm. It is not.

The two signals are different harms and now decide different things:

- Sharing a screen makes a private nudge visible to everyone on the call, and
  once seen that cannot be taken back. Still suppresses delivery.
- Being on a call means others can hear the room but cannot see the screen.
  Delivery goes through.

Speech is the part a call does change. A banner on a call is seen by the user
alone; the same text read aloud is heard by everyone in the room and everyone
on the call, with no screen share needed — and BasedHardware#11801 makes spoken output
routine. So on a call the nudge is shown and not spoken.

`currentPresence()` also reports what it detected whenever it detects
anything. Without it a delivered nudge cannot be told apart from a detector
that saw nothing, and those mean opposite things — the same silent-gate
problem that made the wake word undiagnosable for two days.

Verification:
- xcrun swift test --package-path Desktop --filter 'PresenceAware|
  NotificationSpeech|NotificationSnooze|IntegrationNudge|
  SuggestionAssistantTelemetry' -> 140 tests, 0 failures
- check-proactive-notification-gate.py -> OK
- Live on a named dev bundle during a real Discord call, screen not shared,
  through the real grounding/evaluation/delivery path (probe_suggestion_nudge):

    NotificationService: presence — screenShared=false onCall=true
    Suggestion: delivering [90%] [commitment]
      "CleanMyMac is fine — but you said you'd Call David today"

  The same state on the previous build logged "withheld while others are
  present". Not live-verified: suppression while sharing. That path's detector
  and policy inputs are unchanged by this diff, and the sharing cases are
  covered by unit tests, but the live control could not be run — see below.

Known gap, pre-existing and unrelated to this diff: `isShareIndicatorWindow`
matches Zoom, Microsoft Teams and browser stop-sharing bubbles. Discord
publishes no such window — while sharing, its only on-screen windows are
`title="Window"` and the channel title — so a Discord screen share is not
detected at all. Matching an untitled window would suppress nudges for anyone
with Discord open, so it is filed rather than guessed at.

Failure-Class: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification pass on the current head 54a535c7b0 (the delta since the last pass on 02eca203 is the "ok"/"okay" corroboration fix), plus one new blocking finding about the PR description.

The delta checks out. WakeWordSegmentParser.candidates(for:) now lets only the vocative "hey" corroborate a bare homophone, while "ok"/"okay" still corroborate the literal phrase; testOkayDoesNotCorroborateABareHomophone pins the example sentence in both forms, with the still-firing forms pinned alongside.

One new issue blocks a required check, and it is in the PR body, not the code. PR Metadata Preflight fails with:

PR body line 72: unused exception for unchanged source desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift
Manifest checks failed: product-file-line-count-ratchet

Lines 70 and 72 of the description declare line-count exceptions for:

  • RealtimeHubController.swift | 1540 -> 1595 | runWakeWordTurn sits beside runHeadlessPTTTurn ...
  • RealtimeHubSession.swift | 1658 -> 1666 | sendSpokenCommand is an eight-line sibling of sendTestTextInput ...

Neither claim matches the diff:

  • Neither file appears among the 20 changed files, and no commit in the branch's 26-commit history touches either one.
  • Both files sit at exactly 1540 / 1658 lines on the PR base and on current main — the growth the exceptions describe exists nowhere.
  • runWakeWordTurn and a RealtimeHubSession.sendSpokenCommand do not exist anywhere in the repo. The submission path that does exist in this diff is FloatingControlBarManager.submitSpokenCommand -> sendFollowUpQuery / openAIInputWithQuery in FloatingControlBarWindow.swift, whose 5309 -> 5325 exception at line 68 is accurate.

So the description documents work the PR does not contain. If the RealtimeHub pieces were dropped in one of the origin/main merges, they need to come back; if they were superseded by the FloatingControlBarWindow path, please delete the two stale exception lines so the required check can pass and the description matches what would merge. This is the only red required check on the head.

Everything else on this head re-verified clean:

  • VoicePlaybackEchoPolicy.swift — conservative thresholds (4-word minimum, 0.8 coverage to drop, 5-word lookahead, 4-mismatch end, 24 anchors), prefix-then-suffix consumption, sentence-break requirement before a residue is extracted; errs toward keep throughout.
  • LocalTranscriptionService.swiftisEndpointed (0.6 s of quiet under the shared floor after >= 1 s of voiced audio), leadingSilenceSamples (stops at the first speech chunk, keeps two chunks of lead-in), 0.5 s pump tick; LocalTranscriptionEndpointTests pins each, including the live hallucination case.
  • AppState+ListenEvents.swift — the single ingest guard in front of barge-in, wake word, transcript upsert, and memory, with residue substitution before WakeWordService.observe.
  • FloatingBarVoicePlaybackService.swift — per-word 15 s expiry with the 300-word cap, recorded on both the synthesizer and player-fallback paths.
  • AssistantSettings.swift, SettingsContentView+General.swift, TranscriptionService.swift, the five changelog entries, and the capture-lifecycle.yaml covers additions — consistent with the previous passes.

Still with the human maintainers, unchanged from the last pass: the product call on transcript-based triggering with auto-send, and whether the two always-on surfaces (pause endpointing for every on-device session, echo filtering whenever playback has been active) merge here or as standalone fixes first. The stale-exception cleanup above is mechanical and does not need to wait on those.

Thanks @aryanorastar — the iterative live-testing discipline on this branch remains the standard.


by AI on behalf of David.

@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level splitting the first of the two always-on surfaces out, per your bundling question. Pause endpointing is now #12181, branched from current main with 12a7a34 cherry-picked — no wake-word code on it at all.

That one was genuinely separable and shouldn't have been waiting on a product decision about an opt-in toggle: it applies to every on-device transcription session regardless of whether anyone enables the wake word. Verified rather than assumed — LocalTranscriptionEndpointTests + LocalTranscriptionDuplicatePolicyTests give 18/18 on that branch with nothing from this one present, and scripts/pr-preflight passes 22/22 locally.

Correcting myself on the other half. I told you both were "already isolated commits, so splitting is cheap." That was right for endpointing and wrong for echo suppression. Going back through the commits properly:

commit separable
12a7a34 pause endpointing yes — 3 files, no wake-word coupling → now #12181
46bc2e8 echo core yes
02eca20 echo anchoring yes
c5f95d9 no

c5f95d9 isn't co-committed work, it's coupled work. Its own message says so:

The wake word's own "assistant is speaking" guard is removed. It blocked a real barge-in... Whether Omi is speaking is not the question; whether this segment is Omi is, and VoicePlaybackEchoPolicy answers that directly.

So the wake word's busy-guard removal is only safe because echo policy exists. Splitting echo out doesn't give you two independent PRs — it gives you an echo PR that this one then depends on, and I'd be re-cutting commits whose test strings were captured live against them. That's a worse trade than leaving it, so I've left it, but it's your call and I'll do it if you'd rather have the ordering explicit.

Net effect: this PR is now the wake word plus echo suppression, and echo suppression is load-bearing for it rather than riding along.

Everything else is unchanged — checks green on 54a535c7, and the stale exception lines that were the one blocking finding in your last review are deleted, with preflight re-run and passing.

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification follow-up on the unchanged head 54a535c7b0 — the blocking finding from the last pass is resolved, and the split/bundling work since is checked below.

The PR-description blocker is fixed. The two stale RealtimeHubController.swift / RealtimeHubSession.swift exception lines are gone from the body; the only remaining exception (FloatingControlBarWindow.swift | 5309 -> 5325) matches the file on the head exactly, and PR Metadata Preflight is green — the required checks on this head are now fully green.

The endpointing split checks out. #12181 carries exactly the three separable pieces — LocalTranscriptionService.swift, LocalTranscriptionEndpointTests.swift, and the pause-endpointing changelog entry — with no wake-word code on that branch. The first of the two bundling questions is resolved: latency for every on-device session no longer waits on this PR. (When #12181 merges first, the duplicated files here are the same commit cherry-picked, so the overlap should collapse — worth a glance at rebase time.)

I verified the coupling argument against the head, and accept it. c5f95d9 removed the wake word's own "assistant is speaking" guard so a barge-in during playback can still fire. On the head, WakeWordService.observe gates on enabled / conversation-active / speaker attribution / dedup / cooldown — and on nothing about playback. That is only sound because AppState+ListenEvents.swift routes every segment through VoicePlaybackEchoPolicy.classify before WakeWordService.observe, so Omi's own voice (including an answer that literally begins "Omi, …") never reaches the parser. Echo suppression is load-bearing here rather than riding along; splitting it out would produce a dependency chain, not two independent PRs. Leaving it bundled is the right call unless a maintainer wants the ordering explicit.

With that, no code-level blockers remain on this head. The parser's homophone corroboration (only the vocative "hey" corroborates a bare homophone, "ok"/"okay" still corroborate the literal phrase), the echo policy's keep-biased thresholds (4-word minimum match, 0.8 coverage to drop, prefix-then-suffix consumption with a sentence-break requirement), FloatingBarVoicePlaybackService.swift's per-word 15 s expiry recorded on both the synthesizer and player-fallback paths, and the four new test suites were all verified in the prior pass on this exact head and are unchanged.

What's left is genuinely a maintainer call, not a code one: whether transcript-based triggering with auto-send is the right v1 hands-free interaction for Omi, and final sign-off on shipping the echo filter as an always-on behavior on the ambient ingest path. The code side of this PR is in good shape.

Thanks @aryanorastar — the discipline on this branch (live measurements, correcting your own claims in place, splitting what turned out to be actually separable) is what makes a change of this size reviewable.


by AI on behalf of David.

…d a silent echo

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 <noreply@anthropic.com>
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level thanks for verifying the coupling argument against the head rather than taking my word for it — checking that WakeWordService.observe gates on nothing about playback, and that the safety comes from AppState+ListenEvents.swift routing through VoicePlaybackEchoPolicy.classify first, is exactly the thing I was asserting and hadn't proven.

Your note about #12181 collapsing at rebase time was right, and it's now moot: #12181 is approved and I've merged current main into it, so once it lands the duplicated files here become the same commit and the overlap disappears on the next merge from main.

One mechanical thing is holding this, and I don't think it's intentional. Both of your last two passes are on the same commit:

CHANGES_REQUESTED  54a535c7b0  Aug 24
COMMENTED          54a535c7b0  Aug 25   ← "no code-level blockers remain"

The Aug 24 review's only blocking finding was the two stale exception lines. You confirmed yesterday that they're gone, that the remaining exception matches the file exactly, and that PR Metadata Preflight is green — but that pass went in as a comment rather than a review, so the request-changes from the day before is still what GitHub is enforcing. Merge box reads "1 change requested" against a head you've cleared.

Same pattern as #11804, where five passes of yours are issue comments and the only formal review is a COMMENTED from Aug 18 — so that one also reads as reviewed while sitting on an unsatisfied gate.

Not asking you to approve the product call. That one is genuinely a human's: transcript-based triggering with auto-send as the v1 hands-free interaction, and sign-off on the echo filter as always-on behaviour on the ambient ingest path. Both are still open and I'd rather have a "no" on either than have them decided by default. Just flagging that the code gate and the product gate have gotten tangled, and only the first one is stale.

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 <noreply@anthropic.com>
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level — the realtime demo David asked for is in, behind wakeWordUsesRealtime (default off, so the measured text path is untouched and the two are comparable on one machine).

Detection does not move. Both paths read the same ambient transcript, so this changes nothing about the 60% recognition ceiling measured earlier. What moves is the exchange: the model speaks its own answer instead of TTS reading a text reply, and the session keeps context across turns.

WakeWord: submitting 'tell me one short fact about the moon.'
RealtimeHub[gemini:gemini-3.1-flash-live-preview]: turn begin (activityStart)
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…

That last line is the microphone hearing Gemini speak, and being dropped rather than recorded as the user. ~1.2s from end of speech to the model.

Eight-command battery, real microphone, five synthesised voices

type result
conversational spoken, success
tool / action — "open my tasks page" called get_tasks, reason=success
technical — "what is a race condition" spoken, success
general knowledge spoken, success
personal / memory — "what did I work on today" spoken, success
time — "what is the date today" spoken, success
screen — "what is on my screen" fails closed

Three ordering constraints, all found by running it, none visible in review:

  • The Gemini activity window opens inside commitTurn(), not beginTurn. Waiting for it before sending times out; the send must be buffered and flushed by the commit.
  • A turn with no route is refused at commit as a stale physical commit, so .selectRoute(.hub) has to precede beginTurn.
  • The warm session tears down when idle, so a wake word in a quiet stretch found no session and fell through to chat — third of three commands, while the first two spoke.

Screen questions fail closed, and the guard is right to do it:

tool_call screenshot({})
ptt_screen_evidence stage=screenshot_requested evidence=unavailable
rejected screen report without a current transport receipt reason=evidence_unavailable
completion_failed=reducer_did_not_resolve action=terminal_fail_closed

Push-to-talk establishes a screen-evidence receipt for its turn; a wake-word turn establishes none, so the observation is refused. I am not loosening a fail-closed evidence gate to make a demo look better — it needs wiring into the protocol properly.

Two further known limits, both recorded in the commits:

  • The provider forms its own input transcript from the silence fed to satisfy Gemini's non-empty-activity requirement, and hallucinates from it. Supplying the known command fixes our journal and the chat bubble, not the model's view of the turn. The real remedy is a text turn that opens no audio activity (clientContent rather than realtimeInput) — a wire-format change, out of scope while this is off by default.
  • The answer still renders a response panel. Hands-free should stay in the notch with the existing glow and speak, with the panel as fallback. Attempted and reverted tonight rather than shipped half-built: .voiceOnly needs an .automation turn to publish its own synthetic captured-input finalization, and without it the turn sits in finalizing, where the reducer refuses playback.

Also in this range: playback echo now covers the realtime path (the hub plays natively, so FloatingBarVoicePlaybackService never saw it and Gemini's own answer was being recorded as the user).

324 tests, make preflight 23/23 on each commit.

@Git-on-my-level
Git-on-my-level dismissed their stale review August 26, 2026 15:18

Resolved on the current head: the two Line-Count-Exception entries this review flagged (RealtimeHubController.swift, RealtimeHubSession.swift) now match files the PR actually changes, and the repo's product-file line-count ratchet passes against the current PR body. See the follow-up verification pass for details.

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification pass on the new head 1a630d37 — the three commits since 54a535c7b0 add the realtime answer path behind wakeWordUsesRealtime (default off). The previously-requested PR-description fix plus this delta are checked below; the earlier CHANGES_REQUESTED is being dismissed as resolved.

The stale review's blocker is resolved on this head. It asked for the two RealtimeHubController.swift / RealtimeHubSession.swift exception lines to match reality. They now do — both files are actually changed in this delta, and I re-ran the repo's own check_product_file_line_count_ratchet.py with the current PR body against this head: all four Line-Count-Exception entries match the diff exactly (1499→1504, 1540→1622, 1658→1666, 5309→5325). check_product_invariants.py also passes. One note: PR Metadata Preflight shows skipped on this head because that job only runs on PR-body/label edits — the last body edit predates this push — so the local reproduction above is the evidence the manifest is clean.

The realtime delta reads correctly. Walking the 8 changed files:

  • RealtimeHubController.swiftrunWakeWordTurn mirrors runHeadlessPTTTurn's lifecycle step for step (automation-turn mint → hub route select → warm wait → beginTurn → two 100 ms silence frames → text → finalize + commit), and the ordering comments match the headless path's hard constraints (buffered text flushed when beginInputTurn opens the window; silence frames because Gemini rejects pure-text windows with 1007). wakeWordInputTranscript is cleared in both turn-prep paths (RealtimeHubController+PushToTalk.swift:78, resetTurnState) so a wake word's text can't leak into a later spoken turn.
  • RealtimeHubSession.swiftsendSpokenCommand is an eight-line labeled sibling of sendTestTextInput over the same private buffering path; nothing new on the wire.
  • RealtimeHubController+SessionDelegate.swift — the override precedence testProviderTranscriptOverride ?? wakeWordInputTranscript keeps harness turns authoritative over ambient text; and recordExternallySpokenText(text) at the provider-speech delegate is the important one — realtime audio is played natively, so the echo policy's history never saw it. Now every realtime reply (not just wake-word ones) feeds the 15 s echo window, closing a gap the echo-suppression work had for natively-played answers.
  • FloatingBarVoicePlaybackService.swiftrecordExternallySpokenText dedupes the two provider stream shapes (identical → skip, cumulative extension → record only the delta, divergence → replace), which matters because a padded history matches more of what a person says — the direction that deletes real speech.
  • WakeWordService.swift / AssistantSettings.swift — the flag is default-off with the measured chat path untouched, and the hub path falls back to chat when no session appears within 5 s. Detection is unchanged on both paths.
  • Changelog entry 20260824-wake-word-realtime-turn.json is accurate ("Behind an off-by-default setting…").

Non-blocking notes: the flag has no settings UI (defaults write only) — fine for an evaluation, but worth deciding whether it grows UI or is removed before the framing hardens; and during the up-to-5 s session warm the user gets no feedback, so a first-ever command after a cold start can feel lost before the fallback fires. Neither blocks.

Still a human call. The remaining questions are product, not code: transcript-based triggering with auto-send as the v1 hands-free interaction, sign-off on the echo filter as an always-on behavior on the ambient ingest path, and now how long the realtime evaluation flag should ride in the product. The needs-maintainer-review label stays for those.

Thanks @aryanorastar — the live-measured logs in the thread (including the echo drop of the realtime session's own moon answer) back the changelog claims.


by AI on behalf of David.

@aryanorastar

Copy link
Copy Markdown
Contributor Author

Code-wise, this is complete and ready for a maintainer decision. The latency fix, wake-word flow, spoken/realtime evaluation path, and playback-echo protection are implemented and verified; the current head is mergeable and all checks are green.

The only remaining blocker is product sign-off on transcript-based auto-send and the always-on echo filter. I’m not holding any unfinished code locally—once maintainers choose the direction, I can make any requested adjustment.

aryanorastar and others added 2 commits August 28, 2026 19:53
… panel

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 <noreply@anthropic.com>

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on the new head 7fd0e2d — the notch-answering work in ccaaebb plus the main merge. The hands-free direction reads well against the feature's premise, and guarding expansion at the single choke point all four panel entry points reach (FloatingControlBarWindow.resizeAnchored, with the resizeToResponseHeight / beginMainResponseHeight guards beside it) is the right shape.

One blocking item — a pinned source contract went stale and both required Desktop Swift checks are red because of it:

  • Desktop Swift Static & Test Contracts and Desktop Swift Build & Tests fail on AgentPillLifecycleTests.testTypedSendDelegatesResponseSizingToWindow (desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift:1083), which asserts the exact declaration func beginVisibleMainQuery(_ message: String, fromVoice: Bool, animated: Bool = true). ccaaebb extended that signature with presentsSurface: Bool = true and wrapped it across lines (FloatingControlBarWindow.swift:5363), so the pinned string no longer matches. It is the only failing suite in the run, and the production behavior is intact — the view's call site still passes defaults, so typed sends present the panel. The fix is to update the pin to the new signature; ideally also extend that contract to cover presentsSurface defaulting to true and the answersQuietly guards, since that is now the load-bearing behavior of this surface.

Two non-blocking hardening notes on the new quiet-answer plumbing:

  • submitHandsFreeCommand sets the static suppressNextVisibleSurface latch before sendFollowUpQuery; if openAIInputWithQuery early-returns on its window/provider guards the latch is never consumed, and the next typed query through prepareVisibleQueryState would silently answer quietly once. Clearing the latch on that early-return path (or consuming it on failure) closes it.
  • state.answersQuietly is cleared in the onSendQuery re-wire (FloatingControlBarWindow.swift:3838) and submitSpokenCommand (:4128), but the default wiring installed in setup (:3076) does not clear it, and the comment at the flag's set-site (:5370) says any surface-presenting query clears it — the code does not quite do that yet. Moving the clear into beginVisibleMainQuery when presentsSurface is true would make the invariant local to the flag's owner.

Also re-verified on this head, unchanged from the earlier pass: the echo-policy prefix match with residue preservation in VoicePlaybackEchoPolicy.swift ahead of WakeWordService.observe in AppState+ListenEvents.swift, homophone-tolerant command extraction in WakeWordSegmentParser.swift, and capture-lifecycle.yaml's covers list includes the new sources.

Once the pinned contract is updated and both Desktop Swift checks are green, this is in good shape for a maintainer's product pass — the hands-free wake-word surface (default-off setting, notch-quiet answers, optional realtime path behind a flag) is a direction call that deserves maintainer sign-off before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level removed the positive-signal Good PR — positive signal, not a formal approval label Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macOS needs-maintainer-review Needs a human maintainer to sign off before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants