Skip to content

Voice UX: replies that arrive, in the language you speak - #22

Open
luca-71 wants to merge 5 commits into
ethanplusai:mainfrom
luca-71:feat/voice-ux
Open

Voice UX: replies that arrive, in the language you speak#22
luca-71 wants to merge 5 commits into
ethanplusai:mainfrom
luca-71:feat/voice-ux

Conversation

@luca-71

@luca-71 luca-71 commented Aug 10, 2026

Copy link
Copy Markdown

Five changes from setting JARVIS up on a fresh Mac and trying to talk to him. Independent of #21 — this branch is cut from main.

Each commit stands alone and explains its own reasoning; the summary below is what they add up to.

The replies were not arriving

Nine call sites sent JARVIS's reply only inside if audio:. With no Fish Audio key — or a rate-limited one — the reply was dropped with no trace in the UI. The startup greeting and every proactive notification went straight to nothing.

Three of those sites also passed the raw bytes from synthesize_speech to send_json as "data": audio, without base64. Bytes are not JSON-serializable, so the send raised, and in two cases a bare except Exception: pass swallowed it. Those paths ("Fix Yourself", the calendar/mail lookups) delivered neither audio nor text even with a working Fish key, silently.

The client's stated "text fallback when TTS fails" then only ran console.log, so anything that did arrive reached the devtools console and nowhere else. There is now a caption below the orb, shown whether or not audio arrived, so it doubles as subtitles.

It could not understand anyone who is not American

recognition.lang was hardcoded to en-US. Spoken Italian came back as phonetic English nonsense — come stai arrived as comic Style. There is now a Spoken Language selector in the settings panel, persisted as SPEECH_LANG and read live.

Two things this required beyond a select element:

  • Chrome reads lang at construction. Reassigning it on a session that has already run is silently ignored, so voice.ts rebuilds the recognition object. The retired session's onend checks whether it has been superseded, or it restarts and races the new one for the microphone.
  • The system prompt is English and answered Italian questions in English, so a non-English selection appends a language directive.

Hesitation was read as a finished sentence

Chrome closes a recognition segment at every pause, and each "final" result was sent immediately. A sentence spoken with any hesitation reached JARVIS as several questions — he answered half a thought, then got the rest as a new one. The logs show single words like hey arriving alone.

Fragments are now collected and sent after a 1s gap, with a 6s ceiling (steady dictation never produces a gap, so an uncapped timer re-arms forever). Muting discards what is pending. Barge-in stays immediate — audio is cut on the first fragment, not at flush.

Dependencies

npm audit fix, no --force: vite 6.4.1 → 6.4.3 plus transitive bumps, all within the ranges package.json already declares. The one that mattered is the Vite dev server's arbitrary file read via WebSocket (GHSA-p9ff-h696-f583) — the documented workflow runs that dev server.

Known limitations, measured not guessed

  • Action routing is less reliable outside English. On one repeated question, English picked the right action 4/4 while Italian misrouted to a screen capture 4/4. Three prompt wordings were tried; the one that halved the misrouting also broke language adherence on 2 of 4 replies, so the bare directive was kept. This is a weakness of the English prompt, not something a trailing sentence fixes reliably.
  • 46 hardcoded English strings in server.py ("Right away, sir.") stay English in every language. Translating them is an i18n project, not a setting.
  • Screen descriptions come back in English — they originate in a separate LLM call in screen.py that never sees the language directive.
  • Fragment accumulation costs 1s before JARVIS starts thinking. That is the price of knowing the sentence ended; UTTERANCE_GAP_MS trades it back.

Testing

pytest tests/ gives 35 passed, 8 failed — identical to this branch's base, so no regressions. The 8 pre-existing failures are unrelated: 7 need playwright install, and test_browse_action_keywords imports ACTION_KEYWORDS, a symbol that no longer exists in server.py. tsc --noEmit is clean.

Verified by hand against a live server: language round-trips through the panel and applies without a restart, and replies arrive as captions with no Fish key configured.

🤖 Generated with Claude Code

Luca Trisiello and others added 5 commits August 10, 2026 17:58
npm audit fix, no --force needed: vite 6.4.1 -> 6.4.3, plus transitive
bumps to postcss, nanoid, and picomatch. All within the ranges package.json
already declares, so only the lockfile moves.

The one that mattered here is the Vite dev server's arbitrary file read via
WebSocket (GHSA-p9ff-h696-f583) — this project's documented workflow runs
that dev server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine call sites sent JARVIS's reply only inside `if audio:`, so whenever
Fish Audio was unconfigured, rate-limited, or failing, the reply was
dropped with no trace in the UI. The startup greeting and every proactive
notification — research complete, build finished, project connected — went
straight to nothing. Each site now falls back to a "text" message, which
the client can surface.

Three of those sites also passed the raw `bytes` from synthesize_speech
straight to send_json as `"data": audio`, without base64. Bytes are not
JSON-serializable, so the send raised — and in two cases the exception was
swallowed by a bare `except Exception: pass`. Those paths ("Fix Yourself"
and the calendar/mail lookups) delivered neither audio nor text even with
a working Fish key, and did so silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client's stated "text fallback when TTS fails" only ran console.log,
so a reply that arrived without audio reached the devtools console and
nowhere else. With no Fish Audio key the interface was not merely silent
but blank — the orb reacted to nothing and said nothing.

Add a caption below the orb, in the existing palette. It is shown whether
or not audio arrived, so it doubles as subtitles for a working voice.

It holds for 4s plus 60ms per character, capped at 20s. Reading time
rather than a fixed delay, because without a voice the caption is the
entire response and vanishing mid-sentence loses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
recognition.lang was hardcoded to en-US, so anyone speaking another
language was transcribed phonetically into English nonsense — "come stai"
arrived as "comic Style". Add a Spoken Language selector to the settings
panel, persisted as SPEECH_LANG and read live, so the choice applies
without a restart.

voice.ts rebuilds the recognition object on a language change rather than
reassigning .lang. Chrome reads that property at construction and ignores
it on a session that has already run, so the reassignment silently kept
the old language. The retired session's onend checks whether it has been
superseded, or it would restart and race the new one for the microphone.
main.ts resolves the language before opening the microphone, so the first
session is built correctly and the fragile switch path is reserved for
live changes from the panel.

The system prompt is English and would answer an Italian question in
English, so a non-English selection appends a language directive. Kept to
a bare directive deliberately: wordings that also discussed action
selection measurably weakened the language adherence they were added to
enforce.

Known limitation: action routing is less reliable outside English. On one
repeated question, English chose the right action 4/4 while Italian
misrouted to a screen capture 4/4. The 46 hardcoded English strings in
server.py ("Right away, sir.") also stay English — translating them is an
i18n project, not a setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chrome closes a recognition segment at every pause, and the client sent
each "final" result the instant it arrived. A sentence spoken with any
hesitation reached JARVIS as several questions: he answered half a thought,
then received the rest as a new one. The logs show single words like "hey"
arriving alone.

Collect fragments and send once the speaker has actually stopped, after a
1s gap. Two edges that would otherwise bite:

- A 6s ceiling. Steady dictation never produces the gap, so without a cap
  the timer re-arms forever and the reply never comes.
- Muting discards what is pending, so half a sentence cannot arrive a
  second after the user silenced him.

Barge-in stays immediate: the audio is cut on the first fragment, not at
flush, since interrupting is what the user wanted the moment they spoke.

Costs 1s before JARVIS starts thinking. That is the price of knowing the
sentence ended; UTTERANCE_GAP_MS trades it back for more split sentences.

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