THE CHALLENGE

Voice assistants feel robotic when they finish their whole answer before letting you speak. Natural conversation means low latency, streaming everywhere, and — hardest of all — graceful interruption.

  • One WebSocket: Mic capture (16 kHz PCM) up, synthesized audio (48 kHz) down — every event a JSON envelope, validated with Zod.
  • Round Lifecycle: Every turn carries a UUID roundId, so both client and server can drop late events from a previous round the moment you interrupt.
  • Silence Detection: 500 ms of silence finalizes the transcript and hands off to the LLM — no push-to-talk button.

HOW IT WORKS

The backend orchestrates three streaming services and keeps them all cancellable at any moment.

STREAMING PIPELINE

  • Speech-to-Text: Cartesia ink-whisper streams a word-level transcript diff in real time as you talk.
  • LLM: The reply streams from gpt-4o-mini via OpenRouter, buffered until each sentence boundary.
  • Text-to-Speech: Each completed sentence is synthesized by Cartesia sonic and streamed to the client as PCM chunks — speech starts before the answer is finished.
Voice Assistant listening state

INTERRUPTION HANDLING

  • Instant Cutoff: Speaking while a response is in flight aborts the LLM stream, clears the TTS queue, and stops in-flight synthesis.
  • Self-Discarding Chunks: Late audio from the old round carries a stale roundId and is dropped on both ends — no ghost speech.
  • Zero Downtime: The state flips straight back to LISTENING and the new round begins immediately.
  • No Self-Hearing: Both directions run through one AVAudioEngine graph with voice processing enabled, so the assistant never hears itself and talks over its own voice.

STATE MACHINE

  • Four States: IDLE → LISTENING → PROCESSING → SPEAKING, owned by a single orchestrator coordinating the whole pipeline.
  • Auto-Recovery: Any failure lands in ERROR and recovers back to LISTENING automatically — the conversation never dead-ends.
  • Mirrored Client: The SwiftUI app gates its audio queue on the current roundId and clears it on every new response.

TECHNICAL APPROACH

Node.js & TypeScript

Hono server with a ws WebSocket layer, per-client routing and Zod-validated envelopes

Cartesia STT & TTS

ink-whisper transcription at 16 kHz in, sonic voice synthesis at 48 kHz out

Streaming LLM

gpt-4o-mini via OpenRouter with the Vercel AI SDK, aborted cleanly on interruption

SwiftUI Client

Zero third-party dependencies — URLSessionWebSocketTask, AVAudioEngine, and @Observable state

View on GitHub