【初心者向け】EdgeとChromeからAmiVoice APIを実行してみた Webページ編

はじめに
はじめまして。AmiVoice APIインフラチームメンバーDです。
本記事では、Microsoft EdgeとGoogle ChromeからAmiVoice APIを実行するWebページのサンプルとその作り方を紹介します。
対象
- これからAmiVoice APIを試してみようとしている初心者やノンプログラマー。
できること
- 音声や動画ファイル(WAV(16bit リニアPCM)、MP3、FLAC、Ogg Opus、MP4(AAC)、WebM(Opus))の音声認識。
- マイクまたはシステム音の音声認識。
- 話者ダイアライゼーションと感情解析。
- 音声認識の結果を使ったWebVTT形式の字幕ファイルのサンプルの生成。
AmiVoice APIのアカウント作成後、すぐに試せます。アカウントがない場合は、利用申し込みを行ってください。
Webページのサンプル
AmiVoice API音声認識Webページサンプル

ソースコード
advanced-media-inc/acp-javascript-sample-applications/speech-recognition-webpage
注意点
- 音声認識と話者ダイアライゼーション、感情解析にAmiVoice APIを利用します。
- 音声認識を実行するとAmiVoice APIの利用料がかかります。感情解析はオプションで追加料金が発生します。利用料金をご確認ください。
- 音声認識結果を受け取る前にWebページを閉じた場合でも送信された音声データは、AmiVoice APIの課金対象になります。
- Windows 10のMicrosoft EdgeとGoogle Chromeでのみ動作確認を行っています。その他の環境では正常に動作しない可能性があります。
- ブラウザー上でファイルの操作や保持をするため、巨大なファイルや大量のファイルを利用すると、ブラウザーのメモリーが不足する可能性があります。
- Webページ上の作業内容は保存されません。Webページを閉じると失われます。
- Webページは、localhostもしくはhttps://でアクセスする必要があります。
- 外部のライブラリ Opus Recorder v8.0.5を利用しています。lib/opus-recorder/encoderWoker.min.jsはOpus Recorderのファイルです。
- 上記以外のファイルのJavaScriptのコードのご利用に制限はありませんが、保証は一切おこなっていません。利用者ご自身の責任でご利用ください。問い合わせにも対応できません。
仕様について
- AmiVoice APIの同期HTTP音声認識API、非同期HTTP音声認識API、WebSocket音声認識APIを実行できます。ただし、すべてのパラメーターに対応しているわけではありません。
- 音声ファイルは、ブラウザー上で一度32bit リニアPCMにデコードされた後、モノラル 16kHzのDVI/IMA ADPCM(独自ヘッダー付き)またはOgg Opusに変換され、AmiVoice APIのサーバーに送信されます。
- マイクとシステム音も同様に16kHzのDVI/IMA ADPCM(独自ヘッダー付き)またはOgg Opusに変換後、AmiVoice APIのサーバーに送信されます。
- 音声フォーマットの変換を行っているため、AmiVoice APIを直接実行した場合と結果が異なる可能性があります。
- マイクとシステム音の録音と音声認識は最大1時間までです。これは、AmiVoice APIの制限ではありません。
- 非同期HTTP音声認識APIのジョブの状態の取得は、30秒ごとに行うようにしています。
- 音声認識が終わると、音声認識結果のJSON、JSONから抽出したテキスト、JSONを加工したWebVTT形式の字幕ファイルのサンプルのリンクが作成されます。
- WebSocket音声認識APIの認識結果は複数のJSONに分かれていますが、複数のJSONを1つの配列にまとめたものをダウンロードできるようにしています。
- 音声ファイルとWebVTTをソースに設定したHTMLのVideo要素が自動で作られます。
- Video要素は、Video要素のコントロールのオプションから字幕の切り替えや音声ファイルのダウンロードが行えます。
- 映像が含まれない音声ファイルの場合、Video要素のコントロールの裏側に字幕が隠れてしまう場合があります。字幕が表示されない場合は、マウスカーソルをVideo要素の外側に移動させてみてください。
使い方
同期HTTP音声認識API
- AmiVoice APIのAPPKEYを設定。
- 音声ファイルを選択。(音声+映像のファイルも可。)
- 「同期HTTP音声認識API実行」ボタンをクリックすると認識が開始します。
- 音声認識が完了すると結果が表示され、WebVTT形式の字幕ファイルのサンプルが作成されます。
非同期HTTP音声認識API
- AmiVoice APIのAPPKEYを設定。
- 音声ファイルを選択。(音声+映像のファイルも可。)
- 「非同期HTTP音声認識API実行」ボタンをクリックすると認識が開始します。
- 音声認識が完了すると結果が表示され、WebVTT形式の字幕ファイルのサンプルが作成されます。
WebSocket音声認識API
- AmiVoice APIのAPPKEYを設定。
- 「WebSocket音声認識API開始」ボタンをクリックすると録音と認識が開始します。
- 音声認識が完了すると、結果が表示されます。
- 「WebSocket音声認識API停止」ボタンをクリックすると録音と認識が終了し、WebVTT形式の字幕ファイルのサンプルが作成されます。
作り方
1. 非同期HTTP音声認識APIの実行
AmiVoice APIのマニュアルにあるPythonのサンプルコードをもとにJavaScriptのコードを作成します。
まずは「音声認識ジョブの作成」を行う最小限のコードを作成します。
HTTPリクエストを送信する方法は、fetchを使う、jQueryやAxiosなどのライブラリを利用するなどいろいろありますが、ここではXMLHttpRequestを利用します。
なお、jQueryとAxiosも内部ではXMLHttpRequestを使用しているようです。
/** * 音声認識ジョブを作成します。 * appKey {string} APPKEY * audioFile {File} 音声ファイル */ function postJob(appKey, audioFile) { var fd = new FormData(); var domain = "grammarFileNames=-a-general"; fd.append("d", domain); fd.append("u", appKey); // appKeyにはAmiVoice APIのAPPKEYを指定。 fd.append("a", audioFile); // audioFileはFileオブジェクト。 var httpRequest = new XMLHttpRequest(); httpRequest.addEventListener("load", function (event) { if (event.target.status === 200) { var resultJson = JSON.parse(event.target.responseText); if (!resultJson.sessionid) { // ジョブ登録失敗 } // ジョブ登録成功 } else { // ジョブ登録失敗 } }); httpRequest.open("POST", "https://acp-api-async.amivoice.com/recognitions", true); httpRequest.send(fd); }
APPKEYと音声ファイルをマルチパートフォームデータ形式で送信し、HTTPステータス 200とsessionidが取得できればジョブの作成は成功です。
「音声認識ジョブの作成」のリクエストは、マルチパートフォームデータ形式である必要があること、またプリフライトリクエストに対応しておらず、余計なヘッダーの設定やアップロードの進捗状況を取得するためのリスナーの追加を行うと、ブラウザーでCORSのエラーが発生し、リクエストを送ることができないことに注意してください。
この後、APPKEYとsessionidを使って「ジョブの状態の取得」を行います。
/** * ジョブの状態を取得します。 * appKey {string} APPKEY * sessionid {string} ジョブのID */ function getJobStatus(appKey, sessionid) { var httpRequest = new XMLHttpRequest(); httpRequest.addEventListener("load", function (event) { if (event.target.status === 200) { var resultJson = JSON.parse(event.target.responseText); if (resultJson.status === "completed") { // 音声認識完了 } else if (resultJson.status === "error") { // 音声認識失敗 } else { // 音声認識未完了 } } else { // 音声認識ジョブの状態の取得失敗 } }); httpRequest.open("GET", "https://acp-api-async.amivoice.com/recognitions/" + sessionid, true); httpRequest.setRequestHeader("Authorization", "Bearer " + appKey); httpRequest.send(); }
音声認識ジョブの状態の取得は、completedまたはerrorが返ってくるまで繰り返します。
音声認識ジョブの作成と異なり、AuthorizationヘッダーでAPPKEYを指定する必要があることに注意してください。
音声認識ジョブの作成後、ジョブの状態の取得を繰り返し呼ぶようにし、オプションパラメーターやエラー処理を追加したオブジェクトが下記になります。
※ 非同期HTTP音声認識APIのすべてのパラメーターを網羅しているわけではありません。
var AsyncHrp = function () { /** APIエンドポイント */ this.serverUrl = "https://acp-api-async.amivoice.com/v1/recognitions"; /** エンジンモード名 */ this.engineMode = "-a-general"; /** ログ保存オプトアウト */ this.loggingOptOut = true; /** ユーザー登録単語 */ this.profileWords = ""; /** フィラー単語の保持 */ this.keepFillerToken = false; /** 話者ダイアライゼーション */ this.speakerDiarization = false; /** 感情解析 */ this.sentimentAnalysis = false; /** ジョブ状態取得インターバル(ミリ秒) */ this.checkJobStatusInterval = 30000; /** 処理経過コールバック function(message, sessionId) */ this.onProgress = null; /** エラー発生コールバック function(message, sessionId) */ this.onError = null; /** 処理完了コールバック function(resultJson, sessionId) */ this.onCompleted = null; }; /** * ジョブを登録します * @param {string} appKey APPKEY(uパラメーター) * @param {File} audioFile 音声ファイル(aパラメーター) */ AsyncHrp.prototype.postJob = function (appKey, audioFile) { var that = this; var fd = new FormData(); var domain = "grammarFileNames=" + that.engineMode + (that.loggingOptOut ? " loggingOptOut=True" : "") + (that.keepFillerToken ? " keepFillerToken=1" : "") + (that.profileWords.length > 0 ? " profileWords=" + encodeURIComponent(that.profileWords) : "") + (that.speakerDiarization ? " speakerDiarization=True" : "") + (that.sentimentAnalysis ? " sentimentAnalysis=True" : ""); fd.append("d", domain); fd.append("u", appKey); fd.append("a", audioFile); var httpRequest = new XMLHttpRequest(); httpRequest.addEventListener("load", function (event) { if (event.target.status === 200) { var resultJson = JSON.parse(event.target.responseText); if (!resultJson.sessionid) { if (that.onError) that.onError("Failed to create job - " + resultJson.message, null); return; } if (that.onProgress) that.onProgress("queued", resultJson.sessionid); // checkJobStatusInterval後にジョブの状態取得 setTimeout(function () { that.getJobStatus(appKey, resultJson.sessionid); }, that.checkJobStatusInterval); } else { if (that.onError) that.onError(event.target.responseText, null); } }); httpRequest.addEventListener("error", function (event) { if (that.onError) that.onError("Request error", null); }); httpRequest.addEventListener("abort", function (event) { if (that.onError) that.onError("Request abort", null); }); httpRequest.addEventListener("timeout", function (event) { if (that.onError) that.onError("Request timeout", null); }); httpRequest.open("POST", that.serverUrl, true); httpRequest.send(fd); }; /** * ジョブの状態を取得します * @param {string} appKey APPKEY * @param {string} sessionId ジョブのセッションID */ AsyncHrp.prototype.getJobStatus = function (appKey, sessionId) { var that = this; var httpRequest = new XMLHttpRequest(); httpRequest.addEventListener("load", function (event) { if (event.target.status === 200) { var resultJson = JSON.parse(event.target.responseText); if (that.onProgress) that.onProgress(resultJson.status, sessionId); if (resultJson.status === "completed") { if (that.onCompleted) that.onCompleted(resultJson, sessionId); } else if (resultJson.status === "error") { if (that.onError) that.onError(resultJson.error_message, sessionId); } else { // checkJobStatusInterval後にもう一度ジョブの状態取得 setTimeout(function () { that.getJobStatus(appKey, sessionId) }, that.checkJobStatusInterval); } } else { if (that.onError) that.onError(event.target.responseText, sessionId); } }); httpRequest.addEventListener("error", function (event) { if (that.onError) that.onError("Request error", sessionId); }); httpRequest.addEventListener("abort", function (event) { if (that.onError) that.onError("Request abort", sessionId); }); httpRequest.addEventListener("timeout", function (event) { if (that.onError) that.onError("Request timeout", sessionId); }); httpRequest.open("GET", that.serverUrl + "/" + sessionId, true); httpRequest.setRequestHeader("Authorization", "Bearer " + appKey); httpRequest.send(); };
使用方法です。
const asyncHrp = new AsyncHrp(); asyncHrp.onProgress = function (message, sessionId) { console.log((sessionId !== null ? "[" + sessionId + "]" : "") + message); }; asyncHrp.onError = function (message, sessionId) { console.log((sessionId !== null ? "[" + sessionId + "]" : "") + message); }; asyncHrp.onCompleted = function (resultJson, sessionId) { console.log(resultJson.text); }; asyncHrp.engineMode = "-a-general"; asyncHrp.postJob(appKey, audioFile);
2. 同期HTTP音声認識APIの実行
非同期HTTP音声認識APIの実行ができていれば、同期HTTP音声認識APIの実行は簡単です。
音声認識ジョブの作成だけで認識結果が返ってくるようなイメージです。
ただし、音声認識が終わるまでサーバーとの接続を維持し続ける必要があることや音声データの最大容量などの制限事項が異なること、パラメーターの指定方法が一部異なることに注意してください。
マルチパートフォームデータ形式である必要があること、プリフライトリクエストに対応しておらず、余計なヘッダーの設定やアップロードの進捗状況を取得するためのリスナーの追加を行うと、ブラウザーでCORSのエラーが発生し、リクエストを送ることができないところは、非同期HTTP音声認識APIの音声認識ジョブの作成と同じです。
「非同期HTTP音声認識APIの実行」で作成したJavaScriptのコードを一部変更し、下記のようなコードになります。
※ 同期HTTP音声認識APIのすべてのパラメーターを網羅しているわけではありません。
var EasyHrp = function () { /** APIエンドポイント */ this.serverUrl = "https://acp-api.amivoice.com/v1/recognize"; /** エンジンモード名 */ this.engineMode = "-a-general"; /** ログ保存オプトアウト */ this.loggingOptOut = true; /** ユーザー登録単語 */ this.profileWords = ""; /** フィラー単語の保持 */ this.keepFillerToken = false; /** 話者ダイアライゼーション */ this.speakerDiarization = false; /** エラー発生コールバック function(message, sessionId) */ this.onError = null; /** 処理完了コールバック function(resultJson, sessionId) */ this.onCompleted = null; }; /** * ジョブを登録します * @param {string} appKey APPKEY(uパラメーター) * @param {File} audioFile 音声ファイル(aパラメーター) */ EasyHrp.prototype.postJob = function (appKey, audioFile) { var that = this; const fd = new FormData(); var domain = "grammarFileNames=" + that.engineMode + (that.keepFillerToken ? " keepFillerToken=1" : "") + (that.profileWords.length > 0 ? " profileWords=" + encodeURIComponent(that.profileWords) : "") + (that.speakerDiarization ? " segmenterProperties=useDiarizer=1" : ""); fd.append("d", domain); fd.append("u", appKey); fd.append("a", audioFile); var httpRequest = new XMLHttpRequest(); httpRequest.addEventListener("load", function (event) { if (event.target.status === 200) { var resultJson = JSON.parse(event.target.responseText); if (resultJson.code !== "") { if (that.onError) that.onError(resultJson.message, null); return; } if (that.onCompleted) that.onCompleted(resultJson, null); } else { if (that.onError) that.onError(event.target.responseText, null); } }); httpRequest.addEventListener("error", function (event) { if (that.onError) that.onError("Request error", null); }); httpRequest.addEventListener("abort", function (event) { if (that.onError) that.onError("Request abort", null); }); httpRequest.addEventListener("timeout", function (event) { if (that.onError) that.onError("Request timeout", null); }); const url = that.loggingOptOut ? that.serverUrl.replace(new RegExp("^(.+)(/recognize)$"), "$1/nolog$2") : that.serverUrl; httpRequest.open("POST", url, true); httpRequest.send(fd); };
const easyHrp = new EasyHrp(); easyHrp.onError = function (message, sessionId) { console.log(message); }; easyHrp.onCompleted = function (resultJson, sessionId) { console.log(resultJson.text); }; easyHrp.engineMode = "-a-general"; easyHrp.postJob(appKey, audioFile);
なお、AmiVoice API クライアントライブラリでも同期HTTP音声認識APIを実行できます。
パラメーターが豊富で録音機能もありますので、ぜひご確認ください。
3. WebSocket音声認識APIの実行
AmiVoice API クライアントライブラリを利用します。
まずは、WebSocket音声認識APIの開始です。
※ WebSocket音声認識APIのすべてのパラメーターを網羅しているわけではありません。
Wrp.serverURL = "wss://acp-api.amivoice.com/v1/"; Wrp.grammarFileNames = "-a-general"; Wrp.authorization = appKey; Wrp.resultUpdated = function (result) { // 認識の途中結果(Uイベント) const resultJsonPart = JSON.parse(result); console.log(resultJsonPart.text); }; Wrp.resultFinalized = function (result) { // 認識完了(Aイベント) const resultJsonPart = JSON.parse(result); console.log(resultJsonPart.text); }; Wrp.feedDataPauseEnded = function () { // WebSocket停止後の処理 }; Wrp.feedDataResume();
次に停止です。
Wrp.feedDataPause();
このJavaScriptを実行するWebページにはlocalhostもしくはhttps://でアクセスする必要があること、マイクの使用を許可する必要があることに注意してください。
4. MP4(AAC)とWebM(Opus)への対応
音声フォーマット対応表からわかるように、AmiVoice APIはMP4(AAC)やWebM(Opus)といった音声フォーマットに対応していません。
しかしながらMicrosoft EdgeやGoogle Chromeといった主要なブラウザーはMP4(AAC)やWebM(Opus)のデコードに対応しています。
そこで音声ファイルをサーバーに送信する前にブラウザーの機能を使ってAmiVoice APIが対応しているフォーマットに変換し、音声認識が行えるようにします。
具体的には、BaseAudioContext.decodeAudioData()を使って32bit float リニアPCMを取得し、これを16bit リニアPCMに変換します。
BaseAudioContext.decodeAudioData()は、MP4の動画ファイルのような映像と音声の両方が格納されたファイルからも音声データのデコードができます。これにより、ブラウザーが対応している動画ファイルであれば、音声認識ができるようになります。
ただし、AmiVoice APIでは対応しているOgg Speexは非対応になります。
const reader = new FileReader(); reader.onload = () => { const AudioContext = window.AudioContext || window.webkitAudioContext; const audioContext = new AudioContext({ sampleRate: 16000 }); // 音声ファイルを32bit float リニアPCMに変換 audioContext.decodeAudioData(reader.result, function (audioBuffer) { // 32bit float リニアPCMを16bit リニアPCMに変換 var pcmData = new Uint8Array(audioBuffer.length * 2); var index = 0; for (var audioDataIndex = 0; audioDataIndex < audioBuffer.length; audioDataIndex++) { var pcm = audioBuffer[audioDataIndex] * 32768 | 0; if (pcm > 32767) { pcm = 32767; } else if (pcm < -32768) { pcm = -32768; } // 16bit リニアPCMデータ(リトルエンディアン) pcmData[index++] = (pcm) & 0xFF; pcmData[index++] = (pcm >> 8) & 0xFF; } }, () => { addLog("Can't decode audio data."); }); }; reader.readAsArrayBuffer(audioFile);
32bit float リニアPCMから16bit リニアPCMへの変換は、32bit float リニアPCMの-1.0~1.0に32768をかけて-32768~32767に変換します。32768は範囲外のため32767にします。 また32bit float リニアPCMでは-1.0~1.0の範囲外の値、16bitの-32768~32767に収まらない値も持てるため、範囲外の値は丸めて収めます。
5. 複数チャンネルの音声データをモノラルへ変換
AmiVoice APIにステレオなどの複数チャンネルの音声データを送信すると、1チャンネル目の音声のみが音声認識の対象となります。
サーバーに送信する前にモノラルの音声データに変更することで、複数チャンネルの音声データを認識させます。
やり方はいろいろあると思いますが、ここではChannelMergerNodeを利用してモノラルへの変換を行います。
// 音声ファイルを32bit リニアPCMに変換 audioContext.decodeAudioData(reader.result, async function (audioBuffer) { // モノラルにダウンミックス const OfflineAudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; const offlineAudioContext = new OfflineAudioContext(audioBuffer.numberOfChannels, audioBuffer.length, audioBuffer.sampleRate); const merger = offlineAudioContext.createChannelMerger(audioBuffer.numberOfChannels); const source = offlineAudioContext.createBufferSource(); source.buffer = audioBuffer; for (let i = 0; i < audioBuffer.numberOfChannels; i++) { source.connect(merger, 0, i); } merger.connect(offlineAudioContext.destination); source.start(); const mixedBuffer = await offlineAudioContext.startRendering(); // モノラルにダウンミックスされた32bit float リニアPCMデータ const float32PcmData = mixedBuffer.getChannelData(0); merger.disconnect(); source.disconnect(); audioContext.close(); }, () => { addLog("Can't decode audio data."); });
複数チャンネルの音声データを認識させる方法としては、チャンネルごとに音声データを分離させてそれぞれ認識させる方法も考えられます。この場合、同時に音声認識を開始したからといって時間の同期をとって認識結果が返ってくるわけではないことに注意してください。
6. 音声データの圧縮(DVI/IMA ADPCM (AMI 独自形式))
4.と5.で音声ファイルをモノラルの16bit リニアPCMにすることができるようになりました。
ただ、このままだともとの音声データが圧縮形式だった場合、もとのデータサイズに比べてサーバーに送信するデータのサイズが巨大になってしまいます。
そこで音声データの圧縮を行います。
今のところ詳細な仕様についてマニュアルに明記されていませんが、AmiVoice API クライアントライブラリにPCMデータをDVI/IMA ADPCM (AMI 独自形式)に圧縮する処理があるため、これを利用することにします。
ちなみに「AMI 独自形式」の「AMI」は、「Advanced Media, Inc.」の略です。
recorder.jsから上記の処理を抜き出し、Web Workerにすると下記のようになりました。
onmessage = (event) => { var adpcmAarrayBuffer = toADPCM(event.data[0], event.data[1]); postMessage(adpcmAarrayBuffer, [adpcmAarrayBuffer]); } /** * モノラルの32bitリニアPCMデータをDVI/IMA ADPCM (AMI 独自形式)に変換します。 * @param {Float32Array} float32PcmData PCMデータ * @param {number} audioSamplesPerSec サンプリングレート * @returns 変換結果 */ function toADPCM(float32PcmData, audioSamplesPerSec) { // ADPCM用に後で4で割り切れるように偶数個に調整 var bufferLen = float32PcmData.length; if (bufferLen % 2 !== 0) { bufferLen++; } // 32bit float リニアPCMを16bit リニアPCMに変換 var pcmData = new Uint8Array(bufferLen * 2); var index = 0; for (var audioDataIndex = 0; audioDataIndex < float32PcmData.length; audioDataIndex++) { var pcm = float32PcmData[audioDataIndex] * 32768 | 0; // 小数 (0.0~1.0) を 整数 (-32768~32767) に変換... if (pcm > 32767) { pcm = 32767; } else if (pcm < -32768) { pcm = -32768; } // 16bit リニアPCMデータ(リトルエンディアン) pcmData[index++] = (pcm) & 0xFF; pcmData[index++] = (pcm >> 8) & 0xFF; } float32PcmData = null; let adpcmData = new Uint8Array(16 + pcmData.length / 4); let adpcmDataIndex = 0; // DVI/IMA ADPCM (AMI 独自形式)のヘッダー adpcmData[adpcmDataIndex++] = 0x23; // '#' adpcmData[adpcmDataIndex++] = 0x21; // '!' adpcmData[adpcmDataIndex++] = 0x41; // 'A' adpcmData[adpcmDataIndex++] = 0x44; // 'D' adpcmData[adpcmDataIndex++] = 0x50; // 'P' adpcmData[adpcmDataIndex++] = 0x0A; // '\n' adpcmData[adpcmDataIndex++] = (audioSamplesPerSec & 0xFF); adpcmData[adpcmDataIndex++] = ((audioSamplesPerSec >> 8) & 0xFF); adpcmData[adpcmDataIndex++] = 1; // channels adpcmData[adpcmDataIndex++] = 2; // type adpcmData[adpcmDataIndex++] = 0; adpcmData[adpcmDataIndex++] = 0; adpcmData[adpcmDataIndex++] = 1; adpcmData[adpcmDataIndex++] = 2; adpcmData[adpcmDataIndex++] = 0; adpcmData[adpcmDataIndex++] = 0; // DVI/IMA ADPCM データ ima_state_ = 1; ima_state_last_ = 0; ima_state_step_index_ = 0; var oldData = new DataView(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength); for (var i = 0; i < oldData.byteLength; i += 4) { var pcm1 = oldData.getInt16(i, true); var pcm2 = oldData.getInt16(i + 2, true); var ima1 = linear2ima_(pcm1); var ima2 = linear2ima_(pcm2); adpcmData[adpcmDataIndex++] = ((ima1 << 4) | ima2); } pcmData = null; return adpcmData.buffer; }; // <!-- for ADPCM packing var ima_step_size_table_ = [ 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767 ]; var ima_step_adjust_table_ = [ -1, -1, -1, -1, 2, 4, 6, 8 ]; var ima_state_; var ima_state_last_; var ima_state_step_index_; function linear2ima_(pcm) { var step_size = ima_step_size_table_[ima_state_step_index_]; var diff = pcm - ima_state_last_; var ima = 0x00; if (diff < 0) { ima = 0x08; diff = -diff; } var vpdiff = 0; if (diff >= step_size) { ima |= 0x04; diff -= step_size; vpdiff += step_size; } step_size >>= 1; if (diff >= step_size) { ima |= 0x02; diff -= step_size; vpdiff += step_size; } step_size >>= 1; if (diff >= step_size) { ima |= 0x01; vpdiff += step_size; } step_size >>= 1; vpdiff += step_size; if ((ima & 0x08) != 0) { ima_state_last_ -= vpdiff; } else { ima_state_last_ += vpdiff; } if (ima_state_last_ > 32767) { ima_state_last_ = 32767; } else if (ima_state_last_ < -32768) { ima_state_last_ = -32768; } ima_state_step_index_ += ima_step_adjust_table_[ima & 0x07]; if (ima_state_step_index_ < 0) { ima_state_step_index_ = 0; } else if (ima_state_step_index_ > 88) { ima_state_step_index_ = 88; } return ima; } // -->
DVI/IMA ADPCMにAmiVoice APIの独自ヘッダーを付けたものがDVI/IMA ADPCM (AMI 独自形式)です。
16bit リニアPCMデータを約1/4のサイズにすることができます。ただし、非可逆圧縮で音声の劣化があります。音声データを圧縮することで送信するデータのサイズは小さくなりますが、認識率は低下します。そのため、圧縮することを推奨しているわけではありません。
認識率を優先する場合は、無圧縮の16bit リニアPCMや可逆圧縮のFLACをご検討ください。
toADPCM()の先頭でデータの数を偶数個に調整する処理がありますが、これは付け足したものです。recorder.jsのもとの処理では、必ず偶数個のデータが渡ってくるため、この処理は存在しません。
使用方法です。
// モノラルの32bit リニアPCMを独自ヘッダー付きのDVI/IMA ADPCMに変換 const audioFileConverter = new Worker('./scripts/ami-adpcm-worker.js'); audioFileConverter.onmessage = (event) => { const convertedAudioFile = new Blob([event.data], { type: "application/octet-stream" }); audioFileConverter.terminate(); }; audioFileConverter.postMessage([float32PcmData, 16000], [float32PcmData.buffer]);
Web Workerは、他のプログラミング言語のスレッドに相当するもので、並列処理を行うためのものです。HTMLを操作することができなかったり、利用できるAPIに制限があって何にでも利用できるわけではありませんが、今回のような処理には適しています。なお、残念ながらWeb WorkerからAudioContextを利用することはできませんでした。
Worker.postMessage()の第2引数は、初めて見ると違和感を感じるかもしれませんが、これは移譲可能オブジェクトの指定で、Web Workerにオブジェクトを渡す際のオブジェクトのコピーのコストを下げる効果があります。ただし、以降、Worker.postMessage()の呼び出し元のスレッドからは、このオブジェクトが利用できなくなります。
AmiVoice API クライアントライブラリのWebSocket音声認識APIのサンプル(wrp.js)では、オプションを有効にすることでDVI/IMA ADPCM (AMI 独自形式)に圧縮してからサーバーに音声が送られるように既になっています。
このオプションは、Recorder.adpcmPackingElementとRecorder.adpcmPackingで設定できます。
7. 音声データの圧縮(Ogg Opus形式)
DVI/IMA ADPCM (AMI 独自形式)は短くて軽いコードで処理ができてよいのですが、一般的なプレイヤーでは再生できず、MP3などと比べると圧縮率が低いため、別の形式も検討します。
WebRTCでよく使われるOgg Opus形式です。
MediaRecorderでできないかテストしてみたのですが、Microsoft EdgeとGoogle Chromeの対応はWebMのみで、残念ながらOgg Opusでの録音には対応していませんでした。
MITライセンスで公開されているサードパーティーのライブラリOpus Recorderを利用します。
まず、公開されているopus-recorder/example/fileEncoder.htmlを参考にラッパーを作成します。
// @see https://github.com/chris-rudmin/opus-recorder var OpusEncoderWrapper = function () { /** opus recorderのencodeWorker */ this.encodeWorker = null; /** 入力音声のサンプリングレート */ this.originalSampleRate = 16000; /** エンコーダーのサンプリングレート */ this.encoderSampleRate = 48000; /** Ogg Opusの1ページの最大フレーム数 デフォルト 40 */ this.maxFramesPerPage = 40; /** 複雑度 0~10 */ this.complexity = 10; /** リサンプリングクオリティ 0~10 */ this.quality = 3; /** encode開始済みの場合true */ this.isStarted = false; /** Ogg Opusのデータ */ this.totalArray = null; /** opus recorderのバッファサイズ */ this.bufferLength = 4096; /** 処理完了コールバック。useStreamがfalseもしくはisDebugがtrueのときはOgg Opus全体のデータが渡されます。 function(Uint8Array) */ this.onCompleted = null; /** Ogg Opusのページ作成完了コールバック。ページデータが渡されます。 function(Uint8Array) */ this.onAvailable = null; /** trueの場合、変換結果をソースにしたaudio要素をHTMLに追加します。 */ this.isDebug = false; /** ストリームデータ(ページ)を使用する場合はtrue。 */ this.useStream = true; /** opus recorderのencodeWorkerのOjectURL。Chrome拡張機能用。 */ this.workerObjectURL = null; }; /** * a, b TypedArray of same type * @param {*} a * @param {*} b * @returns */ OpusEncoderWrapper.prototype.concatTypedArrays = function (a, b) { var c = new (a.constructor)(a.length + b.length); c.set(a, 0); c.set(b, a.length); return c; }; /** * 初期化を行います。 */ OpusEncoderWrapper.prototype.initialize = async function () { var that = this; // Chrome拡張機能用。WebサイトのCSPで制限されている場合は、エラーになります。 if (typeof chrome !== 'undefined' && chrome.runtime) { try { const response = await fetch(chrome.runtime.getURL('./lib/opus-recorder/encoderWorker.min.js')); const workerJs = await response.text(); that.workerObjectURL = URL.createObjectURL(new Blob([workerJs], { type: "text/javascript" })); that.encodeWorker = new Worker(that.workerObjectURL); } catch (e) { return false; } } else { that.encodeWorker = new Worker('./lib/opus-recorder/encoderWorker.min.js'); } that.totalArray = new Uint8Array(0); that.encodeWorker.postMessage({ command: 'init', encoderSampleRate: that.encoderSampleRate, bufferLength: that.bufferLength, originalSampleRate: that.originalSampleRate, maxFramesPerPage: that.maxFramesPerPage, encoderApplication: 2049, // type VOIP 2048, Full Band Audio 2049, Restricted Low Delay 2051 encoderFrameSize: 20, encoderComplexity: that.complexity, resampleQuality: that.quality, numberOfChannels: 1, encoderBitRate : 24000 }); return true; } /** * encodeWorkerを開始します。 */ OpusEncoderWrapper.prototype.start = function () { var that = this; if (!that.encodeWorker) { return false; } if (that.isStarted) { return false; } that.isStarted = true; that.encodeWorker.onmessage = function (e) { if (e.data.message === "done") { //finished encoding - save to audio tag if (that.onCompleted) { that.onCompleted(that.totalArray); } that.encodeWorker.terminate(); that.encodeWorker = null; that.isStarted = false; // Chrome拡張機能用。 if (that.workerObjectURL !== null) { URL.revokeObjectURL(that.workerObjectURL); that.workerObjectURL = null; } if (that.isDebug) { var fileName = new Date().toISOString().replaceAll(/[:.]/g, "") + ".opus"; var dataBlob = new Blob([that.totalArray], { type: "audio/ogg; codecs=opus" }); var url = URL.createObjectURL(dataBlob); var audio = document.createElement('audio'); audio.controls = true; audio.src = URL.createObjectURL(dataBlob); audio.title = fileName; var link = document.createElement('a'); link.href = url; link.download = fileName; link.innerHTML = link.download; var li = document.createElement('li'); li.appendChild(link); li.appendChild(audio); document.body.appendChild(li); } } else if (e.data.message === "page") { if (that.isDebug || !that.useStream) { that.totalArray = that.concatTypedArrays(that.totalArray, e.data.page); } if (that.onAvailable) { that.onAvailable(e.data.page); } } }; that.encodeWorker.postMessage({ command: 'getHeaderPages' }); }; /** * encodeWorkerを終了させます。 * @returns */ OpusEncoderWrapper.prototype.stop = function () { var that = this; if (!that.encodeWorker) { return false; } if (that.isStarted) { that.encodeWorker.postMessage({ command: 'done' }); } else { that.encodeWorker.terminate(); that.encodeWorker = null; // Chrome拡張機能用。 if (that.workerObjectURL !== null) { URL.revokeObjectURL(that.workerObjectURL); that.workerObjectURL = null; } } return true; }; /** * encodeを実行します。 * @param {Float32Array} float32PcmData * @returns */ OpusEncoderWrapper.prototype.encode = function (float32PcmData) { var that = this; if (!that.encodeWorker) { return false; } if (!that.isStarted) { return false; } var bufferLength = Math.min(that.bufferLength, float32PcmData.length); for (i = 0; i < float32PcmData.length; i += bufferLength) { var tempBuffer = new Float32Array(bufferLength); for (j = 0; j < bufferLength; j++) { tempBuffer[j] = float32PcmData[i + j]; } that.encodeWorker.postMessage({ command: 'encode', buffers: [tempBuffer] }, [tempBuffer.buffer]); } return true; };
ファイルをOgg Opusに変換する場合の使用方法です。
// モノラルの32bit リニアPCMをOgg Opusに変換 const opusEncoderWrapper = new OpusEncoderWrapper(); opusEncoderWrapper.originalSampleRate = 16000; opusEncoderWrapper.useStream = false; opusEncoderWrapper.onCompleted = function (opusData) { // 変換後のファイル const convertedAudioFile = new Blob([opusData], { type: "audio/ogg; codecs=opus" }); }; await opusEncoderWrapper.initialize(); opusEncoderWrapper.start(); opusEncoderWrapper.encode(float32PcmData); opusEncoderWrapper.stop();
AmiVoice API クライアントライブラリのWebSocket音声認識API(Wrp)から利用する場合は、recorder.jsとwrp.jsに処理を追加する必要があります。
まずはrecorder.jsの初期化です。
var opusEncoder_ = null; async function initialize_() { ... // 下記を追加 if (opusEncoder_ !== null) { opusEncoder_.onAvailable = null; opusEncoder_.stop(); opusEncoder_ = null; } if (recorder_.useOpusRecorder) { opusEncoder_ = new OpusEncoderWrapper(); opusEncoder_.originalSampleRate = audioContext_.sampleRate; opusEncoder_.maxFramesPerPage = 5; if (!(await opusEncoder_.initialize())) { opusEncoder_ = null; } }
次にrecorder.jsのaudioProcessor_onaudioprocess_recorded_とaudioProcessor_onaudioprocess_downSampling_recorded_の処理を変更します。
audioProcessor_onaudioprocess_recorded_ = function (event) { ... // 変更 // if (recorder_.recorded) recorder_.recorded(pcmData_.subarray(pcmDataOffset, pcmDataIndex)); if (opusEncoder_ !== null) { if (!opusEncoder_.isStarted) { opusEncoder_.start(); opusEncoder_.onAvailable = function (opusData) { if (recorder_.recorded) recorder_.recorded(opusData); } } opusEncoder_.encode(audioData); } else { if (recorder_.recorded) recorder_.recorded(pcmData_.subarray(pcmDataOffset, pcmDataIndex)); }
audioProcessor_onaudioprocess_downSampling_recorded_ = function (event) { ... while (temporaryAudioDataSamples_ == temporaryAudioData_.length) { ... // 変更 // if (recorder_.recorded) recorder_.recorded(pcmData_.subarray(pcmDataOffset, pcmDataIndex)); if (opusEncoder_ === null) { if (recorder_.recorded) recorder_.recorded(pcmData_.subarray(pcmDataOffset, pcmDataIndex)); } ... } // 追加 if (opusEncoder_ !== null) { if (!opusEncoder_.isStarted) { opusEncoder_.start(); opusEncoder_.onAvailable = function(opusData) { if (recorder_.recorded) recorder_.recorded(opusData); } } opusEncoder_.encode(audioData); }
recorder.jsのstopTracksに停止処理を追加します。
stopTracks = function() { ... if (waveData_) { ... } // 追加 if (opusEncoder_ !== null) { opusEncoder_.stop(); } if (recorder_.pauseEnded) recorder_.pauseEnded(reason_, waveFile_);
次にrecorder.jsのresume_()でcodecを渡しているところの内容を書き換えます。
// 変更 // if (recorder_.resumeEnded) recorder_.resumeEnded(((ima_state_ > 0) ? "" : "MSB") + (audioSamplesPerSec_ / 1000 | 0) + "K"); if (recorder_.resumeEnded) { if (opusEncoder_ !== null) { if (recorder_.resumeEnded) recorder_.resumeEnded("OPUS16K"); } else { if (recorder_.resumeEnded) recorder_.resumeEnded(((ima_state_ > 0) ? "" : "MSB") + (audioSamplesPerSec_ / 1000 | 0) + "K"); } }
次にwrp.jsの変更です。
サーバーへの接続完了前に録音を開始する作りになっていて、接続完了前に録音した音声はサーバーに送信されません。
Ogg Opusのヘッダーが送信されないとサーバーで音声フォーマットの判定に失敗するため、録音後すぐに送信できない音声データは一時領域に格納しておき、接続完了後、最初の音声データ送信の直前に送信するようにします。
録音した音声と実際にサーバーに送られた音声の時間にずれがあると、後で作ろうとしている字幕ファイルも位置ずれしてしまうため、Ogg Opus以外でも必要な変更です。
なお「OPUS16K」は古い仕様の音声フォーマットの指定方法で、現在は「16K」を指定することでヘッダーから自動的に判定されます。
// 追加 var preRecordedData_ = []; if (recorder_) { ... recorder_.resumeEnded = function(codec) { ... preRecordedData_ = []; }; recorder_.pauseEnded = function(reason) { ... preRecordedData_ = []; }; recorder_.recorded = function(data) { // 変更 // if (state_ === 5) { // data = recorder_.pack(data); // feedData__(data); // } if (state_ === 5) { preRecordedData_.forEach(preData => { if (wrp_.codec !== 'OPUS16K') { preData = recorder_.pack(preData); } feedData__(preData); }); preRecordedData_ = []; if (wrp_.codec !== 'OPUS16K') { data = recorder_.pack(data); } feedData__(data); } else { preRecordedData_.push(data); } };
Ogg Opusの圧縮率ですが、AmiVoice APIのマニュアルのガイドラインでは「10分の1程度」となっているため、encoderBitRateに24000を設定し、約10分の1になるようにしています。Ogg Opusは可変ビットレートのため、24000ぴったりになるわけではなく、平均するとだいたいこれくらいの値になるということのようです。
ビットレートは、16bit リニアPCM でサンプリングレートが16000Hzの場合、16000(Hz) × 16(bit) = 256kbpsになり、24kbpsで約10分の1になります。ちなみにこの「bps」の「b」はバイトではなくビットで、24kbpsは、1秒のデータ量が24キロビットということになります。kB/sのようなバイトの値が欲しい場合は、1バイト=8ビットなので、8で割ります。24kbpsの場合、3kB/sです。
Ogg Opusも非可逆圧縮で音声の劣化があり、音声データを圧縮することで送信するデータのサイズは小さくなりますが、認識率は低下します。そのため、圧縮することを推奨しているわけではありません。
認識率を優先する場合は、無圧縮の16bit リニアPCMや可逆圧縮のFLACをご検討ください。
なお、Ogg Opusのファイルの内容を確認したい場合は、OpusEncoderWrapperのisDebugをtrueにすると、HTMLにOgg Opusのaudio要素が追加されるようになり、確認することができます。
8. ブラウザータブの音声またはシステムの音声の取得
AmiVoice API クライアントライブラリのWebSocket音声認識API(Wrp)のrecorder.jsで、getUserMedia()のかわりにmediaDevices.getDisplayMedia()を呼ぶことでブラウザータブの音声やシステムの音声を取得し、音声認識に利用することができます。
注意点は、Microsoft EdgeやGoogle Chromeなどの一部のブラウザーでしかサポートされていないこと、videoもtrueにする必要があること、実行時に表示されるタブなどの選択画面で「音声(オーディオ)の共有」にチェックを入れ、録音の対象を選択する必要があることです。
サンプルでは下記のパラメーター設定を行っています。
navigator.mediaDevices.getDisplayMedia(
{
video: true,
audio: {
audioGainControl: false,
channelCount: 1,
echoCancellation: false,
googAutoGainControl: false,
latency: 0.1,
noiseSuppression: false,
sampleRate: 16000,
sampleSize: 16,
volume: 1,
},
selfBrowserSurface: "include",
}
getUserMedia()とgetDisplayMedia()を両方の音声を認識させたい場合は、2つの音声をミックスします。やり方はいろいろありますが、このサンプルでは以下のようにしています。
let audioStream = await navigator.mediaDevices.getUserMedia( { audio: { audioGainControl: false, channelCount: 1, echoCancellation: false, googAutoGainControl: false, latency: 0.1, noiseSuppression: true, sampleRate: 16000, sampleSize: 16, volume: 1, }, video: false } ); let displayStream = await navigator.mediaDevices.getDisplayMedia( { video: true, audio: { audioGainControl: false, channelCount: 1, echoCancellation: false, googAutoGainControl: false, latency: 0.1, noiseSuppression: false, sampleRate: 16000, sampleSize: 16, volume: 0.7, }, selfBrowserSurface: "include", } ); // マイク音声とシステム音声のミックス const audioCtx = new AudioContext({ sampleRate: 16000 }); const dest = audioCtx.createMediaStreamDestination(); let micSource = audioCtx.createMediaStreamSource(audioStream); let systemSource = audioCtx.createMediaStreamSource(displayStream); micSource.connect(dest); systemSource.connect(dest); let mixedStream = new MediaStream(); mixedStream.addTrack(dest.stream.getTracks()[0]);
オプションパラメーターの値については、録音した音声を聞いてよさそうと感じたものを設定しており、主観的なものです。
オプション機能はなるべく無効にしたほうが音質はよさそうです。ただし、環境にもよると思いますが、マイクのノイズがひどかったので、getUserMedia()のnoiseSuppressionはtrueにしました。
ヘッドセットを利用しない環境で回り込みを避けたい場合は、getUserMedia()のechoCancellationはtrueにしたほうがよいかもしれません。
ただし、getUserMedia()のechoCancellationはtrueにすると、Google Chromeで録音デバイスをステレオミキサーにした場合に音声が取れなくなる問題が過去にありました。
selfBrowserSurface: "include"は、getDisplayMedia()の呼び出し元のブラウザータブを録音対象として選択できるようにするためのオプションです。
環境によって最適な設定は異なると思いますので、いろいろ試してみてください。
マイクの音とシステムの音(スピーカーの音)をミックスした場合は、発話が重なった部分の音声は認識率が低下するため、なるべく発話が重ならないようにする必要があります。
マイクの音とシステムの音(スピーカーの音)を音声認識させる方法としては、2つのWebSocketを使ってそれぞれWebSocket音声認識APIを実行する方法も考えられます。この場合、2つの接続の間で時間の同期を取って音声認識が行われるわけではないことに注意してください。認識結果が返ってくる順番が前後する可能性があります。
9. 音声ファイルの情報の取得
BaseAudioContext.decodeAudioData()で音声ファイルのサンプリングレートが取れそうに見えますが、実際に取れるのは、AudioContextのサンプリングレートで音声ファイルのサンプリングレートではありませんでした。
MediaStreamTrackProcessorとAudioDataを使ってHTMLVideoElementのストリームからであればサンプリングレートが取れたのでこれを利用します。
ただし、MediaStreamTrackProcessorとAudioDataは実験的なAPIとなっています。
/** * 音声ファイルの情報を取得します。 * @param {File} audioFile 音声ファイル * @returns 音声ファイルの情報 */ async function getAudioInfo(audioFile) { const getAudioInfo_ = function (audioFile) { return new Promise((resolve, reject) => { if (typeof MediaStreamTrackProcessor !== 'undefined') { const videoElement = document.createElement("video"); videoElement.width = 0; videoElement.height = 0; videoElement.volume = 0.01; videoElement.autoplay = true; document.body.appendChild(videoElement); videoElement.onplay = function () { videoElement.onplay = null; const stream = videoElement.captureStream(); const audioTrack = stream.getAudioTracks()[0]; const processor = new MediaStreamTrackProcessor({ track: audioTrack }); const processorReader = processor.readable.getReader(); processorReader.read().then(function (result) { if (result.done) { return; } videoElement.pause(); videoElement.currentTime = 0; stream.getAudioTracks().forEach((track) => { track.stop(); }); try { processorReader.cancel(); } catch (e) { } const audioDuration = videoElement.duration; URL.revokeObjectURL(videoElement.src); videoElement.src = ""; document.body.removeChild(videoElement); resolve( audioFile.type + " " + result.value.sampleRate + "Hz " + result.value.numberOfChannels + "ch " + Math.floor(audioDuration) + "sec" ); }); }; videoElement.src = URL.createObjectURL(audioFile); } else { resolve(null); } }); }; return await getAudioInfo_(audioFile); }
なお、Ogg Opusのサンプリングレートは、Ogg OpusのIDヘッダーにある入力サンプリングレートではなく、48000Hzが返ってきます。
AmiVoice APIは、IDヘッダーの入力サンプリングレートでサンプリングレートを判断するため、違いがあることに注意してください。
10. WebVTT形式の字幕ファイルの作成
音声認識の結果からWebVTT形式の字幕ファイルを作成します。
WebVTTは専用のプレイヤーを用意しなくてもブラウザーが対応しています。
読みや時間情報、話者ダイアライゼーションの結果も使用したいため、音声認識結果のJSONのtokens(単語単位の結果)を使って作成することにします。
注意点は下記の通りです。
- 音声認識結果のtextには半角スペースが挿入されており、writtenを連結しても再現できません。
- 自動挿入された句読点には、written以外のキーが存在しない場合があります。
- 日本語エンジンの場合、spokenにはひらがな、カタカナ、「_」、「.」が含まれる可能性があります。ふりがなとして使用する場合は、「_」と「.」の扱いを決めておく必要があります。
- フィラー単語を残す設定の場合、writtenに「%えー%」のような単語の前後が「%」の単語が返ってくるので、writtenを認識結果のテキストとして使用する場合は前後の「%」を除去する必要があります。
- 話者ダイアライゼーションが有効であっても話者ラベルの推定に失敗してラベルがつかない単語がある場合があります。
上記を踏まえて作成したコードが下記になります。
onmessage = (event) => { let vtt = ""; try { vtt = jsonToWebVTT(event.data); } catch (e) { vtt = "WEBVTT\n"; } postMessage(vtt); } /** * 簡易的に音声認識結果のJSONをWebVTTに変換をします。 * @param {object} json * @returns WebVTT */ function jsonToWebVTT(json) { // 動作 // segmentsと句読点で分割してタイムスタンプとスタイルをつけます。(「、」と「。」は削ります。) // 1つのsegmentを句読点で分割したときに1秒未満になる場合は、分割せずに半角スペースを入れます。 // ただし、前から順にしか見ないので、segmentの末尾は1.2秒未満になる可能性があります。 // ルビタグがあるところとないところがある場合、ないところにタイムタグの表示が反映されなかったため、すべてにルビタグをつけるようにしています。 // 話者ダイアライゼーションで付与されるlabelがある場合、ボイスタグとそのスタイルspeaker0~speaker9のみを出力します。 // 感情解析の結果がある場合、感情解析のみの字幕キューを出力します。 // メモ // 自動挿入された句読点は、writtenしかない場合、spokenが"_"になっている場合があります。 // 話者ダイアライゼーションが有効でもlabelがつかない単語が存在する場合があります。 const WEBVTT_STYLE = ` STYLE ::cue(v[voice=speaker0]) { color: #ffffff; } STYLE ::cue(v[voice=speaker1]) { color: #ffd1d1; } STYLE ::cue(v[voice=speaker2]) { color: #cbf266; } STYLE ::cue(v[voice=speaker3]) { color: #b4ebfa; } STYLE ::cue(v[voice=speaker4]) { color: #edc58f; } STYLE ::cue(v[voice=speaker5]) { color: #87e7b0; } STYLE ::cue(v[voice=speaker6]) { color: #c7b2de; } STYLE ::cue(v[voice=speaker7]) { color: #66ccff; } STYLE ::cue(v[voice=speaker8]) { color: #ffff99; } STYLE ::cue(v[voice=speaker9]) { color: #87e7b0; } `; // 字幕キューのスタイル const CUE_STYLE = " line:0 align:left"; // 感情解析結果の字幕キューのスタイル const SENTIMENT_CUE_STYLE = " align:right"; // 字幕キューの最短表示時間 const MIN_CUE_DISPLAY_TIME = 1200; let vtt = ""; let segments = []; if (typeof json.segments !== 'undefined') { // 非同期HTTP音声認識APIの音声認識結果の場合 segments = json.segments; } else if (Array.isArray(json)) { // WebSocket音声認識APIの音声認識結果を配列にまとめたものの場合 segments = json; } else { // 同期HTTP音声認識APIの音声認識結果の場合 segments = [json]; } let hasLabel = false; // labelがあるかどうか let id = 1; for (let segment of segments) { let tokensByLabel = {}; let lastLabel = ""; // segmentをlabelごとに分類 for (let token of segment.results[0].tokens) { // writtenは最低限必要 if (typeof token.written !== 'undefined') { let label = ""; if (typeof token.label !== 'undefined') { if (!hasLabel) hasLabel = true; label = token.label; } else { // labelがない場合、前の単語と同じlabelであるとみなす。 if (lastLabel.length > 0) { label = lastLabel; } else { label = "nolabel"; } } if (typeof tokensByLabel[label] === 'undefined') { tokensByLabel[label] = []; } // 表記の末尾が「?」(全角)で読みの末尾が「_」の場合は、「?」(全角)を後の処理用に別のtokenに分ける。 if (/.+?$/.test(token.written) && typeof token.spoken !== 'undefined' && /.+_$/.test(token.spoken)) { token.written = token.written.slice(0, -1); token.spoken = token.spoken.slice(0, -1); tokensByLabel[label].push(token); tokensByLabel[label].push({ written: "?" }); } else { // フィラー単語は表記の前後に「%」があり、この「%」は不要なので削除。 if (/^%.+%$/.test(token.written)) { token.written = token.written.replace(/^%(.*)%$/, "$1"); } tokensByLabel[label].push(token); } lastLabel = label; } } // labelごとに処理 for (let label in tokensByLabel) { let startTime = -1; let endTime = -1; let cueText = ""; let lastWritten = ""; for (let token of tokensByLabel[label]) { if (typeof token.endtime !== 'undefined') { endTime = token.endtime; } // 文の区切り文字の場合 // 日本語は、「、」「。」「?」。 // 英語の場合、文の区切りではない記号のときは無視したいため、自動挿入された可能性が高いもの(spokenがない、もしくはspokenが"_")。 if (token.written === "、" || token.written === "。" || token.written === "?" || ((typeof token.spoken === 'undefined' || token.spoken === '_') && /^[,.?!]$/.test(token.written))) { // 次の単語の前にスペースを入れるかどうかの判定用に単語の表記を保存 lastWritten = token.written; // 先頭でない場合 if (cueText !== "") { // 削除せずに残す文字の場合 if (/^[,.?!?]$/.test(token.written)) { cueText += ("<ruby>" + token.written + "</ruby>"); } // segmentの途中で字幕キューを分けるケース。 if (startTime !== -1 && endTime !== -1 && (endTime - startTime >= MIN_CUE_DISPLAY_TIME)) { if (cueText.indexOf("<v", 0) === 0) { cueText = (cueText.trim() + '</v>'); } vtt += toVttCue(id, startTime, endTime, cueText, CUE_STYLE); id++; cueText = ""; startTime = -1; endTime = -1; lastWritten = ""; } } else { // キューの先頭にある場合は無視 lastWritten = ""; } } else { // 句読点以外の場合は、starttimeとendtime必須にする if (typeof token.starttime !== 'undefined' && typeof token.endtime !== 'undefined') { // 末尾が数字またはアルファベット、,.?!:;の単語と先頭がアルファベットの単語の間にスペースを入れる if ((lastWritten.length > 0) && /[a-zA-Z0-9,.?!:;]$/.test(lastWritten) && /^[a-zA-Z]/.test(token.written)) { cueText += " "; } // 次の単語の前にスペースを入れるかどうかの判定用に単語の表記を保存 lastWritten = token.written; // 字幕キューの開始 if (cueText === "") { startTime = token.starttime; if (label !== 'nolabel') { // ボイスタグ付与 cueText += ("<v " + label + ">"); } } else { // タイムスタンプタグ追加 cueText += ('<' + msToVttTimestamp(token.starttime) + '>'); } let ruby = ""; if (typeof token.spoken !== 'undefined') { // ルビを整形 ruby = token.spoken.replaceAll(".", "").replaceAll("_", " ").replaceAll(/ {2,}/g, " ").trim(); } let written = token.written.replaceAll("_", " ").replaceAll(/ {2,}/g, " ").trim(); // 読みがあり、読みと表記が異なっていて、表記がひらがな、カタカナ、「?」、半角スペース以外を含み、数字のみでない場合は、ルビを付与 if ((ruby.length > 0) && (written !== ruby) && (written.search(/[^\u3040-\u309f\u30a0-\u30ff? ]/) !== -1) && !(/^[0-9]+$/.test(written))) { cueText += ('<ruby>' + escapeVTT(written) + '<rt>' + escapeVTT(ruby) + '</rt></ruby>'); } else { // タイムスタンプタグが機能するように読みがない場合もrubyタグで囲む cueText += ('<ruby>' + escapeVTT(written) + '</ruby>'); } } } } if (cueText !== "") { if (cueText.indexOf("<v", 0) === 0) { cueText = (cueText.trim() + '</v>'); } if (startTime === -1) { startTime = 0; } vtt += toVttCue(id, startTime, Math.max(endTime, startTime + MIN_CUE_DISPLAY_TIME), cueText, CUE_STYLE); id++; } } } // 感情解析の結果 let starttimeTemp = 0; if (typeof json.sentiment_analysis !== 'undefined') { if (typeof json.sentiment_analysis.segments !== 'undefined') { for (let segment of json.sentiment_analysis.segments) { if (segment.starttime - starttimeTemp > 0) { // 感情解析結果の隙間を補完 vtt += toVttCue(id, starttimeTemp, segment.starttime, "ENERGY:000 STRESS:000", SENTIMENT_CUE_STYLE); id++; } const cueText = "ENERGY:" + segment.energy.toString().padStart(3, '0') + " " + "STRESS:" + segment.stress.toString().padStart(3, '0'); vtt += toVttCue(id, segment.starttime, segment.endtime, cueText, SENTIMENT_CUE_STYLE); id++; starttimeTemp = segment.endtime; } } } if (hasLabel) { vtt = (WEBVTT_STYLE + vtt); } vtt = ("WEBVTT\n" + vtt); return vtt; } /** * WebVTTの1キューを返します * @param {number} id * @param {number} starttime * @param {number} endtime * @param {string} text * @param {string} style * @returns 文字列 */ function toVttCue(id, starttime, endtime, text, style) { return toVttTimestampLine(id, starttime, endtime, style) + text + "\n"; } /** * WebVTTのエスケープを行います * @param {string} value * @returns 文字列 */ function escapeVTT(value) { return value.replaceAll(/ {2,}/g, " ") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">").trim(); } /** * WebVTTのタイムスタンプ行を返します * @param {number} id * @param {number} starttime * @param {number} endtime * @param {string} style * @returns WebVTTのタイムスタンプ行 */ function toVttTimestampLine(id, starttime, endtime, style) { return ("\n" + id + "\n" + msToVttTimestamp(starttime) + " --> " + msToVttTimestamp(endtime) + style + "\n"); } /** * 簡易的なmsec→hh:mm:ss.ttt(WebVTTのタイムスタンプ)変換を行います * @param {number} duration 経過時間ミリ秒 * @returns hh:mm:ss.ttt形式の文字列 */ function msToVttTimestamp(duration) { const hour = Math.floor(duration / 3600000); const minute = Math.floor((duration - 3600000 * hour) / 60000); const hh = hour.toString().padStart(2, '0'); const mm = minute.toString().padStart(2, '0'); const ms = (duration % 60000).toString().padStart(5, '0'); return hh + ":" + mm + ":" + ms.slice(0, 2) + "." + ms.slice(2, 5); }
基本的な挙動は下記のとおりです。
- 発話区間、もしくは句読点で字幕のキューを分けます。
- 分けた結果、字幕のキューの表示時間が1.2秒未満になる場合は、分けずに半角スペースを挿入します。
- ひらがな、カタカナ、「?」以外を含み、数字のみで構成されていない単語の場合は、「読み」をつけます。
- 「表記」の「_」は、半角スペースの代替文字のため、半角スペースに置換します。
- 句読点「。」と「、」は字幕ファイルに出力しません。
- 話者ダイアライゼーションの結果のlabelは、WebVTTのボイスタグで出力します。
- 字幕のキューの2つ目以降の単語は、単語のstarttimeをタイムタグで出力します。
- 感情解析の結果は、音声認識の結果とは別に字幕のキューを出力します。ただし出力するのは、Stress、Energyのみです。
- 音声認識結果表示用の字幕のキューにスタイルを設定します。(左上)
- ボイスタグにスタイル(文字の色)を設定します。ただしspeaker0~speaker9までです。
- 上記をWebVTTサンプル1として正規表現による文字列の削除、置換処理を行ったサンプル2~4も作成します。
- WebVTTサンプル2: 表記と読みのみ。WebVTTサンプル3: 表記のみ。WebVTTサンプル4: 読みがあるところは表記のかわりに読みを表示し、読みの前後には半角スペースを挿入。
- WebVTTサンプル1~4は、Video要素のオプションで切り換えることができます。
以上です。最後まで読んでいただき、ありがとうございます。
【同じ発話で比較検証】音声入力エンジンと会話エンジンの認識結果の違いとは

ととのい侍
こんにちは!営業社員の「ととのい侍」です。
今回はAmiVoice APIの音声入力用音響モデルを採用したエンジン(以下「音声入力用エンジン」という。)や会話入力用音響モデルを採用したエンジン(以下「会話用エンジン」という。)の特徴とそれぞれの合致する利用シーンなどについて解説していきます。
音声入力用エンジンと会話用エンジンの違い
まずは、音声認識の基本的な仕組みの説明から入ります。
今回の記事では簡単に説明しますが、詳細が気になる方は以下の記事をご覧ください。
amivoice-tech.hatenablog.com
弊社のハイブリッド型音声認識エンジンは「言語モデル」+「音響モデル」+「発音辞書」からなります。
「言語モデル」とは大量のテキストデータから学習した、ある単語やフレーズの前後にどのような単語やフレーズが出やすいかの確率を表現したモデルです。
最近ではChatGPTなどの大規模言語モデルが登場して盛り上がっていますが、音声認識の言語モデルもそれと似たものです。ChatGPTのような賢さは無い代わりに簡単にカスタマイズができて少ないメモリで高速に動作します。
AmiVoice APIの言語モデルは様々なシーンやビジネスで幅広く利用できる「汎用」、
医療や金融などの専門用語を高精度で認識する「領域特化型」が利用できます。
「音響モデル」とは「あ」「い」「う」などの音の特徴を大量のデータを元に学習したモデルです。同じ日本語でも話し方や話す環境など、用途に応じて最適な音響モデルを用意しています。AmiVoice APIでは音響モデルが基本的に「音声入力」と「会話」の2種類が存在します。
2つのエンジン概要を説明する前に、まずは一般的な音声入力時の発話と会話時の発話の違いを説明します。
■音声入力時の発話
・発話速度が比較的ゆっくりで、発音も明瞭
・言い淀み *1が少ない
・句読点や記号も音声で入力したい場合がある
■会話時の発話
・発話速度が速くなりがちで、発話も不明瞭になりがち
・言い淀みが多くなりやすい
・句読点は発話しない
上記を踏まえて、音声入力用エンジンと会話用エンジンの特徴を表にまとめました。
| 音声入力用エンジン | 会話用エンジン | |
|---|---|---|
| 合致する音声 | ・スマホやタブレットなどの端末に対して話しかけるような口調 ・明瞭な発音 |
・人対人で話すときの会話口調 ・多少不明瞭な発音でも可 |
| 利用シーン | 日報やメールの音声入力、 ボイスボットでの発話 |
会議や通話などの会話 |
| 言い淀みの 学習 |
少ない | 多い |
|
句読点や記号の発話を認識 |
対応 | 一部非対応 |
| 汎用エンジンの学習単語数 | 多い(会話用エンジンの約1.5倍) →音声入力の発話は音響的に易しいので、登録単語が多くても間違いにくい |
普通 |
どちらのエンジンを利用するかは認識させる音声次第です。
例えば、商談後の営業マンがiPhoneに向かって商談内容のメモを認識させる場合は音声入力用エンジンを利用いただき、コールセンターでオペレーターとカスタマーの通話音声を認識させる場合は会話用エンジンを利用いただければと思います。*2
実際に利用しているお客様の具体的な事例については以下をご覧ください。
■音声入力用エンジン
■会話用エンジン
精度検証してみた
それでは実際に2つのエンジンを使って認識率を検証してみようと思います。
検証方法
検証方法や条件は下記になります。
・当社のお客さまから研究開発用としてご提供いただいた音声を使用しました。
・音声は以下の2種類の音声を使用しました。
➀会議やプレゼンなどを会話口調で話す音声
②日報やニュース原稿をデバイスに対して話しかけるような口調で話す音声
・音声ファイルの長さは➀②それぞれ約30分です。
・音声認識エンジンはAmiVoice APIの「音声入力_汎用」と「会話_汎用」を使用しました。
・音声認識精度は(単語単位ではなく)文字単位で計測しました。
・表記ゆれによる誤認識については、自動変換および目視でチェックし修正を行いました。目視のため多少のチェック漏れは残っているかと思います。
・言い淀み(不要語)は正解文および音声認識結果から除去して計算するものとしました。
認識率の測り方については以下の記事をご覧ください。
計測結果
➀会議やプレゼンなどを会話口調で話す音声
| データ | 正解文字数 | 挿入誤り数 | 削除誤り数 | 置換誤り数 | 音声認識精度 |
|---|---|---|---|---|---|
| 音声入力_汎用 | 9789 | 448 | 1316 | 1063 | 71.12% |
| 会話_汎用 | 9788 | 474 | 356 | 350 | 87.94% |
会話口調の言葉は音声入力用エンジンは向いていないようです。
一方で会話用エンジンは音声入力用エンジンと比較すると抜けて認識率がいいですね。
②日報やニュース原稿などをデバイスに対して話しかけるような口調で話す音声
| データ | 正解文字数 | 挿入誤り数 | 削除誤り数 | 置換誤り数 | 音声認識精度 |
|---|---|---|---|---|---|
| 音声入力_汎用 | 5721 | 19 | 28 | 81 | 97.76% |
| 会話_汎用 | 5727 | 38 | 29 | 78 | 97.47% |
音声入力用エンジンと会話用エンジンの差はそこまでないもののわずかに音声入力用エンジンの方が高い認識率でした。②のような利用シーンのみであれば音声入力用エンジンを利用してもよいかもしれません。
認識結果の詳細
➀会議やプレゼンなどを会話口調で話す音声
<正解文>
「採用向けのサイトもうん企業様の撮影なんですけどうんうんこの案件は基本的には私はちょっとお受けしてないんですね」
<音声入力用エンジンの認識結果>
「採用向けのサイトの大きいおさまの撮影なんですけど(うんうん)この案件は基本的には私と同期してないんですね」
<会話用エンジンの認識結果>
「採用向けのサイトのうん企業様の撮影なんですけどうんうんうんこの案件は基本的には私はちょっとお受けしてないんですね」
②日報やニュース原稿などをデバイスに対して話しかけるような口調で話す音声
<正解文>
「自分にとって価値がなければどんなに際立った違いがあっても振り向いてはくれないのです」
<音声入力用エンジンの認識結果>
「自分にとって価値がなければどんなに際立った違いがあっても振り向いてはくれないのです」
<会話用エンジンの認識結果>
「自分にとって価値がなければどんなに気を当たった違いがあっても振り向いてはくれないのです」
考察
・➀は発話がかなり砕けていたり音質が悪いものが多かったため精度が比較的低く出ているのに対し、②は➀よりも気持ちハキハキしゃべっていたり、高品質な音声のため高精度に認識ができています。
・会話用エンジンは➀②どちらの場面でも高精度に認識できる万能型ですが、②のような利用シーン限定で発話する場合は音声入力用エンジンを利用した方が良さそうです。
・➀の音声入力_汎用の削除誤り数(音声ファイルには発話しているが、認識結果には入っていない誤りの文字数)が突出して多いですが、これは音声認識エンジンに音声発話では無いとみなされたものと考えられます。音声入力用エンジンでは曖昧で砕けた言葉は人の声として認定されないのかもしれません。
まとめ
今回は音声入力用エンジンの特徴や会話用エンジンとの比較について紹介しました。
用途によってエンジンを使い分けていただくとより高精度に認識可能であることがお分かりいただけましたでしょうか。
今回検証した言語モデルは汎用のみでしたが、金融や保険などの領域特化型エンジンも「音声入力」と「会話」のエンジンがございますのでお気軽にお試しください。
(各エンジン毎月60分無料です!)
それでは、これにて御免。
この記事を書いた人
-

ととのい侍
新卒3年目の営業社員です。
最近は地方のサウナに行くことが多く、
サウナ後にご当地グルメを喰らうのが幸せです。
ハイブリッド型音声認識とEnd-to-End音声認識の違いと特徴

柴田駿人
深層学習の発展により音声認識の精度は飛躍的に向上し、更にはすべてニューラルネットワークで構成された新たな音声認識システムが登場しました。本記事では新しい方式と従来の方式のメリットやデメリットについて解説します。
- 従来の「ハイブリッド型」と近年台頭してきた「End-to-End」
- End-to-Endはシンプルな音声認識システム
- End-to-Endの弱点(アダプテーションが難しい)
- ハイブリッド型とEnd-to-Endの作り方の違い
- AmiVoiceはハイブリッド型?End-to-End?
従来の「ハイブリッド型」と近年台頭してきた「End-to-End」
音声認識では長らく音響モデル、言語モデル、発音辞書を使って音声認識を行う方法が主流でした。音響モデルにはDNN(Deep Neural Network)とHMM(Hidden Markov Model; 隠れマルコフモデル)を組み合わせるハイブリッド型が用いられるのでこの音声認識システムをハイブリッド型音声認識と呼ぶことにします。音響モデルは各時刻の音声について音響スコアを計算し、発音辞書が音素と単語を結びつけ、言語モデルが単語列に対して言語スコアを計算します。音響スコアと言語スコアを重みつきで足し合わせたスコアが最も高いものを認識結果として出力します。

これに対して近年、単一のニューラルネットワークで音声認識をするEnd-to-End音声認識が台頭してきています。End-to-Endでは音声を入力として文字あるいはサブワードのスコアを出力し、最もスコアの高い文字列(サブワード列)を認識結果として出力します。

音声認識システムのより具体的な内容については以前の記事をご参照ください。
End-to-Endはシンプルな音声認識システム
ハイブリッド型は個別のモジュールを組み合わせながらデコードするためとても複雑な仕組みになっています。学習においては音響モデルだけでも音素のクラスタリング、音素アライメントの作成、ニューラルネットワークの学習などたくさんのステップがあります。一方でEnd-to-Endは単一のニューラルネットワークなのでとてもシンプルです。学習も発音辞書を必要とせず、音声とその書き起こしを使ってニューラルネットワークの学習をするだけで音声認識システムができます。このためEnd-to-Endは開発がしやすいというメリットがあります。
End-to-Endの弱点(アダプテーションが難しい)
ハイブリッド型とEnd-to-Endはどちらも音声をテキストに変換することに変わりはないため、汎用な認識をする場合どちらを使っても問題ありません。しかし特定のタスクで使いたい場合、固有名詞や専門用語などが認識できなかったり意図した認識結果になりづらいことも多いため、アダプテーションをして認識できるようにする必要があります。実際に世の中の多くの音声認識サービスでアダプテーションや単語登録の機能が備わっており、必須の機能となっています。
ハイブリッド型の場合、認識結果には発音辞書に含まれている単語しか出てこないので、認識したい単語が発音辞書にない場合は追加することが必須です。単語追加は発音辞書に単語とその読みを登録するだけで簡単に行えます。単語追加に加えて、ハイブリッド型音声認識ではタスクにあった言語モデルを使うことが重要になるため、テキストデータを集めて言語モデルをアダプテーションすることで認識精度の向上が見込めます。テキストデータは集めやすく、言語モデルのアダプテーションは比較的短時間で行えるためハイブリッド型はアダプテーションしやすい方式となっています。

一方でEnd-to-Endでは発音辞書を持たないので簡単に単語を追加するということができません。単一のニューラルネットワークであるため、基本的には認識させたい単語を含む音声とその書き起こしを使って再学習させる必要があります。テキストだけでアダプテーションができるハイブリッド型と比べて音声も必要となるためデータ収集はとても大変です。またニューラルネットワークの学習は言語モデルのアダプテーションと比較してとても時間がかかります。このような課題については研究が盛んに行われていますが未だ実用化には至っておらず、End-to-Endはアダプテーションが難しい方式となっています。
ハイブリッド型とEnd-to-Endの作り方の違い
データ準備
End-to-Endの学習には音声とその書き起こしのみが必要であり、したがって学習データの準備は人手による書き起こしを行うだけで済みます。一方でハイブリッド型では音声の書き起こしだけでなく、発音辞書も作成する必要があります。発音辞書の作成ではまず音素体系を定め、その音素体系に従ってあらゆる単語の読みを振ることで作成します。音声の書き起こしのみであれば音声を聞き取れる人ならできますが、発音辞書の作成では言語的な知識も必要となるため非常に手間がかかります。
必要なデータ量
一般的に学習に必要なデータ量はEnd-toEndの方が多く、ハイブリッド型では数百時間以上、End-to-Endでは数千時間以上のデータが必要になります。ハイブリッド型ではタスク特化(領域特化)の音声認識を行うことも可能ですが、End-to-Endでは特定のタスクから音声を大量に集めることが困難であるため汎用な音声認識として開発することが多いです。
パラメータ調整
ハイブリッド型では音響モデル、言語モデル、発音辞書を組み合わせながら認識するため、最適な組み合わせ方を見つけなければなりません。たくさんあるパラメータを手作業で調整する必要があり、ノウハウが求められます。End-to-Endでは単一のニューラルネットワークなので手動で調整するパラメータが少なく最適化がしやすいというメリットがあります。
| ハイブリッド型 | End-to-End | |
|---|---|---|
| 構成要素 | 音響モデル 言語モデル 発音辞書 |
単一のモデル |
| 用途 | 汎用 タスク特化 |
汎用 |
| アダプテーション | 容易 | 困難 |
| 学習データ | 数百時間~ | 数千時間~ |
| 外国語音声認識の開発 | 困難 | 容易 |
| パラメータ調整 | 困難 | 容易 |
AmiVoiceはハイブリッド型?End-to-End?
ハイブリッド型、End-to-Endにはそれぞれメリット・デメリットがありますが、製品化するにあたってはアダプテーションのしやすさが重要になります。特に個別のお客様向けの音声認識では意図した認識結果を得られることが求められます。そのため現在でもアドバンスト・メディアの製品ではアダプテーションしやすいハイブリッド型音声認識を主流に扱っています。
特に日本語については長年蓄積した知見があり、細かくパラメータ調整ができるハイブリッド型の方が反映させやすいという側面もあります。一方であまり知見のない言語の音声認識ではEnd-to-Endの利用に積極的に取り組んでいます。
アドバンスト・メディアにおける研究開発ではハイブリッド型、End-to-Endのどちらも行っています。End-to-Endで使われるモデルの構造や学習手法を取り入れることでハイブリッド型の認識精度改善を実現したりと、新しい方式だけでなく従来の音声認識システムも進歩を遂げています。
今後もアドバンスト・メディアでは状況に応じて適切に技術の使い分けを行い、最適と思われる音声認識エンジンを提供します。AmiVoice APIは最新のエンジンを毎月60分無料でご利用いただけます。是非お試しください。
この記事を書いた人
-

柴田駿人
音声認識の研究開発をしています。
精度検証付き!特定用途に特化した音声認識エンジンのご紹介

ととのい侍
こんにちは!営業社員の「ととのい侍」です。
突然ですが、お客様とのお打ち合わせで
「提供中のIVR *1で氏名や住所を音声認識できるようにしたい」
「データ入力アプリに特定の数字や言葉だけを音声認識させたい」
という声をよくいただきます。
今回はそのようなシーンに効果を発揮する音声認識エンジンをご紹介します!
特定用途に特化した4つのエンジン
これからご紹介する以下4つのエンジンはお客様専用のサーバーを構築してご利用いただくAmiVoice API Privateにてご提供可能なエンジンとなります。
➀氏名エンジン
- 苗字と名前がカタカナで認識可能(苗字と名前はセットで発話)
例:ヤマダタロウ - 主に日本人の苗字・名前を学習しています(一部外国人名も学習済み)
- 利用シーンはコールセンターやIVR、ボイスボットなど
②住所エンジン
③ナビ用エンジン
- ナビなどの入力に必要なランドマーク、駐車場、交差点、小売店、道路施設、住所などが認識可能
例:東京タワー、ファミリマート - 全国の市区町村名や番地のほか、一部のマンション名までも認識できる
- 利用シーンはカーナビやタクシーの配車アプリ、動態管理システムなど
④ルールグラマエンジン
- ユーザー側で設定した定型文や単語だけを認識可能
例:数字のみを認識するように設定して「いちご」と発話すると「15」と認識 - 音声コマンドや、予め用意した単語やフレーズに一致するかどうかを判断できる
- 利用シーンは製造業や点検保守などのデータ入力、ボイスボットなど
- 下記の記事で既に詳しく解説しておりますのでぜひご覧ください。
精度検証
エンジンの内容は理解したが認識率はどうなんだろう?と気になりますよね。
ということで、実際に各エンジンを使って精度を検証してみました!
検証の際は汎用エンジンと比較もしています。
見慣れている単語は汎用エンジンでも学習されており精度の比較が難しいため、検証する単語は見慣れないワードにしています。
| エンジン | 音声認識結果 |
|---|---|
| 音声入力_汎用 | 太田と与太郎 |
| 音声入力_氏名 | オオタトヨタロウ |
汎用エンジンでは謎の与太郎さんが登場してしまっているのに対して、氏名エンジンはカタカナでしっかりと認識されています。漢字だと表記ゆれする場合がありますがカタカナであればその心配がなく安心ですね。
②埼玉県南埼玉郡宮代町和戸横町2丁目(2023年3月に更新された住所)
| エンジン | 音声認識結果 |
|---|---|
| 音声入力_汎用 | 埼玉県南埼玉郡宮代町は豊子町2丁目 |
| 音声入力_住所 | 埼玉県南埼玉郡宮代町和戸横町2丁目 |
汎用エンジンは完璧に認識できていないのに対して、
住所エンジンは最近更新された住所でも網羅している可能性が高いです。
③エディオンあなんきだでんき駐車場(徳島県阿南市にある駐車場)
| エンジン | 音声認識結果 |
|---|---|
| 音声入力_汎用 | エディオン阿南木田電機駐車場 |
| 音声入力_ナビ用 | エディオンあなんきだでんき駐車場 |
汎用エンジンでは発話した言葉をそれっぽい漢字で認識しているのに対し、
ナビ用エンジンでは正式名称のひらがな表記でしっかりと認識されています。
☟導入いただいた企業様からも認識率の高さについてコメントをいただいております。
さいごに
今回は、AmiVoice API Privateにて提供している4つのエンジンについて解説しました。
もしもこの記事を見て上記のエンジンに興味を持った方は、下記のお問い合わせページからご連絡下さい。無償で試行環境のご提供も可能です。
お問い合わせ - AmiVoice Cloud Platform
それでは、これにて御免。
この記事を書いた人
-

ととのい侍
新卒3年目の営業社員です。
最近は地方のサウナに行くことが多く、
サウナ後にご当地グルメを喰らうのが幸せです。
*1:IVRとは電話の音声自動応答システムです。
AmiVoice API Private・SDKの「ルールグラマ」認識とは?

大倉尭
皆さん、こんにちは!
AmiVoice API PrivateやSDKでは「ルールグラマ(グラマ認識)」を使ったエンジンが利用可能です。
ですが、「ルールグラマって何?」「普通の音声認識と何が違うの?」と疑問に感じる方も多いと思います。そこで、これから何回かに分けて、AmiVoice API Private・SDKにおけるルールグラマとその使い方について解説していきます。1回目となる本記事では、「ルールグラマとは何か?」を説明します。
ルールグラマとは?
AmiVoice APIなどで提供している音声認識は「ディクテーション(ディクテーション認識)」と呼ばれます。「Dictation=書き取り」の通り、「入力された音声をそのまま文字にする」ことができるものです。ですので、「きょうのてんきは?」と言えば「今日の天気は?」という認識結果が出力されますし、「なまむぎなまごめなまたまご」と言えば「生麦生米生卵」という認識結果が出力されます。
これに対して、AmiVoice API Private・SDKで利用可能である「ルールグラマ」は、「Grammar=文法」という意味の通り、「事前に決めた文法(ルール)に従う表現だけを認識する」ものです。
ルールグラマのイメージとして近いのは、Amazon Echo・Google Homeなどのスマートスピーカーや、音声コマンドで動くロボットなどです。これらは、「今日の天気は?」「テレビつけて」といった特定の文章だけを認識し、それに応じて「今日の天気を表示する」「テレビをつける」といった命令を実行します。その一方で、ルールに入っていない発話は認識されないので、スマートスピーカーやロボットなどに「生麦生米生卵」と言っても、勝手にテレビがつくといったことはありません(「もう一度言ってください」などの返答はあるかもしれませんが)。
このように、特定の単語や文だけを認識したり、それに応じてコマンドを実行したりといったことができるのが「ルールグラマ」である、ということになります。そして、今回AmiVoice API Private・SDKで利用可能なルールグラマでは、どのような単語や文を認識できるようにするかというルールを自分で設定することができます。
ルールグラマの利用シーンの例
このようなルールグラマですが、活用が期待できる利用シーンの例をいくつか挙げてみます。
- 製品の型番などの音声認識
「アルファベットと数字だけ」や「8桁の数字だけ」など、認識できる文字やその文字数を指定したルールを設定できるので、製品の型番などの音声認識に適しています。 - 検査・保守点検記録シートなどへの音声入力
認識できる単語や文を限定することができるので、入力検査や保守点検の記録シートなど、特定の単語や文が使われる際の音声入力に適しています。 - ボイスボット
定型的な発話が多いボイスボットの音声認識にも、ルールグラマを活用することができます。シナリオに応じて複数のルールを用意することも可能です。
ルールグラマのメリット・デメリット
次に、ルールグラマのメリット・デメリットを、ディクテーションと比較して説明します。
メリット
ディクテーションと比べ、ルールグラマは認識される単語・文が限定されるので、正しく認識される確率(認識率)が高くなる傾向があります。
わかりやすくクイズで例えてみましょう。音声認識は、「ある音声」が与えられて、その音声が何と言ったのかというクイズを解くようなものだといえます。ディクテーションとルールグラマはそれぞれ、
① 今、何と言ったでしょう?
(選択肢なし=ディクテーション)
② 今、何と言ったでしょう? A. 犬 B. 猫 C. 猿 D. 熊
(選択肢あり=ルールグラマ)
というクイズに対応するようなものです。選択肢がある②の方が正解率は高いでしょう。仮に「猿(さる)」なのか「樽(たる)」なのか紛らわしい音声が与えられても、②のように選択肢があれば「猿」と結果を返すことができます。
デメリット
「事前に決めた文法(ルール)に従う表現だけを認識する」ということは、逆に言うとルールに従わない表現、ルールで記述できない表現は認識できないということになります。ルールの記述に不備がある場合、「何度発話しても認識されない」というトラブルが起こり得ます。
音声認識したい表現のバリエーションが多い場合など、ルール設定で網羅することが難しい場合は、ディクテーションを利用するのがよいでしょう。
おわりに
本記事では、「ルールグラマとは何か?」について説明しました。ルールグラマの具体的な使い方(ルールの設定方法)については今後の記事で解説していく予定ですので、ご期待ください。
ルールグラマはAmiVoice API Private・SDKで提供されている機能ですが、より手軽にAmiVoice APIの音声認識を試してみたい開発者の方がいましたら、ぜひ https://acp.amivoice.com/ を試してみてください。毎月音声60分までは全エンジン無料で使えます。
ここまでお読みいただき、ありがとうございました!
この記事を書いた人
-

大倉尭
新卒でアドバンスト・メディアに入社。
音声認識の精度向上のための研究開発に携わった経験から、このブログをはじめ、アドバンスト・メディアの技術力をアピールする仕事に挑戦中。
趣味は旅行(主に鉄道)・読書(主に小説)・ボードゲームなど。
"会議"の音声でOpenAIのWhisperとAmiVoiceの音声認識率を比較してみた

安藤章悟
みなさま、こんにちは。
先日こちらの記事でOpenAIのWhisperとAmiVoiceの認識率を比較しました。
この記事を振り返ると「AmiVoiceの得意分野はAmiVoiceが勝ち、Whisperの得意分野はWhisperが勝つ」という当たり前の結果だったので、今回は「会議の音声ではどちらが良い結果になるのか?」という視点で比較をします。
検証方法
検証方法や条件は下記です
- 当社のお客さまから研究開発用としてご提供いただいた会議の音声を使用しました。
- 会議音声は4種類の異なる業種のもので、それぞれ冒頭10分程度の合計40分程度の長さを使用しました。
- 上記音声や、その書き起こしテキストは音声認識エンジンの学習には使っておりません。
- 音声認識処理はAmiVoiceもWhisperもどちらも社内のローカル環境で実行しました。
- 音声認識エンジンはAmiVoice APIの「会話_汎用」の同等品、およびWhisperの「large」「large-v2」を使用しました。
- AmiVoiceは2022年6月頃、Whisper(large)は2022年10月頃、Whisper(large-v2)は2023年2月に音声認識処理をし、それぞれその時の最新のものを使用しました。
- 音声認識精度は文字単位で(単語単位ではなく)計測しました。
- 表記ゆれによる誤認識については、自動変換および目視でチェックし修正を行いました。目視のため多少のチェック漏れは残っているかと思います。
- フィラー(不要語)は正解文および音声認識結果から除去して計算するものとしました。
ここでポイントとして、今回の会議音声は比較的大きな企業様や団体様のもので、司会進行がきちんとされ発言者ごとに個別のマイクがあり、また発言者は参加者全員に説明するために適切な声量で喋っているものです(ひとり言やボソボソ声ではない)。この音声は人間が聞いても聞き取りやすく、つまり会議の音声認識の難易度としては比較的低いと言えるものです。
計測結果
■AmiVoice(会話_汎用)
| データ | 正解文字数 | 挿入誤り数 | 削除誤り数 | 置換誤り数 | 音声認識精度 |
| ① | 2905 | 33 | 46 | 34 | 96.11% |
| ② | 2616 | 23 | 21 | 27 | 97.29% |
| ③ | 3011 | 38 | 23 | 44 | 96.51% |
| ④ | 3047 | 36 | 21 | 39 | 96.85% |
| 合計 | 11579 | 130 | 111 | 144 | 96.68% |
■Whisper(large)
| データ | 正解文字数 | 挿入誤り数 | 削除誤り数 | 置換誤り数 | 音声認識精度 |
| ① | 2903 | 49 | 113 | 199 | 87.56% |
| ② | 2630 | 37 | 190 | 60 | 89.09% |
| ③ | 3009 | 47 | 138 | 146 | 89.00% |
| ④ | 3046 | 65 | 223 | 90 | 87.59% |
| 合計 | 11588 | 198 | 664 | 495 | 88.29% |
■Whisper(large-v2)
| データ | 正解文字数 | 挿入誤り数 | 削除誤り数 | 置換誤り数 | 音声認識精度 |
| ① | 2901 | 60 | 132 | 161 | 87.83% |
| ② | 2625 | 67 | 204 | 55 | 87.58% |
| ③ | 3011 | 40 | 117 | 122 | 90.73% |
| ④ | 3044 | 65 | 189 | 85 | 88.86% |
| 合計 | 11581 | 232 | 642 | 423 | 88.80% |
考察
AmiVoiceが最も良い結果になりました。エラー率(CER)にすると、AmiVoiceは100%-96.68%=3.32%、同様にWhisper(large)は11.71%、Whisper(large-v2)は11.20%となり、3倍以上の非常に大きな差となりました。
AmiVoiceの音声認識率96.68%という値は我々の社内実験の中でも比較的高い水準のものだと言えます。 きちんとマイクがセッティングされ、話者も参加者に向けて明瞭に発話していて、人間にとっても聞きやすい音声であることが高い精度が出た理由だと言えます。
気になるのはWhisperがそれなりに誤認識をしている点です。 誤認識箇所をチェックしていると、目立つ誤認識パターンとして2種類くらいに分類できそうです。
- Whisperの誤認識パターン1:発音が似た単語に文脈を無視して間違っている
- 競合→今日後
- 好調に推移→好調に注意
- どこも投資をしている→ドグも投資している
- ◯◯か何かで→◯◯内科で
- Whisperの誤認識パターン2:あまり見たことのない漢字変換をしている
- 加重平均→過充平均
- 四半期報告→市販機報告
- 余資運用→吉運用
- 残存期間→暫存期間
- 外貨建て→外科だて
- 一過性→一家性
パターン1は人間が聞くと若干「そう聞こえなくもないかな?」と感じる箇所もありますが、文脈などを含めて総合的に考えるとおかしな誤認識のように感じます(このパターンはAmiVoiceでも発生しますが、その頻度が今回の音声ではWhisper方が多かったです)
パターン2はあまりWeb検索でも出てこない漢字変換をしていて、どうしてこのような出力をしたのか謎です。
また、Whisperは削除誤りが突出して多いです。これは原因を調べて驚いたのですが、Whisperは不要と思われる文章を賢く削除する傾向があります。例えば下記のような発話に対してWhisperはこのように出力することがあります。*1
- 発話者「1月、いや2月の1日、あれ2月1日じゃなかったでしたっけすいません、え、いいんでしたっけ、その日の件ですが」
- Whisper「2月1日の件ですが」
この処理があることで音声認識結果の可読性は上がると思いますが、この結果を正解とするわけにはいかないため計算上は省略された箇所は削除誤りという扱いになります。この挙動による削除誤りは用途によっては問題がないのである程度差し引いて評価してもいいかもしれません。
まとめ
今回は会議の音声を使ってAmiVoiceとWhisperの比較をしました。
結果はAmiVoiceの方がエラー率(CER)が1/3以下という大きな差になりました。
Whisperは発音が似た単語に文脈を無視して出力しやすかったり、あまり日本語として使われない漢字変換をしてしまうことによる誤認識が多かったようです。
また、Whisperは不要な文章を賢く削除する傾向があり、そのため削除誤りが多くなりやすいようです。この誤りは用途によっては問題ない場合もあるので、比較評価する際はある程度差し引いて考えもいいでしょう。
Windowsアプリにマイク録音を実装してみた。音声認識アプリ開発の第一歩!

はじめに
こんにちは。小関です。
株式会社アドバンスト・メディアにて、ACP関連の開発を担当しています。
完成形

開発環境
- Windows 10
- Visual Studio 2019
- WPFアプリケーション
- .Net 5.0
実装
以下の手順でアプリを実装していきたいと思います。
- AmiVoice Cloud Platform(ACP)への登録
- プロジェクトの立ち上げ
- MVVMモデルの適応
- 接続マイクから音声データ取得
- ACPを利用してWebSocketを介した音声認識
- 作成したプログラム・UIの連係
ステップ1 AmiVoice Cloud Platform(ACP)への登録
ストリーミングで音声認識をするために、まずはACPに登録します。
登録方法に関しては、下記記事をご覧ください。
ステップ2 プロジェクトの立ち上げ
Visual Studio 2019を開き、「新しいプロジェクトの作成」を押下し、「WPF」で検索を行い、「WPFアプリケーション」を選択する

プロジェクト名を入力し「次へ」を押下し、ターゲットフレームワーク「.Net 5.0」を選択

ステップ3 MVVMモデルの適応
WPFはMVVM(ModelView - View - Model)というデザインパターンを推奨しています。MVVMパターンとはソフトウェアアーキテクチャの一つで、アプリケーションの内部構造を決める指針になります。
今回はこちらのブログを参考にMVVMパターンを意識した内部構造で、アプリケーションを作成していきます。
- MainWindow.xaml・MainWindow.xaml.csを削除し、Views・ViewModels・Modelsフォルダを追加します。フォルダの追加はプロジェクト名の上で右クリック→追加で新しいフォルダを作成できます。画像では「ソリューション」の下にある「RecApp」がプロジェクト名になります。
- ViewsフォルダにMainViewウィンドウクラス、ViewModelsフォルダにMainViewModelクラスをそれぞれのフォルダ上で右クリック→追加で追加します。MainViewウィンドウクラスはウィンドウ(WPF)を選択すると「Window1.xaml」と「Window1.xaml.cs」が追加されます。このままではファイル名が分かりにくいためファイル名を「MainView.xaml」と「MainView.xaml.cs」に変更します。また、この時にMainView.xamlの<window>タグ内の「x:Class="RecApp.Views. Window1"」と書かれたWindow1とMainView.xaml.csのクラス名もMainViewに変更します。

- App.xamlに書かれているStartupUriプロパティを削除し、App.xaml.csでOnStartup()メソッドを以下のコードでオーバーライドします。
App.xaml.cs
using System; using System.Collections.Generic; using System.Configuration; using System.Data; using System.Linq; using System.Threading.Tasks; using System.Windows; using RecApp.Views; using RecApp.ViewModels; namespace RecApp { /// /// Interaction logic for App.xaml /// public partial class App : Application { protected override voidOnStartup (StartupEventArgs e) { base.OnStartup (e);// ウィンドウをインスタンス化 MainView w = new MainView();// ウィンドウに対する ViewModel をインスタンス化 MainViewModel vm = new MainViewModel();// 閉じる際のイベントを設定 w.Closing = vm.Closing ;// ウィンドウに対する ViewModel をデータコンテキストに指定 w.DataContext = vm;// ウィンドウを表示 w.Show (); } } }
ステップ4 接続マイクから音声データ取得
準備
C#で接続マイクから音声データを取得するにはNAudioというライブラリを使用します。
NAudioは音声の入出力、デバイスの選択、フォーマット変換等の音声ファイルに関する処理を手軽に扱うことができるライブラリになります。NAudioの実装はGitHub上にも公開されていますので、気になる方はコチラをご覧ください。
はじめに、NAudioを使えるようにするため、NuGetからライブラリをダウンロードします。ツール > NuGetパッケージマネージャー > ソリューションのNuGetパッケージの管理 > 参照で「NAudio」を検索します。NAudioのバージョンを1.10.0に変更し、対象のプロジェクトにチェックを入れ、インストールします。
※1.10.0でも十分な機能があり、SoundTouchという音声ファイルを再生する際に再生速度や音程をリアルタイムに変更できるライブラリとの互換性があるため、最新バージョンではなく1.10.0を使用しています。
(WPFにはMediaElementという動画・音声再生するタグが存在し、こちらでも再生速度を変更できますが、再生速度を変更した際に無音時間が一定時間存在する場合があります。しかし、SoundTouchを用いた場合では無音時間が存在しないため、違和感のない再生速度変更が可能となります。)

次にNAudio関連の操作を行うクラスを作成するために、Modelsフォルダ内に新しくクラスファイルを作成します(以下、Audioクラスとします)。NAudioを使った処理は全てAudioクラスに記述していきます。
NAudioを用いて音声を録音するにはWaveInクラスとWaveInEventクラスの2種類存在します。両クラス共に
- 録音に使用するマイクデバイスの設定
- 録音した音声についての処理
- 録音終了時の処理
等を行えるクラスになります。WaveInクラスとWaveInEventクラスそれぞれの内部的な処理は異なりますが、できることは同じになります。では、2つのクラスをどのように使い分けるのかというと
WaveIn : GUIアプリケーション
WaveInEvent : コンソールアプリケーション(GUIアプリケーションでも使用可)
のように使用用途が分かれています。また、WaveInクラスではWindows Messagesを使用していますが、WaveInEventクラスではWindows Messagesを使用しない作りになっています。今回はGUIアプリケーションを作成しますので、WaveInクラスを使用して録音部分の処理を記述していこうと思います。
※WaveInとWaveInEventクラスで使用できるメソッド等は同じですので、WaveInEventクラスを使用して今回の作成するアプリを作成するということであれば、WaveInの部分をWaveInEventに変更するだけで録音処理に関する部分は動作します。
録音処理
録音する処理のコードは以下になります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NAudio.Wave;
namespace RecApp.Models
{
/// <summary>
/// 録音状態を表す列挙型
/// <summary>
public enum RecordingState
{
Recording,
Stop,
Error
}
/// <summary>
/// NAudioを操作するクラス
/// <summary>
class Audio
{
// 録音を行うクラス
private WaveIn m_waveIn = null;
// 録音時のフォーマットを設定するクラス
private WaveFormat m_recordinFormat = null;
// 録音状態を管理する変数
public RecordingState m_recordingState { get; private set; }
public Audio ()
{
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat (16000, 1);
}
/// <summary>
/// 録音開始
/// <summary>
public void RecordingStart ()
{
// 初期化&録音時のフォーマットを設定
m_waveIn = new WaveIn ();
m_waveIn.WaveFormat = m_recordinFormat;
// 録音デバイス設定
// デフォルトデバイスで録音
m_waveIn.DeviceNumber = 0;
// 録音中に発生するイベント
m_waveIn.DataAvailable += (_, ee) =>
{
try
{
// エラーが起きていないか
if (m_recordingState == RecordingState.Recording)
{
// 録音した音声データを処理する
}
}
catch (Exception e)
{
// エラー確認
if (m_recordingState == RecordingState.Recording)
{
// 二重に停止処理が起きないようにする
m_recordingState = RecordingState.Error;
// 録音停止
RecordingStop();
}
}
}
// 録音終了時のイベント
m_waveIn.RecordingStopped += (_, __) =>
{
// 録音状態が録音中・エラーの場合はインスタンスを解放する
if (m_recordingState != RecordingState.Stop)
{
// 録音状態変更
m_recordingState = RecordingState.Stop;
// WaveInインスタンス解放
m_waveIn.Dispose();
m_waveIn = null;
// 録音終了時に行う処理
}
}
// 録音開始
m_waveIn.StartRecording();
m_recordingState = RecordingState.Recording;
}
/// <summary>
/// 録音停止
/// <summary>
public void RecordingStop ()
{
// 録音停止
m_waveIn?.RecordingStop();
}
}
}
一つずつ処理を見ていきます。まず始めにコンストラクタで行われている
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat (16000, 1);
では、録音する際の音声フォーマットを指定しています。今回はサンプリング周波数とチャンネル数のみ指定していますが、bit数も設定出来ます。
録音開始時に行う処理がRecordingStartメソッドに書かれています。ここに書かれている
// 初期化&録音時のフォーマットを設定
m_waveIn = new WaveIn ();
m_waveIn.WaveFormat = m_recordinFormat;
// 録音デバイス設定
// デフォルトデバイスで録音
m_waveIn.DeviceNumber = 0;
では録音するために必要な録音デバイスやフォーマットを指定しています。また、録音のたびに新しいWaveInクラスをインスタンス化しています。これはWaveInクラスは同じインスタンスを使用した際に、内部で使用されているWin32 APIの1つである「waveInUnprepareHeader」で「WAVERR_STILLPLAYING」のWindows Messageが送られ、録音開始と同時に録音停止処理が走ることがあり(何らかのエラーが発生した際には録音停止処理が動作する)、これを回避するために毎回新しいインスタンスを作成しています。
DataAvailableに録音した音声データに対して行う処理を記述できます。
※DataAvailableはWaveIn内部のバッファがキューで満たされた際に発火するイベントになります。デフォルトでは100msごとにこのイベントが発火します。
// 録音中に発生するイベント
m_waveIn.DataAvailable += (_, ee) =>
{
try
{
// エラーが起きていないか
if (m_recordingState == RecordingState.Recording)
{
// 録音した音声データを処理する
}
}
catch (Exception e)
{
// エラー確認
if (m_recordingState == RecordingState.Recording)
{
// 二重に停止処理が起きないようにする
m_recordingState = RecordingState.Error;
// 録音停止
RecordingStop();
}
}
}
録音したデータに何らかの処理を行っている最中にエラーが発生した場合に、録音を停止するためにtry-catch文を使用しています。
RecordingStoppedに録音を停止した際の処理を記述できます。
// 録音終了時のイベント
m_waveIn.RecordingStopped += (_, __) =>
{
// 録音状態が録音中・エラーの場合はインスタンスを解放する
if (m_recordingState != RecordingState.Stop)
{
// 録音状態変更
m_recordingState = RecordingState.Stop;
// WaveInインスタンス解放
m_waveIn.Dispose();
m_waveIn = null;
// 録音終了時に行う処理
}
}
RecordingStoppedは一番最後に行われる処理になりますので、ここでインスタンスを解放します。
※RecordingStopメソッドを使用した後はWaveInクラスの内部ではフラグの変更(録音停止)→DataAvailable(未処理のもの)→RecordingStoppedの順に処理が行われます。
「WaveIn」クラスのStartRecordingメソッドで録音が開始され、RecordingStopメソッドで録音を停止します。
// 録音開始
m_waveIn.StartRecording();
m_recordingState = RecordingState.Recording;
/// <summary>
/// 録音停止
/// <summary>
public void RecordingStop ()
{
// 録音停止
m_waveIn?.RecordingStop();
}
ステップ5 ACPを利用してWebSocketを介した音声認識
準備
ACPを利用してWebSocketを介した音声認識のWebSocket部分につきましてはこちらの記事に書かれておりますので、今回はACPのホームページのサンプルプログラムのWebSocket部分をそのままアプリに組み込みたいと思います。
はじめに、コチラよりサンプルプログラムをダウンロードします。
サンプルプログラムの「sample_1.1.8/Wrp/cs/src」にある「com」フォルダーをModelsフォルダー内にコピーします。
※「com」フォルダ内の「Wrp」ファイルにWebSocketに関する処理が記述されています。そのため、自分でWebSocketに関する処理も記述する場合には参考にしてください。
次に、WebSocketを介した音声認識結果について記述するクラスファイルをModelsフォルダに新たに追加します(以下、WrpSimpleクラスとする)。WebSocketを介して返ってきた音声認識結果の処理は全てこのWrpSimpleクラスに記述していきます。
※今回のアプリでは返ってきた音声認識結果をパースし、UI表示しか行いませんが、他にも様々なイベントが存在しています(例えば、発話区間を検出した際に発火するイベント等)。作成したいアプリに合わせて各イベントを使用してください。
認識結果処理
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace RecApp.Models
{
/// <summary>
/// 認識結果を扱うクラス
/// </summary>
class WrpSimple : com.amivoice.wrp.WrpListener
{
public void utteranceStarted(int startTime) { }
public void utteranceEnded(int endTime) { }
public void resultCreated() { }
public void resultUpdated(string result) { }
public void resultFinalized(string result)
{
string text = TextParse(result);
}
public void eventNotified(int eventId, string eventMessage) { }
public void TRACE(string message) { }
/// <summary>
/// 認識結果を変換
/// </summary>
/// <param name="result">JSON形式の認識結果文字列</param>
/// <returns>認識結果
private string TextParse(string result)
{
int index = result.LastIndexOf(",\"text\":\"");
if (index == -1)
{
return null;
}
index += 9;
int resultLength = result.Length;
StringBuilder buffer = new StringBuilder();
int c = (index >= resultLength) ? 0 : result[index++];
while (c != 0)
{
if (c == '"')
{
break;
}
if (c == '\\ ')
{
c = (index >= resultLength) ? 0 : result[index++];
if (c == 0)
{
return null;
}
if (c == '"' || c == '\\' || c == '/')
{
buffer.Append((char)c);
}
else
if (c == 'b' || c == 'f' || c == 'n' || c == 'r' || c == 't')
{
}
else
if (c == 'u')
{
int c0 = (index >= resultLength) ? 0 : result[index++];
int c1 = (index >= resultLength) ? 0 : result[index++];
int c2 = (index >= resultLength) ? 0 : result[index++];
int c3 = (index >= resultLength) ? 0 : result[index++];
if (c0 >= '0' && c0 <= '9') { c0 -= '0'; } else if (c0 >= 'A' && c0 <= 'F') { c0 -= 'A' - 10; } else if (c0 >= 'a' && c0 <= 'f') { c0 -= 'a' - 10; } else { c0 = -1; }
if (c1 >= '0' && c1 <= '9') { c1 -= '0'; } else if (c1 >= 'A' && c1 <= 'F') { c1 -= 'A' - 10; } else if (c1 >= 'a' && c1 <= 'f') { c1 -= 'a' - 10; } else { c1 = -1; }
if (c2 >= '0' && c2 <= '9') { c2 -= '0'; } else if (c2 >= 'A' && c2 <= 'F') { c2 -= 'A' - 10; } else if (c2 >= 'a' && c2 <= 'f') { c2 -= 'a' - 10; } else { c2 = -1; }
if (c3 >= '0' && c3 <= '9') { c3 -= '0'; } else if (c3 >= 'A' && c3 <= 'F') { c3 -= 'A' - 10; } else if (c3 >= 'a' && c3 <= 'f') { c3 -= 'a' - 10; } else { c3 = -1; }
if (c0 == -1 || c1 == -1 || c2 == -1 || c3 == -1)
{
return null;
}
buffer.Append((char)((c0 << 12) | (c1 << 8) | (c2 << 4) | c3));
}
else
{
return null;
}
}
else
{
buffer.Append((char)c);
}
c = (index >= resultLength) ? 0 : result[index++];
}
return buffer.ToString();
}
}
}
WrpSimpleクラスに「com.amivoice.wrp」に存在する、WrpListenerインターフェースを継承させます。
/// <summary>
/// 認識結果を扱うクラス
/// </summary>
class WrpSimple : com.amivoice.wrp.WrpListener
インターフェースを継承したことにより以下の7つを実装しなければなりません。
public void utteranceStarted(int startTime) { }
public void utteranceEnded(int endTime) { }
public void resultCreated() { }
public void resultUpdated(string result) { }
public void resultFinalized(string result)
{
string text = TextParse(result);
}
public void eventNotified(int eventId, string eventMessage) { }
public void TRACE(string message) { }
今回は音声認識が終了した際にUI表示させたいので、resultFinalizedしか使用しません。resultFinalizedは特定の発話区間の音声認識結果が確定した際に発火するイベントになります。音声認識結果はJSON形式の文字列になっています。そのため、パースを行い、必要な部分だけ取り出す必要があります。
「TextParse」メソッドでJSON形式の文字列から認識結果のみを取り出しています。
/// <summary>
/// 認識結果を変換
/// </summary>
/// <param name="result">JSON形式の認識結果文字列</param>
/// <returns>認識結果
private string TextParse(string result)
{
int index = result.LastIndexOf(",\"text\":\"");
if (index == -1)
{
return null;
}
index += 9;
int resultLength = result.Length;
StringBuilder buffer = new StringBuilder();
int c = (index >= resultLength) ? 0 : result[index++];
while (c != 0)
{
if (c == '"')
{
break;
}
if (c == '\\ ')
{
c = (index >= resultLength) ? 0 : result[index++];
if (c == 0)
{
return null;
}
if (c == '"' || c == '\\' || c == '/')
{
buffer.Append((char)c);
}
else
if (c == 'b' || c == 'f' || c == 'n' || c == 'r' || c == 't')
{
}
else
if (c == 'u')
{
int c0 = (index >= resultLength) ? 0 : result[index++];
int c1 = (index >= resultLength) ? 0 : result[index++];
int c2 = (index >= resultLength) ? 0 : result[index++];
int c3 = (index >= resultLength) ? 0 : result[index++];
if (c0 >= '0' && c0 <= '9') { c0 -= '0'; } else if (c0 >= 'A' && c0 <= 'F') { c0 -= 'A' - 10; } else if (c0 >= 'a' && c0 <= 'f') { c0 -= 'a' - 10; } else { c0 = -1; }
if (c1 >= '0' && c1 <= '9') { c1 -= '0'; } else if (c1 >= 'A' && c1 <= 'F') { c1 -= 'A' - 10; } else if (c1 >= 'a' && c1 <= 'f') { c1 -= 'a' - 10; } else { c1 = -1; }
if (c2 >= '0' && c2 <= '9') { c2 -= '0'; } else if (c2 >= 'A' && c2 <= 'F') { c2 -= 'A' - 10; } else if (c2 >= 'a' && c2 <= 'f') { c2 -= 'a' - 10; } else { c2 = -1; }
if (c3 >= '0' && c3 <= '9') { c3 -= '0'; } else if (c3 >= 'A' && c3 <= 'F') { c3 -= 'A' - 10; } else if (c3 >= 'a' && c3 <= 'f') { c3 -= 'a' - 10; } else { c3 = -1; }
if (c0 == -1 || c1 == -1 || c2 == -1 || c3 == -1)
{
return null;
}
buffer.Append((char)((c0 << 12) | (c1 << 8) | (c2 << 4) | c3));
}
else
{
return null;
}
}
else
{
buffer.Append((char)c);
}
c = (index >= resultLength) ? 0 : result[index++];
}
return buffer.ToString();
}
こちらの処理はサンプルプログラムの「sample_1.1.8/Wrp/cs」にある「WrpTester.cs」内のtext_メソッドをそのままコピペしたものになります。
ステップ6 作成したプログラム・UIの連係
準備
ステップ4・5で作成した音声録音・音声認識結果処理、サンプルプログラムから移植したクラス、UIをそれぞれ紐づけていきたいと思います。
初めてに、UIを作成していきます。今回はUIデザインについては言及しません。そのため、WPFのTextBoxとButtonがあればアプリとして動作します。UIを考えたくない方は以下のxamlコードを「MainView.xaml」にコピペしてください。
※コードに記載されているRecAppの部分を全て作成したプロジェクト名に変更する必要があります。
<Window x:Class="RecApp.Views.MainView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:RecApp.Views.Behavior"
mc:Ignorable="d"
Title="録音アプリ" Height="500" Width="300">
<Window.Resources>
<!-- Visibilityをbool値で変換出来るようにするためのコンバータ -->
<BooleanToVisibilityConverter x:Key="BoolVisibilityConverter"/>
<!-- 点滅アニメーション -->
<Storyboard x:Key="BlinkStory">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)" RepeatBehavior="Forever" AutoReverse="True">
<LinearDoubleKeyFrame KeyTime="0" Value="1"/>
<LinearDoubleKeyFrame KeyTime="0:0:1" Value="0"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
<!-- 点滅のためのスタイルの作成 -->
<Style x:Key="BlinkingStyle" TargetType="TextBlock">
<Style.Triggers>
<Trigger Property="IsVisible" Value="True">
<Trigger.EnterActions>
<BeginStoryboard x:Name="BlinkingStoryboard1" Storyboard="{StaticResource BlinkStory}"/>
</Trigger.EnterActions>
<Trigger.ExitActions>
<StopStoryboard BeginStoryboardName="BlinkingStoryboard1"/>
</Trigger.ExitActions>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="40"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Background="LightGray" Panel.ZIndex="2" Opacity="0.5"
Visibility="{Binding BlinkStory, Converter={StaticResource BoolVisibilityConverter}}">
<TextBlock Foreground="Black" FontSize="60" TextAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding BlinkStory, Converter={StaticResource BoolVisibilityConverter}}"
Style="{StaticResource BlinkingStyle}">
録音中
</TextBlock>
</Grid>
<TextBox Grid.Row="0" Text="{Binding RecognitionResultText}" Panel.ZIndex="1"
VerticalScrollBarVisibility="Visible" TextWrapping="Wrap"
local:ScrollToEndBehavior.AutoScrollToEnd="True"/>
<Button Grid.Row="1" Content="{Binding ButtonContent}"
Command="{Binding RecordingCommand}"/>
</Grid>
</Window>
ここでは
- Binding
- ScrollToEndBehavior.AutoScrollToEnd
- Storyboard
- Converter
の4点ついて簡単に説明します。
・Binding
「Binding ***」では***のデータ(プロパティ)にバインディングしています。こうすることで、ViewModel側で要素の値を変更すると自動的にUIに反映されるようになります。[1]
・ScrollToEndBehavior.AutoScrollToEnd
こちらはMVVMに準拠してアプリを作成すると、コードビハインド(MainView.xaml.csにコードを記述すること)を使用しないため、Viewの状態変化により実行される処理を記述することが困難になります。その代替手段としてBehaviorクラスがあります。[2]今回作成するアプリでは末尾に認識結果を追加していきます。そのため、常に最新の認識結果を表示させるには認識結果が出るたびに末尾までスクロールする必要があります。そこで、認識結果が表示されるたびに末尾までスクロールするためにBehaviorクラスを使用します。Behaviorクラスの実装内容は後ほど記述します。
・Storyboard
StoryboardはWPFでアニメーションを作成することができるものになります。ここでは、録音中にテキストボックスを操作出来ないようにするかつ録音中であることが分かるようにするためにBlinkするアニメーションを作成しています。Storyboardタグで囲まれた部分でアニメーションの内容を決めています。今回はOpacityプロパティを変更させ、Blinkさせています。作成したアニメーションを発火するトリガーを設定している部分がStoryboardタグの下に書かれているStyleタグになります。今回はこのStyleを使用している要素が画面に見えている時にアニメーションするようにしています。こちらの記事がStoryboardについて詳しく解説しております。Storyboardについてさらに知りたいという方は参考にしてください。
・Converter
Converterとは文字通り、変換するものことを言います。こちらの記事にも記載されているように、チェックボックスのチェックされているかどうかを表すプロパティであるIsCheckedプロパティ(valueはbool型)をConverterを使用することで文字列で状態を表すことが出来たりします。今回のアプリでは録音中はVisibilityプロパティを「Visibility」に、それ以外では「Hidden」or「Collapse」にしようとしています。Visibilityプロパティのvalueはbool型ではなく、列挙型が用いられており、bool型で画面に表示する・しないが制御が出来ません。そのため、Converterを用いてtrueの時「Visibility」falseの時「Collapse」となるようにしています。
※BooleanToVisibilityConverter クラスというものが標準で実装されています。そのため、こちらを使用することで新しくConverterクラスを作成することなく、行いたい処理を実装出来ます。
ViewModelについて
ViewModelクラスではデータバインディングしたデータやModelをViewと繋げる役割を担います。View要素にデータバインディングするためには、「INotifyPropertyChanged・ICommand」を使用して、View側に値が変更された事を通知します。しかし、これらをそのまま使用するのはなかなか骨が折れます。そのため、手軽に使用するために、NuGetから「Prism.Wpf」をインストールします。PrismとはMVVMフレームワークの一つになります。「Prism」を使うことで「INotifyPropertyChanged・ICommand」を用いたデータバインディング関連の処理を短く簡単に記述することが可能になります。
「MainViewModel.cs」の全体のコードは以下のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.ComponentModel;
using com.amivoice.wrp;
using RecApp.Models;
using Prism.Commands;
using Prism.Mvvm;
namespace RecApp.ViewModels
{
// 録音状態を管理するクラス(ボタン文言変更用)
public class RecordingStateNotifyEventArgs
{
public RecordingState state { get; set; }
}
// テキストボックスに文字を表示させるためのクラス
public class ResultNotifyEventArgs
{
public string result { get; set; }
}
/// <summary>
/// MainView ウィンドウに対するデータコンテキストを表します。
/// </summary>
internal class MainViewModel : BindableBase
{
/// <summary>
/// アプリを切った際のイベント WebSocketの接続を切る
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
internal void Closing(object sender, CancelEventArgs e)
{
// WebSocketを接続したままアプリを落とした場合に
wrp?.disconnect();
}
/// <summary>
/// テキストボックスに書かれている内容
/// </summary>
private string _text;
public string RecognitionResultText
{
get { return this._text; }
set
{
SetProperty(ref _text, value);
}
}
/// <summary>
/// 録音ボタンに表示する文言
/// </summary>
private string _buttonContent = "録音を開始する";
public string ButtonContent
{
get { return this._buttonContent; }
set
{
SetProperty(ref _buttonContent, value);
}
}
/// <summary>
/// 録音中に表示するアニメーションを制御する
/// </summary>
private bool __isBlinkVisibility = false;
public bool IsBlinkVisibility
{
get { return this.__isBlinkVisibility; }
set
{
SetProperty(ref __isBlinkVisibility, value);
}
}
/// <summary>
/// 録音コマンドを取得します。
/// </summary>
public DelegateCommand RecordingCommand { get; }
// WebSocketに関する変数
Wrp wrp;
private Audio m_audio = null;
public MainViewModel()
{
// クリックイベントを設定
RecordingCommand = new DelegateCommand(ButtonClick );
// WebSocket 音声認識サーバイベントリスナの作成
WrpSimple listener = new WrpSimple();
// WebSocket 音声認識サーバの初期化
wrp = Wrp.construct();
wrp.setListener(listener);
// 接続するサーバー名
wrp.setServerURL("wss://acp-api.amivoice.com/v1/");
// 音声フォーマット
wrp.setCodec("LSB16K");
// 使用する辞書
wrp.setGrammarFileNames("-a-general");
// AppKey
wrp.setAuthorization("Your AppKey");
// 認識結果をテキストボックスへ書き込むためのイベント
listener.ResultNotifyHandler += SetTextNotifyHandler;
// NAudioを扱うクラスインスタンス化
m_audio = new Audio(wrp);
// 録音状態に変化があった際にボタンの文言を変更するためのイベント
// 通知を受ける関数の登録
m_audio.RecordingStateChanged += RecordingStateChanged;
// WebSocketの接続状態をテキストボックスへ書き込むためのイベント
m_audio.ResultNotifyHandler += SetTextNotifyHandler;
}
private void RecordingStateChanged(object sender, RecordingStateNotifyEventArgs e)
{
switch (e.state)
{
case RecordingState.Recording:
{
ButtonContent = "録音を停止する";
IsBlinkVisibility = true;
break;
}
case RecordingState.Stop:
case RecordingState.Error:
{
ButtonContent = "録音を開始する";
IsBlinkVisibility = false;
break;
}
}
}
private void SetTextNotifyHandler(object sender, ResultNotifyEventArgs args)
{
RecognitionResultText += args.result + "\r\n";
}
/// <summary>
/// ボタンをクリックした際の動作
/// </summary>
private void ButtonClick()
{
if (m_audio.m_recordingState != RecordingState.Stop)
{
// 停止する
m_audio.RecordingStop();
}
else
{
RecognitionResultText = "";
// 録音する
m_audio.RecordingStart();
}
}
}
}
上から順にコードを追っていきます。
Modelで生成したデータをViewに表示させるにはViewModelに一度データを渡す必要があります。Modelから受け取ったデータをViewに渡すイベントの為にイベント変数を作成します。
// 録音状態を管理するクラス(ボタン文言変更用)
public class RecordingStateNotifyEventArgs
{
public RecordingState state { get; set; }
}
// テキストボックスに文字を表示させるためのクラス
public class ResultNotifyEventArgs
{
public string result { get; set; }
}
今回は録音状態・文字列しかやり取りしないため、録音状態・文字列のみを持つクラスを作成します。
/// <summary>
/// MainView ウィンドウに対するデータコンテキストを表します。
/// </summary>
internal class MainViewModel : BindableBase
「BindableBase」は「INotifyPropertyChanged」を実装する際のヘルパークラスになります。こちらを継承することで、View側に値の変更を簡単に通知させることが可能なります。
/// <summary>
/// アプリを切った際のイベント WebSocketの接続を切る
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
internal void Closing(object sender, CancelEventArgs e)
{
// WebSocketを接続したままアプリを落とした場合に接続を切る
wrp?.disconnect();
}
では、WebSocketと接続したままアプリを落とした場合に、接続を切断するための処理になります。「App.xaml.cs」の「w.Closing += vm.Closing;」で閉じる際の処理をMainViewに追加しています。
※録音中にアプリを落とすとWebSocketと接続したままアプリを落とすことになります。
次にButton・TextBoxにバインディングするデータ・アニメーションを発火させるトリガーについては
/// <summary>
/// テキストボックスに書かれている内容
/// </summary>
private string _text;
public string RecognitionResultText
{
get { return this._text; }
set
{
SetProperty(ref _text, value);
}
}
/// <summary>
/// 録音ボタンに表示する文言
/// </summary>
private string _buttonContent = "録音を開始する";
public string ButtonContent
{
get { return this._buttonContent; }
set
{
SetProperty(ref _buttonContent, value);
}
}
/// <summary>
/// 録音中に表示するアニメーションを制御する
/// </summary>
private bool _isBlinkVisibility = false;
public bool IsBlinkVisibility
{
get { return this._isBlinkVisibility; }
set
{
SetProperty(ref _isBlinkVisibility, value);
}
}
のように記述します。SetPropertyを用いることで値が変更された場合にViewに値が変更された通知を送ります。SetPropartyの詳しい処理内容を知りたい方はGithubにコードが公開されていますのでそちらを参考にしてください。
録音ボタンを押下した際に、録音開始・停止を行うイベントはDelegateCommandを用いてViewに渡します。
/// <summary>
/// 録音コマンドを取得します。
/// </summary>
public DelegateCommand RecordingCommand { get; }
// クリックイベントを設定
RecordingCommand = new DelegateCommand(ButtonClick );
のようにDelegateCommandにボタンを押下した際に行いたい処理を引数として渡します。
WebSocketについては
// WebSocketに関する変数
Wrp wrp;
// WebSocket 音声認識サーバイベントリスナの作成
WrpSimple listener = new WrpSimple();
// WebSocket 音声認識サーバの初期化
wrp = Wrp.construct();
wrp.setListener(listener);
// 接続するサーバ名
wrp.setServerURL("wss://acp-api.amivoice.com/v1/");
// 音声フォーマット
wrp.setCodec("LSB16K");
// 使用する辞書
wrp.setGrammarFileNames("-a-general");
// AppKey
wrp.setAuthorization("Your AppKey");
// 認識結果をテキストボックスへ書き込むためのイベント
listener.ResultNotifyHandler += SetTextNotifyHandler;
のようになります。接続するサーバ名等の上述した別の記事で記載されている部分については今回は触れません。ステップ5で作成したWrpSimpleクラスをWrpクラスのリスナに設定します。これにより、作成した音声認識結果イベントを発火させることが可能になります。また、ViewModelにデータを渡すためにWrpSimpleクラスを変更する必要があります。そのため、ViewModelにデータを渡すためのイベントを新たに定義しています。定義した内容については後ほど記述します。
Audioクラスは
private Audio m_audio = null;
// NAudioを扱うクラスインスタンス化
m_audio = new Audio(wrp);
// 録音状態に変化があった際にボタンの文言を変更するためのイベント
// 通知を受ける関数の登録
m_audio.RecordingStateChanged += RecordingStateChanged;
// WebSocketの接続状態をテキストボックスへ書き込むためのイベント
m_audio.ResultNotifyHandler += SetTextNotifyHandler;
のようになります。ACPサーバに音声を送信するためにWrpクラスが必要になります。そのため、AudioクラスもACPサーバ・ViewModelとの接続するために、クラスを変更する必要があります。変更点については後ほど記述します。ここでは、「ACPサーバ・ViewModelとの接続のために、イベントを定義し、処理を追加している」という認識をして頂ければ十分です。
Behaviorクラスについて
始めにBehaviorクラスを書くためのファイルを作成します。Viewsフォルダに新しくBehaviorファルダを作成します。その中にクラスファイルを作成し、ファイル名を「ScrollToEndBehavior.cs」にします(以下、ScrollToEndBehaviorクラスとする)。新しい認識結果が表示された際に、自動で一番下までスクロールバーが異動する処理をScrollToEndBehaviorクラスに記述していきます。
こちらの記事の添付ビヘイビアを参考に作成したScrollToEndBehaviorクラスは以下のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
namespace RecApp.Views.Behavior
{
class ScrollToEndBehavior
{
/// <summary>
/// 複数行のテキストを扱う
/// テキスト追加時に最終行が表示されるようにする
/// </summary>
public static readonly DependencyProperty AutoScrollToEndProperty =
DependencyProperty.RegisterAttached(
"AutoScrollToEnd", // プロパティ名を指定
typeof(bool), // プロパティの型を指定
typeof(ScrollToEndBehavior), // プロパティを所有する型を指定
new FrameworkPropertyMetadata(false, IsTextChanged) // メタデータを指定
);
[AttachedPropertyBrowsableForType(typeof(TextBox))] // xaml側のプロパティで表示する型指定
// Get Setを記述する必要があるため記述
public static bool GetAutoScrollToEnd(DependencyObject obj)
{
return (bool)obj.GetValue(AutoScrollToEndProperty);
}
public static void SetAutoScrollToEnd(DependencyObject obj, bool value)
{
obj.SetValue(AutoScrollToEndProperty, value);
}
// プロパティ
private static void IsTextChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
TextBox textBox = (TextBox)sender;
if (textBox == null) return;
// イベントを登録・削除
textBox.TextChanged -= OnTextChanged;
bool newValue = (bool)e.NewValue;
if (newValue == true)
{
textBox.TextChanged += OnTextChanged;
}
}
private static void OnTextChanged(object sender, TextChangedEventArgs e)
{
TextBox textBox = (TextBox)sender;
if (textBox == null) return;
if (string.IsNullOrEmpty(textBox.Text)) return;
if (textBox.IsKeyboardFocused == false) textBox.ScrollToEnd();
}
}
}
添付ビヘイビアについてはこちらの記事に詳しく書かれいるので、付与したイベントであるOnTextChangedについてのみ説明します。
OnTextChangedではTextBox内のテキストを変更すれば、末尾までスクロールを行うようにしています。ただし、TextBoxがキーボードフォーカスを持っている場合はスクロールしません。これは認識結果を編集する際に1文字変更するたびに末尾までスクロールされると編集作業が大変になります。そのため、録音中のみスクロールするようにしています。
WPFのフォーカスには2つの概念が存在します。
- キーボードフォーカス
- 論理フォーカス
の2種類のフォーカスが存在します。それぞれの違いは
- キーボードフォーカス:現在キーボード入力を受け取っている要素であり、デスクトップ全体で1つしか存在しない
- 論理フォーカス:任意のフォーカス範囲内に1つしか存在しない。複数存在することがある
詳細を知りたい方は公式のDocを参考にしてください。
録音クラスとWebSocket処理の連係
音声認識するには録音した音声データをACPサーバに渡す必要があります。そのため、Audioクラスを変更します。次節のUIと連係で再度Audioクラスを変更するため、ここではACPサーバと接続し音声認識するために使用するメソッドについて説明します。
using com.amivoice.wrp;
// WebSocket関連のクラス
private Wrp m_wrp = null;
public Audio (Wrp wrp)
{
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat (16000, 1);
m_wrp = wrp;
}
始めに、Wrpクラスを使用するために、上部のusingに「com.amivoice.wrp」を追加し、Audioクラス内でオブジェクトを持つために変数を宣言します。宣言したオブジェクトにAudioクラスをインスタンス化する際に値を渡します。
// 音声認識サーバへの接続
m_wrp.connect()
// 音声認識サーバへの音声データの送信開始
m_wrp.feedDataResume()
// 音声認識サーバへの音声データの送信
m_wrp.feedData(ee.BUffer, 0 , ee.BytesRecorded)
// 音声認識サーバへの音声データの送信完了
m_wrp.feedDataPause()
// 音声認識サーバから切断
m_wrp.disconnect()
ACPサーバに音声データを送る手順は
- サーバへ接続
- 音声データ送信開始メッセージを送信
- 音声データをサーバに送信
- 送信終了メッセージを送信
- サーバから切断
の手順を踏むことでサーバに音声データを送信し、音声認識を行うことができます。これに対応するWrpのメソッドは
- connect
- feedDataResume
- feedData
- feedDataPause
- disconnect
になります。これを順に使用することで、音声認識を行うことが出来ます。feedDataメソッドでACPサーバへ音声データを送信しているので、このメソッドをWaveInクラスのDataAvailableの中で実行する必要があります。
録音クラス・認識結果処理クラスとUIの連係
最終的なAudioクラスは次のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NAudio.Wave;
using com.amivoice.wrp;
using RecApp.ViewModels;
namespace RecApp.Models
{
/// <summary>
/// 録音状態を表す列挙型
/// </summary>
public enum RecordingState
{
Recording,
Stop,
Error
}
/// <summary>
/// NAudioを操作するクラス
/// </summary>
class Audio
{
// 録音を行うクラス
private WaveIn m_waveIn = null;
// 録音時のフォーマットを設定するクラス
private WaveFormat m_recordinFormat = null;
// WebSocket関連のクラス
private Wrp m_wrp = null;
// 接続情報をテキストボックスへ書きこむためのイベント
public event EventHandler<ResultNotifyEventArgs> ResultNotifyHandler;
// 録音状態を管理する変数
private RecordingState _recordingState = RecordingState.Stop;
// ここの値を変更する際にイベントを発火
public RecordingState m_recordingState
{
get
{
return _recordingState;
}
private set
{
// 状態が変化しているか
if (this._recordingState == value) return;
this._recordingState = value;
// データが変更されたときに通知することをここで集中管理
OnRecordingStateChanged(value);
}
}
// 録音状態状態の変更を通知するevent
public event EventHandler<RecordingStateNotifyEventArgs> RecordingStateChanged;
public Audio(Wrp wrp)
{
// サンプリング周波数 16000Hz 1ch 16bit PCMのフォーマット作成
m_recordinFormat = new WaveFormat(16000, 1);
m_wrp = wrp;
}
/// <summary>
/// 録音開始
/// </summary>
public void RecordingStart()
{
// 音声認識サーバへの接続
if (m_wrp.connect() == false)
{
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの接続に失敗しました。");
return;
}
SetText("WebSocket 音声認識サーバへの接続に成功しました。");
// 初期化&録音時のフォーマットを設定
m_waveIn = new WaveIn();
m_waveIn.WaveFormat = m_recordinFormat;
// 録音デバイス設定
// デフォルトデバイスで録音
m_waveIn.DeviceNumber = 0;
// 録音中に発生するイベント
m_waveIn.DataAvailable += (_, ee) =>
{
try
{
// エラーが起きていないか
if (m_recordingState == RecordingState.Recording)
{
// 音声認識サーバへの音声データの送信
if (m_wrp.feedData(ee.Buffer, 0, ee.BytesRecorded) == false)
{
m_recordingState = RecordingState.Error;
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの音声データの送信に失敗しました。");
// 録音停止
RecordingStop();
}
}
}
catch (Exception e)
{
SetText("エラー発生");
SetText(e.Message);
// エラー確認
if (m_recordingState == RecordingState.Recording)
{
// 二重に停止処理が起きないようにする
m_recordingState = RecordingState.Error;
// 録音停止
RecordingStop();
}
}
};
// 録音終了時のイベント
m_waveIn.RecordingStopped += (_,__) =>
{
SetText("録音終了");
// 録音状態が録音中・エラーの場合はインスタンスを解放する
if (m_recordingState != RecordingState.Stop)
{
// 録音状態変更
m_recordingState = RecordingState.Stop;
// WaveInインスタンス解放
m_waveIn.Dispose();
m_waveIn = null;
// 音声認識サーバへの音声データの送信完了
if (m_wrp.feedDataPause() == false)
{
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの音声データの送信完了に失敗しました。");
}
// 音声認識サーバから切断
m_wrp.disconnect();
SetText("WebSocket 音声認識サーバへの接続を切断しました。");
}
};
// 音声認識サーバへの音声データの送信開始
if (m_wrp.feedDataResume() == false)
{
m_wrp.disconnect();
SetText(m_wrp.getLastMessage());
SetText("WebSocket 音声認識サーバへの音声データの送信開始に失敗しました。");
return;
}
// 録音開始
m_waveIn.StartRecording();
m_recordingState = RecordingState.Recording;
}
/// <summary>
/// 録音停止
/// </summary>
public void RecordingStop()
{
// 録音停止
m_waveIn?.StopRecording();
}
/// <summary>
/// 接続状態をテキストボックスへ書き込む
/// </summary>
/// <param name="result">書き込む内容</param
private void SetText(string result)
{
if (ResultNotifyHandler != null)
{
var args = new ResultNotifyEventArgs() { result = result };
ResultNotifyHandler(this, args);
}
}
/// <summary>
/// 録音状態変更イベントを発火
/// </summary>
/// <param name="state"></param>
private void OnRecordingStateChanged(RecordingState state)
{
if (RecordingStateChanged != null)
{
var args = new RecordingStateNotifyEventArgs() { state = state };
RecordingStateChanged(this, args);
}
}
}
}
WebSocketに接続できたかどうか確認するためにイベントを定義し、Modelからの情報をViewに表示させます。
// 接続情報をテキストボックスへ書きこむためのイベント
public event EventHandler<ResultNotifyEventArgs> ResultNotifyHandler;
/// <summary>
/// 接続状態をテキストボックスへ書き込む
/// </summary>
/// <param name="result">書き込む内容</param
private void SetText(string result)
{
if (ResultNotifyHandler != null)
{
var args = new ResultNotifyEventArgs() { result = result };
ResultNotifyHandler(this, args);
}
}
録音状態に応じてボタンの文言やBlinkを表示させたりするためにイベントを作成します。
// 録音状態を管理する変数
private RecordingState _recordingState = RecordingState.Stop;
// ここの値を変更する際にイベントを発火
public RecordingState m_recordingState
{
get
{
return _recordingState;
}
private set
{
// 状態が変化しているか
if (this._recordingState == value) return;
this._recordingState = value;
// データが変更されたときに通知することをここで集中管理
OnRecordingStateChanged(value);
}
}
// 録音状態状態の変更を通知するevent
public event ChangedEventHandler RecordingStateChanged;
/// <summary>
/// 録音状態変更イベントを発火
/// </summary>
/// <param name="sender"></param>
private void OnRecordingStateChanged(object sender)
{
if (RecordingStateChanged != null)
{
var args = new RecordingStateNotifyEventArgs() { state = state };
RecordingStateChanged(this, args);
}
}
次に最終的なWrpSimple.csは次のようになります。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using RecApp.ViewModels;
namespace RecApp.Models
{
/// <summary>
/// 認識結果を扱うクラス
/// </summary>
class WrpSimple : com.amivoice.wrp.WrpListener
{
// 接続情報をテキストボックスへ書きこむためのイベント
public event EventHandler<ResultNotifyEventArgs> ResultNotifyHandler;
public void utteranceStarted(int startTime) { }
public void utteranceEnded(int endTime) { }
public void resultCreated() { }
public void resultUpdated(string result) { }
public void resultFinalized(string result)
{
string text = TextParse(result);
SetText(text);
}
public void eventNotified(int eventId, string eventMessage) { }
public void TRACE(string message) { }
/// <summary>
/// 認識結果を変換
/// </summary>
/// <param name="result">JSON形式の認識結果文字列</param>
/// <returns>認識結果
private string TextParse(string result)
{
int index = result.LastIndexOf(",\"text\":\"");
if (index == -1)
{
return null;
}
index += 9;
int resultLength = result.Length;
StringBuilder buffer = new StringBuilder();
int c = (index >= resultLength) ? 0 : result[index++];
while (c != 0)
{
if (c == '"')
{
break;
}
if (c == '\\ ')
{
c = (index >= resultLength) ? 0 : result[index++];
if (c == 0)
{
return null;
}
if (c == '"' || c == '\\' || c == '/')
{
buffer.Append((char)c);
}
else
if (c == 'b' || c == 'f' || c == 'n' || c == 'r' || c == 't')
{
}
else
if (c == 'u')
{
int c0 = (index >= resultLength) ? 0 : result[index++];
int c1 = (index >= resultLength) ? 0 : result[index++];
int c2 = (index >= resultLength) ? 0 : result[index++];
int c3 = (index >= resultLength) ? 0 : result[index++];
if (c0 >= '0' && c0 <= '9') { c0 -= '0'; } else if (c0 >= 'A' && c0 <= 'F') { c0 -= 'A' - 10; } else if (c0 >= 'a' && c0 <= 'f') { c0 -= 'a' - 10; } else { c0 = -1; }
if (c1 >= '0' && c1 <= '9') { c1 -= '0'; } else if (c1 >= 'A' && c1 <= 'F') { c1 -= 'A' - 10; } else if (c1 >= 'a' && c1 <= 'f') { c1 -= 'a' - 10; } else { c1 = -1; }
if (c2 >= '0' && c2 <= '9') { c2 -= '0'; } else if (c2 >= 'A' && c2 <= 'F') { c2 -= 'A' - 10; } else if (c2 >= 'a' && c2 <= 'f') { c2 -= 'a' - 10; } else { c2 = -1; }
if (c3 >= '0' && c3 <= '9') { c3 -= '0'; } else if (c3 >= 'A' && c3 <= 'F') { c3 -= 'A' - 10; } else if (c3 >= 'a' && c3 <= 'f') { c3 -= 'a' - 10; } else { c3 = -1; }
if (c0 == -1 || c1 == -1 || c2 == -1 || c3 == -1)
{
return null;
}
buffer.Append((char)((c0 << 12) | (c1 << 8) | (c2 << 4) | c3));
}
else
{
return null;
}
}
else
{
buffer.Append((char)c);
}
c = (index >= resultLength) ? 0 : result[index++];
}
return buffer.ToString();
}
/// <summary>
/// 接続状態をテキストボックスへ書き込む
/// </summary>
/// <param name="result">書き込む内容</param
private void SetText(string result)
{
if (ResultNotifyHandler != null)
{
var args = new ResultNotifyEventArgs() { result = result };
ResultNotifyHandler(this, args);
}
}
}
}
WrpSimple.csにもAudio.csで作成したViewに表示するためのイベントを同様に定義しています。こちらでは認識結果を表示するためにイベントを作成しています。
まとめ
今回は「Windowsアプリでマイク録音の部分を実装してみた」に挑戦してみました。皆さんもACPを利用して、音声認識アプリ開発にチャレンジしてみてください。
参考
この記事を書いた人
小関勇太
Pythonでサーバサイドの開発をしています。