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

はじめに

はじめまして。AmiVoice APIインフラチームメンバーDです。
本記事では、Microsoft EdgeGoogle ChromeからAmiVoice APIを実行するWebページのサンプルとその作り方を紹介します。

対象

できること

AmiVoice APIのアカウント作成後、すぐに試せます。アカウントがない場合は、利用申し込みを行ってください。

Webページのサンプル

AmiVoice API音声認識Webページサンプル

AmiVoice API音声認識Webページサンプル

ソースコード

advanced-media-inc/acp-javascript-sample-applications/speech-recognition-webpage

注意点

仕様について

使い方

同期HTTP音声認識API

  1. AmiVoice APIAPPKEYを設定。
  2. 音声ファイルを選択。(音声+映像のファイルも可。)
  3. 「同期HTTP音声認識API実行」ボタンをクリックすると認識が開始します。
  4. 音声認識が完了すると結果が表示され、WebVTT形式の字幕ファイルのサンプルが作成されます。

非同期HTTP音声認識API

  1. AmiVoice APIのAPPKEYを設定。
  2. 音声ファイルを選択。(音声+映像のファイルも可。)
  3. 「非同期HTTP音声認識API実行」ボタンをクリックすると認識が開始します。
  4. 音声認識が完了すると結果が表示され、WebVTT形式の字幕ファイルのサンプルが作成されます。

WebSocket音声認識API

  1. AmiVoice APIのAPPKEYを設定。
  2. 「WebSocket音声認識API開始」ボタンをクリックすると録音と認識が開始します。
  3. 音声認識が完了すると、結果が表示されます。
  4. 「WebSocket音声認識API停止」ボタンをクリックすると録音と認識が終了し、WebVTT形式の字幕ファイルのサンプルが作成されます。

作り方

1. 非同期HTTP音声認識APIの実行

AmiVoice APIのマニュアルにあるPythonサンプルコードをもとにJavaScriptのコードを作成します。
まずは「音声認識ジョブの作成」を行う最小限のコードを作成します。
HTTPリクエストを送信する方法は、fetchを使う、jQueryやAxiosなどのライブラリを利用するなどいろいろありますが、ここではXMLHttpRequestを利用します。
なお、jQueryとAxiosも内部ではXMLHttpRequestを使用しているようです。

function postJob(appKey, audioFile) { var fd = new FormData(); var domain = "grammarFileNames=-a-general";

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) {
            
        }
        
    } else {
        
    }
});

httpRequest.open("POST", "https://acp-api-async.amivoice.com/recognitions", true);
httpRequest.send(fd);

}

APPKEYと音声ファイルをマルチパートフォームデータ形式で送信し、HTTPステータス 200とsessionidが取得できればジョブの作成は成功です。
音声認識ジョブの作成」のリクエストは、マルチパートフォームデータ形式である必要があること、またプリフライトリクエストに対応しておらず、余計なヘッダーの設定やアップロードの進捗状況を取得するためのリスナーの追加を行うと、ブラウザーCORSのエラーが発生し、リクエストを送ることができないことに注意してください。

この後、APPKEYとsessionidを使って「ジョブの状態の取得」を行います。

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 () {

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;


this.onProgress = null;


this.onError = null;


this.onCompleted = null;

};

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);

        
        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);

};

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 {

            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 () {

this.serverUrl = "https://acp-api.amivoice.com/v1/recognize";


this.engineMode = "-a-general";


this.loggingOptOut = true;


this.profileWords = "";


this.keepFillerToken = false;


this.speakerDiarization = false;


this.onError = null;


this.onCompleted = null;

};

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);

};

使用方法も非同期HTTP音声認識APIとほぼ一緒です。

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) {

const resultJsonPart = JSON.parse(result);
console.log(resultJsonPart.text);

}; Wrp.resultFinalized = function (result) {

const resultJsonPart = JSON.parse(result);
console.log(resultJsonPart.text);

}; Wrp.feedDataPauseEnded = function () {

}; Wrp.feedDataResume();

次に停止です。

Wrp.feedDataPause();

このJavaScriptを実行するWebページにはlocalhostもしくはhttps://でアクセスする必要があること、マイクの使用を許可する必要があることに注意してください。

4. MP4(AAC)とWebM(Opus)への対応

音声フォーマット対応表からわかるように、AmiVoice APIはMP4(AAC)やWebM(Opus)といった音声フォーマットに対応していません。
しかしながらMicrosoft EdgeGoogle 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 });

audioContext.decodeAudioData(reader.result, function (audioBuffer) {

    
    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;
            }

        
        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を利用してモノラルへの変換を行います。

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();


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]); }

function toADPCM(float32PcmData, audioSamplesPerSec) {

var bufferLen = float32PcmData.length; if (bufferLen % 2 !== 0) { bufferLen++; }

var pcmData = new Uint8Array(bufferLen * 2); var index = 0; for (var audioDataIndex = 0; audioDataIndex < float32PcmData.length; audioDataIndex++) { var pcm = float32PcmData[audioDataIndex] * 32768 | 0; if (pcm > 32767) { pcm = 32767; } else if (pcm < -32768) { pcm = -32768; }

pcmData[index++] = (pcm) & 0xFF;
pcmData[index++] = (pcm >> 8) & 0xFF;

}

float32PcmData = null;

let adpcmData = new Uint8Array(16 + pcmData.length / 4); let adpcmDataIndex = 0;

adpcmData[adpcmDataIndex++] = 0x23; adpcmData[adpcmDataIndex++] = 0x21; adpcmData[adpcmDataIndex++] = 0x41; adpcmData[adpcmDataIndex++] = 0x44; adpcmData[adpcmDataIndex++] = 0x50; adpcmData[adpcmDataIndex++] = 0x0A; adpcmData[adpcmDataIndex++] = (audioSamplesPerSec & 0xFF); adpcmData[adpcmDataIndex++] = ((audioSamplesPerSec >> 8) & 0xFF); adpcmData[adpcmDataIndex++] = 1; adpcmData[adpcmDataIndex++] = 2; adpcmData[adpcmDataIndex++] = 0; adpcmData[adpcmDataIndex++] = 0; adpcmData[adpcmDataIndex++] = 1; adpcmData[adpcmDataIndex++] = 2; adpcmData[adpcmDataIndex++] = 0; adpcmData[adpcmDataIndex++] = 0;

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; };

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のもとの処理では、必ず偶数個のデータが渡ってくるため、この処理は存在しません。
使用方法です。

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 EdgeGoogle Chromeの対応はWebMのみで、残念ながらOgg Opusでの録音には対応していませんでした。
MITライセンスで公開されているサードパーティーのライブラリOpus Recorderを利用します。
まず、公開されているopus-recorder/example/fileEncoder.htmlを参考にラッパーを作成します。

var OpusEncoderWrapper = function () {

this.encodeWorker = null;


this.originalSampleRate = 16000;


this.encoderSampleRate = 48000;


this.maxFramesPerPage = 40;


this.complexity = 10;


this.quality = 3;


this.isStarted = false;


this.totalArray = null;


this.bufferLength = 4096;


this.onCompleted = null;


this.onAvailable = null;


this.isDebug = false;


this.useStream = true;


this.workerObjectURL = null;

};

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;

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, 
    encoderFrameSize: 20,
    encoderComplexity: that.complexity,
    resampleQuality: that.quality,
    numberOfChannels: 1,
    encoderBitRate : 24000
});

return true;

}

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") {

        if (that.onCompleted) {
            that.onCompleted(that.totalArray);
        }

        that.encodeWorker.terminate();
        that.encodeWorker = null;
        that.isStarted = false;

        
        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'
});

};

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;

    if (that.workerObjectURL !== null) {
        URL.revokeObjectURL(that.workerObjectURL);
        that.workerObjectURL = null;
    }
}
return true;

};

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に変換する場合の使用方法です。

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 (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 (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) { 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) {
    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 EdgeGoogle 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のサンプリングレートで音声ファイルのサンプリングレートではありませんでした。
MediaStreamTrackProcessorAudioDataを使ってHTMLVideoElementのストリームからであればサンプリングレートが取れたのでこれを利用します。
ただし、MediaStreamTrackProcessorとAudioDataは実験的なAPIとなっています。

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(単語単位の結果)を使って作成することにします。
注意点は下記の通りです。

上記を踏まえて作成したコードが下記になります。

onmessage = (event) => { let vtt = ""; try { vtt = jsonToWebVTT(event.data); } catch (e) { vtt = "WEBVTT\n"; } postMessage(vtt); }

function jsonToWebVTT(json) {

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') {
    
    segments = json.segments;
} else if (Array.isArray(json)) {
    
    segments = json;
} else {
    
    segments = [json];
}

let hasLabel = false; 
let id = 1;

for (let segment of segments) {

    let tokensByLabel = {};
    let lastLabel = "";

    
    for (let token of segment.results[0].tokens) {

        
        if (typeof token.written !== 'undefined') {

            let label = "";
            if (typeof token.label !== 'undefined') {
                if (!hasLabel) hasLabel = true;
                label = token.label;
            } else {
                
                if (lastLabel.length > 0) {
                    label = lastLabel;
                } else {
                    label = "nolabel";
                }
            }
            if (typeof tokensByLabel[label] === 'undefined') {
                tokensByLabel[label] = [];
            }

            
            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;
        }
    }

    
    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;
            }
            
            
            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>");
                    }
                    
                    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 {
                
                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 {
                        
                        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;

}

function toVttCue(id, starttime, endtime, text, style) { return toVttTimestampLine(id, starttime, endtime, style) + text + "\n"; }

function escapeVTT(value) { return value.replaceAll(/ {2,}/g, " ") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">").trim(); }

function toVttTimestampLine(id, starttime, endtime, style) { return ("\n" + id + "\n" + msToVttTimestamp(starttime) + " --> " + msToVttTimestamp(endtime) + style + "\n"); }

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);

}

基本的な挙動は下記のとおりです。

以上です。最後まで読んでいただき、ありがとうございます。

この記事を書いた人