🎙️

Amazon Transcribe Streamingでリアルタイム文字起こしを実装する

に公開

はじめに

会社で2日間のハッカソンがあり、Amazon Transcribe を使って音声のリアルタイム文字起こしをすることがあったので、本記事にまとめておきます。

なお、本記事は社内ハッカソン内で実施した技術検証となります。作成したプロダクトについては別記事にまとめております。
https://zenn.dev/ncdc/articles/21b5cb55fc498f

動作環境

  • Node.js
  • TypeScript

準備

音声ファイルを準備します。
Transcribe が推奨している形式は以下です。(参考

  • FLAC
  • PCM
    • 16ビット符号付きリトルエンディアンの音声形式。
      (音の大きさ(振幅)を 2の16乗 段階のプラス・マイナスの値で数値化し、その2バイトのデータを下位バイトから順に記録する方式。)
    • WAVファイルの場合、WAVヘッダーを除去する。

今回、私はPCMを使ってお試ししました。
あみたろの声素材工房 というサイトが、フリーの音声を「PCM44,100Hz/16ビット/モノラルのWAV形式」で提供されていて、こちらを使わせていただきました。

なお、ストリーミング文字起こしの効率を高めるには、PCMでエンコードされた音声を使用した方が良いようです。

文字起こししてみる

必要なライブラリのインストール

$ npm install @aws-sdk/client-transcribe-streaming @aws-sdk/credential-provider wav
$ npm install --save-dev @types/wav

サンプルコード

sample.ts

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エンジニアブログ

Discussion