Anvil Voice¶
anvil-serving voice is the local voice-agent runtime built in this repo. It
runs an OpenAI Realtime-compatible WebSocket server and wires a voice cascade:
Realtime client
-> anvil-serving voice proxy run
-> VAD
-> STT endpoint
-> Anvil Serving Capability Gateway / OpenAI Chat Completions endpoint
-> TTS endpoint
-> Realtime audio output
The voice loop stays within the operator-declared local or private topology
while Anvil Serving's Capability Gateway handles the LLM turn. STT and TTS are
replaceable, topology-owned out-of-process serves: operator topology declares
their resource owner, execution host, and lifecycle boundary. The LLM request
selects one configured capability alias such as llm.voice; its physical
Compute A/B placement is independent of that capability name.
Routing is fail-closed. An unavailable STT, TTS, capability alias, or selected tier returns an error; the request path does not substitute a provider, capability alias, or endpoint. Lifecycle changes, capability-alias changes, and promotion remain separate managed operations with explicit previews and human gates.
Evidence terminology. In this guide, current means the latest public
evidence decision recorded in the benchmark portal. It does not report an
operator's live route assignment, model placement, or service health. Inspect
those private facts through the topology owner's status surfaces.
The voice pipeline is different from voice sidecar. voice sidecar renders
commands or compose manifests for Hugging Face's speech-to-speech project,
where that project owns the Realtime server. anvil-serving voice owns the
Realtime server and cascade itself.
One-shot audio gateway¶
In the public Mini-to-Dark reference topology, the Realtime pipeline uses
topology-owned audio serves through a topology-derived bridge. The optional
one-shot audio gateway is for private HTTP clients that need dictation or
complete read-aloud audio without learning raw host/port details. It is not
part of the existing Workbench Realtime relay and must not replace that relay's
WebSocket URL or token.
Declare [[router.audio_routes]] together with [server].auth_env; each endpoint remains 404
unless it has a bound route for its purpose.
POST /v1/audio/transcriptions accepts JSON
{"purpose":"stt","audio_b64":"...","format":"wav|pcm16|webm_opus","is_final":true}
and returns text, is_final, duration_ms, model, and request_id. duration_ms is null
when the source container has no reliably derivable duration (for example, webm_opus). pcm16
requires an explicit sample_rate and is wrapped as WAV before the multipart hop to the routed
STT serve. Interim audio is rejected: this is a one-shot boundary, not a streaming STT protocol.
POST /v1/audio/speech accepts JSON
{"purpose":"tts","input":"...","response_format":"pcm16"} and returns JSON
{"audio_b64":"...","format":"pcm16","sample_rate":24000,"model":"...","request_id":"..."}.
The gateway requests the routed TTS serve's qualified raw PCM16 contract, validates its media
type and whole sample framing, then base64-encodes the bytes. WAV and MP3 output remain
unavailable until those upstream contracts have separate qualification.
Both endpoints use the same bearer/x-api-key authentication as /v1/responses. Decisions log
only opaque route id, byte counts (including rejected bounded upstream responses), outcome,
latency, and correlation ids; audio bytes/base64, transcript text, and synthesis input are
excluded. The gateway has a separate bounded concurrency pool for parsed upstream operations and
input/output caps. It has no provider or chat fallback: a configured raw serve failure is a typed
error from that route. See configuration and
ADR-0024.
Any Workbench HTTP voice integration must use the Capability Gateway base URL
plus ANVIL_ROUTER_TOKEN, send this canonical shape, use the returned
sample_rate for playback, and keep the existing Realtime relay separate.
Older audio/model/format adapter shapes are not part of this contract.
Command summary¶
| Command | What it does | What it does not do |
|---|---|---|
anvil-serving voice up |
Starts co-located managed STT/TTS, then the managed Realtime proxy. | Does not span hosts, native/external lifecycle, or separate controller owners. |
anvil-serving voice down |
Stops the co-located managed Realtime proxy, then managed STT/TTS. | Does not tear down audio if the proxy stop fails. |
anvil-serving voice audio up |
Validates the voice manifest and starts manifest-owned managed/native STT/TTS lifecycle. | Does not start the Realtime WebSocket server or the LLM router. |
anvil-serving voice audio down |
Stops native STT/TTS processes and stops/removes managed STT/TTS containers. | Does not stop the LLM router; a foreground voice proxy run process stops with Ctrl+C. |
anvil-serving voice audio status |
Reports bounded readiness and lifecycle state for both topology-owned audio serves. | Does not mutate either serve. |
anvil-serving voice audio logs |
Reads a bounded tail from both topology-owned audio serves. | Does not follow logs indefinitely. |
anvil-serving voice proxy run |
Starts the Realtime WebSocket server in the foreground after probing the LLM, STT, and TTS endpoints. | Does not silently continue when required endpoints are unreachable. |
anvil-serving voice proxy up |
Starts the Mini-owned Realtime proxy as a persistent background process. | Does not start STT/TTS models or the audio bridge. |
anvil-serving voice proxy down |
Stops only the persistent Realtime proxy process recorded by Anvil. | Does not stop audio models or unrelated processes. |
anvil-serving voice proxy restart |
Performs a bounded stop/start of the persistent Realtime proxy. | Does not replace a foreground proxy run process. |
anvil-serving voice proxy status |
Reports persistent process identity and readiness. | Does not infer health from a PID alone. |
anvil-serving voice proxy logs |
Reads a bounded tail of persistent proxy logs. | Does not stream without a bound. |
anvil-serving voice benchmark |
Runs one configured end-to-end voice turn and prints latency/quality metrics as JSON. | Does not change the configured capability alias or prove subjective audio quality by itself. |
anvil-serving voice corpus prepare |
Builds a deterministic multi-sample English STT corpus in an evidence workspace. | Does not commit the public archives or change a serve. |
anvil-serving voice corpus validate |
Validates corpus JSONL, WAV/FLAC metadata, paths, hashes, and case count. | Does not contact an STT endpoint. |
anvil-serving voice profiles list |
Lists manifest profiles or validates the resolved manifest for one profile. | Does not mutate lifecycle or start the Realtime server. |
anvil-serving voice proxy bridge |
On Mini, forwards loopback STT/TTS proxy ports to topology-owned Dark endpoints. | Does not bind publicly, manage models, add auth, or inspect audio traffic. |
anvil-serving voice sidecar |
Validates or renders a Hugging Face speech-to-speech sidecar. | Does not run anvil-serving's native Realtime cascade. |
Removed module-level paths¶
The old module-level paths voice start, voice stop, voice run, and
voice bridge remain removed tombstones. They exit with replacement guidance
and do not import or invoke an operational handler.
Manifest-backed commands take --config <voice.toml>. If omitted,
$ANVIL_SERVING_HOME/voice.toml (default ~/.anvil-serving/voice.toml) is
used when present; otherwise the shipped example manifest is used. up,
down, run, and benchmark also accept
--profile <name> to apply [voice.profiles.<name>] before validation.
Relative managed manifest_path values inside the voice manifest resolve
against the voice manifest's own directory, so a host-level
~/.anvil-serving/voice.toml can refer to manifest_path = "serves.toml".
proxy run and benchmark also accept --candidate-overlay <toml> and
--candidate <label> so bounded A/B tests can compose one audio topology with one
LLM candidate without copying manifests.
benchmark additionally accepts --candidate-base-url,
--candidate-model, and --candidate-api-key-env for a voice candidate that is
already loaded on a direct OpenAI-compatible endpoint. Those flags create an
in-memory LLM overlay for that benchmark run only; they do not write the voice
manifest, gateway config, or configured capability alias.
Audio and proxy operations require --topology <operator-topology.toml>. The
topology, not the machine where the command was typed, establishes the resource
owner, execution host/runtime, transport, endpoint meaning, and Mini capacity
policy. Controller-side voice_manage and voice_proxy_manage calls may use
ANVIL_VOICE_TOPOLOGY instead of a topology argument. Missing, split, or
ambiguous ownership fails before any serve or process object is constructed.
Why these commands exist¶
Voice has four separate operational concerns:
- Audio model lifecycle: STT and TTS endpoints may be Docker containers,
native processes on a voice host, or manually managed services.
voice audio up/downowns only this layer. - Realtime session serving:
voice proxy runowns a foreground WebSocket server;voice proxy up/down/restart/status/logsown the persistent Mini service. Neither surface owns model lifecycle. - Evidence capture:
voice benchmark,voice corpus, and the hardware validation scripts measure whether the configured STT -> LLM -> TTS path is usable on the target host. - Private audio forwarding:
voice proxy bridgelistens only on Mini loopback and forwards to topology-derived Dark STT/TTS addresses.
Keeping those concerns separate avoids a common failure mode: a command that
appears to start "voice" but only starts one part of the pipeline. voice audio up
makes STT/TTS available; voice proxy run is the user-facing Realtime server.
The one exception is the deliberately narrow voice up / voice down
aggregate for a co-located Docker-managed stack. It requires matching
host-relative STT/TTS endpoints, container service-DNS proxy endpoints on the
audio-owned anvil-voice network, one shared serves manifest, and voice
group membership for STT, TTS, and the managed proxy. Bring-up runs audio
before the proxy; tear-down reverses the order. The aggregate refuses split
ownership, so the public Mini-to-Dark reference path remains explicit.
Manifest shape¶
A voice manifest has one [voice] section and three endpoint subsections. The
Capability Gateway models audio as two purposes — stt and tts — and each
resolves to exactly one configured audio route. The model values below
illustrate the public reference configuration; they are not part of the
contract. Substitute the exact identities advertised by the topology-owned STT
and TTS serves. The benchmark portal records the latest
public evidence decision for evaluated configurations; its current label does
not describe a live operator route or deployment.
[voice]
name = "anvil-voice"
realtime_host = "127.0.0.1"
realtime_port = 8765
[voice.llm]
base_url = "http://127.0.0.1:8000/v1"
model = "llm.voice"
stream = true
api_key_env = "ANVIL_ROUTER_TOKEN"
history_max_turns = 8
history_max_message_chars = 1200
tool_result_max_chars = 12000
[voice.stt]
base_url = "http://127.0.0.1:30010/v1"
model = "parakeet"
lifecycle = "managed"
[voice.tts]
base_url = "http://127.0.0.1:30011/v1"
model = "kokoro"
lifecycle = "managed"
response_format = "pcm"
protocol = "openai"
[voice.profiles.dark-audio.stt]
base_url = "http://100.64.0.10:30110/v1"
model = "tdt-0.6b-v3"
lifecycle = "external"
[voice.profiles.dark-audio.tts]
base_url = "http://100.64.0.10:30111/v1"
model = "kokoro"
lifecycle = "external"
response_format = "pcm"
Manifest hygiene follows the rest of the repo:
- Use
127.0.0.1for same-host URLs. - Use private tailnet or direct addresses for cross-device endpoint URLs.
- Store credentials in environment variables and reference only the env-var
name with
*_envkeys. - Do not embed credentials in URLs.
- A non-loopback
realtime_hostrequiresrealtime_token_env. - Use profiles for repeatable topology switches instead of copying manifests or maintaining one-off shell scripts.
voice.llm.history_max_turnscontrols session-local memory for completed user/assistant turns. Set it to0for deterministic validation prompts or lower it to cap prompt cost; the default is8.voice.llm.history_max_message_charstrims each remembered user or assistant message before it is replayed into the next LLM request; the default is1200.voice.llm.tool_result_max_charstrims very large realtime tool outputs before the continuation LLM request; the default is12000.voice.llm.speech_chunk_max_charscaps each speakable LLM chunk before it is sent to TTS. Sentence punctuation still wins, but long first sentences are split on word boundaries so first audio does not wait for a large clause; the default is72.voice.tts.protocoldefaults toopenai, which calls{base_url}/audio/speechand consumes raw signed 16-bit PCM. Set it togepardfor Gepard's Cartesia-wire streaming TTS, where Anvil Voice connects to{base_url}/tts/websocketand consumeschunkmessages carrying base64 PCM. Keepresponse_format = "pcm"because the pipeline still emits raw PCM internally. The oldercartesiaspelling is accepted as a local wire-protocol alias; it is not a full Cartesia cloud integration.voice.llm.modelremains the manifest-owned Anvil capability alias. Realtime clients may sendsession.model, but Anvil Voice does not let that field override local routing.
For native audio endpoints, add the lifecycle metadata to the STT/TTS section:
lifecycle = "native"
workdir = "~/code/mlx-audio"
start_command = ".venv/bin/python -m mlx_audio.server --host 127.0.0.1 --port 30010"
pid_file = "/tmp/anvil-voice/stt.pid"
log_file = "/tmp/anvil-voice/stt.log"
ready_timeout = 120.0
stop_timeout = 5.0
STT/TTS lifecycle modes¶
voice.stt.lifecycle and voice.tts.lifecycle choose what voice audio up/down
can manage.
| Lifecycle | Use When | voice audio up |
voice audio down |
|---|---|---|---|
managed |
The audio serve is declared in a serves manifest named by manifest_path (use a dedicated serves.voice.toml, not the shared serves.toml — see below). |
Delegates to the same serve adapter used by anvil-serving serves. |
Stops the matching serve. |
native |
The audio serve is a same-host process, such as MLX Audio on a Mac Mini or laptop. | Starts start_command without a shell, writes pid_file, logs to log_file, and probes /models. |
Stops the PID it started; if no PID is present but the endpoint is up, uses optional stop_command. |
external |
Another supervisor or operator owns the process. | Skips lifecycle and reports that it was skipped. | Skips lifecycle and reports that it was skipped. |
Native lifecycle commands are trusted operator manifest content, similar to a
serves.toml up command. They are parsed with shlex and executed as argv
without a shell.
Keep audio serves out of the shared model-serves manifest¶
Declare managed STT/TTS serves in a separate manifest (for example
serves.voice.toml, referenced from the voice manifest via manifest_path),
not in the host's main serves.toml. Generic lifecycle operations over the main
manifest — anvil-serving serves down and the serves_manage MCP tool — walk
every entry, stop it, and remove its container by default. If that manifest's
LLM containers are already absent, the operation can complete without exposing
the risk created by placing always-on audio in the same lifecycle scope. This
failure mode was observed on the Primary Node reference host on 2026-07-13, when
routine lifecycle work cleanly stopped both audio serves twice.
For the same reason, give the audio compose file its own explicit Compose
project (name: anvil-voice-audio). Compose derives the default project from
the directory, so audio services defined in a second file in the same directory
share the main project and may be removed as orphans by an invocation against
the other file. See examples/primary-node/serves.voice.toml and
examples/primary-node/docker-compose.voice-audio.yml for the reference shape.
The bridge-port publishes follow the directory's loopback-only default: set
VOICE_AUDIO_PUBLISH to the host's tailnet address (see .env.example) so
Mini's realtime proxy can reach :30110/:30111; without it the audio serves
bind loopback only. Audio serve entries declare engine = "audio" — the
truthful non-LLM label in serves status — rather than omitting engine,
which would fall back to a legacy marker guess.
Multi-device expansion¶
Companion Node and Primary Node are reference devices, not fixed product roles. The same voice topology can expand to other laptops or hosts when the configured endpoints are reachable over Tailscale or another private or direct network path. See Device topologies for the broader role model.
Common layouts:
- Voice and audio on one laptop: declare all resources on that host in a
non-reference topology, then run
voice audio upandvoice proxy runthere; keep STT/TTSbase_urlvalues on127.0.0.1; usenativeormanagedlifecycle there. - Voice on one laptop, Capability Gateway on another host: keep STT/TTS local
to the voice laptop and point
[voice.llm].base_urlat the gateway's private tailnet or direct address. - Voice host separate from audio host: set STT/TTS
base_urlvalues to the remote private addresses and uselifecycle = "external"unlessvoice audio upis being run on the audio host itself through local CLI or a controller. - Mini forwarding to loopback-only STT/TTS on Dark: run
voice proxy bridgeon Mini. It binds Mini-local127.0.0.1proxy ports and derives the Dark private target address and model ports from topology. Point the Mini voice profile at those loopback proxy ports.
lifecycle = "native" is intentionally same-host. It starts the manifest
command on the host where anvil-serving voice audio up runs; it is not a remote
shell transport. For remote lifecycle, run the command on the resource-owning
host or use an anvil-serving controller on that host.
Any service bound beyond loopback needs the appropriate token env var and private network controls. Tailscale reachability is the transport requirement; it is not a replacement for router, Realtime, or controller auth.
voice proxy bridge is loopback-only. Non-loopback and wildcard listeners are
rejected; there is no acknowledgement flag that weakens this invariant.
Optional Mini-local audio lab¶
The checked-in Mini-local topology is an optional, public same-host audio lab template. It places STT/TTS on the 16 GB Mac Mini while the LLM turn targets the Primary Node Capability Gateway in the reference design:
- STT:
http://127.0.0.1:30010/v1 - TTS:
http://127.0.0.1:30011/v1 - LLM: the reference Dark Capability Gateway over a private network
This is not the reference OpenClaw Talk or candidate benchmark topology. Companion Node's 16 GB RAM is reserved for OpenClaw Gateway, Anvil Voice Realtime/proxy, Claude Code, and Codex. Do not run STT, TTS, or LLM model serves on Mini during reference validation. Use this manifest only when explicitly testing the optional Mini-local audio mode.
The checked-in manifest is examples/voice/companion-node.toml. It uses
lifecycle = "native" for both audio endpoints and starts MLX Audio with PID
and log files under /tmp/anvil-voice-mini.
This manifest is a portable public template, not a live deployment record.
Install a private operator copy for another laptop or gateway host, then replace
the LLM base_url, expected endpoint host, expected route/model fields, MLX
Audio workdir, and lifecycle fields for that device.
The public Mini-to-Dark reference topology intentionally rejects this manifest because Mini is model-free in that design. To use the optional lab mode, supply a separate private topology that declares Mini as the STT/TTS owner and explicitly permits model workloads; do not overlay or weaken the public reference topology.
OpenClaw Anvil Voice provider¶
OpenClaw can use Anvil Voice as a speech-to-speech realtime provider. In that
topology the OpenClaw Gateway owns the browser or call audio relay, while
Anvil Voice owns STT, the configured llm.voice capability turn, and TTS:
OpenClaw Talk or Voice Call
-> OpenClaw Gateway realtime provider "anvil"
-> ws://127.0.0.1:8765/v1/realtime
-> anvil-serving voice proxy run
-> STT -> [voice.llm] Anvil Serving Capability Gateway -> TTS
Use examples/voice/openclaw-anvil-voice.toml for the public Mini gateway
reference layout. It keeps the Realtime server on Mini loopback, routes the LLM
turn to the reference Dark Capability Gateway over a private address, and
selects topology-owned STT/TTS from the Dark host or a Mini-side proxy to Dark
for reference validation. It is a portable template, not evidence of a live
deployment. Mini-local STT/TTS is declared only as an explicit optional
profile. The template also declares profiles for repeatable switching:
When OpenClaw sends realtime tools in session.update or response.create,
Anvil Voice forwards them to the Chat Completions LLM request. If the model
emits a function call, Anvil Voice surfaces the standard Realtime
response.output_item.added, response.function_call_arguments.done, and
response.output_item.done events, plus an OpenClaw compatibility
conversation.item.done with item.type = "function_call". It waits for
OpenClaw to submit the matching function_call_output, then resumes the same
spoken response. This is the path used by OpenClaw's openclaw_agent_consult
tool for normal agent tools, memory, workspace context, and time-sensitive
information lookups.
dark-audio: Dark-host STT/TTS reached through private bridge ports30110and30111.gepard-fast-tts: Dark-host STT plus the experimental Gepard Fast TTS candidate on Dark port39111. Gepard is Cartesia-compatible, so the TTS profile usesprotocol = "gepard"and a base URL without/v1.mini-dark-audio-proxy: Mini-local proxy ports30110and30111that forward to Dark-host STT/TTS. Use this only after that Mini-side proxy is actually listening.mini-audio: optional Mini-local MLX Audio STT/TTS, with conversational LLM prompt. Do not use it for reference OpenClaw Talk validation or LLM candidate A/B on the 16 GB Mini.mini-validation: Mini-local audio plus the intentionalI understand.validation prompt.- Historical
candidate-qwen3-32b,candidate-gemma4-12b, andcandidate-gemma4-e4bprofiles: retained LLM-only shortcuts from the July 2026 comparison campaign. They preserve the Dark-host audio path and point the LLM stage at candidate ports39000through39002. For a new qualification, prefer the reusable overlays inexamples/voice/candidates/so audio topology and LLM candidate remain independent.
The mini-audio profile lowers voice.llm.speech_chunk_max_chars to 56 for
the Mini-local TTS path. In retained July 2026 Talk measurements, this reduced
first-audio latency versus the 72 character cross-topology default, while a
more aggressive 48 character split produced TTS stream errors on some
sentence fragments.
Public Mini-to-Dark reference operation¶
The checked-in public reference topology is
examples/primary-node/operator-topology.toml. It is a portable design and
validation contract, not a report of live placement or health. Install a
private deployment copy with real addresses as
~/.anvil-serving/operator-topology.toml; the checked-in documentation
addresses are intentionally not live-routable. The reference design declares
Dark as the sole STT/TTS model owner and Mini as the Realtime proxy plus
loopback-forwarding owner. The canonical end-to-end flow is:
Each host's deployed copy must also declare that controller's real
command_host and command_runtime (or set ANVIL_COMMAND_HOST and
ANVIL_COMMAND_RUNTIME). The Dark controller must identify as the Dark audio
runtime; the Mini controller must identify as Mini native. A controller cannot
derive or self-assert the resource owner's identity.
TOPOLOGY=~/.anvil-serving/operator-topology.toml
VOICE_CONFIG=examples/voice/openclaw-anvil-voice.toml
# Preview, then validate the topology-owned Dark audio selection.
anvil-serving voice audio up --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile dark-audio --dry-run
anvil-serving voice audio up --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile dark-audio --confirm
anvil-serving voice audio status --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile dark-audio
# In a dedicated Mini terminal, forward Mini loopback ports to Dark.
anvil-serving voice proxy bridge --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy
# Start and inspect the persistent Mini Realtime proxy.
anvil-serving voice proxy up --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy --confirm
anvil-serving voice proxy status --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy
anvil-serving voice proxy logs --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy --tail 200
Use voice proxy run instead of proxy up when a foreground process is
desired. proxy down and proxy restart require --confirm. Audio and proxy
lifecycle are intentionally independent; neither command starts the other.
For the separate co-located Dark manifest, voice up --dry-run and
voice down --dry-run preview the managed whole-stack aggregate. They are not
valid shortcuts for this Mini/Dark sequence.
Before starting the Mini Realtime proxy, make the topology-owned STT/TTS serves
ready through their owning managed lifecycle. The Mini-facing dark-audio
profile has external lifecycle, so voice audio up validates the manifest
and reports that audio is externally managed; it does not start those serves:
anvil-serving voice profiles list --config examples/voice/openclaw-anvil-voice.toml
anvil-serving voice audio up --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio --dry-run
anvil-serving voice audio up --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio --confirm
anvil-serving voice proxy run --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio
Historical July 2026 candidate campaign examples¶
The following commands retain the setup used for the July 2026 Gepard and voice-LLM candidate campaign. They are reproducibility examples, not live deployment instructions or evidence that a route is active. Start candidates only through the managed lifecycle, preview every mutation, and keep any capability-alias change or promotion behind a separate human gate.
For the retained Gepard Fast TTS campaign, start it on the Dark reference host
through the managed serves surface. The service requires HF_TOKEN for
first-run model access. Keep that token in the shell, in ~/.env, or in a gitignored
examples/primary-node/.env copied from examples/primary-node/.env.example;
never commit it. anvil-serving serves fills missing command environment
variables from ~/.env, then ~/.anvil-serving/.env, then the
manifest-adjacent .env; shell environment variables still win.
Gepard also requires a Postgres voice store, and the Dark experiment compose
starts an internal gepard-postgres container with the required voices
table initialized. Set GEPARD_DATABASE_URL only when you want to use an
external Postgres instead of the managed local store. The checked-in Gepard
defaults (TTS_GPU_MEMORY_UTILIZATION=0.12, TTS_MAX_NUM_SEQS=4) are a
co-residency profile for evaluating TTS beside another Compute B workload; raise
them via env vars only when the reservation ledger shows enough free VRAM.
On the Dark reference host, leave VOICE_TTS_CANDIDATE_PUBLISH unset for
same-host benchmark loops:
anvil-serving serves up tts-gepard-fast --manifest examples/primary-node/serves.toml --dry-run
anvil-serving serves up tts-gepard-fast --manifest examples/primary-node/serves.toml --confirm
anvil-serving voice benchmark \
--config examples/voice/primary-node.toml \
--profile gepard-fast-tts \
--evidence-out .anvil/evidence/voice-gepard-fast-tts.json
If private operator files are installed under ~/.anvil-serving, the same flow
is shorter and checkout-independent:
That primary-node.toml profile marks Gepard as managed and names the
tts-gepard-fast serve. If Mini must reach this candidate directly, start the
Dark service with VOICE_TTS_CANDIDATE_PUBLISH=100.64.0.10.
From the Mini gateway, use the OpenClaw profile only after the Dark service is up and reachable on Dark's private address:
anvil-serving voice proxy run --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile gepard-fast-tts
anvil-serving voice benchmark \
--config examples/voice/openclaw-anvil-voice.toml \
--profile gepard-fast-tts \
--evidence-out .anvil/evidence/voice-gepard-fast-tts.json
The OpenClaw/Mini profile marks Gepard as external because Mini must not host
or manage the model process.
For the retained candidate LLM comparison, start the matching opt-in serve
through the managed serves surface. Leave VOICE_CANDIDATE_PUBLISH unset for same-host benchmark
runs; set it to the Dark host's private/tailnet address only when Mini must
reach the direct candidate endpoint:
anvil-serving serves up voice-qwen3-32b --manifest examples/primary-node/serves.toml --dry-run
anvil-serving serves up voice-qwen3-32b --manifest examples/primary-node/serves.toml --confirm
anvil-serving voice proxy run \
--topology "$TOPOLOGY" \
--config examples/voice/openclaw-anvil-voice.toml \
--profile dark-audio \
--candidate-overlay examples/voice/candidates/qwen3-32b-nvfp4.toml \
--candidate qwen3-32b-nvfp4
anvil-serving voice benchmark \
--config examples/voice/openclaw-anvil-voice.toml \
--profile dark-audio \
--candidate-overlay examples/voice/candidates/qwen3-32b-nvfp4.toml \
--candidate qwen3-32b-nvfp4 \
--evidence-out voice-evidence/qwen3-32b-dark-audio.json
Mini forwarding and OpenClaw configuration¶
To keep OpenClaw and Realtime on Mini while using STT/TTS on the reference Dark host, first make sure Dark's local STT/TTS endpoints are already running and reachable on the Dark host:
curl -s -o /dev/null -w "stt %{http_code}\n" http://127.0.0.1:30010/v1/models
curl -s -o /dev/null -w "tts %{http_code}\n" http://127.0.0.1:30011/v1/models
For STT, a 404 can still prove the HTTP server is listening; connection refusal means the local audio endpoint is not up.
On Mini, start the loopback-only forwarding bridge. Listener and target ports, the Dark host address, and ownership all come from topology; command-line target overrides are only for explicit diagnostics:
anvil-serving voice proxy bridge \
--topology "$TOPOLOGY" \
--config examples/voice/openclaw-anvil-voice.toml \
--profile mini-dark-audio-proxy
Dark's target ports must be reachable from Mini only through the intended private network/ACL. The bridge itself never exposes Mini beyond loopback. Then run the Mini Realtime server with the proxy profile:
anvil-serving voice proxy run --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile mini-dark-audio-proxy
For a persistent service instead of a foreground terminal:
anvil-serving voice proxy up --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile mini-dark-audio-proxy --confirm
When testing a candidate LLM against Dark audio, compose the same candidate
overlay with either dark-audio or mini-dark-audio-proxy.
Then render or apply the matching OpenClaw config. The --voice flag adds the
Talk realtime block next to the normal anvil model provider config:
anvil-serving harness sync openclaw \
--config configs/example.toml \
--base-url http://100.64.0.10:8000/v1 \
--voice \
--voice-realtime-url ws://127.0.0.1:8765/v1/realtime \
--voice-consult-model llm.voice \
--voice-consult-thinking-level off \
--voice-consult-bootstrap-context-mode lightweight \
--out ./openclaw.anvil.json
The generated Talk config selects the OpenClaw provider id anvil and points
it at the Anvil Voice Realtime server. It also pins forced OpenClaw agent
consults to the low-latency llm.voice model and disables consult
thinking for lower spoken-turn latency. It also keeps forced consults on
OpenClaw's lightweight bootstrap path so workspace bootstrap files such as
MEMORY.md are not injected into every spoken turn, without changing the
session's normal selected model:
{
talk: {
consultModel: "llm.voice",
consultThinkingLevel: "off",
consultBootstrapContextMode: "lightweight",
realtime: {
mode: "realtime",
transport: "gateway-relay",
brain: "agent-consult",
consultRouting: "force-agent-consult",
provider: "anvil",
providers: {
anvil: {
realtimeUrl: "ws://127.0.0.1:8765/v1/realtime",
model: "llm.voice",
silenceDurationMs: 200
}
}
}
}
}
--voice-consult-model is optional when the router config exposes the
llm.voice capability alias. During configuration generation,
harness sync openclaw --voice writes llm.voice when that alias is present;
if it is absent, sync writes llm.primary into the generated OpenClaw config.
This is a visible configuration-time selection, not per-request fallback. Anvil
Voice and the Capability Gateway do not retry a failed llm.voice request on
llm.primary. Pass --voice-consult-model llm.primary to select the primary
LLM explicitly. --voice-consult-thinking-level defaults to off
so old Talk configs that carried consultThinkingLevel: "low" are reset during
sync; raise it only when an operator deliberately trades latency for reasoning.
--voice-consult-bootstrap-context-mode defaults to lightweight and replaces
stale talk.consultBootstrapContextMode values during sync; set it to full
only when the voice workflow needs the normal OpenClaw agent bootstrap context.
Same-host Anvil Voice can omit a realtime token. If the Realtime server binds
to a private/tailnet address, set voice.realtime_token_env in the voice
manifest and pass --voice-api-key-env ANVIL_VOICE_REALTIME_TOKEN to the
harness sync command. The emitted OpenClaw config references the env var by
name; it does not contain the token value.
Realtime server¶
voice proxy run validates the manifest, probes the configured LLM/STT/TTS
endpoints, then binds the Realtime WebSocket server at:
It refuses to start if required endpoints are unreachable. A 401, 403, 404, or 405 from a probe still proves that something is listening; a connection failure or 5xx response blocks startup.
Loopback binds may omit realtime_token_env for trusted local development.
Non-loopback binds require a bearer token env var in the manifest.
Assistant transcript events¶
Audio-only sessions keep the established event stream. A client that explicitly
sets output_modalities = ["audio", "text"] (the legacy modalities spelling is
also accepted) additionally receives:
response.output_audio_transcript.deltaafter each successfully synthesized TTS text chunk;- one
response.output_audio_transcript.donebefore the matchingresponse.done.
Each delta and terminal carries response_id, item_id, output_index, and
content_index. The terminal transcript equals the concatenated deltas. Text is
reported when the exact selected candidate starts producing valid audio, so
any conservative text normalization used by the selected TTS path is reflected
without waiting for the whole synthesis chunk. Lexical boundary metadata preserves spaces between words and
sentences without inventing spaces inside hard-split tokens or CJK. Failed or empty
synthesis is never labeled complete. A synthesis failure or the bounded
transcript-size limit produces one correlated assistant_transcript_unavailable
error and no transcript terminal; synthesis failure also ends the response as
failed.
Raw audio, credentials, and answer text are excluded from that error.
The official SDK proof harness requests both modalities and validates delta/terminal correlation, equality, and terminal-before-response ordering. See ADR-0025.
Benchmark and validation¶
Use voice benchmark for a quick configured end-to-end sample. The benchmark
is an endpoint client and is not owned by the Realtime proxy. Use
--scope audio for an LLM-free TTS-to-STT loop that can qualify the Dark audio
stage independently. For reference
OpenClaw Talk and candidate A/B, keep Mini model-free and use dark-audio or
mini-dark-audio-proxy. Run mini-audio only when explicitly validating the
optional same-host/local-audio mode; running it from a non-gateway checkout
only tests that checkout's loopback and is a topology negative control.
anvil-serving voice benchmark --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio
anvil-serving voice benchmark --scope audio --config ~/.anvil-serving/voice.toml --profile dark-audio
For multi-sample STT qualification, prepare/validate the versioned corpus and run the repeated and concurrency lanes separately:
anvil-serving voice corpus prepare \
--config ~/.anvil-serving/voice.audio-dark.toml \
--out .anvil/evidence/stt-corpus
anvil-serving voice corpus validate \
--manifest .anvil/evidence/stt-corpus/manifest.jsonl \
--expected-cases 30
anvil-serving voice benchmark --scope stt \
--config ~/.anvil-serving/voice.audio-dark.toml \
--corpus .anvil/evidence/stt-corpus/manifest.jsonl \
--repetitions 3 --concurrency 1 \
--stt-candidate-overlay examples/primary-node/stt-experiments/overlays/nemotron35-asr.toml \
--evidence-out .anvil/evidence/nemotron35-stt.json
The primary aggregate contains the 24 LibriSpeech recordings; six
Kokoro-generated agent phrases are reported separately. The command writes
stt-benchmark-evidence/v1 atomically and returns nonzero for incomplete runs.
Use the canonical anvil-serving-stt-benchmark skill for lifecycle,
restoration, decision, and publication gates.
For candidate LLM A/B, keep audio topology in --profile and compose the LLM
candidate with an overlay:
anvil-serving voice benchmark \
--config examples/voice/openclaw-anvil-voice.toml \
--profile dark-audio \
--candidate-overlay examples/voice/candidates/qwen3-32b-nvfp4.toml \
--candidate qwen3-32b-nvfp4 \
--evidence-out .anvil/evidence/voice-qwen3-32b-dark-audio.json
For a candidate that is already loaded, use the direct candidate flags instead
of writing a temporary overlay. The candidate URL is relative to the host where
the benchmark command runs: use http://127.0.0.1:<port>/v1 only on the model
host itself, and use the Dark private address when running the benchmark from
Companion Node or another gateway host:
anvil-serving voice benchmark \
--config examples/voice/openclaw-anvil-voice.toml \
--profile dark-audio \
--candidate-base-url http://100.64.0.10:39000/v1 \
--candidate-model qwen3-32b-nvfp4 \
--candidate qwen3-32b-nvfp4-dark-direct \
--evidence-out .anvil/evidence/voice-qwen3-32b-dark-direct.json
Do not combine --candidate-overlay with the direct candidate flags. Both
paths preserve the selected audio profile and only replace [voice.llm] for
the benchmark process. They do not promote the candidate, change the configured
voice capability alias, or make OpenClaw use the candidate outside this
explicit run. Any promotion remains a separate, human-approved managed
operation.
For comparative voice-LLM qualification, pair voice benchmark with
anvil-serving eval benchmark quality against the same loaded endpoint and record
the final anvil-serving serves status --manifest
examples/primary-node/serves.toml after restoring the declared split stack. Voice benchmark JSON is stage-latency
evidence unless the STT hypothesis and WER prove semantic transcription quality
for the prompt; do not treat first-audio timing alone as a model-promotion
gate.
Use --profile dark-audio only after Dark-host bridge ports are listening.
Use --profile mini-dark-audio-proxy only after Mini-local proxy ports
127.0.0.1:30110 and 127.0.0.1:30111 are listening on Mini and forwarding
to Dark audio.
The JSON output includes first-audio latency, total turn latency, STT/LLM/TTS
stage durations, STT WER, TTS RTF, output byte counts, and the observed
STT/LLM text. The durable evidence envelope is
voice-benchmark-evidence/v1 and records:
identity.profile,identity.candidate,identity.llm,identity.stt,identity.tts, andidentity.route.topology.profile,topology.mode,topology.endpoints, andtopology.mini_model_free_assertion.runs[0].latency.ttfa_ms,turn_latency_ms,total_turn_latency_ms,stt_ms,llm_ms,llm_stage_latency_ms, andtts_ms.runs[0].transcript.stt_hypothesis,llm_reply, andreference_text.runs[0].tool.status,successful,tool_call_count, andcalls.
total_turn_latency_ms is the end-to-end STT -> LLM -> TTS duration for the
sample turn. llm_stage_latency_ms is the separately timed LLM stage, so model
latency can be compared without subtracting STT or TTS time. tool.status is
observed when the candidate emitted a realtime tool call such as
openclaw_agent_consult; a textual claim that a tool was used is not counted
as a tool call. This is a smoke measurement, not a promotion gate.
For reference OpenClaw Talk evidence, topology.mini_model_free_assertion must
show a reference profile such as dark-audio or mini-dark-audio-proxy,
mini_hosts_models = false, and passed = true. Companion Node must remain
model-free in this path: it runs OpenClaw Gateway, Anvil Voice
Realtime/proxy, Claude Code, and Codex, while Primary Node owns the router,
candidate LLM serves, and STT/TTS endpoints or bridge ports. Use mini-audio
only for explicit optional same-host Mini-local audio validation.
Interpret stage timing before swapping models:
- Treat a stage as dominant when its p50 elapsed time is at least half of total turn latency, or at least twice the next-largest stage across comparable successful runs.
- Work on the LLM/model path when LLM p50 is the dominant stage, or when LLM
first-output is above about
300 mswhile STT and TTS first-output are below their thresholds. - Work on STT when STT p50 exceeds about
200 ms, WER is unacceptable, or STT errors are present. - Work on TTS/chunking when TTS p50 exceeds about
350 ms, TTS first-output exceeds about250 ms, or the TTS stream errors on normal spoken chunks. - If no stage dominates, prefer cheaper prompt/chunk/profile tuning before loading a new model.
Historical July 2026 campaign result¶
The retained T005/T006 campaign evidence from July 2026 does not justify
promoting a candidate LLM. The only successful timing row was gathered on the
optional Mini-local audio path (ttfa_ms 611.29, turn_latency_ms 789.06, stt_ms 106.28,
llm_ms 356.82, tts_ms 325.95), where LLM and TTS were co-dominant rather
than a clear model-only bottleneck. Candidate rows were retained as topology
negative controls because they failed before STT from a wrong-host loopback
path. Gather comparable successful data with Dark-host or Mini-proxied audio
before any promotion, and keep promotion behind the standard human-approved
serves_promote gate. See the dated
candidate matrix and
final report.
For live Realtime Talk sessions, voice proxy run also emits redacted
voice_stage_timing log lines for the core stt, llm, and tts stages.
Use those lines to attribute latency without exposing prompts or transcripts:
voice_stage_timing stage=llm input_type=GenerateRequest turn_id=rt-turn-7 generation=12 text_chars=84 elapsed_ms=912.4 first_output_ms=488.1 output_count=2 error=false
elapsed_ms is the full stage duration for that input. first_output_ms
shows when the first downstream item was available, which is the useful value
for perceived first-audio latency in streaming LLM and TTS stages. Text values
are logged as character counts only.
If tts first_output_ms is high for a large text_chars value, lower
voice.llm.speech_chunk_max_chars in the active voice profile before changing
models. That keeps the same answer path but starts TTS on smaller word-boundary
chunks.
For the optional Mini-local audio path, keep the checked-in mini-audio
override near 56 unless fresh voice_stage_timing evidence shows a better
value; values near 48 produced stream errors in the retained campaign tests.
If the TTS serve closes a stream before producing any audio for a chunk, the TTS
stage can retry once with a separator-safe spoken form such as up to date
instead of up-to-date; failures after audio has started still
surface as real stage errors.
For explicit optional Mini-local audio proof, use the hardware validation harness:
That report adds target-host checks, router auth proof, endpoint model identity
proof, post-benchmark STT/TTS memory attribution, and a verdict. A non-Mini run
is a negative control unless explicitly allowed with --allow-unsupported.
This harness is not the reference OpenClaw Talk topology because Mini should
remain model-free for reference validation.
MCP and controller operation¶
Agents and OpenClaw should prefer voice_manage for STT/TTS lifecycle:
{
"action": "up",
"config": "examples/voice/openclaw-anvil-voice.toml",
"profile": "dark-audio",
"topology": "~/.anvil-serving/operator-topology.toml"
}
The first call previews the plan and returns a dry-run command. A live mutation requires:
{
"action": "up",
"config": "examples/voice/openclaw-anvil-voice.toml",
"profile": "dark-audio",
"topology": "~/.anvil-serving/operator-topology.toml",
"confirm": true,
"dry_run": false
}
This mirrors serves_manage and router_manage: exact target first, then an
explicit confirmed call. status and logs are immediate bounded reads and do
not require confirmation. The Dark controller may set ANVIL_VOICE_TOPOLOGY
instead of receiving topology on every call.
Use voice_proxy_manage for Mini's persistent Realtime process. Its actions are
up, down, restart, status, and logs; mutations require the same
preview/confirm sequence. Set ANVIL_VOICE_TOPOLOGY on the Mini controller or
pass topology explicitly. Neither MCP tool starts the other subsystem.
Troubleshooting¶
voice audio upsays lifecycle is external: change the manifest tomanagedornative, or keep managing that endpoint manually.voice proxy runrefuses to start: check the/modelsendpoint for the LLM, STT, and TTS base URLs named in the manifest.- Native
voice audio downreportsready_but_unmanaged: the endpoint is answering, but no PID file orstop_commandcan identify what to stop. - Optional Mini-local STT/TTS logs are stored under
/tmp/anvil-voice-miniin the checked-in Mini-local manifest. - Router auth errors should be fixed by setting the env var named in
voice.llm.api_key_env; do not paste token values into the manifest. - If the assistant forgets facts from the same Talk session, verify the active
profile is not setting
voice.llm.history_max_turns = 0, then restartvoice proxy runso the updated manifest is loaded.