Skip to content
anecho.ai

Der Resampler, der 92% eines Mikrofons gefressen hat

soxr.ResampleStream ist ein Burst-Emitter. Gefüttert mit dem 128-Sample-AudioWorklet-Quantum des Browsers gibt er bei 92% der Aufrufe ein leeres Array zurück und reicht dann 95 ms auf einmal heraus. Wir haben diese Leere als „noch am Primen" gelesen und den Block übersprungen — und damit auch den unangetasteten Mikrofonpuffer daneben übersprungen, sodass je 8% des Anrufers das Modell erreichten. Unsere eigene Telemetrie meldete es tagelang als 0.08. Das ist ein Control-Flow-Bug, nicht die von uns zurückgezogene Behauptung einer Resample-Steuer (resample tax) — und hier ist die vierzeilige Sonde, die ihn findet.

Anecho Engineering16. Aug. 20268 Min. Lesezeit
dspresamplingvoice-agentsdebuggingstreaming

Ein Agent, den wir bauten, verband sich, zeigte eine Live-Session, streamte den ganzen Anruf über Audio und antwortete kein einziges Mal. Der Socket blieb offen. Audio verließ weiter den Browser. Nichts kam zurück.

Die Ursache waren vier Tokens Python:

x8 = down(xin)
if not x8.size:
    continue

down ist ein zustandsbehafteter soxr.ResampleStream. Der Guard liest sich als „das Polyphasenfilter ist noch nicht geprimt, überspring diesen Block und komm beim nächsten Mal wieder." Das ist eine vernünftige Annahme über einen Resampler, und über diesen hier ist sie falsch. soxr.ResampleStream primt nicht einmal und emittiert dann stetig. Er puffert intern und emittiert in Bursts, die ganze Session lang — und wer ihn mit kleinen Blöcken füttert, bekommt bei der überwältigenden Mehrheit der Aufrufe ein leeres Array zurück.

Vor der Diagnose eines, das dieser Beitrag nicht ist. Wir haben zuvor eine Behauptung zurückgezogen, wonach Resampling an sich einen festen Anteil des Anrufer-Audios kostet; wir haben die Kontrolle gebaut, unsere Messung hat sie nicht gestützt, und wir führen sie hier nicht stillschweigend wieder ein. Nichts im Folgenden ist eine Eigenschaft von Resampling. Es ist eine Eigenschaft des Emissionsplans einer Bibliothek, der auf ein continue-Statement trifft — und das Audio wurde von unserem Control Flow weggeworfen, nicht von irgendeinem Filter.

Die Messung

Vier Zeilen, reproduzierbar auf jeder Maschine mit installiertem soxr. Füttern Sie einen Stream-Resampler mit dem Render-Quantum des Browsers selbst und zählen Sie, wie oft er Ihnen überhaupt etwas zurückgibt.

import numpy as np, soxr

rs = soxr.ResampleStream(16000, 8000, 1, dtype="float32", quality="VHQ")
sizes = [rs.resample_chunk(np.zeros(128, np.float32)).size for _ in range(400)]
print(sum(s > 0 for s in sizes), "non-empty of", len(sizes))

Auf diesem Host — soxr 1.1.0, macOS auf Apple Silicon — druckt das 33 non-empty of 400. 91.8% der Aufrufe geben also gar nichts zurück, und die 33, die etwas zurückgeben, liefern jeweils fixe 758 Samples: 94.8 ms Audio mit 8 kHz, in einem Klumpen, wieder und wieder, solange die Session dauert.

Die Blockgröße ist eine Eigenschaft des Resamplers, nicht Ihres Inputs. Variiert man den gefütterten Chunk, ändert sich nur die Häufigkeit der Bursts:

Input-ChunkChunk-Dauer bei 16 kHzAufrufe ohne RückgabeBurst-Größe raus
128 Samples (AudioWorklet-Quantum)8 ms91.8%758 Samples (94.8 ms)
256 Samples16 ms83.2%758 Samples
512 Samples32 ms66.5%758 Samples
1024 Samples64 ms32.5%758 Samples
2048 Samples128 ms0%758 oder 1516 Samples

400 Aufrufe pro Zeile, je ein frischer ResampleStream, 16 kHz auf 8 kHz bei VHQ.

Zwei Dinge fallen heraus. Erstens: Es gibt eine Schwelle, und sie ist exakt der Input, der einen Output-Burst füllt: Bei 2:1-Dezimation brauchen 758 Output-Samples 1516 Input-Samples, und wer 1516 pro Aufruf füttert, drückt die leeren Rückgaben auf den einen Priming-Aufruf herunter (0.5% von 200). Bei 2048 kommt sogar der zurück. Darum trifft das niemanden mit serverseitigen 20-ms-Frames von einer SIP-Strecke, und jeden mit einem Browser-Worklet. Zweitens: Die Burst-Größe folgt dem Filter, nicht der Verrohrung: bei HQ sind es 830 Samples, bei LQ 470, bei 48 kHz → 16 kHz sind es 1100. Diese Zahlen gelten für diese Version auf diesem Host, und Sie sollten Ihre eigenen messen, statt unseren zu trauen; was übertragbar ist, ist die Gestalt.

Nichts davon ist ein Defekt in soxr. Ein Polyphasen-Resampler mit langem Anti-Alias-Filter muss Input akkumulieren, bevor er korrekt gefilterten Output produzieren kann, und in ganzen internen Blöcken zu emittieren ist der effiziente Weg dorthin. Die Bibliothek verhält sich exakt so, wie ein Stream-Resampler sich verhalten sollte. Der Bug steckt ausschließlich in dem, was wir aus einer leeren Rückgabe gefolgert haben.

Warum ein leeres Array an zwei Stellen zwei verschiedene Dinge bedeutete

Wir hatten dieselbe Form von Guard in zwei Services, und in einem war sie harmlos und im anderen gravierend. Der Unterschied ist es wert, präzise formuliert zu werden, denn „wir hatten diesen Bug zweimal" ist nicht der interessante Teil.

Auf dem Enhancement-Endpoint lautet der Code:

x8 = down(pcm)
if not x8.size:
    continue          # harmless here
y8 = model.process(x8)
await ws.send_bytes(up(y8))

Alles Nachgelagerte ist abgeleitet aus x8. Ein leeres x8 bedeutet wirklich, dass es noch nichts zu verarbeiten gibt, nichts wurde verworfen, und das Audio sitzt im Puffer des Resamplers und wartet auf seinen Burst. Das continue ist korrekt.

Auf dem Agent-Endpoint saßen dieselben drei Zeilen am Anfang einer Funktion, die zwei parallele Aufgaben hatte: das Audio des Anrufers bei 8 kHz durch das Modell schicken und separat eine unangetastete 16-kHz-Kopie für den Kontrollarm eines A/B-Tests vorhalten. Das continue kehrte zurück, bevor die zweite Aufgabe lief. Mit ausgeschaltetem Filter — der Arm ganz ohne Modell, der Arm, der die ehrliche Baseline sein soll — wurde der rohe Mikrofonpuffer also zusammen mit dem leeren x8 verworfen, mit dem er nichts zu tun hatte.

Das Ergebnis, Ende zu Ende gegen den echten Endpoint gemessen: 3.7 Sekunden Uplink über einen Anruf von 46 Sekunden. Etwa 8% des Anrufers. Null Turns, je.

Die Regel, die wir danach in den Code geschrieben haben, ist enger als „überspringe nie einen Block", denn manchmal ist Überspringen richtig:

Nichts darf einen Arm daran knüpfen, dass der Resampler des anderen Arms Output produziert hat.

Es stand die ganze Zeit in der Telemetrie

Das ist der Teil, der unangenehm sein sollte, und er ist der Grund, warum dieser Beitrag existiert statt einer Commit-Message.

Der Service emittierte bereits etwa alle sechzehn Blöcke eine stats-Nachricht, und diese Nachricht trug bereits ein Feld namens uplink_realtime — Uplink-Sekunden geteilt durch Wanduhr-Sekunden seit Live-Gang der Session. Bei einem gesunden Anruf sitzt es nahe 1.0. Bei den kaputten Anrufen stand es auf 0.08, in einem JSON-Blob, in einer Browser-Konsole, tagelang.

Niemand hat es gelesen, weil das nutzersichtbare Symptom („der Agent antwortet nicht") auf das Modell, den Prompt, die Credentials und die API zeigte, bevor es auf Arithmetik zeigte. Eine Zahl, die sagt 8% des Audios, das Sie zu senden glauben, wird gesendet, lag einen Scroll von der debuggenden Person entfernt — und verlor jedes Mal gegen eine interessantere Hypothese.

Zwei Änderungen kamen daraus hervor, und sie sind billig genug, dass wir beide jedem empfehlen würden, der einen Live-Audiopfad betreibt:

  • Veröffentlichen Sie ein Verhältnis, keinen Zähler. Gesendete Bytes sind unfalsifizierbar — sie steigen auch bei einem kaputten Anruf. Gelieferte Audiosekunden pro Wanduhr-Sekunde haben einen bekannten korrekten Wert, ein falscher Wert ist also ohne Kontext lesbar. Das Verhältnis wird jetzt in den session- und stats-Nachrichten mitgeliefert, mit ausgewiesener Schwelle: unter rund 0.9 heißt, Audio geht verloren oder kommt zu spät.
  • Sagen Sie im Payload, was die Zahl impliziert. Die stats-Nachricht trägt jetzt neben der Zahl den Satz „Geminis VAD kann keinen Turn auf Audio beenden, das sie nie empfangen hat", denn die Zahl allein stellte für niemanden, der den Bug nicht schon gefunden hatte, die Verbindung zum Symptom her.

Der Bug zweiter Ordnung: Die Arme waren nicht vergleichbar

Das Beheben des Verwerfens legte ein subtileres Problem im selben Code frei, und zwar jenes, das das Experiment, für das der Endpoint existiert, stillschweigend entwertet hätte.

Dieser Endpoint ist ein einzelner Schalter: gleicher Sprecher, gleiches Hintergrundrauschen, Filter an gegen Filter aus, mitten im Gespräch. Damit dieser Vergleich irgendetwas bedeutet, müssen sich die zwei Arme im Audio unterscheiden und in nichts sonst. Taten sie nicht. Der gefilterte Arm erbte die ~95-ms-Burst-Kadenz des Resamplers; der ungefilterte Arm reichte das rohe 8-ms-Quantum des Browsers direkt durch. Auf demselben Anruf gemessen: 10.6 Frames pro Sekunde gegen 125.0 — ein 11.8x-Unterschied darin, wie ein und dasselbe Gespräch paketiert wurde.

Jeder Verhaltensunterschied, den ein Zuhörer dem Filter zuschrieb, war mit der Frame-Kadenz konfundiert, und 125 Frames pro Sekunde sind weit mehr, als die API erwartet. Beide Arme werden jetzt vor dem Senden auf einen festen 100-ms-Frame koalesziert, die Kadenz ist also per Konstruktion identisch, und die Rate pro Arm wird berichtet statt angenommen — der Health-Payload trägt uplink_frame_hz für beide Arme, und die sollen dasselbe anzeigen.

Es gibt eine Verriegelung, die es zu benennen lohnt, weil sie von der Art ist, die einen Fix in einen anderen Bug verwandelt. Ein halb gebauter Frame ist Audio, das nicht gesendet wurde. Das Koaleszieren ist nur sicher, weil etwas anderes garantiert, dass der Puffer immer einen Produzenten hat — sonst sitzt das Ende des letzten Satzes des Anrufers in einem halbvollen Frame, genau in dem Moment, in dem der Server es braucht, um zu erkennen, dass der Anrufer aufgehört hat. Diese Garantie ist das Keep-alive aus dem Gemini-Live-Beitrag, und die zwei Änderungen sind nicht trennbar.

Warum überhaupt ein zustandsbehafteter Resampler

Der naheliegende Ausweg ist, das zustandslose soxr.resample() einmal pro Chunk aufzurufen und nie eine leere Rückgabe zu sehen. Tun Sie das nicht.

Ein zustandsloser Aufruf startet das Polyphasenfilter an jeder Chunk-Grenze neu, was bei einem 32-ms-Chunk rund dreißigmal pro Sekunde eine Diskontinuität ins Signal stanzt. Das ist als Brummen in der Chunk-Rate hörbar, und es ist die Art von Artefakt, die ein Zuhörer völlig zu Recht Ihrem Modell anlasten wird. Der zustandsbehaftete Resampler trägt seinen Filterzustand genau deshalb über Aufrufe hinweg, damit das nicht passiert, und die stoßweise Emission ist der sichtbare Preis dieser Korrektheit.

Dieser Preis ist ehrlich und begrenzt: Er ist eine feste Verzögerung am Session-Beginn, kein Jitter. Wir messen ihn, statt ihn anzunehmen — roundtrip_delay_ms() fährt eine Korrelation zwischen Input und Output durch ein Resampler-Paar für runter/rauf und berichtet den Versatz, und das Ergebnis wird in /health als Transportverzögerung veröffentlicht, getrennt gehalten von den 16 ms eigener algorithmischer Latenz des Modells. Zwei verschiedene Verzögerungen mit zwei verschiedenen Eigentümern, berichtet als zwei Zahlen.

Die Checkliste

Wenn Sie einen Audiopfad vom Browser zum Server mit einer Abtastratenwandlung darin betreiben:

  1. Zählen Sie, was Sie tatsächlich senden. Gelieferte Audiosekunden über Wanduhr-Sekunden, pro Session, sichtbar für die Person, die debuggt. Keine Bytes.
  2. Lassen Sie ein continue nie mehr abdecken als das, was es bewacht. Erledigt ein Block zwei unabhängige Aufgaben, ist eine frühe Rückkehr aus der einen ein stilles Versagen der anderen. Trennen Sie sie.
  3. Sondieren Sie den Emissionsplan Ihres Resamplers bei Ihrer realen Blockgröße, mit den vier Zeilen oben. Wer ihn mit 128-Sample-Quanten füttert, bekommt meistens nichts zurück; wer ihn mit 20-ms-Server-Frames füttert, sieht das nie.
  4. Pro Aufruf den Input für einen vollen Output-Block zu füttern, lässt die leere Rückgabe fast verschwinden — aber bauen Sie nicht auf diese Zahl, denn sie hängt von Version und Verhältnis ab, und der Priming-Aufruf ist weiterhin leer. Behandeln Sie stattdessen den leeren Fall korrekt.
  5. Vergleichen Sie keine zwei Arme mit unterschiedlicher Paketierung. Koaleszieren Sie vor dem Transport auf eine Frame-Größe, und berichten Sie die Frame-Rate pro Arm, damit eine Abweichung sichtbar ist statt erschlossen.

Und die allgemeine Lehre, die als Einzige nichts mit Audio zu tun hat: Die Instrumentierung, die das gefangen hätte, existierte bereits und hatte bereits den richtigen Wert. Die Metrik zu bauen ist die einfache Hälfte. Die Metrik sagen zu lassen, was sie impliziert — neben dem Symptom, auf das die Person tatsächlich schaut —, ist die Hälfte, die wir falsch gemacht haben.

Weiter
  • 24. Okt. 2026

    ai-coustics-Alternativen für Voice-Agents und Callcenter (2026)

    Warum Teams über ai-coustics Quail Voice Focus hinausschauen, und eine belegte Shortlist mit Stand Oktober 2026: Krisp VIVA, AssemblyAI Voice Focus, LiveKit Cloud voice isolation, NVIDIA Speaker Focus, Anecho und Open-Source-Baselines. Wir haben ai-coustics bei Live-Gesprächen gegen unser eigenes Modell gebenchmarkt: Gleichstand mit faster-whisper, ein Vorsprung von v8_2 mit Gemini Live, weniger falsche Barge-ins bei ai-coustics, und die Bedingung entscheidet, wer vorn liegt.

  • 22. Okt. 2026

    Krisp-Alternativen für Voice-Agents und Callcenter (2026)

    Warum Teams über Krisp VIVA hinausschauen, und eine belegte Shortlist mit Stand Oktober 2026: ai-coustics Quail Voice Focus, AssemblyAI Voice Focus, LiveKit Cloud voice isolation, NVIDIA Speaker Focus, Anecho und Open-Source-Baselines, jeweils mit Einsatzort, öffentlichem Preis und der Angabe, ob wir es gemessen haben. Krisp haben wir nicht gemessen.

  • 20. Okt. 2026

    Speaker-Isolation-SDKs im Vergleich (2026): Krisp, ai-coustics, AssemblyAI, LiveKit und Anecho

    Ein belegter Vergleich der Sprecherisolation für Voice-Agents und Callcenter, Stand der Angaben Oktober 2026: wo jedes Produkt läuft, deklarierte Latenz, Abtastraten, Integration, öffentlicher Preis und Lizenzierung. Krisp VIVA und Anecho deklarieren 15 ms auf der CPU; ai-coustics nennt unter 30 ms; AssemblyAI und LiveKit verkaufen Isolation innerhalb eines Cloud-Dienstes. Qualitätszahlen nur aus unserem eigenen Benchmark.