Web Speech API の SpeechRecognition
インターフェース(非同期音声認識)を使うと、ウェブページに「音声からテキストへ」の機能を実装できます。
例えば、以下のような機能が考えられます。
- 音声コマンドによるウェブサイトの操作
- 話した内容をテキスト化するメモ機能
- ユーザーの声に反応するウェブアプリケーション
このAPIの便利な点は、ブラウザーの組み込み機能だけで動作するため、ライブラリやサーバーの準備なしに試せることです。Geminiのプレビュー環境でも使えるので、音声認識のデモアプリを簡単に作れます。
ただ、この手軽なAPIには、知っておくべき仕組みと、特にスマートフォンで安定させるためのコツがあります。この記事では、そのための具体的なテクニックを解説します。
APIの仕組みと、不安定さの原因
知っておきたい仕組みとプライバシー
SpeechRecognition
APIは、ブラウザーによって音声データ処理される場所が異なります。
特に、Google ChromeでこのAPIを使用する場合、認識処理はGoogleのサーバーに音声データを送信して行われます。
- 利点: サーバーの強力なAIモデルを使うため、高い精度が期待できます。
- プライバシーに関する考慮事項: ユーザーの音声が外部のサーバーに送信されるため、この点をユーザーに周知する必要があります。サービスに組み込む場合は、プライバシーポリシーでの言及も検討しましょう。
一方で、AppleのSafariなど一部のブラウザーは、プライバシーを重視し、可能な限りデバイス上で処理を完結させようとします。
このように、APIの動作はブラウザーの実装に依存することを理解しておくことが大切です。
よくある「不安定」な挙動
SpeechRecognition
を使っていると、PCでは動くのにスマートフォンでは動かない、といった不安定さを感じます。これはAPIの仕様がまだ正式に固まっていないことや、モバイルOSの制約が主な原因です。
-
no-speech
エラーが頻発する: スマートフォンではバッテリーを節約するため、少し無音状態が続くと認識が終了してしまいます。この挙動はChromiumの不具合として報告されています。no-speech
エラーは、不具合というよりも、APIの設計および実装上の特性です。エラーを致命的なものとして扱うのではなく、セッションが自然に終了した一因と捉え、即座に認識を再開するループ処理で回避します。 -
意図せずセッションが終了する: ブラウザーがバックグラウンドに移動すると、OSがリソースを節約するために認識を強制的に中断します。
-
プラットフォームごとのクセ:
-
iOS Safariの
stop()
問題: iOSのSafariでは、recognition.stop()
を呼んでもすぐに止まらないバグがありました。この問題はStack Overflowで報告されました。recognition.stop()
を呼び出す直前にrecognition.start()
を一度呼び出す回避策が提示されています。 -
Android Chromeで信頼度が0になる: 認識が確定したのに、信頼度(
confidence
)が0
で返ってくることがあります。
-
安定させるための実装テクニック
これらの問題を解決するには、以下の戦略が効果的です。
-
短いセッションの繰り返し:
continuous: true
(話し続けても認識を続けるモード)は不安定なので、false
にして、認識が終わるたびに再開するループを作ります。これが一番のポイントです。 - エラーからの自動復帰: エラーが起きても、アプリが止まらないように自動で復帰させます。
- プラットフォーム判定: OSを判定し、iOS向けのバグ対策などを適用します。
- 信頼度のフィルタリング: 信頼度が低い結果は無視して、誤認識を防ぎます。
実装例
これらの戦略を盛り込んだソースコードです。このまま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>
コードのポイント
-
continuous: false
とonend
での再開ループ
モバイルでの安定化の核となる部分です。認識が一度途切れても、onend
イベントをトリガーにして自動で再開させることで、切れ目ない認識を実現します。 -
iOSのバグへの対処
iOS向けの互換性対策です。stop()
がうまく機能しないことがあるため、ひと工夫加えています。 -
confidence
によるフィルタリング
Androidで時々発生する、信頼度が0
の不確かな結果を採用しないための処理です。これにより、認識結果の品質を高めます。 -
認識したテキストへ「。」を挿入
テキストの区切りに「。」を挿入し、利用しやすくします。
おわりに
Web Speech APIのSpeechRecognition
は、いくつかの癖があるものの、この記事で紹介したような対策を講じれば、デモには十分使うことができます。
プロダクトに使うには商用のクラウドAPIを検討すべきです。オフライン対応が求められる場合は、WebAssemblyベースのライブラリも存在するようです。
ぜひ、このコードをベースに、あなたのプロジェクトで音声認識を活用してみてください。