いま話題の OpenAI API ですが、数多くラインナップされた API の中に音声合成 (Text to speech) の API が用意されています。今回は、ブラウザーだけを使って JavaScript で OpenAI の音声合成を実現する方法を解説します。音声合成の結果となる音声ファイルをダウンロードしてから再生する方法だけでなく、ストリームでダウンロードしながらすぐに再生を開始する方法も解説します。長いテキストの場合、ストリームでの再生は効果絶大です。
なお、本記事は、OpenAI のウェブサイトでユーザー登録を済ませ、Open API の課金手続きを行い、API キーが発行できる方を対象としています。同じ有料でも ChatGPT Plus とは違いますので注意してください。
OpenAI API の音声合成 (Text to speech) の概要
OpenAI API には、みなさんが良くご存じの ChatGPT でおなじみのテキスト生成や画像生成の API だけでなく、音声認識 (Speech to text) や音声合成 (Text to speech) の API も用意されています。
OpenAI のコードサンプルは、OpenAI が用意した SDK を利用することを前提に Python が使われています。そのため、ネット上のブログ記事や Youtube 解説動画では Python コードの紹介をよく見かけます。しかし、OpenAI API では REST API の仕様も公開されていますので、REST API を使えばプログラミング言語を問いません。
音声合成の REST API は非常にシンプルです。OpenAI の API reference の[Audio > Create speech」の欄をご覧いただければ分かりますが、curl コマンドであれば次のようにして指定のテキストの音声ファイルを取得することができます。
curl https://api.openai.com/v1/audio/speech \
-H "Authorization: Bearer sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py" \
-H "Content-Type: application/json" \
-d '{
"model": "tts-1",
"input": "本日の天気予報です。",
"voice": "nova",
"response_format": "mp3"
}' \
--output speech.mp3
Authorization
ヘッダーの値にある sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py
の部分は API キーです(この値はこの記事のためにランダムに作った文字列ですので API キーとしては利用できません)。この curl
コマンドを実行すると、"alloy"
という声で「本日の天気予報です。」と話す音声ファイル speech.mp3
が保存されます。
音声ファイルのフォーマットを表す パラメータ "response_format"
には "mp3"
, "opus"
, "aac"
, "flac"
を指定することができます。
一般のウェブサイトで OpenAI API の Text to speech を使った発話を提供するのは注意が必要です。ソースコードを見れば API キーがばれてしまい、悪用されかねません。しかし、ユーザー認証を持ったウェブサービスや社内システムで利用者が限定される場合には、良いのではないでしょうか。
本記事は、音声ファイルのダウンロードに加え、それを再生するところまでを JavaScript で実現します。さらに、長文の音声合成を行った場合に、合成結果をストリームで受信して、すぐに再生する方法も実現します。
JavaScript で扱う場合の課題
ブラウザーで音声を再生する場合、HTML の audio
要素を使うのが最も手っ取り早いでしょう。しかし、audio
要素では音声ファイルの URL を指定する必要があります。もし OpenAI の音声合成の API が HTTP GET なら audio
要素を使って簡単に再生できるでしょう。
しかし、OpenAI の音声合成の API の HTTP メソッドは POST です。さらに HTTP リクエストヘッダーに Authorization
ヘッダーを加える必要があります。簡単には実現できません。少なくとも HTTP リクエストには fetch API を使う必要があります。
fetch API で OpenAI API にリクエストを送信するところまでは迷うことはないでしょう。しかし、レスポンスデータをどのようにして音声データとして再生するのが難しいと感じるのではないでしょうか。そして、それをストリームで逐次再生するとなると、難易度が上がります。
ダウンロードしてから再生する方法
fetch API で OpenAI API にリクエストを送信して得られる音声ファイルのデータはバイナリーデータです。fetch API ではレスポンスを ArrayBuffer
として取り出すことができますので、それを利用します。
音声ファイルのデータを ArrayBuffer
で取り出せたら、その ArrayBuffer
から Blob
オブジェクトを生成します。次に、その Blob
オブジェクトからオブジェクト URL を生成します。そのオブジェクト URL を使って audio
要素のオブジェクトを生成し、再生を開始します。
// fetch で OpenAI API にリクエスト
const response = await fetch('https://api.openai.com/v1/audio/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py'
},
body: JSON.stringify({
"model": "tts-1",
"input": '本日の天気予報です。',
"voice": "alloy",
"response_format": 'mp3'
})
});
// レスポンスを ArrayBuffer で取り出す
const arybuf = await response.arrayBuffer();
// ArrayBuffer から Blob オブジェクトを生成
const blob = new Blob([arybuf], { type: 'audio/mpeg' });
// Blob オブジェクトからオブジェクト URL を生成
const ourl = window.URL.createObjectURL(blob);
// audio 要素のオブジェクトを生成し、再生を開始
const audio = new Audio(ourl);
audio.play();
OpenAI API では音声ファイルフォーマットのパラメータ "response_format"
に "mp3"
, "opus"
, "aac"
, "flac"
を指定できますが、ブラウザーによって再生できるフォーマットが異なりますので注意してください。2023 年 12 月現在では Safari のみ flac を再生することができませんが、Safari Technology Preview では利用可能になっていますので、いずれはすべてのブラウザーですべての音声ファイルフォーマットがサポートされるでしょう。
Chrome | Edge | Firefox | Safari | |
---|---|---|---|---|
mp3 (audio/mpeg) | 〇 | 〇 | 〇 | 〇 |
aac (audio/aac) | 〇 | 〇 | 〇 | 〇 |
opus (audio/opus) | 〇 | 〇 | 〇 | × |
flac (audio/flac) | 〇 | 〇 | 〇 | 〇 |
ストリームで逐次再生する方法
OpenAI API の音声合成 (Text to speech) は、単純なファイルダウンロードと比べて、合成処理の分だけ時間がかかります。合成したいテキストが長ければ長いほど時間がかかるのは当然です。しかし、その場合、音声ファイルのダウンロードが終わるまで待つのではなく、できる限り早い段階で再生を開始したいと思われるでしょう。
OpenAI API の音声合成 (Text to speech) の公式ドキュメントに「Streaming real time audio」という説明があります。一見、MPEG-DASH (Dynamic Adaptive Streaming over HTTP) や HLS (HTTP Live Streaming) のようなストリーミング技術を使っているのかと思ってしまうかもしれませんが、そういうことではありません。
OpenAI API の音声合成 (Text to speech) は、合成できた部分から小さい塊(チャンク)ごとにダラダラとデータを返してきます。ChatGPT での応答が少しずつダラダラと表示されるのと同じです。これは前述のストリーミング技術とよく似ていますが、技術的には、HTTP プロトコルの Chunk transfer encoding を使っています。つまり、HTTP レスポンスヘッダー "Transfer-Encoding"
に "chunked"
がセットされたうえで、細切れとなったチャンクデータが徐々に送られてきます。
細切れになっているとはいえ、すべてを結合すれば mp3 や aac などの音声ファイルのデータと同じです。ゆえに、前項ではダウンロードデータを mp3 ファイルや aac ファイルであるかのように扱えたわけなのです。
では、このダラダラと細切れに送られてくるデータを JavaScript でどう扱うかというと、fetch API でリクエストした後、レスポンスデータを ReadableStream
を通して逐次処理します。もちろん、チャンクは単なるバイナリーデータでしかなく、audio
要素で再生できる状態ではありません。
音声データのチャンクを audio
要素で再生させるには、MPEG-DASH を扱う Media Source Extensions API の助けを借ります。今回のデータストリームは MPEG-DASH ではありませんが、チャンクを audio
要素に流し込むという点では MPEG-DASH と同じです。
以上を踏まえ、OpenAI API の音声合成 (Text to speech) のレスポンスを逐次再生するには、次のようなコードになります。
(async () => {
btn_el.disabled = true;
// audio 要素のオブジェクトを生成
const audio = new Audio();
// MediaSource オブジェクトを生成して audio 要素の src 属性にセット
const mediaSource = new MediaSource();
audio.src = URL.createObjectURL(mediaSource);
// MediaSource オブジェクトの sourceopen イベントのリスナーをセット
mediaSource.addEventListener('sourceopen', async () => {
// SourceBuffer オブジェクトを生成
const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
// fetch で OpenAI API にリクエスト
const response = await fetch('https://api.openai.com/v1/audio/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py'
},
body: JSON.stringify({
"model": "tts-1",
"input": '本日の天気予報です。',
"voice": "alloy",
"response_format": 'mp3'
})
});
// ReadableStream (response.body) のリーダーを生成
const reader = response.body.getReader();
// ストリームをチャンクごとに処理する
while (true) {
const { done, value } = await reader.read();
if (done === true) {
if (mediaSource.readyState === 'open') {
mediaSource.endOfStream();
}
break;
}
// SourceBuffer オブジェクトにチャンクの ArrayBuffer を追加する
await appendBufferToSourceBuffer(sourceBuffer, value);
}
});
// 再生を開始
audio.play();
})();
// SourceBuffer オブジェクトにチャンクの ArrayBuffer を追加する
function appendBufferToSourceBuffer(sourceBuffer, arybuf) {
return new Promise((resolve) => {
// ArrayBuffer を追加
sourceBuffer.appendBuffer(arybuf);
// SourceBuffer オブジェクトで updateend イベントが発生するまで待つ
sourceBuffer.addEventListener('updateend', () => {
resolve();
}, { once: true });
});
}
OpenAI API では音声ファイルフォーマットのパラメータ "response_format"
に "mp3"
, "opus"
, "aac"
, "flac"
を指定できますが、ブラウザーによって再生できるフォーマットが異なりますので注意してください。
Chrome | Edge | Firefox | Safari | |
---|---|---|---|---|
mp3 (audio/mpeg) | 〇 | 〇 | × | 〇 |
aac (audio/aac) | 〇 | 〇 | × | 〇 |
opus (audio/opus) | × | × | × | × |
flac (audio/flac) | × | × | × | × |
上記サンプルコードの 14 行目で、MediaSource
オブジェクトの addSourceBuffer()
メソッドで MIME-Type を指定して SourceBuffer
オブジェクトを生成するのですが、Chrome, Edge, Safari では addSourceBuffer()
メソッドが "opus"
と "flac"
を受け付けません。そして、Firefox では、そもそも audio をサポートしていないようです。残念ながら Firefox 向けの回避策は見つかりませんでした。
ストリームの効果は?
ダウンロードしてから再生するのと、ストリームで逐次再生するのとでは、再生の開始がどれくらい違うのかが気になりますよね。短いテキストではさほど差を感じませんが、長いテキストでは、その分だけ差が顕著に表れます。
もし、OpenAI API の API キーをお持ちなら、次のサンプルを試してください。ボタンを押してから再生が開始されるまでの経過時間をミリ秒で表示します。ちなみに、サンプルにプリセットされている発話テキストは ChatGPT に架空の天気予報を作ってもらった結果です。
OpenAI の API キーはこのサイトのサーバーでは一切保存していませんのでご安心ください。とはいえ、すでに利用中の API キーを使いまわすことはしないでください。OpenAI の API キーを新たに生成し、ここで使った後、破棄 (revoke) してください。
筆者が試したところ、発話に 1 分ほどかかる長いテキスト(上記サンプルのプリセットの発話テキスト)の場合、ダウンロードしてから再生する方法では、処理開始から再生開始まで 11 秒以上かかりました。一方、ストリームで逐次再生の場合は、処理開始から再生開始まで 1 秒ほどでした。効果絶大です。さらにテキストを長くしても、この再生開始までの時間は大きく増えませんでした(少しは増えます)。
この実験での時間計測ですが、ボタンを押してから、audio
要素で canplaythrough
イベントが発生するまでの経過時間を計測しています。このイベント発生が、事実上の発話開始を表しています。
まとめ
ブラウザーの JavaScript だけで OpenAI API の Text to speech のストリームによる逐次再生の方法が知りたくていろいろとネットを探し回ったのですが、あまり情報が見つかりませんでした。そのため、断片的な情報を組み合わせながら試行錯誤して、なんとか逐次再生ができました。もしかしたら、もっとスマートな方法があるのかもしれません。もしもっと良い方法が見つかれば、この記事を更新します。
OpenAI の Text to speech はストリームで逐次再生するのが理想ですが、Firefox でまったく利用できないのが残念です。日本におけるデスクトップの Firefox のブラウザーシェアは今なお 6% ほどあります。Safari のシェアと同じくらいなだけに、まだ無視するのは惜しいですね。
今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。