はじめに
これまでの記事では、Zoom AI Services の Scribe API を Fast API(同期モード) と Batch API(非同期モード) の2つの切り口でご紹介しました。
今回はその続編として、3つ目のモードである Live モード(ストリーミング) を扱います。
Live モードは、WebSocket に PCM 音声を流し込むと、文字起こし結果がイベントとして返ってくるというものです。ファイルを作ってから投げる Fast / Batch とは違い、話している最中に結果が返ってきます。
本記事では、マイク音声をリアルタイムに文字起こしする最小構成を Node.js で組み立てながら、実際に動かして得られた結果を中心にまとめます。
本記事で紹介するサンプルアプリケーションは、Zoom Communications, Inc. の公式サポート対象ではなく、作成者が個人的に公開しているオープンソースプロジェクトです。Zoom Communications, Inc.とその従業員、および関連会社は、本アプリケーションの使用や保守について責任を負いません。あくまでもインスピレーション、探求、実験のためのものとして、ご自身の責任と楽しみの範囲でご活用ください。技術的な議論やサポートが必要な場合は Zoom Developer Forum をご利用ください。
Live モードは Zoom Meeting や RTMS(Real-Time Media Streams)とは独立して使えます。「PCM16 の音声を WebSocket で流せば動く ASR エンジン」 であり、Zoom ミーティングを開いている必要はありません。手元のマイクでも、自社アプリの音声でも構いません。
Scribe API の3つのモード
改めて、3モードの位置づけを整理します。
| 観点 | Fast API | Batch API | Live モード |
|---|---|---|---|
| プロトコル | HTTPS(POST) | HTTPS(POST + ポーリング) | WebSocket |
| 入力 | ファイル or URL | S3 URI | PCM16 のバイナリフレーム |
| 結果の返り方 | レスポンスに一括 | S3 に JSON 出力 | 発話ごとにイベント |
| レイテンシ | ファイル長に依存 | 分〜時間単位 | 発話終了から数百ms |
| 向いている用途 | 短い録音、即時処理 | 大量処理、定期バッチ | ライブ字幕、音声エージェント |
プロトコルの全体像
Live モードの通信は、ざっくり「繋ぐ → 設定を送る → 音声を流し続ける → イベントを受け取る → 閉じる」の5ステップです。
押さえておくポイントは4つです。
| 項目 | 値 |
|---|---|
| エンドポイント | wss://api.zoom.us/v2/aiservices/scribe/live |
| サブプロトコル |
live-asr(必須) |
| 音声フォーマット | PCM16 / リトルエンディアン / 16kHz / モノラル |
| 送信単位 | 100ms ぶん = 3200 バイトのバイナリフレーム |
音声は バイナリフレームとしてそのまま送ります。JSON でラップしたり Base64 化してはいけません。Fast API がファイルを Base64 で送るのと対照的なので、ここは混同しやすいポイントです。
100ms = 3200 バイトの根拠は単純な掛け算です。
16000 Hz × 2 バイト(16bit)× 0.1 秒 = 3200 バイト
実装してみる
1. 環境セットアップ
mkdir scribe-live && cd scribe-live
npm init -y
npm pkg set type=module
npm install ws jsonwebtoken dotenv
.env には Build Platform(AI Services)の認証情報を入れます。
# .env
ZOOM_API_KEY=xxxxxxxxxxxxxxxxxxxxxx
ZOOM_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ここで使うのは Build Platform(AI Services)の API Key / Secret です。Server-to-Server OAuth のトークンではありません。 取り違えると接続時に 401 / 403 になります。
2. JWT を生成する
Scribe Live の認証は、API Key / Secret から作る HS256 の JWT です。
// auth.js
import jwt from 'jsonwebtoken';
export function generateJwt({ apiKey, apiSecret }) {
const now = Math.floor(Date.now() / 1000);
return jwt.sign(
{
iss: apiKey,
iat: now - 30, // クロックスキュー対策で30秒戻す
exp: now + 3600,
},
apiSecret,
{ algorithm: 'HS256' },
);
}
接続のたびに新しく発行し、使い回さないようにします。
3. WebSocket に接続する
認証トークンの渡し方は 2通り あります。どちらでも接続できます。
// パターンA: Authorization ヘッダ
const ws = new WebSocket(URL, ['live-asr'], {
headers: { Authorization: `Bearer ${token}` },
});
// パターンB: サブプロトコルに載せる
const ws = new WebSocket(URL, ['live-asr', `zoom-api-access-token.${token}`]);
どちらの方式でも live-asr サブプロトコルの提示は必須です。トークンの運び方だけが2通りある、と捉えると分かりやすいです。
なおパターンB は、ブラウザの WebSocket API(Authorization ヘッダを設定できない)からでも接続できることを意味します。ただし JWT をブラウザに置くのはセキュリティ上おすすめできません。実運用ではバックエンドで WebSocket を中継する構成が無難です。
4. session.update で設定を送る
接続して session.created が返ってきたら、設定を JSON テキストフレームで送ります。
ws.send(JSON.stringify({
type: 'session.update',
language: 'ja-JP',
audio: { format: 'pcm16' },
}));
パラメータは config: {...} でネストせず、トップレベルに置いてください。
一部のサンプル実装では config オブジェクトに包む書き方が見られますが、トップレベルに書くのが正解です。
正しく通ると session.updated が返ってきます。
5. 音声を送る
マイク音声は ffmpeg を spawn して raw PCM を受け取るのが手軽です。ネイティブ拡張を含む npm パッケージは環境依存が強いので避けています。
// capture.js
import { spawn } from 'node:child_process';
const inputArgs = {
darwin: ['-f', 'avfoundation', '-i', ':0'], // macOS
linux: ['-f', 'alsa', '-i', 'default'], // Linux
win32: ['-f', 'dshow', '-i', 'audio=マイク'], // Windows
}[process.platform];
export function startMic() {
return spawn('ffmpeg', [
'-hide_banner', '-loglevel', 'error',
...inputArgs,
'-ar', '16000', '-ac', '1', '-f', 's16le', '-acodec', 'pcm_s16le',
'pipe:1',
]);
}
macOS で使えるデバイス番号は次のコマンドで確認できます。
ffmpeg -f avfoundation -list_devices true -i ""
ffmpeg の stdout は好きなサイズで流れてくるので、3200 バイトちょうどに整形してから送信します。
// framer.js — 任意長のチャンクを 3200 バイト固定長に整形する
const FRAME_BYTES = 3200;
export class Framer {
constructor() { this.buf = Buffer.alloc(0); }
push(chunk) {
this.buf = Buffer.concat([this.buf, chunk]);
const frames = [];
while (this.buf.length >= FRAME_BYTES) {
frames.push(this.buf.subarray(0, FRAME_BYTES));
this.buf = this.buf.subarray(FRAME_BYTES);
}
return frames;
}
}
あとは繋ぐだけです。
const framer = new Framer();
mic.stdout.on('data', (chunk) => {
for (const frame of framer.push(chunk)) {
ws.send(frame, { binary: true }); // バイナリフレームで送信
}
});
6. イベントを受け取る
サーバから返ってくるイベントは JSON テキストフレームです。
ws.on('message', (data, isBinary) => {
if (isBinary) return;
const ev = JSON.parse(data.toString());
switch (ev.type) {
case 'session.created':
console.log('session:', ev.session_id);
break;
case 'session.updated':
console.log('ready');
break;
case 'input_audio_buffer.speech_started':
console.log('発話開始', ev.audio_start_ms, ev.item_id);
break;
case 'input_audio_buffer.speech_stopped':
console.log('発話終了', ev.audio_end_ms, ev.item_id);
break;
case 'transcription.completed':
// 注意: テキストは ev.text ではなく ev.transcript
console.log('📝', ev.transcript);
break;
case 'error':
console.error('error:', ev.error?.code, ev.error?.message);
break;
case 'session.closed':
console.log('closed:', ev.reason);
break;
}
});
文字起こしテキストのフィールド名は text ではなく transcript です。 ここは実際に受信した JSON を見て初めて気づく部分でした。
7. 終了する
いきなりソケットを閉じると、最後の発話の文字起こしを取りこぼします。
// 音声送信を止める → session.close を送る → 残りのイベントを待つ → 閉じる
ws.send(JSON.stringify({ type: 'session.close', reason: 'client_request' }));
setTimeout(() => ws.close(1000, 'done'), 3000);
session.closed を受け取ってから閉じるのが理想です。また、異常終了時も必ずソケットを閉じるように SIGINT ハンドラを入れておきましょう。同時セッション数には上限があるため、張りっぱなしのセッションが溜まると次の検証が詰まります。
動かしてみた結果
日本語の音声(macOS の say コマンドで合成したもの)を流したときのコンソール出力です。
[scribe] connecting (auth=subprotocol, shape=flat, lang=ja-JP)
[scribe] session.created id=ls_8287d94404f6
[scribe] session.updated (ready in 188ms)
[audio ] streaming: 3200 B/frame, 10 fps
[speech] started @0ms
[speech] stopped @18900ms
[text ] "おはようございます。今日は、Zoomの新しい音声認識APIについて話します。
ライブモードはWebSocketに音声を流すだけで、話し終わってから数百ミリ秒で
文字起こしが返ってきます。会議の字幕や音声エージェントに使えそうです。"
[scribe] session.closed reason=client_requested
句読点はもちろん、「Zoom」「WebSocket」「API」が英字表記のまま出力されている点に注目してください。カタカナで発音した固有名詞を適切な表記に直してくれています。
なお上の例は合成音声のため間が空かず、1つのセグメントとして返ってきました。実際の会話では発話の切れ目ごとに transcription.completed が分割されて届きます。
レイテンシ
transcription.completed には transcription_latency_ms というサーバ側の計測値が入ってきます(ドキュメント未記載のフィールドですが、実際に返ってきます)。
| 指標 | 実測値 |
|---|---|
接続 → session.updated
|
約 170〜240ms |
| 発話終了 → 文字起こし確定 | おおむね 150〜600ms(短い発話では 150〜350ms 程度) |
長い発話ほど確定までの時間は伸びますが、ライブキャプション用途としては十分実用的な速度です。
イベントの並びに注意
実際にログを取ると、次の発話の speech_started が、前の発話の transcription.completed より先に届くケースが頻繁にあります。VAD(音声区間検出)と文字起こしがパイプラインで動いているためです。
イベントを到着順に対応づけると破綻します。 speech_started / speech_stopped / transcription.completed には共通の item_id(UUID) が入っているので、item_id をキーにした状態管理にしてください。筆者は最初これを怠って、発話が重なった区間のレイテンシ計測値がめちゃくちゃになりました。
まとめ
Live モードを実装するうえで押さえるべき点をまとめます。
- 音声はモノラル 16kHz PCM16 を 3200 バイトずつバイナリで
session.updateはトップレベルに書く- 15秒の無音で close 1006 の無言切断。close ハンドラは必須
最後にサンプルコード全体を付記しておきます。macOSでマイクが接続されている、かつ ffmpeg がインストールされている前提になります。
import 'dotenv/config';
import { spawn } from 'node:child_process';
import jwt from 'jsonwebtoken';
import WebSocket from 'ws'
const SCRIBE_LIVE_URL = 'wss://api.zoom.us/v2/aiservices/scribe/live';
const LANGUAGE = 'ja-JP'; //'en-US'も利用可能;
const FRAME_BYTES = 3200; // 16kHz * 2 bytes * 0.1s = 100ms of PCM16
// auth
const now = Math.floor(Date.now() / 1000);
const token = jwt.sign(
{ iss: process.env.ZOOM_API_KEY, iat: now, exp: now + 3600 },
process.env.ZOOM_API_SECRET,
{ algorithm: 'HS256', header: { typ: 'JWT' } },
);
const ws = new WebSocket(SCRIBE_LIVE_URL, 'live-asr', {
headers: { Authorization: `Bearer ${token}` },
});
// mic -> raw PCM s16le 16kHz mono on stdout
const ffmpeg = spawn('ffmpeg', [
'-loglevel', 'quiet',
'-f', 'avfoundation',
'-i', ':0',
'-ar', '16000',
'-ac', '1',
'-f', 's16le',
'-acodec', 'pcm_s16le',
'pipe:1',
]);
// integrate with the Scribe Live API (ws)
ws.on('open', () => {
console.log('[scribe] connected');
ws.send(JSON.stringify({
type: 'session.update',
language: LANGUAGE, //set language here
audio: { format: 'pcm16' },
}));
let buf = Buffer.alloc(0);
ffmpeg.stdout.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
while (buf.length >= FRAME_BYTES) {
ws.send(buf.subarray(0, FRAME_BYTES));
buf = buf.subarray(FRAME_BYTES);
}
});
});
ws.on('message', (data) => {
const event = JSON.parse(data.toString());
if (event.type === 'transcription.completed') {
console.log(event.transcript);
} else {
console.log(`[scribe] ${event.type}`);
}
});
ws.on('close', (code, reason) => {
console.log(`[scribe] closed ${code} ${reason}`);
ffmpeg.kill();
process.exit(0);
});
ws.on('error', (err) => console.error('[scribe] error', err.message));
process.on('SIGINT', () => {
ws.send(JSON.stringify({ type: 'session.close' }));
ws.close();
});
Zoom AI Services API 関連記事一覧
リソース
