Skip to content

Run local LLMs on the RTX 5880 Ada - #216

Draft
samestep wants to merge 91 commits into
mainfrom
local-llms
Draft

Run local LLMs on the RTX 5880 Ada#216
samestep wants to merge 91 commits into
mainfrom
local-llms

Conversation

@samestep

Copy link
Copy Markdown
Owner

No description provided.

samestep and others added 30 commits August 16, 2026 15:42
Two tiers, because 48 GB of VRAM turns out not to reach a better class
of model than the ~30B one it already holds.

`ollama` serves what fits entirely in VRAM: qwen3.6 at 27b/q8_0 is the
strongest thing that size allows, and the 35b-a3b MoE activates only 3B
parameters per token, so it runs roughly 3x faster for six points of
Artificial Analysis index.

`llama-cpp` serves DeepSeek-V4-Flash, which is 284B parameters but only
~13B active. 97% of it is expert weights, so `fit = "on"` parks those in
system RAM and keeps attention and the KV cache on the GPU. That reaches
well past anything VRAM-resident, at 5.2 tok/s rather than ~65.

llama.cpp is pinned ahead of the b9190 in nixpkgs because DSpark
speculative decoding landed in b10231, and it roughly doubles
DeepSeek's throughput.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
DeepSeek-V4-Flash is the wrong tool for a smart speaker: 5.2 tok/s is
fine for "go think about this" and useless for "turn off the lights".
It also wants ~100 GB of RAM, which fights the sandbox VM. Drop it.

What replaces it is Home Assistant plus the Wyoming voice services,
all of which nixpkgs already packages. The split that matters is that
Home Assistant's own intent matcher handles rote commands without
invoking a model at all, and only unmatched utterances reach ollama —
routing everything through the LLM would be slower than the Alexa this
is meant to replace.

qwen3.6:35b-a3b becomes the assistant model. It activates 3B parameters
per token, so it answers at conversational latency, and ollama now pins
it with OLLAMA_KEEP_ALIVE=-1 rather than evicting it after 30 minutes.

Pipelines, wake words and the conversation agent are chosen in the Home
Assistant UI; the services here announce themselves over zeroconf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
assist_pipeline only handles intents locally when prefer_local_intents
is set, and once the agent has control it keeps just GET_STATE and
media search. The comment claimed the opposite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
The faster-whisper and piper modules default zeroconf on and pass
`--zeroconf`; the openwakeword module has no such option, so Home
Assistant never discovers it. Upstream has supported the flag since
October 2025 and nixpkgs ships v2.1.0, so pass it through extraArgs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
Passing --zeroconf alone made the service fail to start. The package
carries no optional-dependencies, so unlike the faster-whisper module
there is nothing to splice, and wyoming.zeroconf was an ImportError;
take the extra from the wyoming library instead. Its unit also omits
the AF_NETLINK that piper and faster-whisper add for zeroconf, without
which interface enumeration cannot work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
ollama 0.32.3 faults with "CUDA error: an illegal memory access was
encountered" when qwen35moe does constrained decoding for tool calls
whose parameters use arrays or enums, which is what Home Assistant
sends. Reproduced against the API: simple one-string tools never fail,
rich schemas fail intermittently, and the fault poisons the runner's
CUDA context so every later request errors until it restarts. The dense
qwen3.6:27b does not reproduce it at 8, 10 or 12 rich tools.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
The model's training data is stale, and Home Assistant exposes any
script that is exposed to Assist as a callable tool, returning the
script's response to the model. So a script wrapping a local SearXNG
gives it lookup without sending queries to Google.

SearXNG runs on loopback with its built-in HTTP server; uwsgi is for
public instances. Its limiter is off because bot detection would reject
Home Assistant's requests, and JSON is added to the output formats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
Requiring a manual step before the first rebuild is a bad interface,
and the key should not sit in the world-readable Nix store. A oneshot
mints one into the state directory if absent. It needs its own unit
because systemd reads EnvironmentFile before any ExecStartPre of the
service consuming it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
The module hands searx-init the same EnvironmentFile and searx.service
requires it, so searx-init is what reads the file first — and what
failed on the missing file during activation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
It lives in Home Assistant's storage rather than in this config, so a
rebuild does not reproduce it. Keeping the wording here because getting
the model to reach for its tools rather than answer from memory took
several attempts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
Piper was archived upstream on 2025-10-06 and is the weakest part of
the pipeline to listen to. Kokoro is an 82M StyleTTS2 model under
Apache 2.0 with 50-odd voices and much more natural prosody.

Nothing in nixpkgs speaks Wyoming for it, so this runs the community
server as a container: pinned by digest rather than latest, published
on loopback only like the other Wyoming services, and with the model
baked into the image so there is no runtime download. Piper stays
enabled for now so the two can be compared directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
The image is relvacode's fork, whose main.py accepts only
--host/--port/--uri/--debug. I took --voice from the nordwestt source I
had cloned, which is different code, so the container exited with
"unrecognized arguments" on every start until systemd gave up. Voice
selection happens per request from Home Assistant in this fork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
CPU synthesis costs about 0.4 s of every reply, measured across
realistic reply lengths. Only the nordwestt fork publishes a CUDA
image, so switch to it; it reads voices from the model the same way the
relvacode build does, so the voice list survives.

kokoro-onnx picks its execution provider from ONNX_PROVIDER, which is
how the upstream Intel compose file selects OpenVINO. The container
toolkit turns on Docker's CDI support, so the device reference resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
The published cuda image reports 'Available providers:
AzureExecutionProvider, CPUExecutionProvider' — it ships a CPU-only
onnxruntime, so it never had a GPU path. nixpkgs has
python3Packages.kokoro, but that is the torch build and a CUDA torch is
a multi-hour compile that the cudaCapabilities pin makes uncacheable.
Neither is worth the ~0.3 s.

Switching to dockerTools.pullImage answers the more useful complaint:
the image now downloads during the rebuild rather than afterwards on
first start, and the unit drops its network-online.target dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
26.05 ships 0.32.3, which predates the Muse Glimmer architecture; that
landed in 0.32.7, and the unstable input is already pinned at exactly
0.32.7. An overlay is much less work than overriding a buildGoModule by
hand, since the package definition moved between the two releases.

Instantiating the unstable package set with the stable one's config
carries allowUnfree and the cudaCapabilities pin across, so this is
still an sm_89-only build; verified realArches is still ["sm_89"].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
Muse Glimmer landed in 0.32.7 but took four follow-up fixes through
0.32.11 — parser recovery for mangled tool-invoke names, and a
reasoning-template match — and the registry gates on those, so 0.32.7
still gets a 412. Overriding the version by hand does not work either:
llamaCppVersion is let-bound rather than an attribute, and 0.32.13
bumps the vendored llama.cpp from b10242 to b10380 with a hard
mismatch check.

nixos-unstable already carries 0.32.13 while nixpkgs-unstable is two
days behind on 0.32.7, and it gates on the NixOS test suite, so it is
if anything the more conservative channel. This also unblocks Qwen 3.8,
which needs 0.32.12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
Grading "turn on the kitchen light" against a refusal message is not
grading anything; with real entities it becomes a state assertion. The
demo integration supplies lights, switches, fans and covers with
plausible names for free.

Only a handful get exposed to Assist. Every exposed entity is written
into the system prompt, so exposing all of them would inflate prefill
and change the thing being measured — "expose new entities" is now off
for the conversation assistant, which it was not before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
I inverted the filter's polarity. In default_agent.async_handle_intents
a filter returning True means the result is NOT handled locally:

    if not isinstance(result, RecognizeResult) or (
        intent_filter is not None and intent_filter(result)
    ):
        return None

_async_local_fallback_intent_filter returns True for GET_STATE and
MEDIA_SEARCH_AND_PLAY, so those two are the ones withheld from the
local path, and everything else the sentence matcher recognises is
answered without the model. Confirmed by measurement: 'Turn on the
kitchen lights' returns 'Turned on the light' in 0.00 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
The published `cuda` image never had a GPU path: `kokoro-onnx[gpu]` pulls both
`onnxruntime` and `onnxruntime-gpu`, they install over each other under the same
import name, and the CPU one wins. nixpkgs' onnxruntime, though, takes
`cudaSupport`, and everything above it is small: kokoro-onnx is four Python
files, and the Wyoming server is one more. So this drops the container and
builds the stack.

kokoro-onnx wants espeakng-loader and phonemizer-fork, which between them exist
to ship an espeak-ng inside a wheel and to make phonemizer accept it. nixpkgs'
phonemizer is already patched to use pkgs.espeak-ng and already carries the
upstream `set_data_path` commit, so both are dropped, the same substitution
python3Packages.misaki makes.

The CUDA provider also gets `cudnn_conv_algo_search=HEURISTIC`. It defaults to
EXHAUSTIVE, which benchmarks every convolution algorithm the first time it sees
a shape; the model has 88 Conv nodes and a `sequence_length` input, so nearly
every request would be a new shape and would pay the search again.

Two behaviours the container was hiding: the server logs nothing below WARNING
unless LOG_LEVEL says otherwise, so INFO is set and every request now prints its
synthesis time; and its SIGTERM handler cancels the asyncio server without
catching CancelledError, which exits 1 and would make every `systemctl stop` a
failure.

Verified on a machine with no GPU: the whole Python stack builds and serves
Wyoming synthesis on the CPU (54 voices, correct IPA out of espeak-ng, real
audio), the CUDA onnxruntime builds and reports CUDAExecutionProvider, and every
op type in the model but the single STFT has a CUDA kernel registered. The
speedup itself is not verified — that needs the card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
ollama only reuses a cached sequence when the new prompt extends it,
never on a shared prefix, so every fresh conversation re-prefills the
whole system prompt: 0.65 s against 0.19 s when the prefix happens to
be cached, on every single command.

OLLAMA_NUM_PARALLEL=1 was my choice, on the assumption that a single
warm slot was best; that assumed the wrong caching model. Two slots let
the base prefix survive alongside an active conversation. Not more:
slots multiply the KV allocation, and at the 32768 context this host
requests, four would be ~35 GB of KV beside a 30 GB model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
llama-server chooses a slot by LRU unless --slot-prompt-similarity is
set. It defaults to 0.0, has no set_env binding, and ollama never
passes it, so a fresh conversation takes the least recently used slot
regardless of what is cached there. Measured with two slots: a
three-turn conversation still evicted the primed prefix and the next
command cost 0.63 s again.

Since -c is num_ctx x parallel, the second slot was buying extra KV
allocation for nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
Every fresh conversation re-ran the whole system prompt: 0.65 s against
0.19 s when the prefix was already cached, on every command.

llama-server picks a slot by LRU, because --slot-prompt-similarity
defaults to 0 and has no env binding. The LRU path then saves the slot,
tries to load a better match, and calls prompt_clear() when that fails
— discarding a prefix that ordinary common-prefix reuse would have
kept. Enabling the similarity threshold would be the wrong fix anyway:
it compares the common prefix against the *incoming prompt length*, so
a long enough user message would skip a perfectly good cached prefix.

The block is guarded by "update_cache && prompt_cache", and the cache
is only constructed when cache_ram_mib != 0, so switching it off avoids
the clearing entirely. llama-server inherits ollama's environment
(cmd.Env = os.Environ()), so this needs no patch. The level-2 cache
exists to restore evicted conversations when serving many at once;
this host serves one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
Predicted that disabling llama-server's level-2 prompt cache would stop
it clearing the slot and restore common-prefix reuse. Measured after
switching: fresh conversations still cost 0.63 s, unchanged. The
prompt_clear theory was wrong or incomplete.

Recording what is ruled out so the next attempt does not repeat it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
llama-server reuses a cached sequence only when the new prompt extends
it, so a finished conversation leaves [system][user][assistant] in the
slot and the next conversation's [system][user'] diverges: the whole
system prompt is recomputed. Measured 0.65 s of prefill on every
command against 0.19 s when the slot holds just the prefix.

The proxy replays each /api/chat with the conversation stripped, system
message and tools only. That is a prefix of what is cached, so it costs
about 0.10 s and runs after the response has already gone back to Home
Assistant. Replaying the caller's own payload rather than rebuilding it
means the bytes match by construction, including the tool schemas, and
go on matching when the prompt changes — a hand-built copy would drift
silently, with no runtime signal that it had stopped working.

Measured through the proxy, streaming as Home Assistant does: 0.185 s,
with chunks still arriving incrementally.

ollama moves to 11435 on loopback; the primer takes 11434, so nothing
that talks to it needs to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgwAc8cnQQHo3VAPRjrfHM
llama-server identifies the reusable prefix correctly — the log shows
f_sim_best = 0.994 (1836/1848) — and then the context-checkpoint
machinery discards it. Checkpoints cannot truncate at an arbitrary
position, only roll back to a checkpoint, and the nearest one below the
divergence point was at 825. So it re-prefills 1023 tokens.

Two candidate environment variables are recorded but not yet tested.
Also recorded: everything ruled out by measurement, the claims in
earlier commit messages that turn out to be false, and the measurement
traps that produced them — chiefly that a synthetic system prompt is
not Home Assistant's, and that prompt_eval_count reports the full
prompt even on a cache hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_d93535ae-8d15-4da9-a216-a818cae9694a
llama-server was finding 1836 of 1848 tokens reusable and then throwing
1011 of them away, because a COMMON_CONTEXT_SEQ_RM_TYPE_FULL context
cannot truncate at an arbitrary position and has to roll back to the
nearest context checkpoint (824). LLAMA_ARG_CTX_CHECKPOINTS=0 removes the
rollback path. ollama passes its environment through to llama-server, so
no patch is needed.

This is the fix the primer was working around, so the primer goes with
it, and ollama moves back to 11434. Removing it is also what makes the
result measurable: with the primer in the path the slot already holds the
prefix, which is the state the fix is supposed to produce.

Unverified as of this commit. Checkpoints are what let speculative
decoding run on a FULL-only context, so this may cost MTP; the baseline
to beat is 77 tok/s generation and the target is ~0.19 s prefill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit was wrong. Setting -ctxcp to 0 does not fall back to
plain longest-common-prefix reuse; on this model it removes reuse
entirely, and every request re-prefills from zero — 1.8 s, even for a
byte-exact append.

qwen35 has full attention only every 4th layer. The other three quarters
carry a recurrent state, which has no per-position structure to truncate,
so the context reports COMMON_CONTEXT_SEQ_RM_TYPE_FULL and checkpoints
are not an optimization over ordinary prefix reuse — they are the only
prefix reuse there is. /api/show reports full_attention_interval = 4 in
one call, which would have settled this much earlier.

So the defect is the default spacing: checkpoint_min_step is 8192 tokens
and the entire prompt is 1848, so no checkpoint is ever created near the
end of the system prefix, which is where every new conversation diverges.
At 128 the worst-case rollback is 128 tokens and the default 32
checkpoints still cover 4096 tokens of history. They live in host RAM,
not VRAM — ~152 MiB each, against 128 GiB.

Also disproved en route: --swa-full is irrelevant (no sliding window
here), and the env-var channel to llama-server does work, which until now
had only been assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also corrects two things in the findings. Checkpoints are placed at user
message boundaries, not on a grid; checkpoint_min_step only throttles
that placement, so the fix un-suppresses the checkpoint at the end of the
system prefix rather than laying down a denser lattice.

And the claim that checkpoints cost generation speed is withdrawn: 92.8
tok/s with the fix, against 91 with checkpoints off. The 77 tok/s that
comparison rested on was measured on a 28-token prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checkpoint placement was never really about spacing. llama-server has two
placements: is_user_start, at the beginning of each message, and
near_prompt_end as a fallback whose window is one n_ubatch. We were only
ever getting the fallback, so a fresh conversation rolled back up to 512
tokens before the divergence point and re-prefilled them.

is_user_start is directly controllable: message_delimiters is a field on
the /completion request, and llama-server tokenizes those strings and
scans the prompt for them. Give it the user delimiter and it checkpoints
at the end of the system prompt and tool definitions, which is exactly
where a new conversation diverges. ollama never sends the field, because
it renders qwen's template in Go and posts a flat string.

The patch adds an optional MessageDelimiters() method to the renderer
interface, implements it for Qwen35Renderer, and threads it through
llm.CompletionRequest. It applies cleanly to v0.32.13, which is what
nixpkgs ships, and go build plus the renderers and llm tests pass.

This should reach the ~185 ms floor and makes the num_batch trade
unnecessary -- that only tuned the fallback's granularity, at 13% of bulk
prefill throughput. Not taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
samestep and others added 30 commits August 18, 2026 01:08
pymicro-vad uses microWakeWord's architecture -- a classifier over a
sliding window. Feeding bursts of real speech: under ~400 ms never fires at
all, 500 ms fires and holds 650 ms after the audio stops, 1200 ms holds
540 ms. That is a ~500 ms window, and it explains both edges measured
earlier: onset ~340 ms late, release 340-610 ms.

So this is not a bug or an oversight in training. A window-length latency
is inherent and harmless for detecting a discrete wake word; it only hurts
when the model is repurposed to decide when someone stopped speaking.

It also means no threshold fixes it, since silence_seconds is added after
the model reports silence. Hence reverting the patch that lowered it. The
fix is a different VAD; audio_enhancer.py hardcodes MicroVad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arget

Home Assistant retuned after switching to microVAD. Two weeks after the
swap, "Adjust VAD seconds better for microVAD" moved every timing down --
2.0 to 1.25, 0.5 to 0.25, 1.0 to 0.7 -- and three months later the speech
threshold was split into separate before/during values. Both are someone
clawing back latency the model added, not choosing how long a pause should
feel. The ~1.2 s is the leftover, not the goal.

The behaviour it produces is not a coherent tolerance either. Driving HA's
own segmenter with real VAD output: pauses up to 850 ms are tolerated,
pauses over 1100 ms end before the user resumes, but 900-1050 ms ends the
command 30-180 ms AFTER they resumed speaking, because the model needs
~400 ms to notice speech starting as well.

Also: do not assume Silero is the fix. HA shipped it in 2025-12 and
reverted it a month later for lag, broken end-of-speech detection, crashes
and macOS build failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the same clips: microVAD keeps reporting speech for 480-600 ms
after it stops and needs 400-500 ms before reporting any, because it is a
wake-word architecture classifying over a ~500 ms window. Silero releases
in 0-96 ms and fires on 100 ms of speech. That is ~500 ms off every voice
command.

The patch is HA's own, from 079c6daa633, before 329b2c840d8 reverted it for
lag, broken end-of-speech detection, crashes and macOS build failures --
problems for a project shipping to every platform, worth re-testing on one
machine that builds its own software.

Also documents the knobs. There is one timing, silence_seconds, exposed as
VadSensitivity: relaxed 1.25, default 0.7, aggressive 0.25 -- a select
entity per satellite, so a dropdown rather than a patch. The earlier patch
that lowered it was reinventing that and is gone.

For calibration: human turn-taking gaps average ~200 ms and production
systems wait 300-800 ms. Our ~1.2 s is well outside that; microVAD's window
alone rules the band out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nixpkgs runs Home Assistant's test suite during the build, which imports
pysilero_vad, so services.home-assistant.extraPackages is too late -- that
builds the service's runtime environment, not the package. Adding it to
propagatedBuildInputs from home-assistant.passthru.python3Packages, which
is the matching interpreter's package set, builds clean.

Verified in the output: audio_enhancer.py imports
SileroVoiceActivityDetector, pipeline.py constructs SileroVadSpeexEnhancer,
and the cache-boundary patch is still applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model now contributes essentially nothing. Onset is detected 50 ms late
against 340 before, the end 20 ms late against 640, and end of speech to
stt-end drops from 1301 ms to 833 ms. The remaining 704 ms is
silence_seconds itself, so the timings finally mean what they say.

Also makes silence_seconds settable on a pipeline run. The websocket API
already accepted four of the five audio settings; this one was reachable
only through the VAD sensitivity select entity that satellite integrations
create, so it could not be tuned or tested without buying hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With Silero the knob is linear: 0.25 gives 376 ms to stt-end and tolerates
a 250 ms pause, 0.7 gives 789 ms and 700 ms, 1.25 gives 1401 ms and
1300 ms.

Which reframes the Silero win. Latency and tolerance are the same quantity
for both VADs; what differs is the constant added to each. microVAD's
~500 ms window added to both, putting its floor near 750 ms of latency even
at the aggressive setting. Silero adds ~50 ms, so the fast end of the curve
becomes reachable. It is not a better tradeoff, it is access to more of it.

Picking a point is therefore preference, not optimisation. Production
systems sit at 300-800 ms, spanning 0.25 to 0.7 here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saying Silero only lowers the reachable floor was wrong. The danger zone --
pauses where the command ends while the user is already speaking again --
is set by how long the VAD takes to notice speech restarting. microVAD
needs ~400 ms, giving a band 100-200 ms wide at every setting tried
(520-620 ms at 0.25, up to 880-1060 ms at 0.7). Silero notices in 30-100 ms
and there is no such band at any setting.

So it is two wins, not one: access to the fast end of the curve, and never
committing to stop while the user is mid-word.

Measurement note: "fired after the user resumed" is the wrong criterion,
since firing at the end of the resumed speech is normal. It invented a
260 ms band for Silero that does not exist. The right criterion is fired
during the resumed speech.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Home Assistant's conversation agent is set to qwen3.8:27b-mtp-q8_0, but
loadModels declared only qwen3.6 models, so the one the assistant depends
on was not pulled by a rebuild and a fresh machine would have broken.

I probably caused that: runeval.py sets the model through the reconfigure
flow, and killing the two-model eval partway would have left it on
qwen3.8 without restoring it. Stopping a job that mutates configuration
means checking what it changed.

Also refreshes the stale comment. qwen3.8 no longer needs a newer ollama
than we run, and "generation speed is not the binding constraint" is no
longer true now that prefill is ~0.1 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The patches were inline in configuration.nix, which meant they could only
be used by the machine that file configures, and only via sudo on stable
nixpkgs. They are now packages/{ollama,home-assistant}.nix behind an
overlay, exposed as flake outputs for x86_64-linux, aarch64-linux and
aarch64-darwin.

That makes the same definitions usable from a Home Manager config, from
the agent VM, or on a Mac -- `nix run .#ollama-patched`. None of the
patches need a GPU to build, and ollama is not marked broken on
aarch64-darwin, so the Mac comparison needs no further packaging work.

The overlay must come last in nixpkgs.overlays: it patches prev.ollama-cuda,
and the overlay above replaces that with the unstable 0.32.13 the patches
were written against. Running first would patch stable's 0.32.3 and fail.

No derivation changed: Home Assistant still evaluates to
1582r9h0l5ajqf9nk1znw4i2ngsw35aq, the path already built, because Nix names
patch files by content and basename rather than by directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every voice experiment so far has needed a rebuild of the real machine,
which meant a person sitting at a desk watching builds. Home Assistant is
what we iterate on and it needs no GPU, so it can run here instead: same
package as the host, pointed at the host's ollama over the bridge, with
its own state in ~/ha-dev.

Three scripts rebuild it from nothing -- run, onboard, setup -- so the dev
instance cannot drift into a state nobody can reproduce. It answers "is the
bed light on?" in ~450 ms against 433-458 ms on the real machine.

Pinned to nixpkgs-stable to match the host, because the Silero patch does
not apply to the 2026.8.2 in unstable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The measurement scripts lived in scratch, so they were not available on any
other machine -- including the Mac we want to compare against. This one
takes OLLAMA_URL and a model name and reports the three numbers that
matter: fresh-conversation prefill, follow-up prefill, and generation.

Verified against the NixOS host: 587 ms fresh, 109 ms follow-up, 85.6
tok/s on qwen3.8:27b-mtp-q8_0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two flaws, both of which would have been read as hardware differences.

The prompt carried no cache boundary marker, so on a server configured to
look for one the delimiter matched nothing and the probe measured the
fallback path: 587 ms fresh on the NixOS host against 97 ms once the
marker is present. Two machines configured with different markers were
being compared to each other.

And the generation prompt produced 16-20 tokens, so tokens/sec was mostly
startup noise -- 43.6 tok/s on one run against 95.0 tok/s when actually
generating 300. It now asks for a longer sequence and says so loudly if
the model stops early anyway.

Host baseline, qwen3.8:27b-mtp-q8_0: 97 ms fresh, 98 ms follow-up,
95 tok/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same model and machine at temperature 0: 97.3 tok/s counting from 1 to 120,
50.5 tok/s writing prose. Speculative decoding accepts more draft tokens
when the continuation is predictable, so counting is near a best case.

Which means the 85-95 tok/s quoted throughout the findings came from a
counting prompt and overstates a real assistant reply by roughly two times.
The probe now uses prose, which is both nearer the workload and comparable
across machines.

It also avoids a second trap: the counting prompt generated 300 tokens on
CUDA and 17 on Metal, because the backends diverge numerically and one gave
up early, leaving tokens/sec describing startup overhead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same model, patched ollama, probe and settings on both. Prefix caching
works identically on Metal -- a fresh conversation costs 453 ms where
re-reading the prompt would cost 48 s -- so both patches carry over, which
was the thing worth checking.

The gap is prefill throughput: 131 tok/s against 1957. Every Mac figure
follows from it, including the asymmetry where the Mac's follow-up is
slower than its fresh conversation while the RTX has them equal, since the
cost is proportional to tokens re-read.

Generation on prose is 44-50 against 14.1 tok/s. I expected prose to narrow
the gap, since MTP flatters the counting prompt; it did not, because both
machines lose about half their rate on prose.

Also labels the probe's first line honestly -- it is only a cold prefill
when the slot is empty, and reports meaningless numbers otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Smart Turn v3 (pipecat-ai, BSD-2-Clause) is a Whisper Tiny encoder plus a
linear head, 8M parameters, 8 MB of int8 ONNX, reading the waveform rather
than a transcript so it needs no extra speech-to-text pass. Same shape
LiveKit and Pipecat use: cheap VAD for speech and silence, turn model on
top.

It works on real speech. Sweeping cut points through the JFK sample scores
clause and sentence ends at 0.86-0.90 and mid-clause at 0.03. Inference is
~30 ms on this VM's CPU.

It cannot be tested with text-to-speech. Piper scored 0.93-0.99 whether the
sentence was complete or truncated, because a synthesiser gives a fragment
the falling intonation of a finished sentence -- it does not know the text
is a fragment, and this model reads prosody. Validating it needs real
recordings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model ships a labelled test set with synthetic and midfiller flags.
Filtering to real non-synthetic English, 607 samples: 93.2% accuracy,
against 92.63% published. That also validates the preprocessing port, since
a wrong mel pipeline would read as chance.

The threshold matters because the errors are not symmetric. At 0.5, 14.6%
of unfinished utterances get cut off and 1.9% of finished ones wait; at
0.9, 6.1% and 8.7%. Being cut off mid-thought is the failure worth
avoiding, and today every pause longer than silence_seconds cuts you off,
so even the default is a large improvement.

Appending silence to unfinished utterances barely moves the score -- median
0.033 at zero, 0.060 after two seconds -- so repeated checks will not
converge on ending the turn by themselves, and the design needs an explicit
cap for someone who simply trails off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure numpy plus onnxruntime, so Home Assistant does not need transformers
for this. Whisper's feature extraction is reimplemented here and verified
against WhisperFeatureExtractor(chunk_length=8): features match to
0.000000 for audio above, below and exactly at the 8 second window, the mel
filterbank matches to 1e-16, and end-to-end probabilities on real
recordings are bit-identical to the reference implementation.

The docstring carries the warning that cost an experiment to learn: this
model must only be asked about audio that ends where a speaker paused. Fed
audio cut mid-word, 60.6% of polls score above 0.9, because it judges the
prosody up to the cut and cannot know the cut was arbitrary. It answers
"does this sound finished", not "has this person stopped talking".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On reaching silence_seconds the pipeline now asks Smart Turn whether the
utterance sounds complete, and grants another silence window if not. Three
settings on AudioSettings, all reachable over the websocket API so they can
be tuned without satellite hardware: turn_detection, turn_threshold
defaulting to 0.9, and turn_max_seconds defaulting to 3.

The cap is not optional. The model's verdict does not drift as silence
accumulates, so without it someone who trails off holds the turn open
forever.

Inference runs in the executor. It is ~25 ms of CPU, which would otherwise
stall the event loop that is feeding it audio. Any failure is treated as
"finished", which is exactly the behaviour without the model, so a broken
model cannot lose a command.

smart-turn.nix builds against the caller's interpreter, so Home Assistant
gets the module built for its own Python, the same reason pysilero-vad is a
package input rather than a service extraPackage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Streaming a real recording through the dev instance, with no artificial
pause -- the speaker's own pauses are the test:

  silence only, 0.25 s   ended 2.5s  ' And so my fellow Americans'
  silence only, 0.70 s   ended 3.0s  ' And so my fellow Americans!'
  turn detection, 0.25 s ended 5.2s  ' And so my fellow Americans, ASK NOT!'

The model recognised the pause after "Americans," as unfinished and kept
listening, at the shorter threshold. It stops at 5.2 s because
turn_max_seconds was 2.0 and this speaker pauses for effect, which is the
cap working.

Fixes a bug found by that test: process() calls reset() as it reports the
command finished, clearing in_command, so granting another silence window
must restore the flag as well as the counter. The silence counter only
decrements inside a command, so without it the segmenter waits for speech
that never comes and the turn never ends -- runs hung to the 60 s timeout
while the model logged "keep listening" correctly each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sweeping silence_seconds with and without the turn model, measuring both
things that matter. Without it every setting cuts a mid-sentence pause
short, because tolerance is the threshold. With it the pause is held at
every setting tried, down to 0.10.

So silence_seconds can go 0.7 -> 0.1, which takes 600 ms off a finished
utterance, while pauses keep working. The model costs about 100 ms of that
back. Roughly 200 ms from end of speech to decision, against 789 ms for
silence alone at 0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Full pipeline through the real conversation agent, timed from the last
sample of speech to the answer: 1408 ms with silence 0.7 and no turn model,
824 ms with silence 0.1 and the model. Same answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 tool-call runs across qwen3.6:35b-a3b and ornith with no illegal memory
access. That fault, during constrained decoding of tool calls with array or
enum parameters, is what disqualified qwen35moe on ollama 0.32.3; we run
0.32.13, and it appears resolved.

End to end with the turn model and silence 0.1: on a question answered from
the prompt, 817 ms for qwen3.8 against 713-717 ms for the MoE models. On a
control command needing a tool call, 1503 ms against 1116 ms for ornith.

Not a recommendation yet. qwen3.6:35b-a3b said it was unable to turn on the
light where ornith did it, so this needs the scenario eval, not a latency
script. The light's state also carried between runs, which makes the replies
non-comparable; reset entity state when comparing behaviour.

Also factors the shared driving code into dev/halib.py, which hands out
websocket ids centrally -- Home Assistant requires them to increase, and
callers picking their own collided.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dev/eval.py runs the 15 scenarios against the development instance,
resetting entity state before every run so one scenario cannot flatter the
next. Three reps each:

  qwen3.8:27b-mtp-q8_0   38/45   median 441 ms
  qwen3.6:35b-a3b-q4_K_M 39/45   median 466 ms
  ornith:35b-q4_K_M      45/45   median 678 ms

ornith's higher median is partly an artefact of being correct: declining to
act returns faster than acting. On real audio it is the fastest of the
three, 713 ms against 817 for a question and 1116 against 1503 for a
command.

<|fim_pad|> is one special token in all three vocabularies, so the cache
boundary carries over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The settings were opt-in per API call, so a real satellite would have kept
the old behaviour. turn_detection now defaults to on, silence_seconds to
0.25 against upstream's 0.7, and VadSensitivity to 0.7 / 0.25 / 0.1 against
upstream's 1.25 / 0.25 / 0.7.

Safe only because the model supplies the pause tolerance. Without it
silence_seconds has to be both how fast the assistant answers and how long
a pause it forgives, which is why upstream's numbers are long.

With no per-run settings at all: 940 ms from end of speech to answer,
against 1408 ms on the old defaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per stage from the last sample of speech: 243 ms to decide the turn ended,
211 ms to transcribe, 383 ms to answer. The first two are both dead time and
were consecutive for no reason.

speculative_stt snapshots the audio the moment speech stops and transcribes
that during the wait, so the text is ready when the turn is confirmed over.
Speech resuming cancels the snapshot and the normal path is used. Costs one
extra transcription of audio that is discarded when the speaker had not
finished.

118 ms, bounded by how much wait there is to hide behind. Holding a pause
still works with it on: the JFK clip still reaches "ASK NOT".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the last sample of speech to the answer, through the real conversation
agent. The turn model and speculative transcription take it to 717 ms, and
ornith to 600 ms. It also holds a mid-sentence pause now, which no setting
could do before.

The control-command column of that table is not trustworthy, because entity
state carries between runs and a model that says "already on" looks faster
than one that switches the light. Behaviour belongs in dev/eval.py, which
resets state before every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ending the snapshot on the last phoneme, without the silence the full
stream would carry, changed what whisper heard: of six clips two differed,
one a real mishearing -- "set the bad light" for "set the bed light" -- and
one only capitalisation. Appending 250 ms of silence makes all six
identical.

Found by comparing the speculative transcript against the normal one clip
by clip rather than trusting that the same audio gives the same text. This
is a property of the engine, so check it again if the engine changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Silence with no speech, a 0.4 s blip, and an 11 s recording all behave the
same with our settings as with stock ones, so none of this work regressed
them. Silence and the blip run to the pipeline's own 60 s timeout in both
configurations, which is upstream behaviour: no voice command ever starts,
so the segmenter never reports one finishing.

The long recording was reported as "no stt-end" when the transcript was
plainly there. That was the harness: it measures from the moment the last
sample is streamed, and a turn that ends early -- holding pauses, then
hitting the cap -- never reaches that point. It now says so instead of
implying the pipeline failed, and error events carry their reason through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Puts the result and the one idea that matters at the top of the findings,
ahead of the chronological detail, and records what is still open: real
recordings of the person who will use it, speculating past the point where
tool calls fire, streaming speech synthesis, and whether to switch the live
assistant to ornith.

Also lists the experiment scripts in the development README, along with the
transcriber they need, and four things that each cost an hour: websocket ids
must increase, truncating a log Home Assistant holds open fills it with nul
bytes, subentry ids are not in the REST listing, and turn-detection verdicts
are invisible without the logger block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing was spoken until the last token of a reply was generated, because
Kokoro did not advertise supports_synthesize_streaming and Home Assistant
will not stream text into an engine that does not.

The hard part was already done: kokoro-wyoming already splits into
sentences and emits audio for each. Only the input side was missing, so
this adds SynthesizeStart / SynthesizeChunk / SynthesizeStop, buffers the
incoming text, and synthesises each sentence as soon as it is complete.
take_complete_sentences hands over only text up to the last sentence-ending
punctuation, so nothing is spoken from a fragment more text would change.

The model itself does not need to stream. Kokoro synthesises a whole
utterance at once and quickly; it just has to be fed sooner.

Tested with a stub synthesiser, because building the real one needs
onnxruntime with CUDA: fed a reply the way a model produces it, the first
sentence is synthesised after the second chunk while the third sentence is
still arriving, and the event order is audio-start, chunks, audio-stop,
synthesize-stopped. Unverified against the real synthesiser and against
Home Assistant end to end.

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