Skip to content
anecho.ai

マイクの92%を食べたリサンプラー

soxr.ResampleStreamはバースト出力器です。ブラウザの128サンプルのAudioWorklet量子を与えると、呼び出しの92%で空の配列を返し、その後95 msをまとめて渡してきます。私たちはその空を「まだプライミング中」と読んでブロックをスキップしました——それは隣に座っていた手つかずのマイクバッファまでスキップし、結果として通話者の8%しかモデルに届きませんでした。私たち自身のテレメトリは何日もそれを0.08として報告していました。これは制御フローのバグであって、私たちが撤回したリサンプル税の主張ではありません。そして、これを見つける4行のプローブを示します。

Anecho Engineering2026年8月16日11 分で読めます
dspresamplingvoice-agentsdebuggingstreaming

私たちが構築していたエージェントは、接続し、ライブセッションを表示し、通話の間ずっと音声をストリームし、そして一度も答えませんでした。ソケットは開いたまま。音声はブラウザを出続けている。何も返ってこない。

原因は、Pythonの4トークンでした。

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

downはステートフルなsoxr.ResampleStreamです。このガードは「ポリフェーズフィルタがまだプライミングされていないので、このブロックをスキップして次回また来る」と読めます。リサンプラーについて信じるには妥当なことであり、そしてこのリサンプラーについては間違いです。soxr.ResampleStreamは、一度プライミングしてから定常的に出力するのではありません。セッションの間ずっと、内部にバッファしてバーストで出力します。小さなブロックを与えれば、呼び出しの圧倒的多数で空の配列を返します。

診断の前に、この記事がそうではないものをひとつ。私たちは以前、リサンプリングそれ自体が通話者音声の一定割合を奪うという主張を撤回しました。対照を構築したところ、私たちの測定はそれを支持しなかったからです。そして、ここでそれを静かに再導入するつもりはありません。以下のどれひとつとして、リサンプリングの性質ではありません。これは、あるライブラリの出力スケジュールがひとつのcontinue文に出会ったときの性質であり、音声を捨てたのは私たちの制御フローであって、どんなフィルタでもありません。

測定

4行です。soxrが入っているどのマシンでも再現できます。ストリームリサンプラーにブラウザ自身のレンダー量子を与え、何かが返ってくる頻度を数えてください。

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))

このホスト——soxr 1.1.0、Apple silicon上のmacOS——では、これは400回中33回が非空と印字します。つまり呼び出しの91.8%は何ひとつ返さず、返ってくる33回はそれぞれ固定の758サンプル——8 kHz音声の94.8 ms——を、ひと塊で、セッションが続く限り、繰り返し返します。

このブロックサイズは、あなたの入力ではなくリサンプラーの性質です。与えるチャンクを変えても、変わるのはバーストの頻度だけです。

入力チャンク16 kHzでのチャンク長何も返さない呼び出し出力バーストのサイズ
128サンプル(AudioWorkletの量子)8 ms91.8%758サンプル(94.8 ms)
256サンプル16 ms83.2%758サンプル
512サンプル32 ms66.5%758サンプル
1024サンプル64 ms32.5%758サンプル
2048サンプル128 ms0%758または1516サンプル

各行400呼び出し、行ごとに新品のResampleStream、16 kHzから8 kHzへVHQで。

ここから2つのことが落ちてきます。第一に、しきい値が存在し、それは正確に、1回の出力バーストを満たすのに必要な入力です。2:1のデシメーションでは758出力サンプルに1516入力サンプルが必要で、呼び出しあたり1516を与えると、空の返りは最初のプライミング呼び出しの1回(200回の0.5%)まで減ります。2048では、その1回すら返ってきます。だからこそ、SIPレッグからのサーバー側20 msフレームでこれを踏む人は誰もおらず、ブラウザのワークレットでは全員が踏むのです。第二に、バーストサイズは配管ではなくフィルタを追います。HQでは830サンプル、LQでは470、48 kHz → 16 kHzでは1100です。これらの数字はこのホスト上のこのバージョンのものなので、私たちの数字を信用するのではなく自分のものを測ってください。転移するのは形のほうです。

このどれひとつとしてsoxrの欠陥ではありません。長いアンチエイリアスフィルタを持つポリフェーズリサンプラーは、正しくフィルタされた出力を生むためには入力を蓄積しなければならず、内部ブロック丸ごとで出力するのはその効率的なやり方です。ライブラリは、ストリームリサンプラーがそうあるべきとおりに振る舞っています。バグはすべて、空の返りから私たちが結論したことの中にあります。

空の配列が、2つの場所で違う意味を持った理由

私たちは同じ形のガードを2つのサービスに持っていて、一方では無害、他方では深刻でした。その違いは正確に述べる価値があります。「このバグを2回持っていた」ことは、面白い部分ではないからです。

強調エンドポイントのコードはこうです。

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

下流のすべてはx8から導出されます。空のx8は、処理すべきものがまだ何もないこと、何も落とされていないこと、音声はリサンプラーのバッファの中でバーストを待っていることを、純粋に意味します。このcontinueは正しい。

エージェントエンドポイントでは、同じ3行が、2つの並列の仕事を持つ関数の先頭に座っていました。通話者の音声を8 kHzでモデルに通すこと、そして別に、A/Bの対照アームのために手つかずの16 kHzコピーを保持することです。continueは、2つ目の仕事が走る前にリターンしていました。つまりフィルタをオフにした状態——モデルがまったく入っていないアーム、正直なベースラインであるはずのアーム——で、生のマイクバッファは、それとは何の関係もない空のx8と一緒に破棄されていたのです。

実際のエンドポイントに対してエンドツーエンドで測定した結果はこうです。46秒の通話で、アップリンクは3.7秒。 通話者のおよそ8%です。ターンはゼロ。永遠に。

その後コードに書き込んだルールは、「ブロックを決してスキップするな」より狭いものです。スキップが正しいこともあるからです。

一方のアームを、もう一方のアームのリサンプラーが出力を生んだかどうかに条件づけてはならない。

それはずっとテレメトリの中にあった

ここが居心地悪くあるべき部分であり、これがコミットメッセージではなく記事として存在する理由です。

このサービスはすでに、およそ16ブロックごとにstatsメッセージを発行しており、そのメッセージはすでにuplink_realtimeというフィールド——セッションがライブになってからのアップリンク秒数を壁時計秒数で割ったもの——を運んでいました。健全な通話では1.0の近くに座ります。壊れた通話ではそれは0.08に座っていました。JSONブロブの中、ブラウザコンソールの中に、何日も。

誰も読みませんでした。ユーザーに見える症状(「エージェントが答えない」)は、算術を指す前に、モデルを、プロンプトを、認証情報を、APIを指したからです。送っているつもりの音声の8%しか送られていないと言っている数字が、デバッグしている本人のひとスクロール先にあり、そして毎回、より面白い仮説に負けたのです。

そこから2つの変更が生まれました。どちらも安価なので、ライブ音声経路を運用するすべての人に両方を勧めます。

  • カウントではなく、比を公開すること。 送信バイト数は反証不能です——壊れた通話でも増えます。壁時計1秒あたりに配送された音声の秒数には既知の正解があるので、誤った値は文脈なしで読み取れます。これは現在、sessionとstatsのメッセージに、明示されたしきい値つきで載っています。おおよそ0.9未満は、音声が失われているか、遅れて届いていることを意味します。
  • その数字が何を含意するかを、ペイロードの中で言うこと。 statsメッセージは現在、数字の隣に「GeminiのVADは、一度も受け取っていない音声でターンを終わらせることはできない」という文を運んでいます。数字だけでは、すでにバグを見つけた人以外にとって、症状と結びつかなかったからです。

二次のバグ:アームが比較可能ではなかった

破棄の修正は、同じコードの中のより捉えにくい問題を露わにしました。そしてそれは、このエンドポイントがそのために存在する実験そのものを、静かに無効にしていたはずのものです。

このエンドポイントはひとつのスイッチです。同じ話者、同じ背景ノイズ、会話の途中でのフィルタのオン対オフ。この比較が何かを意味するためには、2つのアームは音声において異なり、それ以外の何においても異なってはなりません。そうなっていませんでした。フィルタありのアームはリサンプラーの約95 msのバーストのリズムを引き継ぎ、フィルタなしのアームはブラウザの生の8 ms量子をそのまま通していました。同じ通話での測定では、毎秒10.6フレーム対125.0フレーム——ひとつの会話のパケット化のされ方における11.8xの差です。

聴き手がフィルタに帰属させたあらゆる挙動の差は、フレームのリズムと交絡しており、しかも毎秒125フレームはAPIが期待する量をはるかに超えています。現在は両アームとも、送信前に固定100 msフレームへコアレスされます。リズムは構成上同一になり、アームごとのレートは仮定ではなく報告されます——ヘルスペイロードは両アームのuplink_frame_hzを運び、両者は同じ値を示すはずのものです。

名指しする価値のあるインターロックがあります。ひとつの修正を別のバグに変える類のものだからです。組み立て途中のフレームは、まだ送られていない音声です。コアレッシングが安全なのは、バッファに常にプロデューサーがいることを別の何かが保証しているからに他なりません——さもなければ、通話者の最後の文の末尾は、「通話者が止まった」と検出するためにサーバーがまさにそれを必要とする瞬間に、半分埋まったフレームの中に座り込みます。その保証こそがGemini Liveの記事で説明したキープアライブであり、この2つの変更は分離できません。

そもそも、なぜステートフルなリサンプラーを使うのか

明白な逃げ道は、チャンクごとにステートレスなsoxr.resample()を呼び、空の返りを二度と見ないことです。やめてください。

ステートレスな呼び出しはチャンク境界のたびにポリフェーズフィルタを再始動し、32 msのチャンクならおよそ毎秒30回、信号に不連続を刻印します。チャンクレートのバズ音として聞こえ、聴き手がまったく妥当にあなたのモデルのせいにする類のアーティファクトです。ステートフルなリサンプラーは、まさにそれが起きないようにフィルタ状態を呼び出しをまたいで保持しており、バースト的な出力はその正しさの、目に見えるコストです。

このコストは正直で、有界です。セッション開始時の固定遅延であって、ジッタではありません。私たちはこれを仮定せず、測定します——roundtrip_delay_ms()がダウン/アップのリサンプラー対を通した入出力の相関を取ってラグを報告し、その結果は/healthにトランスポート遅延として公開され、モデル自身の16 msのアルゴリズム的レイテンシとは分けて保たれます。所有者の異なる2つの遅延を、2つの数字として報告するのです。

チェックリスト

サンプルレート変換を含む、ブラウザからサーバーへの音声経路を運用しているなら:

  1. 実際に送っているものを数えること。 セッションごとの、壁時計秒あたりの配送音声秒数を、デバッグする人の目に見える場所へ。バイト数ではなく、です。
  2. continueに、それが守っている対象より多くをカバーさせないこと。 ブロックが2つの独立した仕事をしているなら、一方からの早期リターンはもう一方の静かな失敗です。分割してください。
  3. 自分の実際のブロックサイズで、リサンプラーの出力スケジュールを探ること。上の4行で、です。128サンプルの量子を与えるなら、ほとんどの呼び出しで何も返らないと予想してください。20 msのサーバーフレームを与えるなら、これを目にすることは決してありません。
  4. 呼び出しごとに出力ブロック1つ分の入力を与えると、空の返りはほぼ消えます——しかし、その数字の上に建てないでください。バージョンと比率に依存し、プライミングの呼び出しは依然として空です。代わりに、空のケースを正しく処理してください。
  5. パケット化の異なる2つのアームを比較しないこと。 トランスポートの前にひとつのフレームサイズへコアレスし、アームごとのフレームレートを報告して、乖離が推測ではなく目に見えるようにしてください。

そして一般則をひとつ。ここだけは音声の話ではありません。これを捕まえたはずの計装はすでに存在し、すでに正しい値を抱えていました。メトリクスを作るのは簡単な半分です。そのメトリクスに、それが含意することを、その人が実際に見ている症状の隣で語らせること——それが、私たちが間違えた半分です。

次の記事
  • 2026年10月24日

    音声エージェントとコールセンター向けのai-coustics代替製品(2026年)

    チームがai-coustics Quail Voice Focus以外を検討する理由と、2026年10月時点の掲載に基づく出典付きの候補一覧です。Krisp VIVA、AssemblyAI Voice Focus、LiveKit Cloud音声分離、NVIDIA Speaker Focus、Anecho、オープンソースのベースラインを取り上げます。当社はai-cousticsを当社自身のモデルとライブ会話で比較するベンチマークを行いました。faster-whisperでは互角、Gemini Liveではv8_2が先行、誤ったバージインはai-cousticsのほうが少なく、どちらが先行するかは条件で決まります。

  • 2026年10月22日

    音声エージェントとコールセンター向けのKrisp代替製品(2026年)

    チームがKrisp VIVA以外を検討する理由と、2026年10月時点の掲載に基づく出典付きの候補一覧です。ai-coustics Quail Voice Focus、AssemblyAI Voice Focus、LiveKit Cloud音声分離、NVIDIA Speaker Focus、Anecho、オープンソースのベースラインについて、実行場所、公開価格、当社が測定したかどうかをまとめました。当社はKrispを測定していません。

  • 2026年10月20日

    話者分離SDKの比較(2026年):Krisp、ai-coustics、AssemblyAI、LiveKit、Anecho

    2026年10月時点で掲載されている、音声エージェントとコールセンター向け話者分離の出典付き比較です。各製品の実行場所、宣言されたレイテンシ、サンプルレート、統合、公開価格、ライセンスをまとめました。Krisp VIVAとAnechoはCPUで15 msを宣言し、ai-cousticsは30 ms未満と掲載し、AssemblyAIとLiveKitはクラウドサービスの中で分離を販売しています。品質の数字は当社自身のベンチマークのものだけです。