- Workers AIのwhisper-large-v3-turboを
[ai]バインディング経由で呼べば、音声はCloudflareの外に出ない。処理委託先が1社増えないことが、法人の情報システム審査では技術的な優劣より効く。 - 音声とテキストはR2に置く。同じWorkerからバインディング越しに読み書きするので、署名URLも外向きの認証情報も要らない。漏れる鍵がそもそも存在しない構成になる。
[assets]のrun_worker_firstでフロントを同居させ、[env.staging]で検証環境を丸ごとミラーする。ただしバインディングと環境変数は環境に継承されない——ここが最初の落とし穴。
本記事に掲載するコードは、構成が伝わる最小限に整理したものです。実装から認証・セッション検証・業務ロジックは外し、一部は簡略化した例に置き換えています(該当箇所には注記を付けます)。
なぜ外部の文字起こしAPIに音声を送らないのか?
音声は話者の意図と無関係に個人情報を含みやすく、処理委託先が1社増えるごとに法人側の審査コストが跳ね上がるため。
音声を扱うプロダクトを法人へ入れるとき、最初に止まるのは機能の議論ではありません。「その音声はどこへ行くのか」です。テキスト入力なら人は書く内容を選びますが、話し言葉ではそうなりません。所属や取引先の固有名詞、同僚の名前、体調、社内の人間関係——質問していない情報が、当たり前のように音声へ入り込みます。つまり音声データは、設計上「個人情報を含む前提」で扱うのが実務的です。
ここで外部の文字起こしAPIを1つ挟むと、増えるのはHTTPリクエストではありません。委託先一覧への追記、委託契約の締結、越境移転の説明、回答者向け同意文の改版、そして情報システム部門の再審査。技術的な難易度はほぼゼロなのに、社内を通すための紙の枚数だけが増えます。自社で開発・運用しているAIインタビュープロダクト「CoeSignal」でも、回答画面と利用規約に処理者を明示する運用にしているため、処理者を1社追加する判断はコードを書くより重い意思決定になっています。
Cloudflare Workers AIを選ぶ理由は、この一点に集約されます。Workerの実行基盤・AI推論・オブジェクトストレージが同じベンダーの中で完結するため、音声の送信先が増えません。データの扱いについてもCloudflareは公式ドキュメントで、Customer ContentをWorkers AI上のモデルの学習に使わないこと、Cloudflareや第三者サービスの改善にも使わないこと(明示的な同意がある場合を除く)を記載しています。また推論データは既定では保存されず、R2やKVなどのストレージサービスを併用した場合にのみ保存される、とも明記されています。
出典:Cloudflare公式ドキュメント Workers AI — Data usage(2026年8月9日確認)。記載は改定されるため、審査資料に使う際は必ず一次ソースを直接ご確認ください。
「推論データは保存されない」が成立するのは、自分で保存しない場合だけ。R2に音声を置いた瞬間、それは自社の保存物になり、保持期間・削除・開示の運用は自社の責任になる。この線引きを曖昧にしたまま説明すると、審査でいちばん厳しく突かれる。
全体構成——Workerに何を載せるか
静的フロント・API・AI推論・オブジェクトストレージをWorker 1本に集約し、外向きの通信を持たせない構成にする。
構成はシンプルです。レイヤーごとに使うCloudflareの機能と、wrangler.tomlでの記述を対応させると次のようになります。
| レイヤー | 使う機能 | wrangler.tomlの記述 |
|---|---|---|
| フロントエンド | Workers Static Assets | [assets] directory / binding / run_worker_first |
| API | Workers | main = "worker.js" |
| 音声の文字起こし | Workers AI | [ai] binding = "AI" |
| 音声・テキストの保管 | R2 | [[r2_buckets]] binding = "AUDIO" |
| 検証環境 | Wrangler environments | [env.staging] + 各バインディングの再定義 |
この構成の要は「バインディング」という考え方です。Workers AIもR2も、外向きのHTTPエンドポイントとAPIキーで呼ぶのではなく、ランタイムがenvオブジェクトに渡してくる変数として使います。Cloudflare公式ドキュメントはバインディングを「Workersランタイムがコードに提供する実行時の変数」と説明しています。実務上の意味は明確で、接続先URLもAPIキーも存在しない——だから漏れる鍵もローテーションすべき鍵もありません。シークレット管理の対象が1つ減るのは、監査項目が1つ減るのと同じことです。
出典:Workers AI — Bindings/R2 — Use R2 from Workers(いずれも2026年8月9日確認)
Workers AIの呼び出しはどう書くか?
wrangler.tomlに[ai]を3行書き、env.AI.run()にモデルIDと音声を渡すだけ。認証コードは1行も要らない。
設定側は次の3行です。公式ドキュメントに載っているとおりの最小形で、追加のパラメータはありません。
図:CoeSignalのworker/wrangler.tomlより(コメントは実物)。この記述だけでenv.AIが使えるようになります。
呼び出し側は、音声のバイト列をbase64文字列にしてモデルへ渡します。実装から抜き出したのが次のコードです(引数の受け渡しのみ整理、ロジックは実物)。
短いコードですが、本番で踏んだ落とし穴が2つ埋まっています。
1つ目はbase64変換です。公式サンプルはNode.jsのBuffer.from(buf, 'binary').toString("base64")でbase64を作ります。しかしnodejs_compatフラグを有効にしていない素のWorkers環境にはBufferがありません。代わりにbtoaを使うと、今度はString.fromCharCode.applyへ数百万個の引数を渡すことになり、コールスタックが溢れます。32,768バイトずつのチャンク分割は、この両方を避けるための実装です。数分の音声でも確実に落ちる箇所なので、短い音声だけでテストして通ってしまうと本番で刺さります。
2つ目は出力の受け取り方です。公式のモデルページに記載されているoutput schemaはtranscription_infoオブジェクト(textとword_countを持つ)と、WebVTT形式を含むsegments配列です。実装ではresult.textとresult.transcription_info?.textの両方を見て、どちらの形で返ってきても壊れないようにしています。モデル側の返却形は更新され得るので、片方だけを前提にしないほうが安全です。
入力パラメータはaudioのほかにtask(transcribe / translate、既定はtranscribe)、language、vad_filter、initial_prompt、beam_sizeなどが公式に定義されています。日本語のインタビュー用途ではlanguage: "ja"を明示的に渡す形にしています。
| 項目 | 公式に確認できる値(2026年8月9日時点) |
|---|---|
| モデルID | @cf/openai/whisper-large-v3-turbo |
| 料金 | 音声1分あたり 46.63 neurons。Neuronは1,000 Neuronsあたり$0.011、1日10,000 Neuronsまで無料枠 |
| レート制限 | Automatic Speech Recognitionカテゴリとして 720 requests per minute |
| 音声ファイルの上限 | モデルページに記載なし(後述のとおり自前で上限を決める) |
出典:モデルページ/Workers AI Pricing/Workers AI Limits(すべて2026年8月9日確認)。なお音声1分あたりのドル単価は、モデルページが$0.00051、料金ページが$0.0005と表記が分かれています。見積もりに使う場合はNeuron単価と併記された料金ページ側を基準にし、必ず最新の公式値をご確認ください。
音声とテキストをR2にどう置くか?
[[r2_buckets]]を1つ足し、env.AUDIO.put()で音声とJSONを同じバケットへ書く。取り出しのegress課金がないのが効く。
設定はbindingとbucket_nameの2つだけです。CoeSignalではAUDIOという名前で1バケットを持ち、音声・トランスクリプト・判定ログ・メモをすべてここに集約しています。オブジェクトキーは{セッションID}/{質問ID}_{深掘り回数}を基点にして、音声は拡張子付き(Content-Typeを見てwebm / mp4 / wavを出し分け)、回答レコードは.jsonで並べます。1セッション=1プレフィックスになるので、あとからlistで束ねる操作も、まとめてdeleteする操作も素直に書けます。
設計上のポイントを3つ挙げます。
- バイト列は一度しか読めない。リクエストボディは1回しか読めないため、
await audio.arrayBuffer()を先に1回だけ実行し、そのバッファをWorkers AIへの入力とR2への保存の両方で使い回します。「AIに投げてから保存する」と素直に書くと、保存側で空のボディを掴みます。 - 保存するかどうかは、障害時に「保存しない」側へ倒す。音声を残すかは調査ごとの設定で決まりますが、その設定を読めなかったときに「保存する」側へフォールバックすると、設定参照の一時障害だけで生の音声が残ってしまいます。実装では例外時に
falseを返し、保存しない側へ倒しています。 - R2の条件付き書き込みが排他制御に使える。R2の
putはonlyIfでETag条件を付けられ、条件に合わなければ成功オブジェクトではなくnullを返します。これを使うと、データベースを別途持たずに「同じ回答を二重に処理させない」ロックが書けます。
図:実装のputJsonIfCurrentから、リトライ・エラーラップ・セッション検証を外して条件分岐だけを残した例です。実物はこの周りに再試行とバックオフが付いています。
R2を選ぶ理由はもう一つあります。Cloudflareは公式ドキュメントで、R2を「一般的なクラウドストレージにありがちな高額なegress帯域料金なしに、大量の非構造化データを保存できる」ものとして説明しています。音声のような重いオブジェクトは、置くコストより「後から何度も取り出すコスト」で効いてきます。分析のたびに全件を読み直す運用でも、そこが青天井にならないのは実務的に大きい差です(Cloudflare R2 公式ドキュメント・2026年8月9日確認)。
フロントエンドをどう同居させるか?([assets] と run_worker_first)
静的ファイルを同じWorkerから配信し、Workerを先に走らせたいパスだけrun_worker_firstに列挙する。
Workers Static Assetsを使うと、public/のような静的ディレクトリをそのままWorkerから配信できます。設定はdirectory(配信元)、binding(コードからenv.ASSETS.fetch()で触るための名前)、そしてルーティングを決めるrun_worker_firstです。
図:CoeSignalの設定を短縮したもの(実物のrun_worker_firstはもう少し長いパス一覧です)。
run_worker_firstは公式ドキュメントで「アセットに一致するリクエストであってもWorkerスクリプトを起動するかどうかを制御する」と説明されています。既定のfalseではアセットが優先され、Workerは呼ばれません。ここで重要なのは、真偽値だけでなくルートパターンの配列を渡せる点です。*でのマッチに加え、!を先頭に付けた否定パターンも書けて、否定パターンが優先されます。
この一行が事故防止として効きます。認証が必要な画面(ログイン後の管理画面など)は必ずWorker側を通し、それ以外の画像・CSS・JavaScriptはアセット配信に任せる。もし認証必須のHTMLがアセットとして素通りで配られると、認証チェックの前にページの中身が出てしまいます。ルーティング表の1行で防げる事故を、アプリケーションコード側の分岐で頑張らないほうがいい、という判断です。
なおnot_found_handlingはあえて指定していません。CoeSignalのフロントはSPAではなく、一致しないパスは素の404で構いません。SPAの設定を「とりあえず」入れると、存在しないURLがすべてトップページのHTMLを返すようになり、404が消えてクローラにも運用にも良くない状態になります(Workers Static Assets — Binding・2026年8月9日確認)。
stagingはどう作るか?([env.staging] の落とし穴)
[env.名前]を足すと別Workerとしてデプロイされる。ただしバインディングと環境変数は継承されないので、環境ごとに書き直す。
Wranglerの環境機能を使うと、1つの設定ファイルから複数のWorkerをデプロイできます。公式ドキュメントによれば、環境は<トップレベルのname>-<環境名>という命名で別のWorkerとして展開され、wrangler deploy --env stagingで切り替えます。
ここが最大の落とし穴です。トップレベルに書いた設定が、すべて環境に引き継がれるわけではありません。公式ドキュメントは非継承キーとして次を挙げています。
| 継承されない(環境ごとに再定義が必要) | 継承される |
|---|---|
vars / kv_namespaces / r2_buckets / ai / durable_objects / services / secrets | route / routes など(環境側で上書き可能) |
出典:Wrangler — Environments(2026年8月9日確認)
したがってstagingを作るときは、[env.staging.ai]、[[env.staging.r2_buckets]]、[env.staging.vars]をそれぞれ書き直します。CoeSignalでは検証用に別バケット(aiiv-audio-stripe-test)を割り当て、[env.staging.vars]にMOCK = "1"を置いて外部AI呼び出しを止めた状態で回答者UXを通しで確認できるようにしています。
そしてroutesは継承される側なので、本番の独自ドメインをstagingが引き継がないようroutes = []と明示的に空にします。ここを書き忘れると、staging用のデプロイが本番ドメインのルートを触りに行きます。
非継承の仕様は、慣れるまでは不便に見えるが、安全側に倒れている。r2_bucketsを書き忘れたstagingは「バインディングが無い」ので起動時点で落ちる。これが継承だったら、stagingのつもりで本番バケットへ書き込むほうの事故が起きていた。落ちるほうがいい。
実装上、先に決めておくべきことは何か?
サイズ上限・二重処理の防止・再試行回数・失敗時の返し方の4つ。いずれもAIを呼ぶ前に決める。
文字起こしそのものはenv.AI.run()の1行ですが、本番で効くのはその周りです。CoeSignalの実装で実際に置いているガードを挙げます。
| 論点 | 実装での扱い | 返すレスポンス |
|---|---|---|
| 音声サイズ | 25MiBを超えるものは受け付けない(自前の上限) | 413 AUDIO_TOO_LARGE |
| 二重処理 | R2の条件付き書き込みで「処理中」を予約。処理中判定が古くなる閾値は2分、保存段は10分 | 409 TRANSCRIPTION_IN_PROGRESS |
| 再試行の上限 | 1回答あたり3回まで | 429 TRANSCRIPTION_ATTEMPT_LIMIT |
| 予約の横取り | 保存段に移る時点で所有権を確認し、新しい試行に引き継がれていたら書き込まない | 409 TRANSCRIPTION_RESERVATION_LOST |
| 文字起こし失敗 | テキストが取れないまま保存へ進まない | 502(回答者には再試行を案内) |
| バインディング未設定 | フォールバックも無効なら、処理を始めずに落とす | 500 |
サイズ上限について補足します。whisper-large-v3-turboのモデルページには音声ファイルサイズの上限が記載されていません(2026年8月9日確認)。一方、Workerのリクエストボディ上限はCloudflareのプランに依存し、公式のWorkers Limitsでは Free/Pro が100MB、Business が200MB、Enterprise が既定500MBと記載されています。つまりプラットフォーム上限に依存すると、プランを変えた瞬間に挙動が変わるということです。アプリ側で自前の上限を決めて明示的に弾くほうが、挙動が読めるうえエラーメッセージも親切になります(Workers — Limits・2026年8月9日確認)。
二重処理の防止は、地味ですが実運用で最も効いたガードです。回答者がボタンを連打する、通信が切れて再送される、タブが二重に開いている——同じ音声が2回届くのは珍しくありません。R2の条件付き書き込みでprocessing → persisting → completed / failedという状態を持たせ、処理中の重複リクエストは409で即座に返します。同時に、プロセスが途中で死んだまま予約が残り続けないよう、古い予約は一定時間で無効化しています。
録音側にも1点あります。ブラウザによって使えるコーデックが違うため、MediaRecorderのmimeTypeはaudio/webm;codecs=opus → audio/webm → audio/mp4の順にisTypeSupported()で確かめて使えるものを選び、サーバー側もContent-Typeを見て保存時の拡張子を出し分けます。1つ決め打ちにすると、特定のブラウザだけ録音が始まらない現象になります。
整形レイヤーを足すなら、処理者を増やさない範囲で
Whisperの出力には、同音異義語の変換ミスのような明白な認識誤りが残ります。CoeSignalではLLMで最小限だけ直す整形レイヤーを任意で挟んでいますが、設計方針は明確です。すでに同意文に記載済みのベンダーだけを使う——つまり整形のために処理者を増やさない。加えて、原文を別フィールドに不変で保持し、編集率とタイムアウトのガードを付け、失格・失敗時は黙って原文を使います。整形は「乗ればラッキー」のレイヤーであって、クリティカルパスに置くものではありません。フィラーや言い回しを整えないのも意図的で、発言の一次性が失われるとリサーチのデータとして価値が落ちるためです。
この構成はどんなときに向くか?
録音を後から処理する非リアルタイム用途で、かつ処理委託先を増やしたくない場合。逐次字幕や話者分離が主目的なら別構成を検討する。
| 向くケース | 別の構成を検討すべきケース |
|---|---|
| 録音 → 送信 → 文字起こしの非リアルタイム処理 | 発話中に逐次表示するライブ字幕が主機能 |
| 情報システム部門の審査で委託先を減らしたい | 統制がCloudflare以外のクラウドに寄っている |
| フロントとAPIが1つのアプリとして完結している | 話者分離やタイムスタンプ単位の編集が中心 |
| 音声を大量に保管し、後から何度も読み直す | すでに別ベンダーの文字起こしを審査済みで運用中 |
CoeSignalでも、社外向け調査に限って別ベンダーへのフォールバックを用意しています。ただし既定は無効で、有効化には環境変数の明示的な設定が必要です。そして社内向け調査では、調査の種別が「社内」と判定された時点でフォールバック自体が有効になりません。例外を作るなら、例外が発動しない範囲を先にコードで固定する。設定ファイルのコメントや運用ルールではなく、条件式として書いておく。これが、後から構成を説明するときにいちばん効きます。
音声をAIで扱うプロダクトは、モデルの精度で差がつく局面より、「どこに何が渡るか」を1枚で説明できるかで導入可否が決まる局面のほうが多い。Workers AIとR2で完結させる構成は、その説明を短くするための選択です。関連して、AIインタビューという用途そのものについてはAIインタビューとはで、生成AIの利用コスト設計についてはトークンマネジメントで扱っています。
よくある質問
Cloudflareの公式ドキュメント(Workers AI / Data usage)では、Customer ContentをWorkers AI上のモデルの学習や、Cloudflare・第三者サービスの改善に利用しないと明記されています。また推論データは既定では保存されず、R2やKVなどのストレージサービスを併用した場合に限り保存されるとされています(2026年8月9日確認)。自分でR2へ保存した音声は自社の保存物になるため、保持期間と削除の運用は別途設計が必要です。
whisper-large-v3-turboのモデルページには音声ファイルサイズの上限は記載されていません(2026年8月9日確認)。一方でWorkerのリクエストボディ上限はCloudflareのプラン依存で、Free/Proは100MBと公式に記載されています。実務では公式上限に依存せず、アプリ側で自前の上限を決めて超過を明示的に弾く設計が安全です。CoeSignalの実装では25MiBを超える音声を413で拒否しています。
Neuronという単位での従量課金です。公式の料金ページでは1,000 Neuronsあたり$0.011、1日10,000 Neuronsまでは無料枠と記載されています。whisper-large-v3-turboは音声1分あたり46.63 neuronsです(2026年8月9日確認)。料金は改定されるため、必ず公式の料金ページで最新をご確認ください。
バインディングと環境変数はトップレベルから継承されない点です。Cloudflareの公式ドキュメントでは、vars・kv_namespaces・r2_buckets・ai・durable_objects・services・secretsが非継承キーとして挙げられており、環境ごとに書き直す必要があります。routesは継承されるため、本番の独自ドメインを引き継がせたくない場合はenv側でroutes = []と明示します。
