What it costs to run one meeting through the transcription pipeline, measured against meeting 850 with live LLM calls on 2026-09-09.
Meeting 850 — online GP consultation (ja-JP, 5.0 min): patient with recurrent unilateral pulsatile headache since their early 20s, now increasing in frequency, with visual aura, nausea, photophobia/phonophobia, and sleep-deprivation / red-wine triggers. Doctor rules out red flags, does a brief video neuro exam, diagnoses migraine with aura, prescribes a triptan + antiemetic + prophylactic, and advises a headache diary with follow-up. A short, single-complaint case with a clean two-speaker diarization — a light-to-typical load for the pipeline.
Meeting#... (meeting.rb:279)
└─ TranscriptionJob extract audio → GCS upload → STT BatchRecognize
└─ TranscriptionPollJob poll STT → Gemini speaker ID
└─ TranscriptCorrectionJob Claude Opus transcript correction
└─ TokenizationJob local tokenize + Gemini romaji fallback
└─ PHIDetectionJob Gemini PHI detection
ThirdPartyDetectionJob is not in this chain — it runs a local RF-DETR model
(bin/detect_third_party), so it costs CPU/GPU time only, no metered API.
| # | Call site | Service | Billing basis |
|---|---|---|---|
| 1 | transcription_job.rb:96 stt_audio.attach |
Google Cloud Storage | Storage + write op for the WAV (purged after poll) |
| 2 | transcription_job.rb:62 → Transcriber.submit_batch |
STT v2 BatchRecognize, chirp_3 + diarization + word offsets |
Per audio-minute |
| 3 | transcription_poll_job.rb:59 → SpeakerIdentification.call |
Vertex AI Gemini gemini-3.1-flash-lite |
Per token |
| 4 | transcript_correction_job.rb:11 → TranscriptCorrection.find_issues |
Vertex AI Claude claude-opus-4-6 |
Per token |
| 5 | tokenization_job.rb:19 → SuikaTokenizer.to_romaji → gemini_to_romaji |
Vertex AI Gemini gemini-3.1-flash-lite |
Per token, per uncached CJK token |
| 6 | phi_detection_job.rb:12 → PHIDetection.run |
Vertex AI Gemini gemini-3.1-flash-lite |
Per token |
Not billed: Transcriber.poll_batch (GetOperation polling — quota only),
MediaUtils ffmpeg/ffprobe (local compute), SuikaTokenizer MeCab path (local).
Exact rates for the model builds in the code (chirp_3, gemini-3.1-flash-lite,
claude-opus-4-6) are not separately published. These are early-2026 GCP /
Vertex list prices used as proxies — treat as ±30%.
| Service | Input | Output |
|---|---|---|
STT v2 batch, chirp_3 |
$0.016 / min | — |
| Gemini flash-lite | $0.10 / 1M tok | $0.40 / 1M tok |
| Claude Opus | $15.00 / 1M tok | $75.00 / 1M tok |
| GCS standard | $0.02 / GB-month + $0.005 / 1k writes | — |
STT free tier (first 60 min/month) is ignored as marginal cost.
| Metric | Value |
|---|---|
| Case | Online GP consultation — migraine with aura (new/worsening recurrent headache) |
Video length (original_duration_seconds) |
300.0 s (5 min 0 s) |
STT wall-clock (stt_elapsed_seconds) |
180 s |
| Language | ja-JP |
| Transcript segments (turns) | 54 |
| Transcript words | 1,972 |
| Transcript text | 3,537 chars |
formatted_transcript (fed to PHI detection) |
4,105 chars |
Tokens stored (meeting.tokens) |
2,107 (453 unique) |
| Correction issues found | 9 |
| Step | Model | Input tok | Output tok |
|---|---|---|---|
| Speaker ID | Gemini flash-lite | 360 | 19 |
| Transcript correction | Claude Opus | 11,243 | 1,428 |
| PHI detection | Gemini flash-lite | 2,626 | 40 |
| Romaji fallback | Gemini flash-lite | 51 (×1 call) | 13 |
Only 1 of 453 unique tokens ("拍") hit the Gemini romaji fallback; MeCab handled the rest.
| Step | Calculation | Cost |
|---|---|---|
| Transcript correction (Claude Opus) | 11,243 × $15/M + 1,428 × $75/M | $0.27575 |
| STT BatchRecognize | 5.0 × $0.016 | $0.08000 |
| PHI detection | 2,626 × $0.10/M + 40 × $0.40/M | $0.00028 |
| Speaker ID | 360 × $0.10/M + 19 × $0.40/M | $0.00004 |
| Romaji fallback | 51 × $0.10/M + 13 × $0.40/M | $0.00001 |
| GCS WAV (~9.6 MB, minutes) | storage + 1 write | ~$0.00010 |
| Total | ≈ $0.3562 |
≈ $0.356 per meeting — $0.071 per audio-minute.
Cost split: transcript correction 77%, STT 22%, everything else < 0.1%.
STT scales exactly with duration; correction input scales ~linearly with transcript length; correction output grows sub-linearly. "Other LLM" is the Gemini speaker-ID + PHI + romaji calls combined.
| Duration | STT | Correction (Opus) | Other LLM | Total | Per min |
|---|---|---|---|---|---|
| 5 min (850) | $0.080 | $0.276 | ~$0.0004 | ~$0.356 | $0.071 |
| 15 min | $0.240 | ~$0.75 | ~$0.001 | ~$0.99 | $0.066 |
| 30 min | $0.480 | ~$1.45 | ~$0.002 | ~$1.93 | $0.064 |
| 60 min | $0.960 | ~$2.85 | ~$0.003 | ~$3.81 | $0.064 |
- Transcript correction dominates (77%). At Opus rates the single correction call costs $0.276 for a 5-min meeting — 3.4× the STT cost — and it scales ~linearly with transcript length, so the ratio only grows for longer meetings. Model choice on this one call is the biggest cost lever in the pipeline — a cheaper model here (Sonnet-class, or a Gemini flash/pro tier) would cut the meeting total by an order of magnitude.
- STT is the second lever (22%). If the
batch_recognizecall qualifies for dynamic-batch pricing (~$0.003/min instead of $0.016/min), STT drops to$0.015 and the meeting to **$0.29**. Worth confirming which rateBatchRecognize+inline_response_configactually bills at. - Speaker ID, PHI, romaji are rounding error — together < $0.0004.
- Retries multiply. Each LLM job has
retry_on StandardError, attempts: 3. A transient failure on the correction job re-runs the whole Opus call (+$0.28 each). A worst-case double STT submission (documented SIGKILL race,transcription_job.rb:10-12) adds ~$0.08. - Prompt caching is unused.
ClaudeClient.request_bodysends nocache_controlblocks (cache_creation_input_tokens: 0observed). The ~600-token instruction header is re-billed at full input rate every call; caching it would shave only ~$0.009/meeting, so not worth it at current volume.
bin/rails runner '
m = Meeting.find(850)
segs = m.original_transcription
def g(p, model)
x = GeminiClient.client.generate_content(model: GeminiClient.model_path(model),
contents: [{role:"user",parts:[{text:p}]}],
generation_config: {response_mime_type:"application/json", temperature:0}).usage_metadata
{in: x.prompt_token_count, out: x.candidates_token_count}
end
def c(p, model)
r = ClaudeClient.client.raw_predict(endpoint: ClaudeClient.model_path(model),
http_body: {data: ClaudeClient.request_body(p), content_type: "application/json"})
JSON.parse(r.data)["usage"].slice("input_tokens", "output_tokens")
end
puts g(SpeakerIdentification.build_prompt(segs, doctor_label: Doctor.name, patient_label: Patient.name), GeminiClient::DEFAULT_MODEL).inspect
puts c(TranscriptCorrection.build_prompt(segs), TranscriptCorrection::MODEL).inspect
puts g(PHIDetection.prompt(m.formatted_transcript), GeminiClient::DEFAULT_MODEL).inspect
'