Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

SpeechRecognitionのnot-allowedとno-speechを出し分けてマイク無反応を直した

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

SpeechRecognitionのエラーコードのうちnot-allowed・audio-capture・unsupportedは実際の失敗としてヒントを出し、no-speechとabortedは良性コードとして黙らせる、という区別をしない限りマイクボタンは押しても無反応に見え続ける。

結論

SpeechRecognitionErrorEvent.error を「実際の失敗」と「良性のコード」に出し分けないと、マイクボタンは押しても無反応に見え続ける。 not-allowed(権限拒否)・audio-capture(マイク未検出)・unsupported(API非対応)はユーザーに伝えるべき失敗だが、no-speech(一定時間の無音タイムアウト)とaborted(正常な中断)は通常の利用で普通に起きるので、ここまでヒントを出すと今度は誤警告でUIがうるさくなる。この判定を純関数sttErrorHintに集約し、マイク実機がCIで再現できない分をunit + renderテストで固定して直した。

症状

音声入力を使う会話型AIアシスタントの画面(EditorChat)で、テキスト入力欄の隣にマイクボタン(🎤)があり、押すとuseSpeechToTextフックがWeb Speech APIのSpeechRecognitionを起動する。

{stt.supported ? (
  <button
    type="button"
    style={stt.listening ? micActiveStyle : micStyle}
    onClick={() => {
      if (streaming) return;
      if (stt.listening) stt.stop();
      else stt.start();
    }}
    aria-label={stt.listening ? "音声入力を止める" : "音声入力"}
    aria-pressed={stt.listening}
    title="音声入力"
  >
    🎤
  </button>
) : null}

修正前のこのボタンには、stt.errorを参照する処理が一切無かった。ブラウザ側の権限が拒否されている、マイクが物理的に見つからない、あるいはそのブラウザがそもそもWeb Speech API非対応、といった状態でボタンを押すと、認識はSpeechRecognitionErrorEventerrorを返して止まる。だが画面側はこのerrorをどこにも表示していなかったため、ユーザーからはマイクボタンを押しても何も起きない状態にしか見えなかった。PR #876のレビューでこの点が指摘され、非ブロッキング(マージをブロックしないが直すべき)扱いで対応された。

原因

直前の実装は「stt.errorを画面のどこにも出していない」というだけで、エラー種別ごとの分岐自体が存在しなかった。フック側は失敗のたびにerrorという同じ状態にコードを積むだけで、その先の表示は呼び出し側に委ねられていた。

const Ctor = getRecognitionCtor();
if (!Ctor) {
  setError("unsupported");
  return;
}
recognition.onerror = (event) => {
  setError(event.error);
};

ここで単純に「errorが立っていたら常に警告を出す」という実装にすると、別の問題が起きる。SpeechRecognitionは失敗ではない場面でもerrorを立てるためだ。

  • no-speech … 一定時間 発話が無かった(タイムアウト)。次に話せばよいだけで、失敗ではない
  • aborted … 認識が正常に中断された(ユーザー操作・再開・画面遷移 等)

この2つは音声入力を使っていれば普通に発生する。ここまで拾って警告を出すと、マイクを使うたびに何かしら表示される煩わしいUIになり、かえって「本当に困っている状態(権限拒否やマイク未検出)」のヒントが埋もれる。「エラーを無視する」と「エラーを全部見せる」のどちらも、ユーザーがマイクの不調に気づけない結果になるという点で同じ失敗だった。

直し方

エラーコードから表示すべきヒント文言(またはヒント不要のnull)への写像を、UIから切り離した純関数sttErrorHintに集約した。

// apps/web/lib/teacher-input/stt-error-hint.ts
const BENIGN_STT_ERROR_CODES: ReadonlySet<string> = new Set(["no-speech", "aborted"]);

export function sttErrorHint(error: string | null): string | null {
  if (!error || BENIGN_STT_ERROR_CODES.has(error)) {
    // 未発生(null / 空文字)または良性コードは、失敗ではないのでヒントを出さない。
    return null;
  }
  switch (error) {
    case "not-allowed":
    case "service-not-allowed":
      return "マイクを使えませんでした。ブラウザの設定でマイクの使用を許可してください。";
    case "audio-capture":
      return "マイクが見つかりませんでした。接続を確認してもう一度お試しください。";
    case "unsupported":
      return "このブラウザは音声入力に対応していません。キーボードで入力してください。";
    default:
      // network / その他の想定外コードも「押したのに動かない」失敗なので、汎用ヒントを出す。
      return "音声入力を開始できませんでした。もう一度お試しください。";
  }
}

unsupportedはWeb Speech API自体が返すコードではなく、非対応ブラウザを検出したときにuseSpeechToTextフック側が立てる合成コードだが、sttErrorHintの中では他の失敗コードと同列に扱ってヒントを出す。

呼び出し側のEditorChatは、この戻り値をマイクボタンの直下にrole="status"で出すだけの薄い層に保っている。

// apps/web/app/app/editor/_components/EditorChat.tsx
// 音声入力の「実際の失敗」(権限拒否・マイク無し・非対応 等)だけを拾ってヒントを出す。
// 良性コード(no-speech / aborted)や未発生は null(誤警告回避)。出し分けは純関数に集約(テスト済)。
const micHint = sttErrorHint(stt.error);
{/* 音声入力が実際に失敗したときだけ、マイク直下に短いヒントを出す(role=status で読み上げ)。 */}
{micHint ? (
  <p role="status" style={micHintStyle}>
    {micHint}
  </p>
) : null}

role="status"にしているのは、視覚的にヒントを見なくてもスクリーンリーダーが変化を読み上げるようにするため。ボタン自体の見た目は変えず、失敗したときだけ直下に短文が現れる形にした。

再発防止

マイク実機の挙動(権限ダイアログの応答、実際のマイク未接続など)はCI環境で再現できない。そのためこの修正では「どのエラーコードでヒントを出す/出さないか」という判定ロジックだけをsttErrorHintという純関数に切り出し、実機に依存しない形でunitテストに固定した。

// apps/web/__tests__/teacher-input/stt-error-hint.test.ts
it("良性コード(no-speech / aborted)はヒントを出さない(誤警告回避)", () => {
  expect(sttErrorHint("no-speech")).toBeNull();
  expect(sttErrorHint("aborted")).toBeNull();
});

it("権限拒否(not-allowed / service-not-allowed)は権限を促すヒントを出す", () => {
  for (const code of ["not-allowed", "service-not-allowed"]) {
    const hint = sttErrorHint(code);
    expect(hint).not.toBeNull();
    expect(hint).toContain("許可");
  }
});

さらにEditorChat側はuseSpeechToTextをモックしてstt.errorを差し替えるrenderテストを追加し、「良性コードではrole="status"の要素が出ない」「実際の失敗では出る」という描画側の結線だけを別途固定している。ロジックの正しさと、それが画面に反映されていることを別のテストで分けて担保しているため、今後エラーコードの扱いを変えるときも、意図しない側(誤警告を増やす/本当の失敗を隠す)に倒れたらテストが落ちる。

よくある質問

Q1SpeechRecognitionのerrorは全部エラー表示すればいいのでは?

だめです。no-speech(一定時間無音でのタイムアウト)とaborted(ユーザー操作等での正常な中断)は失敗ではなく、通常の利用の中で普通に発生します。ここまで警告を出すと『次に話せばいいだけ』の場面で誤警告になり、押すたびに何か表示される煩わしいUIになります。良性コードは黙らせ、実際の失敗コードだけヒントを出す必要があります。

Q2unsupportedというエラーコードはWeb Speech APIの標準ですか?

いいえ。ブラウザがWeb Speech API非対応だったときにフック側(use-speech-to-text)が立てる合成コードです。SpeechRecognitionErrorEventが実際に返す値ではなく、アプリ側で追加した状態表現ですが、sttErrorHintの中では他の失敗コードと同じ扱いでヒントの対象にしています。

Q3マイク実機の挙動が絡む処理を、どうやってCIでテストしていますか?

マイク実機の挙動そのものはCIで再現できません。そこで『どのエラーコードでヒントを出す/出さないか』の判定だけを純関数sttErrorHintに切り出し、unitテストで固定しています。UIコンポーネント側はuseSpeechToTextをモックしたrenderテストで、この戻り値がrole=statusの要素として画面に出ることだけを確認する薄い層に保っています。

確認した環境

  • Next.js 16.2.6 / React 19.2.6(pnpm-lock.yaml 解決バージョン)
  • vitest 3.2.6 + @testing-library/react
  • 2026-06-14 にPR #901でマージ(PR #876 Reviewer指摘・非ブロッキング対応)

この記事の根拠

  • TypeScriptファイル 301〜316行目コミット 9152838
  • TypeScriptファイル 108〜115行目コミット e52cca6
  • TypeScriptファイル 138〜143行目コミット e52cca6
  • TypeScriptファイル 1〜45行目コミット e52cca6
  • TypeScriptファイル 206〜217行目コミット e52cca6
  • TypeScriptファイル 332〜337行目コミット e52cca6
  • TypeScriptファイル 1〜45行目コミット e52cca6
  • TypeScriptファイル 1〜76行目コミット e52cca6

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。