Amazon Transcribe Streamingでリアルタイム文字起こしを実装する
はじめに
会社で2日間のハッカソンがあり、Amazon Transcribe を使って音声のリアルタイム文字起こしをすることがあったので、本記事にまとめておきます。
なお、本記事は社内ハッカソン内で実施した技術検証となります。作成したプロダクトについては別記事にまとめております。
動作環境
- Node.js
- TypeScript
準備
音声ファイルを準備します。
Transcribe が推奨している形式は以下です。(参考)
- FLAC
- PCM
- 16ビット符号付きリトルエンディアンの音声形式。
(音の大きさ(振幅)を 2の16乗 段階のプラス・マイナスの値で数値化し、その2バイトのデータを下位バイトから順に記録する方式。) - WAVファイルの場合、WAVヘッダーを除去する。
- 16ビット符号付きリトルエンディアンの音声形式。
今回、私はPCMを使ってお試ししました。
あみたろの声素材工房 というサイトが、フリーの音声を「PCM44,100Hz/16ビット/モノラルのWAV形式」で提供されていて、こちらを使わせていただきました。
なお、ストリーミング文字起こしの効率を高めるには、PCMでエンコードされた音声を使用した方が良いようです。
文字起こししてみる
必要なライブラリのインストール
$ npm install @aws-sdk/client-transcribe-streaming @aws-sdk/credential-provider wav
$ npm install --save-dev @types/wav
サンプルコード
import {
TranscribeStreamingClient,
StartStreamTranscriptionCommand,
AudioStream,
LanguageCode,
} from "@aws-sdk/client-transcribe-streaming";
import { fromIni } from "@aws-sdk/credential-providers";
import * as fs from "fs";
import { Reader as WavReader } from "wav";
import { once } from "events";
import { PassThrough } from "stream";
/** アプリケーション設定値 */
const CONFIG = {
PROFILE: , // AWSプロファイル名
REGION: "ap-northeast-1",
INPUT_FILE_PATH: , // wavファイルのパス
LANGUAGE_CODE: "ja-JP" as LanguageCode, // Transcribe の言語指定
CHUNK_DURATION_MS: 100, // 100msのチャンクに分割する (推奨: 50ms~200ms)
};
/** WAVフォーマットの型 */
interface WavFormat {
audioFormat: number;
channels: number;
sampleRate: number;
byteRate: number;
blockAlign: number;
bitDepth: number;
}
/**
* wav.Reader からのPCMストリームを受け取り、
* AWS推奨のチャンクサイズ (BEST_PRACTICE_CHUNK_SIZE) に
* 分割する非同期ジェネレータ
*/
async function* audioStream(
fileStream: NodeJS.ReadableStream,
chunkSize: number
): AsyncIterable<AudioStream> {
// 内部バッファを初期化
let internalBuffer = Buffer.alloc(0);
// ファイルストリームからデータを読み込み
for await (const chunk of fileStream) {
// チャンクを内部バッファに追加
internalBuffer = Buffer.concat([internalBuffer, chunk as Buffer]);
// 内部バッファがチャンクサイズを超える限り、チャンクを切り出して送信
while (internalBuffer.length >= chunkSize) {
// チャンクを切り出し(0バイト目からchunkSizeバイト目まで)
const audioChunk = Buffer.from(internalBuffer.subarray(0, chunkSize));
// 内部バッファを更新(上記で切り出した分を削除)
internalBuffer = Buffer.from(internalBuffer.subarray(chunkSize));
// AudioEventを送信
yield { AudioEvent: { AudioChunk: audioChunk } };
}
}
// ループが終了した後、バッファに残った最後のデータを送信
if (internalBuffer.length > 0) {
yield { AudioEvent: { AudioChunk: internalBuffer } };
}
}
(async () => {
// AWSクライアントを初期化する
const client = new TranscribeStreamingClient({
region: CONFIG.REGION,
credentials: fromIni({ profile: CONFIG.PROFILE }),
});
// WAVファイルを読み込んでファイルストリームを作成する
const fileStream = fs.createReadStream(CONFIG.INPUT_FILE_PATH);
// WAVパーサーを作成する
const wavReader = new WavReader();
// ファイルストリームをWAVパーサーに接続する。
// WAVパーサーでファイルストリームを解析することで、
// WAVファイルのヘッダーを取り除いた生のPCMデータ(音声データ)が得られる。
// 以降、wavReader自体がPCMデータのストリームとなる。
fileStream.pipe(wavReader);
// --- WAVファイルのヘッダーを取得する ---
// 'format'イベントが発火するまで待機する。
// (もし待機中に'error'イベントが発火したら、Promiseは自動的にrejectされる)
// events.onceメソッドは、イベントが発生したときに渡される引数を、必ず配列にラップして返すため、
// onceが返してきた配列の0番目の要素を取り出してformat変数に代入する。
const [format] = (await once(wavReader, "format")) as [WavFormat];
console.log("WAVフォーマットを検出:", format);
// --- WAVファイルのヘッダーのフォーマットを検証する ---
// Transcribe のストリーミングは、
// 16-bit の PCM データをサポートしている。
// モノラル (シングルチャンネル) の場合、各チャンクは偶数のバイト数である必要がある。
const { sampleRate, channels, bitDepth } = format;
if (channels !== 1 || bitDepth !== 16) {
throw new Error(
`サポート外のWAV形式です。16-bit モノラル (シングルチャンネル) ではありません。 (検出: ${bitDepth}bit, ${channels}ch)`
);
}
// 指定したチャンクサイズ[ms]に基づいて、AWS推奨のチャンクサイズ[バイト]を計算する。
const BEST_PRACTICE_CHUNK_SIZE = (CONFIG.CHUNK_DURATION_MS / 1000) * sampleRate * 2;
console.log(`サンプルレート: ${sampleRate} Hz`);
console.log(`チャンクサイズ: ${BEST_PRACTICE_CHUNK_SIZE} バイト (${CONFIG.CHUNK_DURATION_MS}ms相当)`);
// wavReader(古いストリーム形式)をモダンなストリームに変換する
const pcmStream = new PassThrough();
wavReader.pipe(pcmStream);
// ストリーミング文字起こし開始
const command = new StartStreamTranscriptionCommand({
LanguageCode: CONFIG.LANGUAGE_CODE,
MediaEncoding: "pcm",
MediaSampleRateHertz: sampleRate, // PCMのサンプルレート
AudioStream: audioStream(pcmStream, BEST_PRACTICE_CHUNK_SIZE),
});
// 結果を逐次取得して出力
const response = await client.send(command);
for await (const event of response.TranscriptResultStream!) {
if (event.TranscriptEvent) {
const results = event.TranscriptEvent.Transcript?.Results;
if (results)
for (const res of results) {
if (res.Alternatives && res.Alternatives[0].Transcript)
console.log(res.IsPartial ? "(partial)" : "(final)", res.Alternatives[0].Transcript);
}
}
}
})().catch((err) => {
console.error("アプリケーションエラー:", err);
process.exit(1);
});
実行コマンド
# 実行
$ npx tsx sample.ts
実行結果
実行すると、少しずつリアルタイムで文字起こしされているのがわかります。
WAVフォーマットを検出: {
audioFormat: 1,
endianness: 'LE',
channels: 1,
sampleRate: 44100,
byteRate: 88200,
blockAlign: 2,
bitDepth: 16,
signed: true
}
サンプルレート: 44100 Hz
チャンクサイズ: 8820 バイト (100ms相当)
(partial) 大丈夫
(partial) 大丈夫だよ
(partial) 大丈夫だよ。最初
(partial) 大丈夫だよ。最初から
(partial) 大丈夫だよ。最初からうまく
(partial) 大丈夫だよ。最初からうまくいく人なんて
(partial) 大丈夫だよ。最初からうまくいく人なんていない
(final) 大丈夫だよ。最初からうまくいく人なんていないんだから。
ベストプラティクスのためにやったこと
ストリーミング文字起こしの効率を高めるための推奨事項があります。(参考)
音声チャンクのサイズ
音声チャンクのサイズによってレイレンシーが異なるそうで、各チャンクサイズを50〜200ミリ秒に統一したほうが良いようです。
チャンクサイズの時間からバイトへの変換は下記の式でできます。
chunk_size_in_bytes = chunk_duration_in_millisecond / 1000 * audio_sample_rate * 2
正しいサンプリングレートを指定する
new StartStreamTranscriptionCommand()のときに、サンプリングレート(今回の音声ファイルであれば44,100Hz)を指定します。
なお、上記コードは、WAVヘッダーからサンプリングレートを取得するようにしています。
ちなみに、サンプリングレートとは、音の波を「時間軸(横方向)」でどれだけ細かく分割するか (=音域の再現度)ということのようです。
WAVヘッダーの除去
WAVファイルはWAVヘッダー+PCM(生の音声データ)であり、new StartStreamTranscriptionCommandのときにPCMのみを含めるように記載されていますので、上記コードではそのようにしています。
おわりに
ここまでご覧いただきありがとうございました。
個人的に、Transcribeはジョブ実行しかしたことがなかったので、ストリーミングの方も触ってみて徐々に文字起こしされるところが見れて、良い体験になったと思います。
NCDC株式会社( ncdc.co.jp/ )のエンジニアチームです。 募集中のエンジニアのポジションや、採用している技術スタックの紹介などはこちら( github.com/ncdcdev/recruitment )をご覧ください! ※エンジニア以外も記事を投稿することがあります
Discussion