Voice assistant on Gemini 3.8 Live (TypeScript / Node) with a tool registry and pluggable transports.
browser (AudioWorklet) ── WebSocket PCM ──┐
├─► friday server ── Gemini Live (PCM 16k in / 24k out)
ESP32 / Voice PE ── WebSocket PCM ──┘ │
└─► tools (src/tools/*)
cp .env.example .env # add GEMINI_API_KEY
npm install
npm run dev # http://localhost:8080Browsers only allow the microphone on localhost or HTTPS.
The session closes itself in two ways:
- Friday calls the
end_conversationtool once a request is fully handled and it has no follow-up question, or when you say "goodbye", "thanks", "that's all", etc. The session closes after its final words. - If you stay silent for
FRIDAY_IDLE_TIMEOUT_MS(default 8000) after Friday finishes a turn, the session closes. Set to0to disable. The timer is paused while a tool (e.g. a timer) is still running.
Clients receive {"type":"closed","data":"ended: ..."} and should stop capturing but finish playing queued audio.
Gemini interrupts itself when it detects the user speaking. On speaker devices a little of Friday's own voice leaks back into the microphone before echo cancellation converges, which Gemini can mistake for speech. FRIDAY_VAD_START_SENSITIVITY (default LOW) and FRIDAY_VAD_PREFIX_MS (default 200) tune how eagerly Gemini treats sound as the user talking; raise sensitivity to HIGH or lower the padding if barge-in feels sluggish on headphones or the browser.
Add a defineTool() call in src/tools/builtin.ts (or a new module imported from loadTools()):
defineTool<{ city: string }>({
name: "get_weather",
description: "Weather for a city",
parameters: { type: Type.OBJECT, properties: { city: { type: Type.STRING } }, required: ["city"] },
handler: async ({ city }) => ({ temp_c: 18, sky: "cloudy" }),
});scheduling controls how Gemini surfaces the result: INTERRUPT (default), WHEN_IDLE, or SILENT. Calls run in the background so audio keeps flowing during slow tools.
Tools in src/tools/media.ts:
list_episodes_to_watch– Jellyfin Next Up or recently added episodes.search_library– find series and movies by name, with watch state.get_next_episode– for a series, picks the episode to continue with: partially watched → next unwatched → episode 1.play_on_apple_tv– wakes the Apple TV through Home Assistant, deep-links Infuse to the stream, and marks the item played in Jellyfin (Infuse does not report progress for URL streams).
"Let's continue Band of Brothers" chains search → next episode → play.
Setup:
- Jellyfin: Dashboard → API Keys → create one. Set
JELLYFIN_URL,JELLYFIN_API_KEY, and optionallyJELLYFIN_USER(display name). - Home Assistant with the Apple TV integration set up. Create a long-lived access token (profile → Security) and set
HA_URL,HA_TOKEN, andHA_APPLE_TV_ENTITY(themedia_player.*entity of the Apple TV). - Infuse 7.6.2 or later on the Apple TV. Playback uses
infuse://x-callback-url/play?url=<jellyfin stream url>; the Apple TV must be able to reachJELLYFIN_URL(override withJELLYFIN_PUBLIC_URL).
The stream URL embeds the Jellyfin API key, so keep this on your LAN.
esphome/friday-voice-pe.yaml turns a Home Assistant Voice Preview Edition into a press-to-talk Friday client. It is a thin fork of the official firmware: XMOS echo cancellation, I2S audio, DAC, LEDs, button, dial and mute switch stay as upstream; the Home Assistant voice pipeline, wake word and media player are removed and replaced by the friday_client component in esphome/components/, which speaks the /ws/audio protocol directly.
Prerequisites: ESPHome 2026.9 or newer on your machine (brew install esphome or pip install esphome), the Voice PE on the same LAN as the Friday server, and a USB-C cable for the first flash.
cd esphome
cp secrets.yaml.example secrets.yaml # Wi-Fi, API key (openssl rand -base64 32), OTA password
$EDITOR friday-voice-pe.yaml # set friday_host (and friday_port) under substitutions
esphome run friday-voice-pe.yaml # first time over USB; afterwards it offers OTA
esphome logs friday-voice-pe.yaml # tail the device logUsage: say "hey friday" (or press the top button) to start talking. A short chime confirms the wake word was heard. Friday ends the session itself after handling a request or when you say goodbye; the LEDs go off once its last words have played. While Friday is talking you can talk over it, or say "stop" and Friday ends the session; the button also stops it. The dial sets the speaker volume.
Wake word detection runs on the device with ESPHome's micro_wake_word; the microphone is always on for that purpose, but no audio leaves the device until the wake word fires. The "hey friday" model is a community model from Custom_V2_MicroWakeWords (Apache-2.0), pinned to a commit in the YAML. To use another phrase, change the model: line under micro_wake_word to an official name such as hey_jarvis or okay_nabu, or to another model URL, and reflash.
| LED ring | Meaning |
|---|---|
| Off (or your LED Ring colour) | Idle |
| Warm white twinkle | No Wi-Fi |
| Slow spin | Connecting to Friday |
| Fast spin | Listening |
| Reverse spin | Friday is speaking |
| Red pulse | Error (server unreachable, connection lost, or pressed while muted); clears after 2 s |
| Two red dots | Microphone muted |
| Red every third LED | XMOS voice kit failed to start |
Troubleshooting:
- Red pulse right after pressing: the device cannot reach
ws://<friday_host>:<port>/ws/audio. Checkfriday_host, that the server is running, and that nothing blocks port 8080. The server log prints[friday-voice] session openon success. - Red pulse while muted: the side switch is on, or Mute is on in Home Assistant.
- Choppy or late speech: raise
buffer_durationon thefriday_speakerresampler (default 2000ms) at the cost of a little more delay before Friday starts talking. - Friday reacts to its own voice: all playback must go through
friday_speaker; anything bypassing the mixer defeats the XMOS echo cancellation. - Wake word misses or false triggers: adjust
probability_cutoffforhey_fridayundermicro_wake_word(lower is more sensitive), or trychannels: 0for the engine's microphone. If the community model is not good enough, switch tohey_jarvis. - Friday interrupts itself during long replies: it is hearing its own echo. Keep the client on microphone
channels: 1(no automatic gain control) and leavebarge_in_delayat 1500ms or raise it; on the server,FRIDAY_VAD_START_SENSITIVITY=LOWandFRIDAY_VAD_PREFIX_MS=200are the defaults. SetFRIDAY_LOG_TRANSCRIPTS=1to see what Gemini hears. - Session drops after Wi-Fi hiccups: expected for now. The device shows the error pattern and returns to idle; the server cleans up via its ping timeout.
The component accepts connect_timeout, drain_timeout, error_hold, send_chunk (20ms to 1s, default 100ms) and barge_in_delay (default 1500ms) if you want to tune it. The device stays a normal ESPHome device in Home Assistant for OTA, logs, the Mute switch and the LED Ring light.
Copy mcp.example.json to mcp.json (git-ignored) and list servers. Both stdio (command/args) and streamable HTTP (url/headers) transports work. Each MCP tool is registered as <server>__<tool>; use include/exclude to trim large servers and scheduling to control how Gemini surfaces results. Override the path with FRIDAY_MCP_CONFIG.
Keep the total tool count modest: Gemini reads every declaration and caps at 512.
WS /ws/audio carries raw PCM both ways; see the protocol in src/transports/ws.ts. The web UI and the Voice PE client speak the same protocol. Transports wrap GeminiSession (src/session.ts), so tools and prompt behaviour are shared. A WebRTC transport can be added alongside it later.
- Clients may append
?device=<id>; the id is logged with the session and otherwise ignored for now. Unknown query parameters are ignored. - Binary frames may be any size; batching 100 ms (3200 bytes) per frame is fine for microcontrollers.
- The server pings every
FRIDAY_WS_PING_MS(default 20000) and drops connections that stop answering, which also closes the Gemini session. Set to0to disable.
Run npm test for the transport tests.