アップロードが返すのはトランスクリプトではなくジョブです。すべての作成リクエストに Idempotency-Key を付ければ、リトライによって二つ目の文字起こしが作られたり課金されたりすることはありません。そのあとジョブが完了するまでポーリングし、同じIDから文、単語、またはエクスポートを読み取ります。
ファイルの文字起こしは、VocalStack APIにできることの中で最も単純なものであり、非同期ジョブのあらゆる形をしています。アップロード、待機、読み取りです。アップロードへの応答はすぐに返り、文字起こしとそのジョブを含みますが、まだ単語はありません。プログラムがジョブをポーリングしている間に、サーバーは音声の再生速度よりはるかに速くファイルを文字起こしします。状態が done になると、話者と時間情報つきの文の単位でトランスクリプトを読み取ることも、4つの形式のいずれかでファイルとしてエクスポートすることもできます。話されている言語のままでも、翻訳した状態でも構いません。
バッチAPIならどれにも共通する形があります。IDを返す作成、ポーリングするステータス、あとから取得する結果です。フィールド名だけがVocalStack固有のもので、このチュートリアルではTypeScriptとPythonのSDK、そして必要な箇所ではHTTPの呼び出しを使って順に説明します。
Developers → API keys をダッシュボードで開き、Read と Write の権限を持つキーを作成します。キーは一度だけ表示されます。環境変数にコピーしてください。
export VOCALSTACK_API_KEY="<your API key>"
キーはサーバーやスクリプト向けです。ブラウザに配信されるものは、キーも一緒に配信されてしまいます。そのため、キーはページのコードにもURLにも入れないでください。URLはログに残るからです。ブラウザからストリーミングするには、サーバー側でブラウザ用のリアルタイムセッションを作成します。その方法は別のチュートリアルで説明しています。
すべての呼び出しは、このキーをベアラートークンとして認証します:
Authorization: Bearer vs_key_...
環境変数にキーを設定したら、次はそれを送るクライアントを用意します。
SDKは2種類あります。どちらにも VocalStack というクライアントが1つあり、メソッドはリソースごとにまとめられています。vs.transcriptions、vs.jobs、vs.recordings、vs.realtime などです。TypeScript SDKはNode 20以降とブラウザで動作します。Python SDKはasyncioの上に作られており、Python 3.10以降が必要です。
SDKはnpmやPyPIには公開されていません。ビルドはAPIがすべてのアカウントに配信します。環境に合わせたインストールコマンドは、APIリファレンスのクイックスタートのInstallセクションにあります。そのインストールコマンドはキーをAPIホストにのみ送信します。何もインストールしたくない場合は、以下のHTTP呼び出しに curl と jq があれば十分です。
どちらを選んでも、最初の呼び出しはアップロードです。
POST /v1/transcriptions は、ファイルを multipart/form-data として受け取ります。file パートと、JSONを含む省略可能な options パートです。応答は 201 と文字起こしを返します。その job.recordingId は、ファイルから作られた録音のIDで、以降のすべての呼び出しはこのIDで指定します。
初回の実行では、options.settings 内の2つの設定が重要です:
spokenLanguage: en のようなコード。省略すると言語は自動検出されます。指定すると、ノイズの多い録音や強いアクセントのある音声で精度が上がります。speakerLabels: auto、on、off のいずれか。on の場合、すべての文に話者番号が付きます。auto の場合は、2人目の話者が検出されてから初めてラベルが表示されます。mp3、wav、flac、ogg、m4a、mp4 など、ほとんどの音声・動画ファイルを受け付けます。動画は音声だけを保持します。認識精度にフォーマットはほとんど影響しません。音声認識は16 kHzのモノラルで処理され、受け取った時点ですべてその形式にリサンプリングされるからです。1人が話す48 kHzステレオのWAVは、そのバイト数の大半を高音質の無音の記述に費やしています。同じ話を64 kbpsのモノラルMP3やOpusにした場合と同じ精度で文字起こしされますが、約20倍のサイズになるため、アップロードにも約20倍の時間がかかります。アップロード前に変換するなら、モノラルと控えめなビットレートの2つの設定が変更する価値のあるものです。
Idempotency-Key は、アップロードごとに必ず付けてください。その理由も理解しておいてください。ネットワークのタイムアウトでは、サーバーが何をしたかはわかりません。リクエストを一度も受け取っていないかもしれませんし、ファイルを保存してジョブをキューに入れた後に、応答だけが失われたのかもしれません。そのため、考えずにリトライすると、二重に文字起こしされ、二重に課金されるおそれがあります。キーがそれを防ぎます。同じキーと同じボディで24時間以内にリクエストが繰り返されると、サーバーは何も作成せず、最初の応答を返します。これにより「少なくとも一度」が「実質的に一度だけ」になります。つまり、ルールは「論理的な1回のアップロードにつき1つのキーを、最初の試行の前に生成し、リトライのたびに再利用する」ことです。試行ごとに新しいキーを作ると、リトライはそれぞれ新しいアップロードになり、ヘッダーは何も守れません。
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
const uploadKey = crypto.randomUUID(); // one key for this upload, however many attempts it takes
const tx = await vs.transcriptions.create(
{ file: './talk.mp3', name: 'Talk', settings: { spokenLanguage: 'en', speakerLabels: 'on' } },
{ idempotencyKey: uploadKey },
);
const recordingId = tx.job!.recordingId;
import asyncio
import os
import uuid
from vocalstack import VocalStack
async def main() -> None:
upload_key = str(uuid.uuid4()) # one key for this upload, however many attempts it takes
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
tx = await vs.transcriptions.create(
file="./talk.mp3",
name="Talk",
settings={"spokenLanguage": "en", "speakerLabels": "on"},
idempotency_key=upload_key,
)
assert tx.job is not None
recording_id = tx.job.recording_id
同じ呼び出しをcurlで行う場合:
API={{api}}
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
UPLOAD_KEY=$(uuidgen)
REC=$(curl -sSf "$API/v1/transcriptions" -H "$AUTH" -H "Idempotency-Key: $UPLOAD_KEY" \
-F file=@talk.mp3 \
-F 'options={"name":"Talk","settings":{"spokenLanguage":"en","speakerLabels":"on"}}' \
| jq -r .job.recordingId)
201 は一語も文字起こしされる前に返ってきます。手元にあるのはジョブIDなので、次はそのジョブの完了を待つ必要があります。
ジョブは queued → transcribing → done と進むか、failed で終わります。その際、error メッセージと、原因が機械可読であれば errorCode が付きます。4つ目の状態である cropping は、ダッシュボードのトリミング手順のように、文字起こしする区間を誰かが選ぶまでジョブが保留されていることを意味します。通常のアップロードはこの状態に入りませんが、jobs.wait もジョブがこの状態に達すると返ります。状態が done または failed になるまで GET /v1/recordings/{id}/job を読み取ってください。
Webhookはありません。これは意図的なトレードオフです。Webhookには、あなた側の公開HTTPSエンドポイント、誰も呼び出せないようにするための署名検証、そしてエンドポイントがダウンしているときのリトライポリシーが必要になります。1分ほどで終わるジョブに対しては、これは大変な手間です。ポーリングならそのどれも必要ありません。スクリプトは2秒ごとに「もう終わった?」と尋ねます。後部座席の子どものようですが、子どもと違って、進捗のパーセンテージが返ってきます。
すでに開いているWebページでは、代わりに文字起こしのイベントソケットを使えます(WS /v1/transcriptions/{id}/events。ジョブが進むにつれて job.progress フレームが届きます)。スクリプトの場合は、ポーリングが適切な手段です。
SDKがポーリングを行います。vs.jobs.wait は2秒ごとにジョブを読み取り、読み取るたびに onProgress(Pythonでは on_progress)に渡します。ジョブの progress には pct(0から100)と processedS(これまでに文字起こしされたメディアの秒数)が含まれます。以下のプログラムは、状態またはパーセンテージが変わったときだけ1行を出力します。
let last = '';
const job = await vs.jobs.wait(recordingId, {
onProgress: (j) => {
const line = `${j.state} ${j.progress.pct}%`;
if (line !== last) console.log((last = line));
},
});
if (job.state !== 'done') throw new Error(`transcription ${job.state}: ${job.error ?? ''}`);
last = ""
def on_progress(j) -> None:
nonlocal last
line = f"{j.state} {j.progress.pct}%"
if line != last:
last = line
print(line)
job = await vs.jobs.wait(recording_id, on_progress=on_progress)
if job.state != "done":
raise RuntimeError(f"transcription {job.state}: {job.error}")
wait は failed でも例外を投げず、ジョブを返して確認は呼び出し側に任せる点に注意してください。2秒は講演の音声には適した間隔です。3時間の録音や数百件のバッチでは、間隔(intervalMs、interval_s)を広げるか、ジョブの実行に応じて間隔を徐々に伸ばしてください。最初の読み取りが最も多くのことを教えてくれ、後のものはほとんどサーバーがまだ処理中であることを確認するだけだからです。SDKを使わない場合は、同じループを手で書きます:
while :; do
JOB=$(curl -sSf "$API/v1/recordings/$REC/job" -H "$AUTH")
STATE=$(jq -r .state <<<"$JOB")
echo "$STATE $(jq -r .progress.pct <<<"$JOB")%"
case $STATE in
done) break ;;
failed) jq -r .error <<<"$JOB" >&2; exit 1 ;;
esac
sleep 2
done
状態が done と表示されたら、トランスクリプトの準備は完了です。
GET /v1/recordings/{id}/transcript は、トランスクリプト全体を1つのドキュメントで返します:
sentences: 利用者に表示するもの。それぞれに text、speaker、および秒単位の start と end があります。segments: エンジンの行。それぞれに単語レベルのタイミングが words に含まれます(各単語は text、start、end を持ちます)。speakers: 話者番号ごとに1件のエントリがあり、その name を持ちます。話者は0から番号が振られます。speaker が -1 の場合、その録音では話者ラベルがオフだったことを意味します。?lang=es を付けると、スペイン語に翻訳されたトランスクリプトを読めます。文はその言語で返されます。
const { sentences } = await vs.recordings.transcript(recordingId);
for (const s of sentences) console.log(s.speaker >= 0 ? `[speaker ${s.speaker}] ${s.text}` : s.text);
snapshot = await vs.recordings.transcript(recording_id)
for s in snapshot.sentences:
print(f"[speaker {s.speaker}] {s.text}" if s.speaker >= 0 else s.text)
curl -sSf "$API/v1/recordings/$REC/transcript" -H "$AUTH" \
| jq -r '.sentences[] | if .speaker >= 0 then "[speaker \(.speaker)] \(.text)" else .text end'
2つのタイミングの粒度は、2つの役割を果たします。文は読む単位なので、字幕やトランスクリプトのページが表示するものです。単語は指し示す単位なので、カラオケ風のハイライト、検索ヒット、クリップの切り出しは segments[].words をたどります。エクスポート形式は文から組み立てられます。
GET /v1/recordings/{id}/export は実際のファイルを返します。format は srt、json、xlsx、pdf のいずれかで、ファイルには修正内容も含まれます。翻訳したファイルが必要な場合は lang= を付けます。その場合、エクスポートは指定した言語で、文ごとに1行ずつ読めるように作られ、まだ翻訳されていない文は先に翻訳されます。
SRTは見慣れておく価値があります。ほぼすべてのプレーヤーが受け付ける字幕形式で、プレーンテキストだからです。ファイルは空行で区切られたキューの一覧で、各キューは番号、時間範囲、テキストからなります。タイムスタンプではミリ秒の前にカンマを使います。これはフォーマットが捨てきれなかったフランスの慣習です。
1
00:00:01,000 --> 00:00:04,200
Welcome, everyone. Let's get started.
1つのキューに1文というのが適切な粒度です。各キューは画面に表示されている時間内に読み終えられ、どれも考えの途中で途切れません。録音に話者ラベルがある場合、各キューのテキストは話者の名前とコロンで始まります。
TypeScript SDKでは、vs.recordings.export は Response を返すので、そのバイト列を arrayBuffer() で読み取ります。Pythonでは bytes を返します。
import { writeFile } from 'node:fs/promises';
const srt = await vs.recordings.export(recordingId, 'srt');
await writeFile('talk.srt', Buffer.from(await srt.arrayBuffer()));
const pdf = await vs.recordings.export(recordingId, 'pdf', { lang: 'es' });
await writeFile('talk.es.pdf', Buffer.from(await pdf.arrayBuffer()));
srt = await vs.recordings.export(recording_id, "srt")
with open("talk.srt", "wb") as f:
f.write(srt)
pdf = await vs.recordings.export(recording_id, "pdf", lang="es")
with open("talk.es.pdf", "wb") as f:
f.write(pdf)
asyncio.run(main())
curl -sSf "$API/v1/recordings/$REC/export?format=srt" -H "$AUTH" -o talk.srt
curl -sSf "$API/v1/recordings/$REC/export?format=pdf&lang=es" -H "$AUTH" -o talk.es.pdf
トランスクリプトは保存されるため、ダッシュボードの Transcriptions の下に表示され、リンクで共有でき、いつでもあとから読み直せます。これが中核となるループです。作成、待機、読み取り、エクスポート。このチュートリアルの残りでは、そのバリエーションを扱います。すでにオンラインにあるメディア、遭遇するエラー、そしてファイルにかかる費用です。
メディアがすでにオンラインにある場合は、リンクをAPIに渡すと、APIがファイルを自分で取得します。対象は公開されている http(s) のアドレス(mp3、動画、ポッドキャストのエピソードなど)またはYouTube動画です。呼び出しは同じ POST /v1/transcriptions で、マルチパートのアップロードの代わりにJSONボディ { "url": "..." } を使い、name と settings は前と同じように指定します。SDKでは、vs.transcriptions.createFromUrl(url)(TypeScript)と vs.transcriptions.create(url=...)(Python)が同じリクエストを行います。
何かを作成する前に、GET /v1/transcriptions/probe?url= でリンクが何かを確認できます。その kind は3種類のいずれかです:
file: 終わりのある録音。ここで文字起こしします。live: YouTubeのライブ配信、終わりのないラジオのストリーム、終わりのないHLSプレイリスト。ライブのリンクは終わることがないため、ファイルとして文字起こしすることはできません。代わりにライブ文字起こしの入力元として使ってください。unknown: サーバーには判別できなかったもの。リンクがメディアとして開けない場合(たとえばWebページなど)は、ok が false になり、problem に理由が1文で説明されます。probeは省略可能で、1分あたり60回までに制限されています。
リンクのジョブは fetching で始まり、その後 transcribing と done に移ります。取得中は、progress.phase がサーバーの行っている処理を示します。バイトを受信している間は fetching(相手側が総量を伝えている場合は fetchedBytes と fetchTotalBytes を伴う)、動画から音声を取り出している間は extracting です。progress.phasePct は、その段階をどこまで進んだかを示します。長い動画には時間がかかるため、数秒ごとにポーリングしてください。そのために vs.jobs.wait は intervalMs(Pythonでは interval_s)を受け取ります。
const probe = await vs.transcriptions.probe(url);
if (!probe.ok) throw new Error(`that link did not open: ${probe.problem}`);
if (probe.kind === 'live') throw new Error('a live link: record it with a live transcription instead');
const linkKey = crypto.randomUUID();
const tx = await vs.transcriptions.createFromUrl(url, { name: 'From a link' }, { idempotencyKey: linkKey });
const job = await vs.jobs.wait(tx.job!.recordingId, {
intervalMs: 3000,
onProgress: (j) => {
const p = j.progress;
if (j.state === 'fetching') console.log(`${p.phase ?? 'fetching'} ${p.phasePct ?? 0}%`);
else console.log(`${j.state} ${p.pct}%`);
},
});
リファレンスのリンクを文字起こしするガイドには、両方の言語とHTTPによる完全なプログラムがあります。リンクからの取得にはさらに失敗の要因が加わるため、実際に遭遇するエラーを挙げておきます。
失敗したすべての呼び出しは、1つのJSONエンベロープで応答します。code は分岐に使う文字列、status はHTTPステータスです。message は人間向け、requestId はサポート向けです。data の下にある retryable は、同じリクエストを再送すると効果があるかどうかを示します。details には、そのコードが必要とする情報が入ります。SDKはすべて、同じフィールドを持つ VocalStackError という1つのエラー型を投げます。
初日から扱うべきコードは3つあります。
402 insufficient_credit: アカウントのクレジットが尽きています。何も課金されていません。details.balance はドル建ての残高、details.topUpUrl はクレジットを追加できる場所です。費用を事前に知ることができた場合(たとえば長さのわかっているファイルの場合)、details.required にその金額が入ります。429 rate_limited: Retry-After 秒待ってください。details.metric が creates_per_hour の場合、バーストの制限ではなく、プランの作成レート(1時間あたりに新しく開始できる文字起こしの数)に達しています。limit と resetsAt が上限と、その枠が空く時刻を示します。503 unavailable: サービスが一時的に利用できません。Retry-After の後にリクエストを送り直してください。SDKは 429 と 503 を Retry-After の後に再試行します。デフォルトでは2回です(クライアントの maxRetries / max_retries)。ただし 503 は、二度送っても安全な呼び出しに限ってのみ再試行されます。つまり GET、または Idempotency-Key 付きの作成です。キーのない作成は、最初の試行が成功したかどうかをSDKが知ることができないため、利用者の判断に任されます。これが、すべてのアップロードにキーを付けるべき実際の理由です。
すべての応答は RateLimit-Limit、RateLimit-Remaining、RateLimit-Reset も返すので、混み合ったパイプラインは 429 が出る前に速度を落とせます。
{
"code": "insufficient_credit",
"status": 402,
"message": "this account has no credit left; add credit to continue",
"data": {
"retryable": false,
"details": { "balance": -0.42, "required": 0.075, "currency": "usd", "plan": "payg", "topUpUrl": "https://…" }
},
"requestId": "01J…"
}
エラーコードの全一覧はエラー、リトライ、制限にあります。その 402 は金銭に関わる唯一のエラーなので、ファイルにどれだけかかるかを確認しておきましょう。
ファイルは音声1時間あたり$0.20で、秒単位(1分あたり$0.0033)で課金され、最低5分です。30分のファイルは$0.10です。プログレスバーが埋まる間に飲むコーヒーより安い、10セントです。料金はすべてのプランで同じで、APIでもダッシュボードでも同じです。新規アカウントは一度限りの$5のサインアップクレジットから始まり、カードの登録は不要です。
このチュートリアルに関係する、プランの制限は3つです。
GET /v1/realtime/capabilities が limits.maxUploadBytes として報告します。両方のうち小さい方が適用され、それを超えるファイルは何も文字起こしされる前に 413 で拒否されます。長い録音では、WAVではなく圧縮したモノラルのファイルをアップロードするもう一つの理由になります。GET /v1/billing はプラン、残高、上限をいつでも報告します。料金の全表は料金ページにあります。
ここからの次のステップは、ファイルの完了を待てないときのリアルタイムの音声ストリーミング、または、上のコードをいっさい書かずに、MCPサーバー経由で同じアカウントにAIエージェントを接続し、文字起こしや検索を任せるAIエージェントの接続です。