Skip to content

Speech output: text to speech through Ollama (phonon) - #298

Merged
arjunjaggi34 merged 4 commits into
mainfrom
claude/tts-ollama-293
Oct 28, 2026
Merged

arjunjaggi34 merged 4 commits into
mainfrom
claude/tts-ollama-293

Conversation

@arjunjaggi34

Copy link
Copy Markdown
Contributor

Closes #293

What this does

The other half of voice: answers read aloud. A speech client in zorp-voice (OllamaSpeech) speaks text through a local Ollama server's OpenAI-compatible POST /v1/audio/speech with the phonon model, and lists a model's voices from POST /api/show. zorp-web exposes it behind the existing voice feature:

  • GET /api/speech/status — reachable, model present, voices.
  • GET /api/speech/voices?model=
  • POST /api/speech/chunks — splits long text on sentence boundaries (the one chunker, in Rust), so the browser starts playback on the first chunk.
  • POST /api/speech — synthesizes one chunk, returns the audio.

All four exist in every build and answer 501 without the feature, the way the voice routes do. Browser: a speaker button under every answer, a read-aloud toggle in the toolbar, and model + voice pickers in Settings. The TTS model/voice are saved settings (config.rs), resolved like the chat model.

Rules kept (CLAUDE.md, ADR 0077 / 0078 / 0137)

  • The text goes to a loopback address or nowhere. An answer can quote a person's files, so TTS gets the same boundary as recorded audio and recall: zorp-loopback's LoopbackUrl/LoopbackResolver (worded for spoken text via SpeechWording), redirects off, proxy-from-env off, no cloud TTS fallback. A connection-counting canary (zorp-voice/tests/no_remote.rs) proves it by counting hits on a canary, not by trusting an error.
  • Speaking is something a person turns on. No model tool starts or stops speech; the speaker button and read-aloud toggle are the only starts, both in the browser.
  • Spoken text is only the rendered answer. Read-aloud speaks the finished answer text from the live streaming path only, so reopening a conversation does not read its history aloud, and it never speaks tool output.
  • The model is a setting, not a constant. speak/voices take the model by name; the voice picker is driven by the model's /api/show.

Where the code and the issue differ

  • The /api/show voices field name (issue flagged "confirm against the docs"): docs.ollama.com is not in this cloud session's allowed network, so I could not confirm the exact field. extract_voices searches the answer for the first string array under a voices or *.voices key and falls back to the default voice — it never fails the call, and it's the one place to pin once the field is confirmed. Called out per the instruction to say where I diverged.
  • Mac app routes + request-body test (step 6) and the fully hands-free speaking mode (step 7) are follow-ups. The read-aloud toggle plus the existing voice input already give most of a speaking mode; the auto-send loop and the Swift mirror are the parts left. (The Swift desktop job can't be compiled from this Linux session; it stays green because the bridge and existing app are unchanged.)

Tests

  • cargo test -p zorp-voice (incl. speech unit tests, the no_remote speech canary cases, and a scripted request-body test) passes.
  • cargo test -p zorp-web --features voice passes (speech routes: chunks split, empty body rejected, 501 without the feature).
  • web/: npm run check, npm test (incl. 7 new speech-output cases), npm run build:site pass.
  • ADR 0142 added; CLAUDE.md / AGENTS.md bullet added beside the voice one; docs/DECISIONS.md updated.

Generated by Claude Code

Adds the other half of voice: answers read aloud. A speech client in
zorp-voice (OllamaSpeech) speaks text through a local Ollama server's
OpenAI-compatible POST /v1/audio/speech with the phonon model, and lists
a model's voices from POST /api/show. zorp-web exposes it behind the
existing voice feature at GET /api/speech/status, GET /api/speech/voices,
POST /api/speech/chunks and POST /api/speech, registered in every build
and answering 501 without it, the way the voice routes do.

Rules kept (CLAUDE.md, ADR 0077/0078/0137):
- The text goes to a loopback address or nowhere: same zorp-loopback
  guard as recorded audio and recall, redirects off, proxy off, no cloud
  TTS fallback. A connection-counting canary in
  zorp-voice/tests/no_remote.rs proves it.
- Speaking is something a person turns on: a speaker button under every
  answer and a read-aloud toggle are the only starts; no tool starts or
  stops speech.
- Spoken text is only ever the rendered answer, taken from the live
  streaming path so reopening a conversation does not read it aloud.

The TTS model and voice are saved settings (config.rs), resolved like the
chat model, with Settings pickers in the browser. Long text is split on
sentence boundaries under phonon's per-request limit by the one chunker,
zorp_voice::chunk_for_speech, exposed at /api/speech/chunks so playback
starts on the first chunk.

The /api/show voices field name could not be confirmed against Ollama's
docs from this environment, so extract_voices searches for the first
string array under a voices or *.voices key and falls back to the default
voice; it is the one place to pin once the field is confirmed. The Mac
app routes and a fully hands-free speaking mode (auto listen, send and
speak) are follow-ups.

cargo test -p zorp-voice, cargo test -p zorp-web --features voice, and
the web/ check, test and build all pass.

Closes #293

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7qLzxWmLqkYQQLiQqJtiu
Ported from the #294 branch. Spawning a just-written executable can briefly
fail with Text file busy (errno 26) when a sibling thread's fork holds a
write handle on it, which flaked test_supervisor_spawn_lifecycle_and_events
on Linux CI (a pre-existing flake in zorp-train, which this TTS change does
not otherwise touch). Retry the spawn a couple of times on that one errno.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7qLzxWmLqkYQQLiQqJtiu
Three endpoints were built as format!("{}{path}", url) with a leading
slash on the path, so a ZORP_SPEECH_URL ending in "/" produced a "//".
Ollama tolerates it, but a configured trailing slash should not change
the request. Add a base() helper that trims one trailing slash and a
test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7qLzxWmLqkYQQLiQqJtiu
# Conflicts:
#	docs/DECISIONS.md
#	web/index.html
#	web/src/api.ts
#	web/src/main.ts
#	zorp-agent/src/config.rs
#	zorp-web/src/api.rs
#	zorp-web/src/settings.rs
@arjunjaggi34
arjunjaggi34 merged commit 7a33746 into main Oct 28, 2026
11 of 12 checks passed
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.

Speech output: text to speech through Ollama (Phonon), with voice and model selection

2 participants