Sprachgesteuerte Telefonvermittlung mit OpenAI Whisper und Asterisk

5. März 2026

In vielen Museen stehen sie noch – alte Telefonvermittlungen, stumm und unberührt hinter Glas. Dieses Projekt erweckt eine solche Anlage zum Leben: Besucher heben den Hörer ab, nennen einen Namen oder eine Nummer, und die Vermittlung stellt durch. Was wie Magie klingt, steckt dahinter: das quelloffene Spracherkennungsmodell Whisper von OpenAI, kombiniert mit der Telefonanlage Asterisk – zusammengebaut auf einem schlichten Raspberry Pi.

Erstellt habe ich das Skript hauptsächlich mit Claude Sonnet 4.6. Zum Einsatz kam die kostenlose Version dieser KI. Einen kleinen Teil hat die KI Gemini übernommen als ich Claude nicht erreichen konnte.  Bei Rückfragen empfehle ich den Einsatz dieser beiden Sprachmodelle. Mir persönlich gefällt Claude Sonnet 4.6 besser.

Skript, Konfigurationen, Soundfiles in einer ZIP-Datei: Sprachgesteuerte Vermittlung mit Whisper und gTTS.zip

Umgebung: Fujitsu Esprimo Q520 mit 8 GB RAM und Ubuntu Mate 24.04 LTS, Python 3, Asterisk 20.6.0

Es ist sehr praktisch die Entwicklung auf einem Desktop-Rechner oder Laptop mit Ubuntu oder einem anderen Debian vorzunehmen. Der Asterisk auf diesem Rechner ist per IAX2 mit einem anderen Asterisk verbunden, der als Gateway zur Außenwelt dient. Man spart sich dadurch das Arbeiten mit Filezilla für FTPS und den SSH-Zugang. Dateien können direkt bearbeitet werden.

Wie installiert man Whisper und alle notwendigen Abhängigenkeiten für das Pythonskript? Die HTML-Datei whisper_installation gibt Auskunft darüber.

Gemini Generated Image ehj5xbehj5xbehj5
Von Gemini (Banana2) erzeugtes Bild einer romantisch verklärten Darstellung von einer Handvermittlung aus den 1920er Jahren.

Wie das Skript whisper_vermittlung.py funktioniert: Das Skript wird von der Telefonanlage Asterisk aufgerufen, sobald ein Anrufer in der Warteschleife gesprochen hat. Asterisk nimmt die Spracheingabe als WAV-Datei auf und übergibt den Dateipfad als Parameter an das Skript. Von diesem Moment an übernimmt das Skript die vollständige Kontrolle über die Auswertung.

Flussdiagramm
Flussdiagramm des Pythonskripts von Claude Sonnet erstellt.

Zunächst liest das Skript die sogenannten AGI-Header ein – das ist ein technisches Handshake-Protokoll zwischen Asterisk und dem externen Programm. Diese Zeilen enthalten Informationen über den aktuellen Anruf, werden aber vom Skript nur konsumiert und nicht weiterverarbeitet. Erst danach beginnt die eigentliche Arbeit.

Als nächstes lädt das Skript die Namensliste aus der Datei vermittlung.json. Diese Datei enthält eine einfache Zuordnung: Name links, Nebenstellen-Nummer rechts. Ist die Datei nicht vorhanden oder fehlerhaft, arbeitet das Skript trotzdem weiter – die Namenserkennung entfällt dann einfach, und nur direkt gesprochene Ziffern können noch erkannt werden.


Die sprachgesteuerte Vermittlung in der Praxis. Der Anrufer möchte die „Zeitansage Deutschland“ hören und wird nach seiner Ansage mit der Nummer 119 verbunden. Die 119 war tatsächlich damals die Nummer der Zeitansage.

Bevor das Audiomaterial an Whisper übergeben wird, durchläuft die WAV-Datei eine Vorverarbeitung durch das Programm sox. Dabei wird die Audiodatei auf 16.000 Hz hochgesampelt (Whisper arbeitet intern mit dieser Abtastrate), auf einen einzigen Audiokanal reduziert und durch einen Bandpassfilter geschickt, der nur Frequenzen zwischen 300 und 3.200 Hz durchlässt. Das entspricht exakt dem Frequenzband eines analogen Telefongesprächs. Außerdem wird die Lautstärke normalisiert. Diese Vorverarbeitung verbessert die Erkennungsrate bei Telefonaudio erheblich, weil Whisper sonst auf Breitband-Sprache trainiert ist und Telefonrauschen außerhalb dieses Bandes stören kann.

Anschließend wird das Whisper-Modell geladen und die Transkription gestartet. Das Modell ist so konfiguriert, dass es ausschließlich Deutsch erkennt. Durch den Anfangs-Prompt – eine Liste aller bekannten Nummern und Namen – wird Whisper in die richtige Richtung gelenkt, sodass es Wörter wie „Roland“ oder „1004“ bevorzugt erkennt, anstatt ähnlich klingende aber bedeutungslose Alternativen zu wählen. Das Ergebnis ist ein einfacher Rohtext in Kleinbuchstaben.

Dieser Rohtext wird nun in zwei Schritten ausgewertet. Im ersten Schritt versucht das Skript, Ziffern zu extrahieren. Es durchsucht den Text Wort für Wort und übersetzt sowohl ausgeschriebene Zahlwörter („eins“, „zwei“, „drei“ usw.) als auch direkte Ziffernzeichen in eine Ziffernfolge. Hat der Anrufer also „eins null null vier“ gesagt, entsteht daraus die Zeichenkette „1004“. Wenn eine Ziffernfolge von mindestens drei Zeichen gefunden wird, gilt die Erkennung als erfolgreich, und das Skript gibt diese Nummer sofort zurück. Längere Eingaben wie Durchwahlen funktionieren auf dieselbe Weise.

Wurde keine brauchbare Ziffernfolge gefunden, startet der zweite Schritt: die Namenserkennung. Das Skript zerlegt den Rohtext in einzelne Wörter und bildet zusätzlich Zwei-Wort-Kombinationen, um auch Vor- und Nachnamen zusammen erkennen zu können. Jede dieser Kombinationen wird mit der geladenen Namensliste verglichen. Zuerst wird auf exakte Übereinstimmung geprüft. Schlägt das fehl, berechnet das Skript den sogenannten Levenshtein-Abstand – ein Maß dafür, wie viele einzelne Buchstaben-Änderungen nötig wären, um aus dem erkannten Wort den gespeicherten Namen zu machen. Ist dieser Abstand zwei oder kleiner, gilt der Name als erkannt. Das bedeutet: ein einzelner Buchstabendreher oder ein Versprecher wird toleriert, zu weit entfernte Wörter werden jedoch verworfen.

Bildschirmfoto zu 2026 03 04 21 24
Die Spracherkennung belastet die CPUs meines Esprimos für knapp 10 Sekunden zwischen 60 und 90%. Zu viele Anfragen auf einmal dürfen es also nicht sein.

Wurde ein Name erkannt, lässt das Skript über gTTS (Google Text-to-Speech) eine kurze Bestätigungsansage erzeugen, zum Beispiel „Verbindungsaufbau mit Roland.“ Diese wird als MP3 gespeichert, von sox in das Asterisk-kompatible WAV-Format konvertiert und dann über die Telefonleitung abgespielt. So hört der Anrufer eine Rückmeldung, bevor er durchgestellt wird.

Am Ende übergibt das Skript das Ergebnis als Asterisk-Variable namens RECOGNIZED zurück an die Telefonanlage. Enthält diese Variable eine Nummer, stellt Asterisk die Verbindung her. Enthält sie „nichts“, weiß Asterisk, dass die Erkennung gescheitert ist, und kann den Anrufer um eine Wiederholung bitten. Bei einem technischen Fehler enthält sie „error“, was eine eigene Fehlerbehandlung auslösen kann.

Temporäre Audiodateien werden am Ende automatisch gelöscht, unabhängig davon, ob die Erkennung erfolgreich war oder nicht.

Das Python-Skript: Der Ort ist /usr/share/asterisk/agi-bin/whisper_vermittlung.py

#!/usr/bin/env python3
# /usr/share/asterisk/agi-bin/whisper_vermittlung.py
# Museumsprojekt Handvermittlung
# Erkennt Ziffernfolgen und Namen, gibt Nummer zurück

import sys
import os
import json
import subprocess
import whisper
from gtts import gTTS

MODELL_PFAD = "/opt/whisper-models/base.pt"
NAMEN_PFAD  = "/etc/asterisk/vermittlung.json"

# ── AGI-Hilfsfunktionen ──────────────────────────────────────────────────────

def agi_send(cmd):
    sys.stdout.write(cmd + "\n")
    sys.stdout.flush()

def agi_read():
    return sys.stdin.readline().strip()

def agi_log(msg):
    agi_send(f'VERBOSE "{msg}" 1')
    agi_read()

def agi_set_var(name, value):
    agi_send(f"SET VARIABLE {name} {value}")
    agi_read()

def agi_header_lesen():
    while True:
        line = sys.stdin.readline().strip()
        if not line:
            break

# ── Namensliste laden ────────────────────────────────────────────────────────

def namen_laden():
    try:
        with open(NAMEN_PFAD, 'r', encoding='utf-8') as f:
            daten = json.load(f)
            return daten.get("nebenstellen", {})
    except Exception as e:
        return {}

# ── Fuzzy-Matching ───────────────────────────────────────────────────────────

def levenshtein(a, b):
    if len(a) < len(b):
        return levenshtein(b, a)
    if len(b) == 0:
        return len(a)
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a):
        curr = [i + 1]
        for j, cb in enumerate(b):
            curr.append(min(
                prev[j + 1] + 1,
                curr[j] + 1,
                prev[j] + (ca != cb)
            ))
        prev = curr
    return prev[-1]

def namen_suchen(text, nebenstellen, schwelle=2):
    woerter = text.lower().split()
    woerter = [w.strip(".,!?-") for w in woerter]

    # Einzel- und Zwei-Wort-Kombinationen bilden
    kandidaten = []
    for i, w in enumerate(woerter):
        kandidaten.append(w)                           # einzelnes Wort
        if i + 1 < len(woerter):
            kandidaten.append(w + " " + woerter[i+1]) # Zwei-Wort-Kombination

    for kandidat in kandidaten:
        # Exakter Treffer
        if kandidat in nebenstellen:
            return kandidat, nebenstellen[kandidat]
        # Fuzzy-Treffer
        bester     = None
        beste_dist = schwelle + 1
        for name in nebenstellen:
            dist = levenshtein(kandidat, name)
            if dist < beste_dist:
                beste_dist = dist
                bester     = name
        if bester and beste_dist <= schwelle:
            return bester, nebenstellen[bester]

    return None, None

# ── Ziffern extrahieren ──────────────────────────────────────────────────────

def ziffern_extrahieren(text):
    ZIFFER_MAP = {
        "null": "0", "0": "0",
        "eins": "1", "1": "1", "ein": "1", "eine": "1",
        "zwei": "2", "2": "2", "zwo": "2",
        "drei": "3", "3": "3",
        "vier": "4", "4": "4",
        "fünf": "5", "5": "5",
        "sechs": "6", "6": "6",
        "sieben": "7", "7": "7",
        "acht": "8", "8": "8",
        "neun": "9", "9": "9",
    }
    erkannte = []
    for char in ".,-?!":
        text = text.replace(char, " ")
    for wort in text.split():
        w = wort.strip()
        if w in ZIFFER_MAP:
            erkannte.append(ZIFFER_MAP[w])
        else:
            if w.isdigit():
                erkannte.extend(list(w))
    return "".join(erkannte)

# ── gTTS Ansage ──────────────────────────────────────────────────────────────

def ansage_sprechen(text):
    """Erzeugt eine kurze Sprachansage via gTTS und spielt sie über Asterisk ab."""
    mp3_pfad = "/tmp/ansage.mp3"
    wav_pfad = "/tmp/ansage.wav"
    try:
        # gTTS erzeugt MP3
        tts = gTTS(text=text, lang="de")
        tts.save(mp3_pfad)

        # sox konvertiert MP3 zu WAV für Asterisk
        subprocess.run([
            "sox", mp3_pfad,
            "-r", "8000",
            "-c", "1",
            "-e", "signed-integer",
            "-b", "16",
            wav_pfad
        ], check=True)

        # Asterisk abspielen
        agi_send("STREAM FILE /tmp/ansage \"\"")
        agi_read()

    except Exception as e:
        agi_log(f"gTTS Fehler: {e}")
    finally:
        try:
            os.remove(mp3_pfad)
            os.remove(wav_pfad)
        except:
            pass

# ── Hauptprogramm ────────────────────────────────────────────────────────────

def main():
    agi_header_lesen()
    agi_log("whisper_vermittlung.py gestartet")

    if len(sys.argv) < 2:
        agi_log("Fehler: kein WAV-Pfad übergeben")
        agi_set_var("RECOGNIZED", "error")
        return

    wav_pfad = sys.argv[1]
    temp_wav = "/tmp/whisper_clean.wav"

    if not os.path.exists(wav_pfad):
        if os.path.exists(wav_pfad + ".wav"):
            wav_pfad = wav_pfad + ".wav"
        else:
            agi_log(f"Datei nicht gefunden: {wav_pfad}")
            agi_set_var("RECOGNIZED", "error")
            return

    # Namensliste laden
    nebenstellen = namen_laden()
    if not nebenstellen:
        agi_log("Warnung: Namensliste leer oder nicht gefunden")

    # Prompt aus Namen und Nummern bauen
    nummern_prompt = " ".join(nebenstellen.values())
    namen_prompt   = " ".join(nebenstellen.keys())

    try:
        # 1. AUDIO-FILTERING: Optimiert für Telefon-Frequenzen
        subprocess.run([
            "sox", wav_pfad, "-r", "16000", "-c", "1", temp_wav,
            "highpass", "300", "lowpass", "3200", "norm", "-6"
        ], check=True)

        # 2. MODELL LADEN
        model = whisper.load_model(MODELL_PFAD)

        # 3. TRANSKRIPTION
        result = model.transcribe(
            temp_wav,
            language="de",
            fp16=False,
            temperature=0,
            beam_size=10,
            best_of=10,
            condition_on_previous_text=False,
            initial_prompt="Nebenstellen: " + "1004 1088 1006 1033 1831 " + \
                           "1040 1073 1066 1822 1832 1834 1837 1839 1844 1847 222 " + \
                           "333 10883 1050 1049 1059 1023 " + "junghard volker ralf " + \
                           "roland silvia inge sebastian michael christian jürgen " + \
                           "benjamin wolfgang rainer uwe thomas axel musik zeitansage " + \
                           "deutschlandfunk wetter bitcoin kurs konfererenzraum " + \
                           "lange janson meine nummer heise online datum kontrafunk " + \
                           "datum deutsch datum englisch " + \
                           "postleitzahlen volker smartphone raspi mobil temperatur laufzeit echotest ",
            compression_ratio_threshold=2.4,
            logprob_threshold=-1.0,
            no_speech_threshold=0.6
        )

        rohtext = result["text"].strip().lower()
        agi_log(f"ROHTEXT: [{rohtext}]")

        # 4. Zuerst Ziffernfolge suchen
        ziffern = ziffern_extrahieren(rohtext)
        if ziffern and len(ziffern) >= 3:
            agi_log(f"Ziffern erkannt: {ziffern}")
            agi_set_var("RECOGNIZED", ziffern)
            agi_set_var("RECOGNIZED_TYPE", "nummer")
            return

        # 5. Dann Name suchen
        name, nummer = namen_suchen(rohtext, nebenstellen)
        if name:
            agi_log(f"Name erkannt: {name} -> {nummer}")
            # gTTS Bestätigung abspielen
            ansage_sprechen(f"Verbindungsaufbau mit {name.capitalize()}.")
            agi_set_var("RECOGNIZED", nummer)
            agi_set_var("RECOGNIZED_NAME", name)
            agi_set_var("RECOGNIZED_TYPE", "name")
            return

        # 6. Nichts gefunden
        agi_log("Nichts erkannt")
        agi_set_var("RECOGNIZED", "nichts")
        agi_set_var("RECOGNIZED_TYPE", "nichts")

    except Exception as e:
        agi_log(f"Fehler: {e}")
        agi_set_var("RECOGNIZED", "error")

    finally:
        try:
            os.remove(temp_wav)
            os.remove(wav_pfad)
        except:
            pass

if __name__ == "__main__":
    main()

Das json-File: Die vermittlung.json gehört nach /etc/asterisk und kann jederzeit erweitert werden. Die Daten sind hier fiktiv.

{
    "nebenstellen": {
        "uwe":                    "2037",
        "ralf":                   "3339",
        "thomas":                 "1144",
        "trump donald":           "4450",
        "zeitansage deutschland": "119",
        "deutschlandfunk":        "401",
        "wetter deutschland":     "353",
        "bitcoin kurs":           "356",
        "konferenzraum":          "8001",
        "wetter postleitzahlen":  "354",
        "echotest":               "223",
        "raspi laufzeit":         "350",
        "raspi temperatur":       "349",
        "meine nummer":           "55555",
        "heise online":           "355",
        "kontrafunk":             "420",
        "datum englisch":         "2424",
        "datum deutsch":          "88403002",
        "musik":                  "349"
    }
}

Auszug aus der extension.conf: Ort: etc/asterisk. Mit der 500 wird die  Vermittlung aufgerufen. Das besondere hier ist, dass die vom Pythonskript aufgebauten Verbindungen per IAX2 zu einem anderen Asterisk-Server geleitet werden.

; IAX2-Verbindung zum Raspi11 mit der Vorwahl 8835
exten => _8835X.,1,NoOp(IAX2 - Esprimo ruft Raspi an mit Vorwahl 8835)
 same => n,Dial(IAX2/outgoing-raspi111/${EXTEN:4})

exten => 500,1,Answer(2)
 same => n,Set(CHANNEL(language)=de)
 same => n,Playback(de/vermittlung/vermittlung1)
 same => n,Set(AGC(RX)=on)
 same => n,Set(VOLUME(RX)=2.5)
 same => n,Record(/tmp/sprache.wav,3,20,s)
 same => n,Playback(beep)
 same => n,AGI(whisper_vermittlung.py,/tmp/sprache.wav)
 same => n,GotoIf($["${RECOGNIZED}" = "nichts"]?no_number,1)
 same => n,GotoIf($["${RECOGNIZED}" = "error"]?no_number,1)
 same => n,NoOp(Typ: ${RECOGNIZED_TYPE} Name: ${RECOGNIZED_NAME} Nummer: ${RECOGNIZED})
 same => n,Playback(de/vermittlung/vermittlung3)
 same => n,Wait(1)
 same => n,SayDigits(${RECOGNIZED})
 same => n,Wait(1)
 same => n,Playback(de/vermittlung/ich_wiederhole)
 same => n,Wait(1)
 same => n,SayDigits(${RECOGNIZED})
 same => n,Wait(1)
 same => n,Dial(IAX2/outgoing-raspi111/${RECOGNIZED})
 same => n,Hangup()

; exten => no_number,1,Playback(beep)
; same => n,Hangup()

Sprachdateteien: Sie sind bei mir im Ordner /usr/share/asterisk/sounds/de/vermittlung untergebracht.

Obiges Video: Was in Echtzeit in der Asterisk-Konsole beim Aufruf der  Vermittlung passiert.

Wie sich dieses Projekt über zwei Tage entwickelt hatte: Es begann mit einer simplen Idee: Eine historische Telefonvermittlung soll Anrufe per Spracherkennung weiterleiten. Klingt machbar, dachte ich. Zwei Tage später weiß ich, dass der Teufel im Detail steckt.

Der erste Kandidat war Vosk, eine lokale Open-Source-Spracherkennung. Die Idee war elegant: Audio direkt aus Asterisk per EAGI-Schnittstelle streamen, kein Zwischenspeichern, keine Verzögerung. Die Realität war ernüchternd. Vosk verweigerte konsequent die Arbeit mit der kryptischen Meldung „Failed to process waveform“. Stundenlange Fehlersuche, Hex-Dumps des Audiostreams, verschiedene Konvertierungsversuche – nichts half. EAGI und Vosk wollten einfach nicht miteinander.

Also Plan B: WAV-Datei aufnehmen, sox zur Aufbereitung, dann Vosk. Das funktionierte – fast. Das kleine deutsche Modell kannte das Wort „fünf“ schlicht nicht, zumindest nicht mit aktivierter Grammar. Ohne Grammar wurde es erkannt, mit Grammar nicht. Ein Bug, eine Eigenart, wer weiß. Dazu kam dass Vosk bei jedem Anruf das Modell neu laden musste – mehrere Sekunden Wartezeit, für eine Vermittlung inakzeptabel. Vosk flog raus.

Whisper von OpenAI war die Rettung. Ebenfalls lokal, ebenfalls kostenlos, aber deutlich intelligenter. Allerdings auch hier kein einfacher Weg. Das tiny-Modell halluzinierte fröhlich „1, 2, 3, 4“ wenn man eine vierstellige Nummer sprach. Das base-Modell war besser, aber der entscheidende Durchbruch kam erst mit dem initial_prompt – einer Liste aller bekannten Nummern und Namen die Whisper als Kontext bekommt. Ohne diesen Prompt rät Whisper wild drauflos.

Dann das Feintuning mit verschiedenen Telefonen. Jedes Gerät klingt anders, jedes hat andere Codec-Einstellungen, manche sind leiser, manche haben mehr Rauschen. AGC einschalten, Lautstärke verstärken, sox-Filter anpassen. Parameter wie beam_size, best_of und no_speech_threshold wurden iterativ optimiert bis die Erkennungsrate über 95% lag.

Damit es sich auch wie eine echte Vermittlung anfühlt, mussten noch deutsche Sprachprompts her. Asterisk bringt von Haus aus englische Ansagen mit, die deutschen Pendants mussten erst beschafft werden. Zusätzlich wurden drei eigene Ansagen benötigt – eine Begrüßung, eine Wartemeldung und eine Verbindungsansage. Die wurden per Text-to-Speech online generiert, im richtigen Format für Asterisk aufbereitet und eingebunden. Wenn schon, dann richtig.

Ursprünglich sollte das alles auf einem Raspberry Pi laufen. Der Pi ist bereits als IAX2-Gateway im Einsatz, wäre also naheliegend gewesen. Aber Whisper base auf einem Pi ist schlicht zu langsam – die Wartezeit wäre für Museumsbesucher unzumutbar. Also wurde die Spracherkennung auf einen Fujitsu Esprimo Q520 ausgelagert, einen kleinen aber feinen Mini-PC mit 8 GB RAM. Auch der geht während der Erkennung kurz in die Knie – alle vier CPU-Kerne springen auf 60 bis 90 Prozent. Eine Nvidia-Grafikkarte würde die Verarbeitungszeit von fünf Sekunden auf unter eine Sekunde drücken.

Als letztes kam noch eine kleine aber feine Erweiterung hinzu: Wenn ein Name erkannt wird, bestätigt das System die Erkennung mit einer gesprochenen Ansage – „Verbindungsaufbau mit Michael“ zum Beispiel. Umgesetzt mit gTTS, dem Google Text-to-Speech Modul für Python. Wenige Zeilen Code, gTTS erzeugt eine MP3, sox konvertiert sie ins richtige Format für Asterisk, Asterisk spielt sie ab, die Datei wird gelöscht. Das war dank Claude in weniger als einer Stunde erledigt – inklusive dem kleinen Stolperstein dass sox das Paket libsox-fmt-mp3 braucht um MP3-Dateien lesen zu können, und dass Asterisk kein a-law WAV mag sondern PCM 16-bit. Auch das gehört dazu: Selbst kleine Erweiterungen haben ihre Tücken.

Den praktischen Nutzen im Alltag darf man dabei nicht vergessen: Wer kennt schon alle Durchwahlen auswendig? Namen sprechen und verbunden werden ist deutlich komfortabler als im Telefonbuch blättern. Für den produktiven Einsatz braucht man allerdings schnellere Hardware – die fünf Sekunden Wartezeit erinnern im Moment eher an die Geduld die man früher bei einer echten Handvermittlung aufbringen musste. Was für das Museumsprojekt durchaus seinen Charme hat, im Büroalltag aber schnell zur Geduldsprobe wird.

Warum so ein Projekt für Telefonie mit kostenlosen Spracherkennungsprogrammen so schwierig ist: G.711 (alaw/ulaw), der klassische Telefonstandard, ist technisch keine Kompression im eigentlichen Sinne, sondern logarithmische PCM-Quantisierung mit 8 kHz Samplingrate – was nach Nyquist maximal 4 kHz Frequenzübertragung bedeutet. Das reicht für Sprache gerade so aus, ist aber weit entfernt von dem, was Spracherkennungsmodelle erwarten. G.722 dreht das teilweise um: Es ist zwar komprimiert (ADPCM), überträgt aber Frequenzen bis 8 kHz und klingt als HD-Voice deutlich natürlicher. Mobilfunk ist nochmal eine andere Baustelle – Codecs wie AMR oder AMR-WB sind stark verlustbehaftet und auf minimale Funkbandbreite optimiert. Bei schlechtem Empfang reduziert der Codec die Bitrate dynamisch, was Artefakte und den typischen metallischen Klang erzeugt. Dazu kommen Paketlöcher bei LTE und ständige Handover zwischen Funkmasten.

Für Spracherkennung ist das alles problematisch, weil die Modelle nicht nur mit begrenzter Bandbreite klarkommen müssen, sondern auch mit codec-spezifischen Artefakten, die im Trainingsdatensatz schlicht nicht vorhanden sind. Android Voice Typing würde damit sehr fehlerhafte Ergebnisse liefern, Whisper hat zusätzlich das Architekturproblem – für Batch-Verarbeitung gebaut, nicht für Echtzeit-Streams, halluziniert bei Stille und erwartet 16 kHz. Vosk war der Versuch einer schlanken Streaming-Lösung, ist aber in der Praxis zu ungenau – schlechte Ziffernerkennung und generell unbefriedigende Ergebnisse.

Die großen Callcenter haben das Problem gelöst, aber mit erheblichem Aufwand: spezialisierte kommerzielle Systeme von Nuance, Google CCAI oder Amazon Transcribe, die explizit auf echten Telefongesprächen trainiert wurden und jahrelange Optimierung auf die verschiedenen Codec-Artefakte, 4 kHz Bandbreite und Echtzeit-Streaming hinter sich haben.

Prinzipiell könnte man als Privatperson ebenfalls auf solche Cloud-APIs zugreifen – Google Speech-to-Text, Amazon Transcribe oder Azure Speech bieten das an, und die Qualität wäre tatsächlich gut. Das Problem ist der Preis: Bei einem Asterisk-Hobbyprojekt summieren sich die Kosten pro Minute schnell auf einen Betrag, der nicht mehr verhältnismäßig ist. Was für ein Callcenter mit klarem Geschäftsmodell selbstverständlich ist, wird für den privaten Bastler schnell zum Kostenproblem.

Für selbst gehostete Lösungen mit Asterisk bleibt die Lücke damit bestehen: Gute Offline-Spracherkennung für Telefonie-Audio gibt es im Open-Source-Bereich schlicht nicht zufriedenstellend, und die guten Lösungen sind entweder zu teuer oder nur in der Cloud verfügbar.

Diskussion: https://www.wumpus-gollum-forum.de/forum/thread.php?board=73&thread=111