リアルタイムの文字起こしは二度届きます。まず変わり続ける下書きとして、次に変わらない確定行として。下書きを差し替え可能な末尾として、確定行を履歴として描画すれば、ライブ字幕表示の残りは配管にすぎません。
リアルタイムセッションは、VocalStack APIが行う最も小さな処理です。サーバーがHTTP呼び出しを一回行い、署名付きのstreamUrlを受け取ります。クライアントはそのURLにWebSocketを開き、取り込んだ生の音声を送信し、話者から少し遅れて届く文字起こしをJSONのテキストフレームとして読み取ります。音声が終わるとeofを送り、サーバーはフラッシュして、セッションが計測した内容を報告し、接続を閉じます。
何も保存されません。文字起こしは通信の中にしか存在せず、字幕表示、会議のオーバーレイ、アプリ内の音声機能に必要なのはまさにそれです。後で単語を残し、共有し、書き出したい場合は、代わりにライブ文字起こしとブロードキャストを使うべきです。その方法は最後のセクションで説明します。
この形は、多くのストリーミング音声APIが共有する形です。HTTPでの認証情報の交換、音声が入りJSONが出てくる長時間接続のソケット、そして仮の文字と確定した文字を区別する仕組みです。APIごとに名前は違いますが、中身の部品はほとんど変わりません。ここでは順番に見ていきます。
POST /v1/realtime/sessionsを呼び出します。応答は{ sessionId, streamUrl, expiresAt }です。streamUrlにWebSocketを開きます。session.readyが届くまで、session.loadingのハートビートを待ちます。transcript.draftを読み取り(末尾を差し替え)、transcript.committedを読み取ります(確定行を追加)。{"type":"eof"}を送り、session.doneを読み取ります。APIキーが必要なのは手順1だけなので、その部分だけがサーバー上で行われます。
Developers, API keysで、Writeの権限を持つキーを作成します。キーはサーバーやスクリプト用です。Webページから送られたものやURLに含まれるキーはAPIが拒否するため、セッションは自分のルートの背後で作成し、ページが受け取るのはstreamUrlだけにしてください。
URLには、ブラウザの制限に由来する独自のトークンが含まれています。これは一般的にも知っておく価値があります。ブラウザのWebSocketコンストラクターはリクエストヘッダーを設定できないため、キーを入れられるAuthorizationヘッダーがありません。ブラウザ向けの音声APIはどれも、ソケットを短命のトークンでURLに載せて(あるいはサブプロトコル名に忍ばせて)認証しています。短命であることが肝心です。URLはログや履歴に残るため、含まれるトークンは期限切れになる必要があります。expiresAtより前に接続してください。
SDKはパッケージレジストリからではなく、開発者エリアからダウンロードします。クイックスタートのインストールセクションに、この環境向けの実際のコマンドが載っています。TypeScript SDKを使うと、サーバーのルートは次のようになります:
// On your server, behind a route of yours (POST /api/transcribe, say):
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
export async function createStream(): Promise<{ sessionId: string; streamUrl: string }> {
const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en' });
return { sessionId, streamUrl };
}
SDKを使わなくても、fetch一回で済みます。以下の例では英語に加えてスペイン語も指定し、文の組み立てをオフにしています。これは後のワイヤーサンプルと合わせています:
const res = await fetch('{{api}}/v1/realtime/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ spokenLanguage: 'en', translate: ['es'], sentences: { enabled: false } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the error envelope: { code, message, … }
const { streamUrl } = (await res.json()) as { sessionId: string; streamUrl: string; expiresAt: string };
すべてのオプションにはデフォルト値があります。最初に知っておくと良いのはspokenLanguage(省略すると言語が検出されます)、translate(翻訳先言語のリスト)、speakerLabels、latencyModeです。残りは後で必要になったときに説明します。
streamUrlを、音声を取り込む仕組みに渡してください。ここから先は、どのキーも使いません。
streamUrlには、ヘッダーなしでそのまま接続します。セッションの開始中、サーバーはsession.loadingのハートビートを送ります。その後にsession.readyが届きます。それより前に音声を送らないでください。早すぎた音声は後で使うために保持されず、サーバーはエラーとして扱い、errorフレームを送ってソケットを閉じます。
ソケットが開いた後は、すべての失敗が次の形式のフレームで届きます:{"type":"error","code":"…","message":"…","retryable":true|false}。codeは決められた一覧の値(invalid_audio、invalid_options、invalid_token、capacity_exceeded、session_expired、slow_consumer、engine_unavailable、internal)のいずれかで、messageはログ用、retryableは新しいセッションを試す価値があるかどうかを示します。最初のフレームから処理してください:
const ws = new WebSocket(streamUrl); // the token is in the URL: no header
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
switch (f.type) {
case 'session.loading':
console.error('starting…');
break;
case 'session.ready':
startMicrophone(); // send audio only after session.ready
break;
case 'error':
console.error(`${f.code}: ${f.message}`);
if (f.retryable) scheduleReconnect(); // a new session, with backoff; see "Stop"
break;
}
});
生のクライアントでは、さらに二つのことを自分で扱う必要があります。SDKはすでに対応しています。第一に、拒否されたアップグレードには、APIの通常のHTTPエラーの形式が付いてきます。401 invalid_token、410 session_expired、またはRetry-Afterヘッダー付きの503 unavailableです。Retry-Afterの指示に従ってください。
第二に、混雑は無音のように見えるもので、エラーとしては現れません。どのフレームも30秒以内に届かなければ、capacity_exceededとして扱い、再度接続してください。新しいセッションは通常、通ります。ローディングのフレームは届くのにsession.readyが来ない場合は、engine_unavailableとして扱います。これも再試行可能です。SDKでは、RealtimeSession.connectはsession.readyで解決し、readyDeadlineMsを過ぎると正しいコードとともに失敗します。
session.readyが届くと、サーバーは音声が話される速さで届くことを期待します。
音声は、生のPCMのバイナリフレームとして送ります。32ビット浮動小数点、リトルエンディアン、16 kHz、モノラル(pcm_f32le、セッションの既定のaudio形式)です。取り込まれたらすぐに、約100〜250ミリ秒のチャンクで、チャンクの間に文字起こしを待たずに送信してください。
バイト計算は一度やっておく価値があります。16,000サンプル毎秒、1サンプル4バイトなので、1秒は64,000バイトです。つまり100ミリ秒は6,400バイト、250ミリ秒は16,000バイトです。音声認識モデルは一般的に16 kHzのモノラルで学習されており、そのためリアルタイムAPIの多くがこの形式を求めます。一方、マイクやブラウザは44.1 kHzや48 kHzで、しばしばステレオで取り込みます。バイトを送る前に、何かが音声をリサンプリングし、チャンネルを一つにまとめる必要があります。ここではそれがffmpegで、ブラウザではSDKのマイクヘルパーが行います。48 kHzの音声を送って16 kHzと宣言しても失敗はしません。ただし、話し声がスローモーションのように聞こえるものとして、精度の低い文字起こしになります。
Node.jsでは、ffmpegが最も簡単な音声入力デバイスです:
import { spawn } from 'node:child_process';
function startMicrophone(): void {
// Raw PCM: f32le, 16 kHz, mono, as binary frames. macOS: -f avfoundation -i :0, Linux: -f pulse -i default.
const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
mic.stdout.on('data', (chunk: Buffer) => {
// Backpressure: bytes the socket has accepted but not yet put on the wire.
// Two seconds of audio (128,000 bytes) queued means the link, not the server, is behind.
if (ws.bufferedAmount > 128_000) console.error(`link behind by ${ws.bufferedAmount} bytes`);
ws.send(new Uint8Array(chunk));
});
mic.on('close', () => ws.send(JSON.stringify({ type: 'eof' })));
process.once('SIGINT', () => mic.kill());
}
ws.sendはブロックせず、キューに入れるだけです。bufferedAmountはそのキューのサイズで、健全な回線では0付近にとどまります。ライブ音声は一時停止できないため、増え続けるキューへの有効な対処は、それに気づくことであり、フレームを捨てることではありません。サーバーもバックプレッシャーをかけます。サーバーはリアルタイムより数秒先の音声まで受け付け(limits.audioBufferS)、それ以上は、追いつくまで読み取りを止めます。返される側では、確定はロスなくキューに入れられ、下書きは統合されるため、読み取りが遅いクライアントは、より少なく、より新しい下書きを見ることになります。確定すら追いつけないクライアントには、slow_consumerとretryable: falseのerrorフレームが届き、切断されます。これを見かけたら、メッセージハンドラーが仕事をしすぎています。
ブラウザでは、@vocalstack/sdk/micのstreamMicrophoneがマイクを要求し、16 kHzにリサンプリングして、チャンクをclockフレームとともに送信します。clockフレームは、ブラウザがタブのスロットリングを行った際に、サーバーがギャップを記録できるようにするものです。ページはルートからセッションを受け取り、キーは一切見ることがありません:
import { RealtimeSession } from '@vocalstack/sdk';
import { streamMicrophone } from '@vocalstack/sdk/mic';
export async function transcribeMicrophone(
stream: { sessionId: string; streamUrl: string },
render: (committed: string, tail: string) => void,
): Promise<void> {
const session = await RealtimeSession.connect(stream);
const mic = await streamMicrophone(session); // asks for the microphone, sends 16 kHz PCM
document.querySelector('#stop')?.addEventListener('click', () => {
void mic.stop();
session.eof();
});
let committed = '';
for await (const ev of session) {
if (ev.type === 'transcript.draft') render(committed, ev.segments.map((s) => s.text).join(' '));
if (ev.type === 'transcript.committed') {
committed += `${ev.segment.text} `;
render(committed, '');
}
}
}
render(committed, tail)というシグネチャ全体が、UIのパターンの核心です。次のセクションでその理由を説明します。
文字起こしを構成するフレームは二種類あり、両者を取り違えることが最も多い間違いです。
まず、なぜ二種類あるのかです。音声認識モデルは、語が何であったかを、後に続く語の情報も使って判断します。「I scream」と「ice cream」は、文が続くまでは同じ音に聞こえます。そのためリアルタイムの認識器は、まず早めに推定を出し、より多くの音声が届くにつれてそれを修正し、十分に聞き取れてから初めて確定させます。どのストリーミング音声APIも、何らかの形でこれを公開しています。推定を「中間」や「部分」の結果と呼び、確定したものをis_finalで示すものもあります。用語は違っても、考え方は同じです。ここでは推定を下書き、確定したテキストを確定と呼びます。
transcript.draftはライブの末尾です。前回の確定以降に聞き取られた語で、まだ変わり続けています。各下書きは、直前の下書きを丸ごと置き換えるものです。segmentsで末尾を描き直してください。追加してはいけません。下書きには、確定になったものと対応付けるためのseqが意図的に付いていないので、対応付けを試みないでください。
transcript.committedは確定です。一つのsegmentで、seq(0、1、2、欠番なし)、start、end、text、speakerと、単語ごとのタイミングを持つwordsが含まれます。確定を追加し、それがカバーする下書きを捨ててください。次の下書きは、この確定が終わった位置から始まります。
committed: [seq 0] [seq 1]
draft: "how are" ← frame 1
draft: "how are you do" ← frame 2 REPLACES frame 1
committed: [seq 0] [seq 1] [seq 2 "How are you doing?"]
← the commit consumes the tail; drop the draft
下書きを追加する UI では、重複してちらつくテキストが表示されます。字幕表示に、話者にはなかった吃りのような途切れが生じます。すべての確定したセグメントを描画し、その後ろに最新の下書きを描画して、確定が届いたときに下書きを消してください。確定には、どの下書きにも現れなかった語が含まれることがあります。
上のfetchの例(translate: ["es"]、文の組み立てオフ)でのワイヤー上の数秒の音声を示します:
{"type":"session.loading"}
{"type":"session.ready"}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good","speaker":0,"tentative":true}]}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good morning every","speaker":0,"tentative":true}]}
{"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[{"start":0.0,"end":0.3,"text":"Good"},{"start":0.3,"end":0.8,"text":"morning,"},{"start":0.8,"end":1.4,"text":"everyone."}]}}
{"type":"transcript.translated","seq":0,"translations":{"es":"Buenos días a todos."}}
{"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for","speaker":0,"tentative":true}]}
これら以外に、二つのフレームがあります。transcript.amendedは、確定したセグメント内の語が修正されたとき、同じseqでtextやspeakerを新しくして、その場で修正します。すでに描画した行に適用してください。transcript.wordsは、確定の直前に末尾の単語のタイミングをストリームします。これにはevents.wordsが必要で、既定でオンになっています。確定には、イベントの設定がどうであれ、常にwordsが埋め込まれています。最後に、未知のフレームタイプは無視してください。新しい種類は予告なく追加されます。
ここでは、ターミナルを字幕表示として使う、Python SDKの読み取りループを示します。ここでも文の組み立てはオフにしているので、翻訳はtranscript.translatedとして届き、errorフレームはループからVocalStackErrorとして送出されます:
import asyncio
import os
from vocalstack import RealtimeSession, VocalStack, VocalStackError
MIC = ["ffmpeg", "-loglevel", "error", "-f", "avfoundation", "-i", ":0",
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"]
async def main() -> None:
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
created = await vs.realtime.create_session(
spoken_language="en", translate=["es"], sentences={"enabled": False}
)
session = await RealtimeSession.connect(created.session_id, created.stream_url)
mic = await asyncio.create_subprocess_exec(*MIC, stdout=asyncio.subprocess.PIPE)
async def feed() -> None:
assert mic.stdout is not None
while chunk := await mic.stdout.read(8000): # 125 ms of audio
await session.send_audio(chunk)
await session.eof() # the server flushes, then sends session.done
feeder = asyncio.create_task(feed())
try:
async with session:
async for event in session:
if event.type == "transcript.draft":
# The live tail: replace it, never append it.
tail = " ".join(s.text for s in event.segments)
print(f"\r\x1b[K\x1b[2m{tail}\x1b[0m", end="", flush=True)
elif event.type == "transcript.committed":
# Final: append it (seq 0, 1, 2...), and the tail it covers is gone.
print(f"\r\x1b[K{event.segment.text}", flush=True)
elif event.type == "transcript.translated":
print(f" es: {event.translations.get('es')}")
await feeder
print(f"done ({session.done_reason})", session.stats)
except VocalStackError as e:
# An `error` frame mid-stream, or a refused connect.
print(f"\nerror {e.code}: {e.message} (retryable: {e.retryable})")
finally:
if mic.returncode is None:
mic.kill()
asyncio.run(main())
上のような、セグメントごとの翻訳は簡単な形です。読み手が好む形は、独自のセクションで扱います。
translate: ["es", "fr"]は、セッションの進行に合わせて翻訳を要求します。届き方は文の組み立て(sentences: { enabled: true }、既定でオン)によって変わります。
文の組み立てをオフにすると、各確定の少し後にtranscript.translatedフレームが届きます。そこには確定のseqが含まれ、言語をキーとするtranslationsが付いています。そのフレームを、該当するセグメントに付けてください。ここまでの例はこの方式を使ってきました。単純で、確定のログのように読めます。一つのセグメントに一つの翻訳、話者が息をついたところが文の区切りです。
文の組み立てをオンにすると、サーバーは確定を文全体にまとめて、その文を翻訳します。こちらの方が格段に読みやすくなります。transcript.sentenceフレームは、sentenceIdで示される一つの文を丸ごと置き換えるものです。statusはopenからclosedへ進み(破棄の意味のdroppedもあります)、translationsは開いている間は暫定で、閉じると確定します。sentenceIdで挿入または更新し、追加はしないでください。TypeScript SDKには、まさにこのためのapplySentenceが用意されています:
import { applySentence, type SentenceSegment } from '@vocalstack/sdk';
let sentences: SentenceSegment[] = [];
for await (const ev of session) {
if (ev.type !== 'transcript.sentence') continue;
sentences = applySentence(sentences, ev.sentence); // upsert by sentenceId
if (ev.sentence.status === 'closed') console.log(`${ev.sentence.text}\n es: ${ev.sentence.translations?.es}`);
}
翻訳は文字起こしより一、二秒遅れて届きます。音声でも読み上げたい場合はvoice: ["es"]を追加してください(文の組み立てが必要です)。閉じた各文のスペイン語は、transcript.voiceフレームとして届きます。そこにはBase64のOgg/Opus(24 kHz、モノラル)のクリップが含まれています。長い文は断片(partsのpart)に分かれて届くので、続けて再生してください。暫定のテキストは読み上げられることはありません。音声翻訳の詳細はライブ翻訳ガイドにあり、遅れはどこで生じるのかでは遅延の連鎖を説明しています。
翻訳先の言語は、作成時に固定されるわけではありません。
session.updateのテキストフレームは、その時点以降のspokenLanguage、translate、speakerLabels、latencyMode、voice、voiceByLangを変更します。省略したフィールドは変わりません。
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDKではsession.update(…)として公開されています。translateはリスト全体を置き換え、[]を指定すると翻訳がオフになります。すでに届いたセグメントは、再文字起こしや再翻訳はされません。話者ラベルをオンにすると、そこから先の発話にだけ話者ラベルが付きます。話されたことはそのまま残ります。設定が変えるのは、これから先のことだけです。
音声が終わったら、{"type":"eof"}を送ります(SDKではsession.eof())。サーバーは残りの音声を文字起こしし、最後の確定を送ってからsession.doneを送り、接続を閉じます。代わりにcancelを送ると、すぐに停止して残りは捨てられます。
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reasonはeof、max_duration(セッションがプランの最大時間に達し、同じフラッシュを実行したもの)、またはcancelledのいずれかです。statsはサーバーが計測した値です。audioSは送った音声の長さ、processingSはセッションが実行されていた経過時間、realtimeFactorはその比率です。ライブセッションでは、経過時間にeof後の最終フラッシュが含まれるため、1をわずかに下回る値になります。これを大きく下回る場合、セッションは音声の長さよりも大幅に長くかかっています。最初に確認すべきは音声がどれだけ速く届いたかです。ファイルの場合、同じ数値は、文字起こしの戻りが再生速度の何倍だったかを示します。
セッションの長さは最大でGET /v1/realtime/capabilitiesのlimits.maxDurationSまでです。音声はプランのライブ料金で秒単位のクレジットを消費し、ほかの文字起こしと同じ5分の最低課金が適用されます。音声を受け取った各セッションは、最低5分で課金されます(料金)。同じソケットでファイル全体も扱えます。input: { kind: "file" }でセッションを作成し、ファイルそのもののバイト列(mp3、wav、flac、ogg、m4a)とeofを送ると、文字起こしは再生速度よりずっと速く返り、途中でtranscript.progressフレームが届きます。
再開はできません。接続が切れるとセッションは終わります。再接続するには新しいセッションを作成する必要があり、それには独自の最低課金が発生します。そして、ライブの音源からseqを0に戻して再度ストリーミングします。再接続の仕組み自体は、どのソケットAPIにも共通の標準的なパターンで、一度きちんと作っておく価値があります:
errorフレームでretryable: trueが示されたとき、503でRetry-Afterが付いたとき、またはフレームが30秒届かないとき(capacity_exceededとみなす)にだけ再試行してください。invalid_tokenとsession_expiredは、古いstreamUrlで再試行するのではなく、サーバーから新しいstreamUrlを取得することを意味します。作成用のルートは呼び出しても負荷が小さいものにし、切断は常に新しい開始として扱ってください。再開がないのは、何も保存しないことの代償です。文字起こしをソケットより長く残す必要があるなら、保存するのはあなたの側です。
同じストリームを録音として保存し、後で修正し、書き出し、それぞれの言語で視聴者に読ませたい場合は、代わりにライブ文字起こしとして公開してください。ライブ配信ガイドでは、同じPCMソケットを文字起こしとブロードキャストのリンクを通して使う方法を説明しています。視聴ガイドでは購読者側を扱い、各視聴者が言語を選び、途中から参加した人はlastSeqのカーソルから追いつく仕組みを説明しています。部屋ごとにそれがどう見えるかは、一つのリンク、すべての言語で扱っています。
保存したいファイルの場合は、保存するルートとしてAPIでファイルを文字起こしするがあります。フレームの完全なリファレンス、すべてのフィールドを含むものは、APIリファレンスのマイクをストリームするガイドにあります。