Skip to content

feat(voice): the browser holds the mic and the speaker, and nothing else - #2396

Merged
2witstudios merged 2 commits into
pu/gpt-realtimefrom
pu/rt-client
Aug 11, 2026
Merged

2witstudios merged 2 commits into
pu/gpt-realtimefrom
pu/rt-client

Conversation

@2witstudios

Copy link
Copy Markdown
Owner

C-C — the browser holds the mic and the speaker

The server side has been merged and working since #2393/#2395. Nothing had ever driven it from a browser. This is that half: the first real end-to-end exercise of the relay route from a page.

The page creates the peer connection, adds the mic track, opens the oai-events data channel, POSTs its SDP offer to POST /api/voice/realtime/call, and applies the answer. It never contacts api.openai.com, never holds an ephemeral secret, and is never sent a tool schema.

What's here

File What it is
lib/ai/realtime/connect.ts The handshake against our route. Every effect is a parameter.
lib/ai/realtime/voice-target.ts Pure. Navigating vs rebinding.
lib/ai/realtime/chain-schedule.ts Pure. When to hand off ahead of the cap.
lib/voice/mic-errors.ts Extracted from useVoiceMode, now shared by both paths.
contexts/VoiceSessionContext.tsx The session. Wiring only — it holds no decisions.

Why the provider is in Layout and not the sidebar

RightPanel is unmounted outright when the sidebar closes (Layout.tsx: rightPanelVisible && …). A session owned by the panel would hang up every time somebody collapsed it. The sidebar is voice's home, not its owner.

The test starts a call from inside the panel, then unmounts the panel, then remounts it and finds the same callId still running — the property is asserted, not just arranged.

Chaining past the cap

A call has a server-enforced ceiling. A conversation does not.

Before the cap lands, the client mints a fresh call on the same conversationId, which the server reseeds from that thread — the transcript is the durable layer, so the new session already knows what was said. The swap is make-before-break, so there is never a moment with no session. The microphone is handed over as an independent track clone: one permission prompt for the whole conversation, no blink in the recording indicator, and stopping the outgoing call cannot take the incoming call's audio with it. The replacement negotiates muted, so two live sessions cannot both hear the same sentence.


⚠️ Three things about the server contract — flagged, not worked around

1. The route now reports maxDurationMs (I changed the contract).
Chaining needs to know when the server will hang up. REALTIME_MAX_SESSION_SECONDS is per-deployment env the browser cannot read, so the call route now returns it. The alternative was a client-side copy of a server env var — a copy that drifts, whose failure mode is a user cut off mid-sentence on the one deployment that tuned it. Additive; existing fields unchanged.

Caveat in the code: apps/realtime's own hour-long socket ceiling (MAX_CALL_DURATION_MS) is not importable from web (no dependency edge, deliberately), so the route reports the cap this tier owns. Correct unless a deployment sets REALTIME_MAX_SESSION_SECONDS above 3600.

2. There is no hop for updating a live call's locationContext. ← the real gap
"Navigating mid-call updates locationContext instead of rebinding" is a settled decision, but VOICE_BRIDGE_ROUTES has attach and nothing else. The provider does its half — it tracks the latest location and carries it into every call it opens — but between chains, the tools answer "what's on this page?" with the page the call started on. The fix belongs on the server contract (an update route the realtime server applies to its held context), not in a client that would otherwise have to fake it by rebinding, which is exactly what the design forbids.

3. There is no client-facing hangup. When the user hangs up or hard-refreshes, the browser closes the peer connection; nothing tells the realtime server, which holds its socket until REALTIME_IDLE_TIMEOUT_SECONDS (120s) reaps it. No audio flows in that window so nothing bills, but the concurrency slot is held. Bounded, not free.


The seam for C-D (voice UI) — build on exactly this

The UI chunk consumes two hooks and nothing else. Do not mount an <audio> element and do not own a session per panel — that is the failure mode this provider exists to prevent.

const { start, stop, setLocationContext } = useVoiceSessionControls(); // stable identity
const { status, error, transcript, userSpeaking, tools,
        attached, callId, target, localStream, remoteStream } = useVoiceSession();
  • Trigger, on any route: start({ conversationId, type, contextId?, agentPageId? }).
  • Agent switcher: call start(…) with the new agent's target. It rebinds for free — do not stop() then start(), that discards the make-before-break path and flashes an idle state.
  • Route changes: call setLocationContext(…). Never call start on navigation.
  • Idempotent: start with the target already bound is a no-op, so a re-render handing back the same target cannot restart a call mid-sentence.
  • attached: false is a working audio call with no tools, no transcript, no metering. Say so in the UI rather than implying full capability.
  • The <audio> element and the microphone belong to the provider.

Not in scope, deliberately

No UI chrome (that's C-D). No changelog entry — voice is not user-reachable until C-D lands a trigger, matching what #2387/#2388/#2391/#2395 did.

Gate

  • bun run typecheck (monorepo root) — 17/17
  • bun run lint15/15
  • 345 voice-related tests green; full web suite 16,996 passed, the only failures being the known DB-gated integration files ("Test database not reachable"), which fail identically on the base branch.
  • Coverage of the new modules: 96.8% stmts / 91.97% branch / 100% funcs.
  • Mutation-checked, not asserted on faith — each of these was broken in the source and watched go red, then restored: detaching the connection-state handler before close (a hangup must not read as a drop), cloning the reused mic, cleanup on route refusal, mic handover on chain, the active-attempt guard on events, and teardown on a dropped connection.

Tests drive fakes for RTCPeerConnection/getUserMedia/fetch. No live network, no real microphone.

Base: pu/gpt-realtime.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PhbBndG131JyacZCqXrF5w

The server side of the audio-native call has been merged and working since
#2393/#2395 — mint, relay, HMAC handoff, seeding, tool dispatch, transcript
persistence, metering — and nothing had ever driven it from a browser. This is
that half: the page opens the peer connection, adds the mic track, opens the
`oai-events` data channel, POSTs its SDP offer to our relay route, and applies
the answer. It never contacts api.openai.com, never holds an ephemeral secret,
and is never sent a tool schema.

VoiceSessionProvider is mounted in Layout, ABOVE RightPanel. That placement is
load-bearing, not incidental: `rightPanelVisible && …` unmounts the right
sidebar outright when it closes, so a session owned by the panel would hang up
every time somebody collapsed it. The sidebar is voice's home, not its owner —
and the test starts a call from inside the panel and then unmounts the panel,
so the property is asserted rather than merely arranged.

Every decision is a pure module the provider only wires together: `voice-target`
(navigating is not rebinding — walking to another page moves locationContext,
choosing another agent moves the conversation), `chain-schedule` (when to hand
off), and the already-merged `sessionReducer` (what the UI shows).

CHAINING. A call has a server-enforced ceiling; a conversation does not. Before
the cap lands, the client mints a fresh call on the SAME conversationId — which
the server reseeds from that thread, because the transcript is the durable layer
— and swaps make-before-break, so there is no moment with no session. The
microphone is handed over as an independent track clone: no second permission
prompt, no blink in the recording indicator, and stopping the outgoing call
cannot take the incoming call's audio with it. The replacement negotiates
muted, so two live sessions cannot both hear the same sentence.

The ceiling had to come from the server. `REALTIME_MAX_SESSION_SECONDS` is
per-deployment env the browser cannot read, so the call route now reports
`maxDurationMs`. The alternative was a client-side copy of a server env var,
whose failure mode is a user cut off mid-sentence on the one deployment that
tuned it.

`getMicPermissionErrorMessage` moved out of useVoiceMode into
`lib/voice/mic-errors` rather than being ported: both paths ask the same API and
hit the same five failures, and its desktop-Electron branch (System Settings,
not "browser settings", which an Electron shell does not have) is not one to
keep two copies of. Denied and missing stay different outcomes with different
advice.

Gate: monorepo `bun run typecheck` 17/17, `bun run lint` 15/15, 345 voice tests
green. Teardown claims are mutation-checked, not asserted on faith.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhbBndG131JyacZCqXrF5w
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 24b0e4a1-f4ca-485e-8397-8e24c72545c2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4d0240ae8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* the internal attach — whose own upstream timeout is 15s, plus the moment of
* swapping which stream feeds the speaker.
*/
export const CHAIN_LEAD_MS = 20_000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Increase the lead to cover the complete handshake

When either OpenAI request is slow, 20 seconds is insufficient to complete a replacement before the old call is capped. runCallHandshake performs the mint and call requests sequentially, each with a 15-second timeout, followed by a handoff with another 5-second timeout; the old call therefore can close while the replacement is still pending, and onConnectionLost tears that pending attempt down. Base this lead on the full worst-case handshake or keep the pending replacement alive when the capped call closes.

Useful? React with 👍 / 👎.

Comment on lines +300 to +301
if (options.chained && previous?.connection) {
previous.connection.stop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep attached calls until the replacement is attached

When the realtime handoff is refused or unavailable, the route deliberately still returns HTTP 200 with attached: false, so connectVoiceCall reports ok: true. This branch consequently stops the healthy attached call and replaces it with an audio-only call that has no tools, transcript persistence, or usage metering; global/per-user saturation or a transient realtime outage can therefore leave a full session interval unmetered and unpersisted. Require an attached replacement when the outgoing call is attached, or retain it and retry.

Useful? React with 👍 / 👎.

Comment on lines +281 to +284
console.error(
`[voice] chain failed, staying on the current call: ${result.detail}`,
);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retry a failed chain before the current call expires

When the single replacement request encounters a transient network error or 502, the chain timer has already fired and this return schedules no further attempt. The currently working call is still subject to its hard cap, so one brief failure guarantees that the user is disconnected roughly one lead interval later. Schedule another attempt within the remaining headroom rather than abandoning chaining permanently.

Useful? React with 👍 / 👎.

…vives

Review caught a real hole. `VoiceSessionContext.test.tsx` mounts its own
provider, so it proves a session survives a child unmounting — but replace
`<VoiceSessionProvider>` in Layout with a passthrough and all 25 of those tests
still pass, while every call in production would hang up the moment the user
closed the sidebar. The one load-bearing fact of this chunk was unguarded.

This asserts it against the REAL Layout tree. Everything heavy is mocked EXCEPT
the thing under test: the provider is the real one, mounted by the real Layout,
and `RightPanel` is replaced by a probe that CONSUMES the session — so a
provider that is missing, passthrough, or moved inside the panel fails at render
rather than subtly. The call is then started from inside that probe and the
sidebar gate is closed underneath it.

Verified by breaking Layout three ways and watching it go red, then restoring:
  1. provider replaced with a passthrough (the exact review mutation) — 3 red
  2. provider moved inside the `rightPanelVisible &&` region — 3 red
  3. provider kept as an ancestor but re-keyed on `rightPanelVisible`, so it
     remounts on toggle — 2 red, on `stop` having been called and the reopened
     panel finding no call. That one isolates the SURVIVAL assertion, proving it
     is not decorative: the hooks never throw, only the call dies.

No existing test was weakened to make this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhbBndG131JyacZCqXrF5w
@2witstudios
2witstudios merged commit 3ec364b into pu/gpt-realtime Aug 11, 2026
1 check passed
2witstudios added a commit that referenced this pull request Aug 11, 2026
The audio-native path is merged end to end — server call plane (#2393),
server behaviour (#2395), browser + lifecycle (#2396), UI (#2397) — so the
old pipeline is dead weight on the conversational path. This removes exactly
that much and nothing else.

WHAT WENT, and why each piece could not stay:

- `useVoiceMode` + `/api/voice/transcribe`: the loop itself. Whisper existed to
  turn audio into text before inference; the realtime session hears the audio
  directly, so there is nothing left for it to do. Verified with a repo-wide
  search that no other caller reaches the route.
- `useVoiceModeStore` and everything reading it — `VoiceCallPanel`,
  `VoiceModeSettings`, `VoiceModeBorder`, and the mic button in the chat box's
  footer. The store's only writer was that button. Left in place, the border
  would be UI that can never render and the button an affordance that toggles
  a mode nothing implements. The way into voice is the nav-bar trigger.
- `selectVoiceStreamText`, `selectVoiceActivationBaseline` and
  `selectPostBaselineAssistantMessage`: three pure selectors whose only job was
  deciding which written reply the old path should speak. Spoken turns now
  arrive as ordinary messages, so nothing derives a "what to say out loud"
  from the message list any more.
- The Whisper rate in `voice-pricing`, and `VOICE_HOLD_ESTIMATE_CENTS` — the
  flat hold that existed because STT could not know its own cost until the
  provider answered. Both had exactly one caller, the deleted route.

WHAT DELIBERATELY STAYED. `/api/voice/synthesize`, the tts-1/tts-1-hd rates,
`estimateVoiceHoldCents`, `VOICE_MAX_INFLIGHT` and `chunkForTts` all back
Read Aloud, which is an open PR (#2173) and a genuinely different feature: an
audio-native conversation does not replace "read this to me". `mic-errors`
stays because the realtime path is now its only consumer.

`chunkForTts` is kept despite having no in-tree caller on this branch — its last
one went with `VoiceCallPanel` — because `useReadAloud` imports `flushForTts`
from it on #2173. Deleting it would break work in flight. knip does not report
it, so it needed no ignore. The one knip.json line added is for
`@radix-ui/react-slider`: deleting VoiceModeSettings left `components/ui/slider.tsx`
as its only importer, and `src/components/ui/**` is already ignored.

TESTS DELETED WITH THEIR SUBJECTS, never to make the gate pass:
`useVoiceModeStore.test.ts`, `transcribe/route.test.ts`, and the three stream
selector tests. `voice-pricing.test.ts` loses its Whisper describe block; the
unknown-model and 1¢-floor assertions are kept, retargeted off `whisper-1`.
`whisper-1` survives as a fixture in the admin billing-coverage tests, where it
stands for historical usage rows that still exist in the database.

Gate: monorepo `bun run typecheck` 17/17, `bun run lint` 15/15, knip ratchet
green with an unchanged baseline. Unit suites pass (lib 9175, web 17057); the
only red files are the DB-backed integration tests, which need a Postgres this
worktree has no access to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYjZgYpebAVgBq9f5oVt81
@2witstudios
2witstudios deleted the pu/rt-client branch August 11, 2026 13:46
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.

1 participant