home-assistantvoicewhisperollamallm

Lokale Spracherkennung für Home Assistant: Whisper statt LLM

/Veröffentlicht am/Aktualisiert am

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:

  1. über das Wyoming-Protokoll — längenpräfixierte JSON-Events plus rohes PCM über einen normalen TCP-Socket, oder
  2. ü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 False nach dem Transcript. 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 leeres Transcript zurü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. Erst pkill -f wyoming-gemma-stt.py, dann systemctl --user enable --now.
  • nc -z <eigene-LAN-IP> <port> beweist nichts. Loopback geht an der Firewall vorbei. Bei DEFAULT_INPUT_POLICY=DROP in /etc/default/ufw braucht es eine explizite Regel, egal was nc sagt: 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 run

    Dieses Profil läuft auf claude-haiku-4-5. Der API-Server ist profilgebunden, das ist also kein Config-Schalter, sondern ein zweiter Dienst. Ein Environment=HERMES_PROFILE=... reicht dafür übrigens nicht — der Singleton-Lock hängt an get_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, eine KEY=-Zeile). Der Shim liest seinen API-Key sonst aus der .env des Standardprofils — zeigst du ihn auf einen anderen Port, bekommst du 401er. Da _load_key() zuerst os.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:

  1. tools in ollama show beschreibt Formatunterstützung, keine Kompetenz. Ein Modell kann syntaktisch einwandfreie Tool-Calls erzeugen und sich bei großem Prompt trotzdem nie entscheiden, einen abzusetzen.
  2. Die Ausfallachse ist die Prompt-Größe, nicht die Parameterzahl. Modelle der 12B-Klasse degradieren zu generischem Assistenten-Geplauder, statt laut zu scheitern.
  3. 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.

Ich bin René, CTO aus Pasching bei Linz. Wenn dein Problem größer ist als ein Rollladen — Streaming, Cloud, lokale LLMs, Technikführung — dann steht hier, was ich beruflich mache.