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?

ブラウザーでSpeechRecognitionによる音声認識を安定させるコツ

Last updated at Posted at 2025-07-27

Web Speech API の SpeechRecognition インターフェース(非同期音声認識)を使うと、ウェブページに「音声からテキストへ」の機能を実装できます。

例えば、以下のような機能が考えられます。

  • 音声コマンドによるウェブサイトの操作
  • 話した内容をテキスト化するメモ機能
  • ユーザーの声に反応するウェブアプリケーション

このAPIの便利な点は、ブラウザーの組み込み機能だけで動作するため、ライブラリやサーバーの準備なしに試せることです。Geminiのプレビュー環境でも使えるので、音声認識のデモアプリを簡単に作れます。

ただ、この手軽なAPIには、知っておくべき仕組みと、特にスマートフォンで安定させるためのコツがあります。この記事では、そのための具体的なテクニックを解説します。

APIの仕組みと、不安定さの原因

知っておきたい仕組みとプライバシー

SpeechRecognition APIは、ブラウザーによって音声データ処理される場所が異なります。

特に、Google ChromeでこのAPIを使用する場合、認識処理はGoogleのサーバーに音声データを送信して行われます。

  • 利点: サーバーの強力なAIモデルを使うため、高い精度が期待できます。
  • プライバシーに関する考慮事項: ユーザーの音声が外部のサーバーに送信されるため、この点をユーザーに周知する必要があります。サービスに組み込む場合は、プライバシーポリシーでの言及も検討しましょう。

一方で、AppleのSafariなど一部のブラウザーは、プライバシーを重視し、可能な限りデバイス上で処理を完結させようとします。

このように、APIの動作はブラウザーの実装に依存することを理解しておくことが大切です。

よくある「不安定」な挙動

SpeechRecognitionを使っていると、PCでは動くのにスマートフォンでは動かない、といった不安定さを感じます。これはAPIの仕様がまだ正式に固まっていないことや、モバイルOSの制約が主な原因です。

  1. no-speechエラーが頻発する: スマートフォンではバッテリーを節約するため、少し無音状態が続くと認識が終了してしまいます。この挙動はChromiumの不具合として報告されています。no-speechエラーは、不具合というよりも、APIの設計および実装上の特性です。エラーを致命的なものとして扱うのではなく、セッションが自然に終了した一因と捉え、即座に認識を再開するループ処理で回避します。

  2. 意図せずセッションが終了する: ブラウザーがバックグラウンドに移動すると、OSがリソースを節約するために認識を強制的に中断します。

  3. プラットフォームごとのクセ:

    • iOS Safariのstop()問題: iOSのSafariでは、recognition.stop()を呼んでもすぐに止まらないバグがありました。この問題はStack Overflowで報告されました。recognition.stop()を呼び出す直前にrecognition.start()を一度呼び出す回避策が提示されています。

    • Android Chromeで信頼度が0になる: 認識が確定したのに、信頼度(confidence)が0で返ってくることがあります。

安定させるための実装テクニック

これらの問題を解決するには、以下の戦略が効果的です。

  1. 短いセッションの繰り返し: continuous: true(話し続けても認識を続けるモード)は不安定なので、falseにして、認識が終わるたびに再開するループを作ります。これが一番のポイントです。
  2. エラーからの自動復帰: エラーが起きても、アプリが止まらないように自動で復帰させます。
  3. プラットフォーム判定: OSを判定し、iOS向けのバグ対策などを適用します。
  4. 信頼度のフィルタリング: 信頼度が低い結果は無視して、誤認識を防ぎます。

実装例

これらの戦略を盛り込んだソースコードです。このままHTMLファイルとして保存すれば、すぐに試せます。

ソースコード
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Web Speech APIによる音声認識デモ</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link
    href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=Noto+Sans+JP:wght@400;500;700&display=swap"
    rel="stylesheet">
  <style>
    body {
      font-family: 'Noto Sans JP', 'Inter', sans-serif;
      -webkit-tap-highlight-color: transparent;
      /* iOSでタップ時のハイライトを無効化 */
    }

    .status-light {
      width: 16px;
      height: 16px;
      border-radius: 50%;
      display: inline-block;
      margin-right: 8px;
      transition: background-color 0.3s, box-shadow 0.3s;
      flex-shrink: 0;
    }

    .recognizing {
      background-color: #ef4444;
      /* red-500 */
      animation: pulse 1.5s infinite;
      box-shadow: 0 0 8px #ef4444;
    }

    .waiting {
      background-color: #3b82f6;
      /* blue-500 */
    }

    .stopped {
      background-color: #6b7280;
      /* gray-500 */
    }

    @keyframes pulse {

      0%,
      100% {
        opacity: 1;
      }

      50% {
        opacity: 0.6;
      }
    }
  </style>
</head>

<body class="bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 min-h-screen flex flex-col">

  <main class="container mx-auto p-4 flex-grow flex flex-col">
    <div class="bg-white dark:bg-gray-800 shadow-lg rounded-xl p-6 flex flex-col h-full">
      <div class="flex items-center justify-between mb-4">
        <h1 class="text-2xl font-bold text-gray-900 dark:text-white">堅牢な音声認識テスト</h1>
        <div id="status-indicator" class="flex items-center">
          <div id="status-light" class="status-light stopped"></div>
          <span id="status-text" class="font-medium text-gray-600 dark:text-gray-300">停止中</span>
        </div>
      </div>

      <div id="controls" class="flex space-x-2 mb-4">
        <button id="toggle-btn"
          class="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-3 px-4 rounded-lg transition-transform transform active:scale-95">
          認識を開始
        </button>
        <button id="clear-btn"
          class="w-full bg-gray-500 hover:bg-gray-600 text-white font-bold py-3 px-4 rounded-lg transition-transform transform active:scale-95">
          クリア
        </button>
      </div>

      <div id="error-display"
        class="bg-red-100 dark:bg-red-900 border-l-4 border-red-500 text-red-700 dark:text-red-200 p-4 rounded-md mb-4 hidden"
        role="alert">
        <p class="font-bold">エラー</p>
        <p id="error-message"></p>
      </div>

      <div class="flex-grow flex flex-col bg-gray-50 dark:bg-gray-700 rounded-lg p-4 overflow-hidden">
        <h2 class="text-lg font-semibold mb-2 text-gray-700 dark:text-gray-200">認識結果:</h2>
        <div id="transcript-container"
          class="w-full flex-grow bg-white dark:bg-gray-800 rounded-md p-3 overflow-y-auto text-lg leading-relaxed">
          <p id="final-transcript"></p>
          <p id="interim-transcript" class="text-gray-500"></p>
        </div>
      </div>
    </div>
  </main>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      const VoiceApp = {
        // --- 設定項目 ---
        config: {
          lang: 'ja-JP',
          platform: {
            isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
            isIOS: /iPhone|iPad|iPod/i.test(navigator.userAgent),
          }
        },

        // --- DOM要素キャッシュ ---
        dom: {
          toggleBtn: document.getElementById('toggle-btn'),
          clearBtn: document.getElementById('clear-btn'),
          statusLight: document.getElementById('status-light'),
          statusText: document.getElementById('status-text'),
          finalTranscript: document.getElementById('final-transcript'),
          interimTranscript: document.getElementById('interim-transcript'),
          errorDisplay: document.getElementById('error-display'),
          errorMessage: document.getElementById('error-message'),
        },

        // --- 状態管理 ---
        state: {
          isRecognizing: false, // アプリとして認識中かどうかの状態
          finalTranscript: '',
          ignoreOnend: false, // 意図的な停止時に onend を無視するフラグ
        },

        recognition: null,

        init() {
          this.updateUI('stopped');

          const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
          if (!SpeechRecognition) {
            this.handleUnsupportedBrowser();
            return;
          }

          this.recognition = new SpeechRecognition();
          this.configureRecognition();
          this.bindEvents();
          console.log("音声認識アプリの初期化が完了しました。");
          console.log("モバイル環境:", this.config.platform.isMobile);
        },

        configureRecognition() {
          this.recognition.lang = this.config.lang;
          this.recognition.interimResults = true;
          // [重要] モバイルでは continuous: false が安定動作の鍵。
          // PCでは true でも比較的安定しているため、利便性を優先。
          this.recognition.continuous = this.config.platform.isMobile ? false : true;
        },

        bindEvents() {
          this.dom.toggleBtn.addEventListener('click', () => this.toggleRecognition());
          this.dom.clearBtn.addEventListener('click', () => this.clearTranscript());

          this.recognition.onstart = () => {
            console.log('Event: onstart - 認識セッション開始');
            this.updateUI('recognizing');
          };

          this.recognition.onend = () => {
            console.log('Event: onend - 認識セッション終了');
            // 意図的な停止(stopボタン押下)の場合は、何もしない。
            if (this.state.ignoreOnend) {
              console.log('onendを意図的に無視します。');
              this.state.ignoreOnend = false;
              return;
            }

            // [核となるロジック] 
            // continuous: false の場合、または何らかの理由で continuous: true が終了した場合、
            // アプリが「認識中」の状態であれば、自動的に再開する。
            if (this.state.isRecognizing) {
              console.log('自動的に認識を再開します...');
              this.recognition.start();
            } else {
              this.updateUI('stopped');
            }
          };

          this.recognition.onerror = (event) => {
            console.error('Event: onerror - エラー発生', event.error);

            // 'no-speech' はモバイルで頻発する。これはバグではなく仕様に近い挙動。
            // このエラーが発生しても、onendが後続で呼ばれるため、
            // onendの再開ロジックに任せる。UI上はエラーとして表示しない。
            if (event.error === 'no-speech') {
              this.state.ignoreOnend = true; // onendでの再開を一時的に止める
              console.warn('無音タイムアウト (no-speech)。次のセッションを待機します。');
              // 短い遅延後、手動で再開を試みることで、より堅牢になる
              if (this.state.isRecognizing) {
                setTimeout(() => {
                  try {
                    this.recognition.start();
                  } catch (e) { console.error("再開試行中にエラー", e); }
                }, 100);
              }
              return;
            }

            this.updateUI('error', `エラー: ${event.error}`);
            this.state.ignoreOnend = true; // エラー時は onend の自動再開を止める
          };

          this.recognition.onresult = (event) => {
            this.handleRecognitionResult(event);
          };
        },

        toggleRecognition() {
          if (this.state.isRecognizing) {
            this.stopRecognition();
          } else {
            this.startRecognition();
          }
        },

        startRecognition() {
          if (this.state.isRecognizing) return;
          console.log('認識を開始します...');
          this.state.isRecognizing = true;
          this.state.finalTranscript = this.dom.finalTranscript.textContent; // 既存のテキストを保持
          this.dom.interimTranscript.textContent = '';
          this.dom.errorDisplay.classList.add('hidden');
          this.state.ignoreOnend = false;
          try {
            this.recognition.start();
          } catch (e) {
            console.error("認識開始時にエラーが発生しました。", e);
            this.updateUI('error', '認識を開始できませんでした。');
            this.state.isRecognizing = false;
          }
        },

        stopRecognition() {
          if (!this.state.isRecognizing) return;
          console.log('認識を停止します...');
          this.state.isRecognizing = false;
          this.state.ignoreOnend = true; // ユーザーによる意図的な停止であることを示すフラグ

          // [重要ワークアラウンド]
          // 古いiOS Safariではstop()が効かないことがあるため、一度start()を呼んでから即座にstop()を呼ぶ。
          // 最新版では不要かもしれないが、互換性のために残す。
          if (this.config.platform.isIOS) {
            console.log('iOS向けの停止処理を実行します。');
            // stop()が即時でないため、一度ダミーでstart()を呼んでからstop()する
            try {
              this.recognition.start();
              setTimeout(() => {
                this.recognition.stop();
                this.updateUI('stopped');
              }, 50);
            } catch (e) {
              this.recognition.stop();
              this.updateUI('stopped');
            }
          } else {
            this.recognition.stop();
            this.updateUI('stopped');
          }
        },

        handleRecognitionResult(event) {
          let interimTranscript = '';
          for (let i = event.resultIndex; i < event.results.length; ++i) {
            const transcript = event.results[i][0].transcript;
            const confidence = event.results[i][0].confidence;

            // isFinalがtrueでもconfidenceが0の場合があるため(特にAndroid)、両方チェック
            if (event.results[i].isFinal) {
              if (confidence > 0) {
                console.log(`確定結果 (信頼度: ${confidence.toFixed(3)}): ${transcript}`);
                this.state.finalTranscript += transcript + '';
              } else {
                console.warn(`確定結果を破棄 (信頼度: 0): ${transcript}`);
              }
            } else {
              interimTranscript += transcript;
            }
          }
          this.dom.finalTranscript.textContent = this.state.finalTranscript;
          this.dom.interimTranscript.textContent = interimTranscript;
        },

        clearTranscript() {
          console.log('テキストをクリアします。');
          this.state.finalTranscript = '';
          this.dom.finalTranscript.textContent = '';
          this.dom.interimTranscript.textContent = '';
          this.dom.errorDisplay.classList.add('hidden');
        },

        updateUI(state, message = '') {
          const light = this.dom.statusLight;
          light.className = 'status-light'; // reset classes

          switch (state) {
            case 'recognizing':
              light.classList.add('recognizing');
              this.dom.statusText.textContent = '認識中...';
              this.dom.toggleBtn.textContent = '認識を停止';
              this.dom.toggleBtn.classList.remove('bg-blue-500', 'hover:bg-blue-600');
              this.dom.toggleBtn.classList.add('bg-red-500', 'hover:bg-red-600');
              this.dom.errorDisplay.classList.add('hidden');
              break;
            case 'stopped':
              light.classList.add('stopped');
              this.dom.statusText.textContent = '停止中';
              this.dom.toggleBtn.textContent = '認識を開始';
              this.dom.toggleBtn.classList.remove('bg-red-500', 'hover:bg-red-600');
              this.dom.toggleBtn.classList.add('bg-blue-500', 'hover:bg-blue-600');
              break;
            case 'error':
              light.classList.add('stopped');
              this.dom.statusText.textContent = 'エラー';
              this.dom.errorMessage.textContent = message;
              this.dom.errorDisplay.classList.remove('hidden');
              this.dom.toggleBtn.textContent = '再試行';
              this.dom.toggleBtn.classList.remove('bg-red-500', 'hover:bg-red-600');
              this.dom.toggleBtn.classList.add('bg-blue-500', 'hover:bg-blue-600');
              break;
          }
        },

        handleUnsupportedBrowser() {
          console.error('このブラウザはWeb Speech APIをサポートしていません。');
          this.dom.toggleBtn.disabled = true;
          this.dom.clearBtn.disabled = true;
          this.updateUI('error', 'お使いのブラウザは音声認識に対応していません。PC版のChromeやEdge、Safariをお試しください。');
          this.dom.toggleBtn.textContent = '非対応';
        }
      };

      VoiceApp.init();
    });
  </script>
</body>

</html>

コードのポイント

  1. continuous: falseonend での再開ループ
    モバイルでの安定化の核となる部分です。認識が一度途切れても、onendイベントをトリガーにして自動で再開させることで、切れ目ない認識を実現します。

  2. iOSのバグへの対処
    iOS向けの互換性対策です。stop()がうまく機能しないことがあるため、ひと工夫加えています。

  3. confidenceによるフィルタリング
    Androidで時々発生する、信頼度が0の不確かな結果を採用しないための処理です。これにより、認識結果の品質を高めます。

  4. 認識したテキストへ「。」を挿入
    テキストの区切りに「。」を挿入し、利用しやすくします。

おわりに

Web Speech APIのSpeechRecognitionは、いくつかの癖があるものの、この記事で紹介したような対策を講じれば、デモには十分使うことができます。

プロダクトに使うには商用のクラウドAPIを検討すべきです。オフライン対応が求められる場合は、WebAssemblyベースのライブラリも存在するようです。

ぜひ、このコードをベースに、あなたのプロジェクトで音声認識を活用してみてください。

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?