Lokale Spracherkennung für Home Assistant: Whisper statt LLM
Ich hatte den Benchmark schon gewonnen, bevor ich überhaupt angefangen habe. So hat es sich jedenfalls angefühlt.
Die Idee war simpel: Ich habe eine lokale Ollama-Instanz, ich habe mit
gemma4:e4b ein Modell, das laut ollama show tatsächlich audio in den
Capabilities stehen hat — also kann ich Spracherkennung für Home Assistant
Assist komplett im Haus machen, ohne Cloud, ohne API-Key. Ich habe mir ein paar
Testsätze mit edge-tts --voice de-AT-JonasNeural synthetisiert, beide Engines
drübergejagt, und das Ergebnis war eindeutig: gemma4 schlägt faster-whisper
base bei österreichischem Deutsch. Wo whisper Wörter verschluckt oder
verstümmelt hat, war gemma wortgenau. Bei sauberem Englisch Gleichstand.
Fertig, dachte ich. War es nicht.
Die Tabelle, die den Benchmark zerlegt hat
Sobald echtes Mikrofonmaterial durchlief — Assist im Browser, ~-27 dB RMS, Zimmerhall, ein Mensch statt eines Sprachsynthesizers — ist gemma4 auf denselben aufgezeichneten WAV-Dateien eingebrochen, auf denen faster-whisper wortgenau war:
| echter Mikrofon-Clip | gemma4:e4b | faster-whisper base |
|---|---|---|
| “close the office shutter and also close shutter number one in the living room” | “schließe den Offen-Schatten am Wohnende des Livengro” | ✅ wortgenau |
| “It’s unbelievable, it’s not working” | “Das beliebte Netzwerk” | ✅ wortgenau |
Das ist nicht “etwas schlechter”. Das ist unbrauchbar. Und es kommen Fehlermodi dazu, die ein klassischer ASR-Decoder gar nicht haben kann, weil hier ein Sprachmodell mit Audio-Encoder sitzt und kein Transkriptionsmodell:
- Es antwortet, statt zu transkribieren. Wörtlich zurückgekommen: “I’m sorry, but no audio was provided. Please provide the audio you would like me to transcribe.” Und einmal, noch schöner, einfach: “Transcribe this audio.” Ja. Genau das war die Aufgabe.
- Es halluziniert in zufällige Sprachen. Auf englischer Sprache:
जो उसमें ऑफ शटर,в смысле office shot,Ça me lève le réseau. - Es erkennt die gesprochene Sprache komplett falsch und “übersetzt” dann in die, auf die es sich festgelegt hat.
Die Lektion daraus ist die eigentliche Lektion dieses Artikels, und sie gilt weit über Spracherkennung hinaus:
Ein ASR-Backend auf synthetisierter Sprache zu benchmarken ist wertlos. TTS-Ausgabe ist sauber, pegelnormalisiert und akzentneutral. Sie versteckt exakt die Schwächen, auf die es ankommt.
Wenn du selbst testest: nimm echte Äußerungen auf (im Bridge-Skript unten macht
das --dump-dir) und miss auf denen. Ein Fixture, das nie durch einen
Raum gegangen ist, beweist nichts über ein Mikrofon in einem Raum.
Für Sprachnotizen am Handy — nahes Mikro, Opus, eine Person — ist gemma4:e4b weiterhin okay. Für Assist ist es das nicht. Das Backend ist also faster-whisper, und der Rest dieses Artikels beschreibt, wie das in Home Assistant reinkommt.
Home Assistant kann kein Audio an deinen Dienst schicken
Der erste Reflex war bei mir: Webhook. Assist nimmt auf, POSTet die Datei irgendwohin, ich gebe Text zurück. Das geht nicht. Eine Assist-Pipeline akzeptiert eine STT-Engine nur auf zwei Wegen:
- über das Wyoming-Protokoll — längenpräfixierte JSON-Events plus rohes PCM über einen normalen TCP-Socket, oder
- über eine eigene
stt-Plattform, also eine komplette Custom-Integration.
Wyoming ist mit Abstand der billigere Weg: rund 150 Zeilen Python, die Engine taucht nativ im STT-Dropdown auf, kein einziger Eintrag in einer HA-Konfigdatei und kein HA-Neustart. Die Kette sieht so aus:
Mikro (Companion-App / ESPHome-Satellit / Browser)
→ HA Assist Pipeline
→ Wyoming tcp://<host>:10300
→ dieses Skript: PCM → WAV → faster-whisper (in-process)
Der HA-seitige Vertrag ist überschaubar. Man baut mit dem wyoming-Python-Paket
einen AsyncServer plus AsyncEventHandler und beantwortet vier Events:
class WhisperEventHandler(AsyncEventHandler):
async def handle_event(self, event: Event) -> bool:
if Describe.is_type(event.type):
await self.write_event(self.info.event())
return True
if Transcribe.is_type(event.type):
transcribe = Transcribe.from_event(event)
if transcribe.language:
self._language = transcribe.language
self._audio = bytearray()
return True
if AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
self._rate = chunk.rate
self._width = chunk.width
self._channels = chunk.channels
self._audio.extend(chunk.audio)
return True
if AudioStop.is_type(event.type):
text = ""
try:
text = await self._transcribe(bytes(self._audio))
except Exception: # noqa: BLE001 - never kill the Assist pipeline
_LOGGER.exception("transcription failed")
finally:
self._audio = bytearray()
_LOGGER.info("transcript: %r", text)
# HA opens a fresh connection per utterance.
await self.write_event(Transcript(text=text).event())
return False
return True
Zwei Details in diesem Block sind teuer erkauft:
- Das
return Falsenach demTranscript. Home Assistant macht pro Äußerung eine frische Verbindung auf. Lässt du sie offen, desynchronisiert die nächste. - Der
except-Block, der alles schluckt. Lässt du eine Exception durchlaufen, stirbt nicht nur die Transkription, sondern die gesamte Assist-Pipeline mit einer generischen Fehlermeldung. Lieber ein leeresTranscriptzurückgeben: dann hat Assist dich eben nicht gehört, statt kaputt zu sein. - Die Sample-Rate liest man vom Chunk, nicht aus einer Konstante.
Die Modellgröße entscheidet sich an einem einzigen Wort
Englisch ist bei base erledigt. Österreichische Komposita sind es nicht — und
sie scheitern lautlos, weil ein kleines Modell ein plausibel aussehendes
Wort ausspuckt und nichts im Log irgendetwas meldet. Genau das ist die Falle:
Die Pipeline sieht kerngesund aus.
11-Sekunden-Fixture, Referenzsatz mit “Rollo im Büro” und “Terrassenmarkise”:
| Modell | Device | Latenz | Ausgabe |
|---|---|---|---|
base |
CPU int8 | 1,7 s | “das Rodo in Büro … Terrasse nach Kise” ✗ |
small |
CPU int8 | 5,0 s | “das Rollo in Büro … Terrassenakise” ~ |
large-v3-turbo |
CPU int8 | 21,5 s | “das Rollo im Büro … Terrassenmarkise” ✓ |
Alle drei haben die Sprache korrekt erkannt — de @ 0,99–1,00. Das Problem
ist nicht die Spracherkennung, sondern der Wortschatz. Wer hier an der
Sprachdetektion debuggt, sucht an der falschen Stelle.
Die Regel, die ich mir daraus gezogen habe: Dimensioniere für die schwierigste Sprache im Haus, nicht für die einfachste. Und teste an den Komposita, nicht an “schalte das Licht ein”.
Und large-v3-turbo mit 21,5 Sekunden? Unbrauchbar für Sprache. Auf GPU wären
das 1–2 Sekunden. Nur: Die GPU gehört jemand anderem. Auf der RTX 2080 Ti mit
11 GB lädt Ollama für ein anderes Projekt — einen laufenden Chatbot — bei
Bedarf gemma4:e4b, und mit großem num_ctx hält das gut 10 GB. Ollama wirft
das Modell zwar nach ein paar Minuten Leerlauf wieder raus, aber whisper
dauerhaft daneben zu legen heißt, dass die nächste Chatbot-Anfrage entweder
wartet oder auf die CPU fällt. Für whisper bleibt zuverlässig nichts.
Ich hätte das Modell entladen können. Ich habe es nicht getan, und das ist
Absicht: Bevor du ein residentes Modell von der Karte wirfst, finde heraus,
was davon abhängt. In diesem Fall hätte das Entladen eine sichtbare Störung
auf einer Live-Seite verursacht. Wer VRAM braucht, senkt besser das num_ctx
des Platzhirschs, als ihn abzuschießen — das kostet nur Kontextlänge.
Ergebnis: STT läuft bei mir auf CPU mit small. Kompromiss zwischen den 1,7 s
von base und dem Wortschatz von large-v3-turbo. Ehrlich gesagt sitze ich
damit genau auf dem Modell, das “Terrassenakise” produziert hat. Ich weiß das.
--language erzwingen macht aus Whisper einen Übersetzer
Der zweite Dauerbrenner: Wenn du Whisper eine Sprache vorschreibst, transkribiert es nicht mehr, sondern übersetzt. Das war eine der größten Quellen für Unsinn im Log, und es sieht nicht nach einem Bug aus — es sieht nach einem schlechten Modell aus.
Bei uns wird am selben Mikrofon Deutsch und Englisch gesprochen, also läuft der
Dienst mit --language auto. Im Skript ist das genau eine Zeile:
# None => auto-detect. HA sends the pipeline language on every
# Transcribe event; "auto" on the command line ignores it, which is
# what you want if you speak more than one language at this mic.
lang = None if self.cli_args.language == "auto" else (self._language or None)
Was damit immer noch nicht geht: Code-Switching innerhalb eines Satzes. “Ich geh eine different language an” bleibt kaputt. Das ist inhärent, nicht konfigurierbar.
Ebenfalls wichtig: vad_filter=True. Home Assistant polstert Äußerungen mit
Stille, und ohne VAD halluziniert Whisper in diese Stille hinein.
Der Namens-Schandfleck, zu dem ich stehe
Der laufende systemd-User-Unit heißt so:
[Unit]
Description=Wyoming STT bridge (Ollama gemma4:e4b) for Home Assistant Assist
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/home/masta/.hermes/bin/wyoming-gemma-stt.py --uri tcp://0.0.0.0:10300 --model small --language auto
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
wyoming-gemma-stt.py, Beschreibung “Ollama gemma4:e4b” — und drin läuft
faster-whisper. Das ist kein Schlampfehler, sondern Absicht, und im Code steht
auch, warum:
AsrProgram(
# Name kept as "gemma-ollama" so HA's existing entity
# stt.gemma_ollama and the pipeline binding keep working.
name="gemma-ollama",
description=f"faster-whisper {model} speech-to-text",
...
)
Home Assistant leitet die Entity-ID und die Pipeline-Bindung aus dem
AsrProgram-Namen ab. Meine STT-Entity heißt deshalb bis heute
stt.gemma_ollama. Änderst du den Namen beim Backend-Wechsel, musst du die
Wyoming-Integration entfernen, neu hinzufügen und die Engine in jeder Pipeline
neu auswählen. Behältst du ihn, ist der Austausch des kompletten ASR-Backends
für HA unsichtbar — kein Neustart, kein Klick.
Ich habe den Namen behalten. Der Preis ist ein Dienst, dessen Name über sein eigenes Innenleben lügt. Für ein System, das ich alleine warte, nehme ich das; in einem Team würde ich einmal den sauren Apfel essen und umbenennen.
Beim Einrichten noch zwei Dinge, die Zeit gekostet haben:
- Port 10300 doppelt belegt. Eine manuell in den Hintergrund geschobene
Kopie lässt den systemd-Unit mit
OSError: [Errno 98]in eine Crash-Schleife laufen. Erstpkill -f wyoming-gemma-stt.py, dannsystemctl --user enable --now. nc -z <eigene-LAN-IP> <port>beweist nichts. Loopback geht an der Firewall vorbei. BeiDEFAULT_INPUT_POLICY=DROPin/etc/default/ufwbraucht es eine explizite Regel, egal wasncsagt:sudo ufw allow from 192.168.1.0/24 to any port 10300 proto tcp.
Das Modell wird beim Start geladen — nach einem Neustart also rund 10 Sekunden warten, bevor der Dienst Verbindungen annimmt.
Zweite Baustelle: Assist versteht nur Schablonen
Damit hört mein Setup zwar zu, aber es versteht immer noch nur das, was die eingebaute Intent-Erkennung an Phrasen-Schablonen kennt. “Ist oben noch irgendwas offen?” ist keine Schablone. Zusätzlich matcht Assist nur auf Entitäten, die explizit freigegeben sind, und zwar auf deren wörtlichen Namen — zwei Entitäten, die “Büro Rollo” enthalten, sind mehrdeutig und ergeben einen stillen No-Op. Man tunt das mit Aliassen, nicht mit Umbenennen.
Der Ausweg wäre ein anderer Konversationsagent. Und da läuft man in eine Wand:
Die OpenAI-Integration von Home Assistant verweigert fremde Endpunkte
hart. Wörtlich: “works only with the official OpenAI API endpoint and does
not support OpenAI-API-compatible third-party services, proxies, or
alternative…” — Core-Issue #137087, Stand Januar 2026 weiterhin offen.
Über HACS gibt es einen Extended OpenAI Conversation-Fork mit base_url.
Die native Ollama-Integration nimmt dagegen jede URL entgegen. Also habe ich nicht gegen HA gekämpft, sondern einfach Ollamas Dialekt gesprochen: ein kleiner Shim, der sich als Ollama ausgibt und alles an das Agenten-Framework weiterreicht, das bei mir ohnehin zu Hause läuft. Kein HACS, keine Custom-Komponente, kein HA-Neustart.
HA klopft dabei mehr ab als nur /api/chat. Fehlt eine Route, lässt sich die
Integration nicht hinzufügen:
| Route | Zweck |
|---|---|
GET / |
Erreichbarkeitsprobe, muss Ollama is running zurückgeben |
GET /api/version |
Versions-String |
GET /api/tags |
Modell-Liste — ein einziger Fake-Eintrag hermes-agent |
POST /api/show |
Metadaten/Capabilities pro Modell |
POST /api/chat |
der eigentliche Zug, sowohl stream:true als auch false |
Streaming darf man fälschen. Ein ndjson-Chunk mit dem vollen Text und
done:false, danach einer mit leerem Content und done:true — HA schluckt das:
await resp.write(json.dumps({
"model": body.get("model", MODEL_NAME),
"created_at": now,
"message": {"role": "assistant", "content": content},
"done": False,
}).encode() + b"\n")
await resp.write(json.dumps({
"model": body.get("model", MODEL_NAME),
"created_at": now,
"message": {"role": "assistant", "content": ""},
"done": True,
"done_reason": "stop",
"total_duration": elapsed_ns,
"eval_count": len(content.split()),
}).encode() + b"\n")
Und die Regel, die man beim Shim nie brechen darf: Lass niemals einen Fehler des Backends als HTTP-Fehler durch. Gib den Fehlertext als Assistenten-Antwort zurück, sonst zeigt Assist nur ein generisches Scheitern ohne jeden Hinweis:
except Exception as exc: # noqa: BLE001 - always answer HA with something
_LOGGER.exception("hermes call failed")
content = f"Hermes unreachable: {exc}"
Der Shim läuft als eigener User-Unit:
[Unit]
Description=Ollama-API shim exposing Hermes as an HA conversation agent
After=network-online.target hermes-gateway.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=%h/.hermes/ha-shim.env
ExecStart=%h/.hermes/bin/ha-hermes-ollama-shim.py --host 0.0.0.0 --port 11435 --hermes-url http://127.0.0.1:8643
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
Zwei Entscheidungen darin sind bewusst:
-
--hermes-url http://127.0.0.1:8643, nicht der Standardport des Hauptprofils. Das Hauptprofil ist mein großes, teures Modell mit jedem aktivierten Werkzeug — das Letzte, was ich hinter ein Dauer-Mikrofon hänge. Hinter Assist hängt ein eigenes, kleines Profil mit eigenem Gateway-Prozess:ExecStart=/home/masta/.hermes/hermes-agent/venv/bin/hermes -p home_assistant gateway runDieses Profil läuft auf
claude-haiku-4-5. Der API-Server ist profilgebunden, das ist also kein Config-Schalter, sondern ein zweiter Dienst. EinEnvironment=HERMES_PROFILE=...reicht dafür übrigens nicht — der Singleton-Lock hängt anget_hermes_home()/gateway.pid, und das zweite Unit läuft in eine Crash-Schleife mit “Gateway already running”. Man braucht den CLI-Flag-p. -
EnvironmentFile=%h/.hermes/ha-shim.env(chmod 600, eineKEY=-Zeile). Der Shim liest seinen API-Key sonst aus der.envdes Standardprofils — zeigst du ihn auf einen anderen Port, bekommst du 401er. Da_load_key()zuerstos.getenv("API_SERVER_KEY")prüft, reicht das Injizieren über das Unit; am Skript muss man nichts ändern.
Der Agent selbst bleibt an 127.0.0.1 gebunden. Nach außen sichtbar ist nur der
Shim — der hält den Key, HA sieht ihn nie.
Und dann hat das gute lokale Modell doch verloren
Ich wollte das natürlich lokal haben. Also gemma4:12b hinter den Shim: 92,1 %
Schnitt auf dem öffentlichen HA-Leaderboard, die zweitbeste lokale Option dort.
Das Ergebnis hat meine Erwartung exakt umgedreht:
| Kontext | Ergebnis |
|---|---|
| standalone, ~12 Zeilen Entity-Liste im Prompt | 0,6–0,9 s, korrekte Entity-ID (cover.rollo_lr_1), bestes österreichisches Deutsch aller getesteten Modelle (“Des schalt i da glei zu”) |
| hinter dem Agenten-Shim (~31k Token System-Prompt + volles Tool-Schema) | null Tool-Calls, 30–100 s pro Antwort, antwortet wie ein generischer Chatbot |
Auf “Wie viel Strom produziert meine PV-Anlage gerade?” kam wörtlich:
“Das kann ich leider nicht sehen. Als KI habe ich keinen Zugriff auf deine Hardware, dein Smart-Home-System…”
— während die ha_*-Werkzeuge ungenutzt in seinem Schema lagen. Direkt daneben.
Mit Namen. Gebeten, einen
Shell-Befehl auszuführen und die Ausgabe einzufügen, hat es geantwortet:
“What is the specific problem or task? …Feel free to just start describing what
you need!”
Das ist derselbe Denkfehler wie am Anfang dieses Artikels, nur eine Ebene höher. Ein kleiner handgebauter 12-Zeilen-Kontext ist für ein Agenten-Setup genauso wenig repräsentativ wie eine TTS-Datei für ein Mikrofon: Beides entfernt genau den Druck, der das Ding kaputtmacht.
Drei Dinge, die ich mitgenommen habe:
toolsinollama showbeschreibt Formatunterstützung, keine Kompetenz. Ein Modell kann syntaktisch einwandfreie Tool-Calls erzeugen und sich bei großem Prompt trotzdem nie entscheiden, einen abzusetzen.- Die Ausfallachse ist die Prompt-Größe, nicht die Parameterzahl. Modelle der 12B-Klasse degradieren zu generischem Assistenten-Geplauder, statt laut zu scheitern.
- Das Symptom sofort erkennen: flüssige, freundliche, thematisch passende Prosa, die nichts beantwortet und nichts aufruft. Daran ist mit Prompt-Formulierungen nichts zu reparieren.
Und die Leaderboard-Zahl ist nicht falsch — sie misst nur etwas anderes, nämlich HAs native LLM-API mit einem kleinen Tool-Set. Nicht eine 31k-Token-Harness.
Hinter dem Shim läuft deshalb Haiku. Auch das nicht gratis: Auf derselben Frage brauchte Haiku ~3,4 s gegenüber ~16,8 s für Opus, hat dabei aber “6 Rollläden” gesagt und dann sieben aufgezählt, und die Nuance verloren, die Opus gefunden hatte (dass es im Haus überhaupt keinen Innentemperatursensor gibt, nur den im Auto). Für Befehle gut genug, für Inventar- und Denkfragen schwächer. Nach einem Modellwechsel also die Genauigkeit erneut prüfen, nicht nur, ob überhaupt eine Antwort kommt.
Wo ich gelandet bin
Was heute tatsächlich läuft, drei User-Units, alle active:
wyoming-gemma-stt.service → faster-whisper small, CPU int8, --language auto, :10300
ha-hermes-shim.service → Ollama-Dialekt auf :11435 → 127.0.0.1:8643
hermes-gateway-ha.service → Profil "home_assistant", claude-haiku-4-5
In Home Assistant sichtbar als:
stt.gemma_ollama (Wyoming, heißt lügnerisch nach gemma)
conversation.hermes (über den Shim)
conversation.home_assistant (eingebaut)
Und jetzt der Teil, den ich nicht schönreden will: Das ist kein fertiger Sprachassistent.
- Meine Standard-Pipeline ist weiterhin
conversation.home_assistant. Die Hermes-Pipeline existiert parallel und ist bewusst nicht die bevorzugte. Der Agent bekommt vollen Werkzeugzugriff ohne Bestätigungsschritt — wer ans Mikro kommt, kommt an alles. Das will ich nicht als Default. - Kein Wake Word. Beide Pipelines haben
wake_word_entity: null. - Keine gesprochene Antwort. Beide haben
tts_engine: null. Die Antwort kommt als Text im Chat-Sheet. Der Rückweg fehlt komplett. - Push-to-Talk, nur in der Companion-App. Und zwar ausschließlich im In-App-Assist-Dialog (Drei-Punkte-Menü → Assist → Mikro im Chat-Sheet).
- iOS-Kurzbefehle und der Action-Button erreichen meine STT-Engine nie.
Diese Wege benutzen Apples
Dictate text, transkribieren auf dem Gerät und übergeben HA einen String via “Assist with Provided Input”. Die Pipeline-STT-Engine wird nicht aufgerufen, und mein Dienst loggt nichts. Das hat mich einen halben Abend gekostet, weil “es nimmt nicht auf” und “der Dienst ist tot” von außen gleich aussehen. Wenn dein Wyoming-Log leer bleibt: frag zuerst, welchen Knopf du gedrückt hast, bevor du Firewall, Modell oder Mikrofonrechte debuggst. - ~2,5–3 s CPU-Latenz für die Transkription, plus mindestens ~2 s für eine Agentenantwort. Für Push-to-Talk okay. Für einen Wake-Word-Satelliten viel zu langsam.
Was ich anders machen würde
Ich hätte zuerst den Aufnahmeweg debuggt, dann das Modell. --dump-dir
schreibt jede Äußerung auf Platte; Pegel, Dauer und Sample-Rate beantworten
“funktioniert das Mikro überhaupt” in einem einzigen Befehl. Ich habe stattdessen
am Modell geraten.
Ich hätte von Anfang an auf echtem Mikrofonmaterial gemessen. Der gesamte gemma-Umweg — inklusive des Dienstnamens, der mich jetzt überlebt — existiert nur, weil ich einem Benchmark auf synthetischer Sprache geglaubt habe — einem, den ich selbst gebaut hatte, was eigentlich der erste Hinweis hätte sein müssen.
Und ich hätte das große Modell einmal durch die echte Harness geschickt, bevor ich mich über die Leaderboard-Platzierung gefreut habe.
Was als Nächstes ansteht: der TTS-Rückweg, damit Assist antwortet statt nur zu
schreiben, und ein ehrlicher Versuch mit large-v3-turbo auf GPU — sobald ich
dem Chatbot, der auf der 2080 Ti wohnt, ein kleineres num_ctx verpasst habe,
ohne ihn dabei umzubringen.