Anime AR Stock

Vol.9

声で発動し、音を録画に残す — マイクとスピーカーが同じ音声セッションを取り合う

  • Web Speech API
  • Web Audio
  • iOS Safari
  • screen recording
マイクWeb Speech API
SpeechRecognitionwebkit fallback
フレーズ判定技ごとのフック
VFX 発火state machine
図9. 認識結果は VFX 状態機械へのトリガー。途切れたらフック側で聞き直す。

音声認識: 表記ゆれと切断

コンストラクタは SpeechRecognition ?? webkitSpeechRecognition で解決し、continuous = true・interimResults = true・lang = ja-JP で聞き続けます。API が無いブラウザでは null を返し、体験側は印やボタンだけの経路に切り替えます。

Web Speech API は「螺旋丸」「らせんがん」「ラセンガン」「螺旋がん」「螺旋眼」などを返します(フックのコメントに実際に観測した表記を残しています)。NFKC 正規化 → 空白・記号・長音の除去 → カタカナをひらがなへ、の順で揃え、ローマ字は正規化前の原文に対して別の正規表現で見ます。interim と final の両方で同じ発話が一致するので、発動は 800ms のデバウンスで 1 回に絞ります。

切断対策は、実際に起きた切れ方ごとに分けています。認識が自然に終わったら(onend)250ms 後に再開。画面収録や他のアプリがマイクを掴むと audio-capture / aborted で切れるので、そのときは 400ms 後に再開。再開の start() 自体が失敗したら、待ち時間を 2 倍ずつ(最短 400ms、最長 2,000ms)延ばして付け直します。not-allowed だけは再開せず、「カメラとは別にマイクを許可してください」と出します。

hooks/useRasenganSpellSpeech.ts(要点抜粋)
1recognition.onerror = (event) => {2  const code = event.error ?? 'unknown';3  if (code === 'not-allowed' || code === 'service-not-allowed') {4    setError('マイク許可が必要です(カメラとは別にマイクを許可してください)');5    shouldListenRef.current = false;6    return;7  }8  // 画面収録や他アプリがマイクを使うと audio-capture / aborted で切れる。聞き続ける。9  if (code === 'audio-capture' || code === 'aborted' || code === 'network') {10    scheduleRestart(400);11  }12};13recognition.onend = () => {14  if (shouldListenRef.current) scheduleRestart(250);15};

問題: iPhone の画面収録に効果音が入らない

anime-ar-stock の術は、ユーザーが iPhone の画面収録で撮って SNS に載せることを想定しています。ところが当時の効果音は Web Audio API(AudioContext)でその場で合成していて、画面収録の動画には効果音だけが入っていませんでした。一方、印を結んだときの「ドン」(seal_don.mp3 を <audio> で再生)は録画に入っていました。

ここから 2 日間で、次の順に試しました。

  • 8/28: AudioContext の出力を MediaStreamDestination 経由で <audio>.srcObject に流す → 入らない。
  • 8/28: ページの音声セッションが ambient のままだと考え、最初のタップで無音の WAV を <audio> でループ再生して playback に固定する → 入らない。
  • 8/29: 効果音を発火の瞬間に OfflineAudioContext でレンダリングし、Blob URL の <audio> で再生する → それでも入らない。
  • 8/29: Node のスクリプト(scripts/generate-purple-audio.mjs)で、ブラウザと同じ DSP を使って効果音を事前に WAV へ焼き、静的ファイルとして配信する → 入った。

実機で確認できたのは「iOS の画面収録(ReplayKit)は AudioContext 由来の音を、MediaStreamDestination を経由しても、Blob URL にしても録らない。ネットワークから配信された実ファイルを <audio src> で鳴らした音は必ず録る」という挙動でした。螺旋丸の効果音はもともと .wav ファイルだったので、AudioContext を通すのをやめて直接再生する形にしています。

ハマった点: 録画を直そうとして音声入力を壊した

8/29 の途中で、虚式「紫」の音声発動が反応しなくなり、青・赤の生成音も鳴らなくなりました。原因として考えたのは、録画対策で入れた 2 つの仕掛けです。Start ボタンを押した瞬間に 5 つの音声ファイルを同時に再生・停止して iOS の再生制限を解除する「プローブ」と、無音 WAV のループ。この 2 つが、まさに SpeechRecognition がマイクを取りにいく瞬間に音声セッションを激しく揺さぶっていた、という見立てです。しかも、どちらも録画には効いていないことが既に分かっていたので、両方とも外しました。

すると今度は、青・赤の生成音が完全に無音になりました。この 2 つの効果音は Start ボタンのクリックの中ではなく、MediaPipe の処理ループの中から new Audio().play() で鳴らしています。iOS はユーザー操作の外での再生を許さないので、プローブを全部外したことで再生の許可そのものが無くなっていました。最終的に、印の「ドン」で既に問題なく動いていた形 ── Audio 要素 1 つを音量 0 で play → pause → 先頭に戻す ── だけを戻して解決しています。

5 ファイル同時プローブ + 無音ループ
メリット
すべての効果音の再生を確実に解除できる、はずだった。
デメリット
Start の瞬間に音声セッションを揺さぶり、音声認識のマイク取得と衝突した(と見ている)。録画には効果なし。
プローブなし
メリット
音声セッションに触らない。
デメリット
ユーザー操作の外(MediaPipe のループ)から鳴らす効果音が無音になる。
Audio 1 つの単発プローブ採用
メリット
再生の解除に必要な最小限。既に別の箇所(印の「ドン」)で動作実績があった。
デメリット
ループ系の音(AudioContext のまま)は録画に入らないまま。

採用理由: 「効いていない仕掛けを足し続けない」ことを優先した。録画に必要なのは実ファイルでの再生で、再生の解除に必要なのは Audio 1 つのプローブだけだった。

残っている課題もあります。フレームごとに音程や音量を変える持続音(融合時のドローン音、飛行中の風切り音など)は、毎フレーム事前レンダリングするわけにいかないので AudioContext のままで、画面収録には入りません。

実装から得た知見

  • iOS の画面収録は AudioContext 由来の音を録らない。SNS で共有される前提の効果音は、配信された実ファイルで鳴らす。
  • マイク(音声認識)とスピーカー(効果音)は同じ音声セッションを共有する。再生側の小細工が入力側を壊すことがある。
  • 効かなかった回避策はすぐに外す。原因不明のまま残すと、次の不具合の容疑者が増えるだけだった。
  • 音声認識の切断は「どう切れたか」で再開の仕方を変える。画面収録がマイクを掴むケースは、撮影用途では日常的に起きる。