0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

再接続したのにマイクが戻らない — 有効にしていたのは、これから捨てられる古いマイクだった

0
Posted at

📝 この記事は forge.workstyle.tech に掲載した記事の転載です。

Web サイトに埋め込む音声対話アバターを運用している。ページを開くと繋がり、話しかけると
返事をする。裏では WebRTC でブラウザと音声パイプラインが繋がっている。

サーバを再起動したら、アバターが黙ったままになった。

👤 (話しかける)
🤖 (無言)
👤 (もう一度話しかける)
🤖 (無言)

見た目は何も壊れていない。アバターは立っているし、マイクのボタンも ON のままだ。
ページをリロードするまで、訪問者は「壊れている」ことに気づけない。

そもそも再接続の仕組みが無かった

最初に確かめたのはここだった。切れたあとに繋ぎ直すコードが、どこにも無い。

繋ぐ処理は「ページを開いたとき」に一度呼ばれるだけで、切れたときの経路が存在しない。
サーバの再起動どころか、地下鉄に入った・Wi-Fi が切り替わった・スリープから復帰した、
そのどれでも同じことが起きる。デプロイのたびに、たまたま繋いでいた訪問者が無言の
アバターに向かって話しかけていたことになる。

指数バックオフで繋ぎ直す処理を足した。

const waits = [1000, 2000, 4000, 8000, 15000, 30000];
const wait = waits[Math.min(retryRef.current, waits.length - 1)];
retryRef.current += 1;
setTimeout(() => reconnectRef.current(), wait);

最後の 30 秒で打ち切らずに繰り返す。ここは意図的にそうした。訪問者は「あとで戻ってくる」
のではなく「そのページを開いたまま」なので、諦めた瞬間にそのセッションは終わる。
繋ぎ直すコストはほぼゼロだから、諦める理由がない。

デプロイして、サーバを再起動してもらった。

[widget] 接続が切れた — 1秒後に再接続 (1回目)

……そこで止まった。1 回目のログは出るのに、2 回目が出ない。繋がってもいない。

冪等でない関数を、冪等のつもりで呼んでいた

繋ぐ処理の先頭はこうなっていた。

const connect = useCallback(async () => {
    if (pcRef.current) return;      // ← ここ
    ...

古い接続オブジェクトが残っていると、何もせず戻る。 ページを開いたときに一度だけ呼ぶ
関数としては正しい。二重に繋がないためのガードだ。

だが再接続では、まさにその「古い接続」が残っている状態から呼ぶことになる。
connect() は毎回、静かに何もせずに戻っていた。エラーも出ない。ログも出ない。

先に disconnect() を呼ぶようにした。今度は繋がった。

15:09:43  サーバ再起動
15:10:16  音声パイプラインが切断を検知
15:10:28  再接続(12秒後)

会話の履歴も残っていた。会話 ID をブラウザの sessionStorage に持たせてあるので、
繋ぎ直しても「続きから」になる。ここまでは狙いどおりだった。

マイクだけが戻らない

繋がったのに、話しかけても返事が来ない。マイクのボタンは ON のままだ。

サーバ側のログで確認できた。音声パイプラインに、音の大きさと「人の声らしさ」を
30 秒ごとに出す診断を仕込んである。

[vad] 直近30秒 判定1500回 発話0回 惜しい0回(平均 声0.00 音量0.00)

音量 0.00 が続いている。 音が 1 サンプルも届いていない。ボタンは ON なのに、
実際のマイクは切れている。

マイクのトラックは、繋ぐときに取得して既定では無効にしてある。ボタンを押した
タイミングで有効にする作りだ。だから再接続したあとも、元が ON だったなら
有効に戻してやる必要がある。そのコードは書いてあった。書いてあるのに効かない。

ソースを読んで、3回外した

ここから恥ずかしい時間が続く。コードを読んで仮説を立て、直し、外す、を 3 回やった。

疑ったもの 理由 結果
利用量の上限による機能制限 制限がかかるとマイクを切る処理がある 制限はかかっていなかった
8 秒後の状態確認が誤判定している 再接続の成否を後から確かめる処理を足していた 無関係だった
繋ぐ処理の内部で順序が入れ替わる 非同期の処理が並んでいる ここも違った

3 回とも「ソースを読んで、ありそうな筋を選んだ」だけだ。どれも読めば筋が通って見える。
実際に起きていることは 1 つも確認していない。

4 回目に、諦めて診断ログを入れた。誰がいつマイクを切っているかを、そのまま出す。

ログ1行で終わった

返ってきたのはこれだった。

[widget] マイク復帰の後: {呼んだ: true, 有効: [true]}
[voicepipe] 新しいマイクトラックを取得(既定は無効)    ← ★これが後
[widget] マイク復帰の2秒後: {有効: [true], 戻された: true}

有効にしたあとに、新しいマイクを取得している。

つまり私が有効にしていたのは、これから捨てられる古いトラックだった。そのあと
connect() が新しいトラックを作り、既定どおり無効で始めていた。

「後から誰かに無効へ戻された」のではない。順序が逆だっただけだ。3 回の仮説は
全部「誰が切ったのか」を探していたが、誰も切っていなかった。

早すぎた理由は、嘘をつく状態変数だった

では、なぜ有効化が新しいトラックより先に走ったのか。条件はこう書いてあった。

if (voicepipe.status === 'connected') { ... }   // 繋がったら有効化する

接続状態の更新はこうなっていた。

pc.oniceconnectionstatechange = () => {
  const s = pc.iceConnectionState;
  if (s === 'connected' || s === 'completed') {
    setStatus('connected');
  } else if (s === 'failed' || s === 'disconnected' || s === 'closed') {
    if (s === 'failed') setStatus('failed');    // ← failed のときだけ
    ...

disconnected でも closed でも、状態は 'connected' のままだった。
更新するのは failed のときだけ。

だから「繋がったら有効化する」という条件が、切断を検知した直後にすでに成立していた。
待っているつもりで、待っていなかった。

この状態変数は間違ってはいない。disconnected は一時的な断で復帰することもあるから、
すぐ failed にしないのは妥当な判断だ。問題は、そういう含みを持った変数を
「いま繋がっているか」の判定に流用したこと
にある。

直し方は単純だった。状態変数を見るのをやめて、繋ぐ処理の完了そのものを待つ。

voicepipe.disconnect();
await voicepipe.connect();                              // 完了を待つ
if (micOnRef.current) voicepipe.setMicEnabled(true);     // その後で有効化

connect() は新しいトラックの取得と offer/answer を終えてから返るので、この時点なら
確実に新しいトラックを掴む。「状態を見て判断する」から「順番に実行する」に変えただけだ。

動いた

サーバを再起動して確認した。

15:10:28  再接続(サーバ再起動の45秒後 / 切断検知の12秒後)
15:17:42  [vad] 音量 0.51 発話0回      ← マイクは生きたまま待機
15:18:12  [vad] 発話31回 声0.03        ← 話しかけた
15:17:49  [Talk TTS] 'こんにちは。本日はどのようなご用件でしょうか?'

副産物がひとつあった。マイクの生死は、サーバ側のログだけで判定できる。

音量
マイク OFF 0.00
マイク ON 0.5 前後

これに気づいてから、訪問者にブラウザのコンソールを開いてもらう必要がなくなった。
「マイクが入っているはずなのに反応しない」という報告に、こちらだけで裏が取れる。

持ち帰ったこと

冪等でない関数を、冪等のつもりで呼んでいないか。 if (すでにある) return; は
初期化のガードとして正しいが、「やり直す」経路から呼ばれた瞬間に静かに何もしない関数
になる。エラーが出ないぶん、見つけるのに時間がかかる。

状態を表す変数が、一部の失敗しか見ていないことがある。 今回の status は
「failed になったか」を表す変数であって、「いま繋がっているか」を表す変数ではなかった。
名前は後者に見える。その変数を条件にした処理は、全部おかしくなる。
待ちたいものがあるなら、状態を覗くより完了を await するほうが確実だ。

ソースを読んで立てた仮説は、3回外した。 診断ログは 1 回で当てた。
コードは「何が起こりうるか」しか教えてくれない。「何が起きたか」は測るしかない。
筋の通った仮説を 3 つ思いつけた時点で、それは読んでも分からないという証拠だったはずだ。

補足: 切れる原因はサーバの再起動だけではない

再接続を入れる前に、そもそもどれくらい切れるのかを整理した。

  • デプロイ・Pod の再起動・ノードの入れ替え
  • Wi-Fi ↔ モバイル回線の切り替え、地下・エレベータ
  • スマートフォンのスリープと復帰(バックグラウンドで接続が畳まれる)
  • 途中の NAT や TURN のタイムアウト(無音が続くと落ちることがある)

サーバ側の都合は運用でいくらか減らせるが、下の 3 つは減らせない。
「切れない」ではなく「切れても戻る」に投資するのが正しいという判断になった。


元記事: https://forge.workstyle.tech/blog/reconnected-but-the-mic-stayed-off/?utm_source=qiita&utm_medium=crosspost&utm_campaign=reconnected-but-the-mic-stayed-off

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?