Skip to content

feat: voice mode, a global voice assistant over all conversations - #234

Merged
stippi merged 15 commits into
mainfrom
feat/voice-mode
Oct 9, 2026
Merged

stippi merged 15 commits into
mainfrom
feat/voice-mode

Conversation

@daniel-kurzynski

Copy link
Copy Markdown
Collaborator

Summary

Adds voice mode: a global voice assistant you talk to about your conversations — what is running, what an agent answered, start a conversation in a project or send one a message. It runs next to the sessions, not inside one, and acts only through conversation tools. The design is in docs/voice-mode.md.

Realtime model and audio

  • llm::realtime: the OpenAI Realtime protocol over WebSocket, with connectors for OpenAI-type providers and AI Core deployments.
  • New audio crate: microphone and speakers at 24 kHz. macOS uses the voice-processing I/O unit (echo cancellation); elsewhere cpal with half-duplex gating. Builds without the voice feature link no audio backend.

Voice agent (code_assistant_core::voice)

  • VoiceService and the agent loop, with tools list_conversations, list_projects, message_conversation and get_conversation.
  • Spoken notifications when a conversation finishes or waits for the user. A pure Floor state machine keeps them from talking over the user or the model: they wait until playback has drained, plus a quiet period.
  • SessionService gains session_content and session_activity_states for the tools.

GPUI

  • Microphone, mute and status in the title bar, and a transcript popover.
  • Settings → Voice, stored in voice.json.
  • macOS bundle: microphone usage description and audio-input entitlement.

Fixes

Testing

  • Unit tests for the Floor state machine, the voice tools, notifications, the voice config, the realtime connectors and protocol, and the audio resampler.
  • Regression test: a session created in a project gets the default model.
  • cargo test, cargo clippy --all-targets --all-features -- -D warnings and cargo fmt --check pass.

daniel-kurzynski and others added 15 commits October 8, 2026 18:09
The main screen hands the session listing to the sidebar only when it
differs from the last one it saw. Its initial value is an empty list, so
an empty listing (a fresh install, or a new data directory) never
reached the sidebar. The sidebar then never counted its sessions as
loaded and never laid out the folders of the projects saved in
projects.json: the Projects view stayed empty.

Gpui now records when the first listing arrived, and the main screen
hands that first listing over even when it is empty.
A model-to-deployment mapping becomes part of the AI Core provider form
only when its add button is clicked. A mapping typed into the add row
and saved right away was dropped without notice, and the form closes on
save, so the loss showed only when the provider was opened again.

Saving now includes a fully typed add row (model ID and deployment ID)
unless a mapping for that model already exists.
A realtime speech model you talk to about your conversations: what is
running, what an agent answered, start a conversation or send one a
message. It runs next to the sessions, not inside one, and acts only
through conversation tools.

- llm::realtime: OpenAI Realtime protocol and WebSocket transport;
  connectors for OpenAI-type providers and AI Core deployments.
- audio crate: microphone and speakers at 24 kHz; macOS uses the
  voice-processing I/O unit (echo cancellation), elsewhere cpal with
  half-duplex gating.
- code_assistant_core::voice: VoiceService, the agent loop, the tools
  (list_conversations, list_projects, message_conversation,
  get_conversation) and notifications when a conversation finishes or
  waits. The pure Floor state machine keeps them from talking over the
  user or the model: they wait for playback to drain and a quiet period.
- SessionService: session_content and session_activity_states.
- GPUI: microphone, mute and status in the title bar, a transcript
  popover, and Settings -> Voice (voice.json).
- macOS bundle: microphone usage description and audio-input entitlement.

See docs/voice-mode.md.
SessionService::create_session created a session bound to a project
without a model configuration, so the first agent run failed with
"Session has no model configuration". Sessions without a project
already got the default model; project sessions now do too.
VoiceService waited for the agent task on Stop, but the task does not
read its control channel while it connects or backs off between
reconnects, and no connection attempt had a timeout. On a network that
swallows the handshake, voice mode stayed on "Connecting" and the
worker hung, with later Start, Stop and Mute commands queued behind it.

Every connection attempt now gives up after 20 seconds, and Stop aborts
an agent that does not end within a second.
Late audio deltas were dropped only for the item whose playback the
barge-in stopped. A response the user interrupted before its first
audio arrived, for example a notification whose response.create was in
flight when the user started to speak, had no such item: its audio
played over the user, and nothing truncated it.

Model audio now plays only while the model has the floor.
Three gaps in the floor state machine:

- Cancelling our own response on its creation (the user barged in while
  response.create was in flight) also cancelled the user-turn timer. If
  no answer to the user followed, the floor stayed with the user and no
  notification went out until the user spoke again.
- A tool output that arrived during the user's turn waited for the
  model's answer to the user. When the turn ended without one, the
  output was never answered.
- With several function calls in one response, the first output to
  complete triggered response.create. The model answered on partial
  results and the next output started another response.

Tool outputs now count as unanswered until a response starts. The model
answers them once no tool runs any more and the floor is free: after
playback, from Cooling or Idle, or when the user's turn ends without a
response.
Muting dropped the microphone audio but told the floor nothing. Muted
mid-sentence, the server never reported the end of speech, so the floor
stayed with the user and no notification was delivered, although muted
users are meant to get them. Muting now ends the user's turn like the
end of speech does.

A voice tool that panicked or hung never reported back. The floor
counted it as running for the rest of the voice session and held every
notification back. Each call now reports an error after a panic or
after 30 seconds.
SessionService::session_content ran on the global lane while holding
the manager lock, and the file-backed SessionSource loaded sessions
with every stored tool result resolved from its blob. A voice
get_conversation on a large session held up all frontend commands and
the agents' checkpoint commits, for tool results it never reads.

session_content now runs as an I/O query on the blocking pool, and the
SessionSource loads sessions unresolved: session queries read the
messages only, never the execution records, which also speeds up the
search_sessions and get_session_content tools.
A voice failure went into the error popover, which belongs to the
viewed session: any activity change of that session cleared the
message, and dismissing it sent ClearError, which reset the session.

The title bar now shows a "Voice stopped" chip with the reason in its
tooltip until voice mode starts again.
The output callback runs on the audio device's real-time thread and
took the same mutex that push() held while it extended one VecDeque of
samples. The model sends audio faster than real time, so a long answer
grew that queue to megabytes, and a reallocation (or the tokio thread
being preempted) under the lock made the callback miss its deadline:
clicks and dropouts mid-answer. The cpal input callback took the lock
too, for is_playing().

push() now converts a chunk before it takes the lock and only moves it
into the queue, render() plays one silent buffer instead of waiting
when the lock is held, and the playing flag is an atomic.
On macOS a denied microphone permission does not make the voice
processing unit fail: it records silence. Voice mode then connected,
showed "Listening" and never heard anything, with nothing pointing to
the cause.

The macOS backend now checks the AVFoundation authorization status
before it opens the devices and fails with a message that names the
setting. An undecided permission still lets the system ask on the
first capture.
The barge-in test asserted that the speakers were cleared as soon as it
received the truncate event, but the agent clears them only after it
sent that event, on its own task. On a busy CI runner the assertion ran
first. The test also slept 50 ms and assumed the audio delta was queued
by then.

Tests now wait for the fake devices to reach the expected state. A
20 ms delay injected before clear_playback reproduced the CI failure
and passes now; 320 runs in eight parallel loops passed.
@stippi
stippi merged commit 6d19185 into main Oct 9, 2026
5 checks passed
@stippi
stippi deleted the feat/voice-mode branch October 9, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants