Transcription Pipeline
Episode transcription runs as the TranscriptionWorkflow in workers/ai-processor/src/workflows/transcription.ts. Whisper (@cf/openai/whisper-large-v3-turbo) transcribes MP3 audio in chunks; a GLM pass (@cf/zai-org/glm-4.7-flash) assigns heuristic speaker labels; the result is stored as a versioned R2 artifact plus a canonical episode_transcripts row, editable on the transcript surface and publishable to the RSS feed as WebVTT.
Job creation paths
Both paths converge on createAndEnqueueAiJob() (src/lib/server/ai-jobs.ts); see the section overview for the row-then-message contract.
Manual: POST /api/ai/transcriptions/:episodeId
src/api/routes/ai/index.ts. Chain: requireAuth() then zValidator (language: ISO 639-1 or '' for auto-detect; confirmReplace) then requirePodcastRoleByResolver('member') then requireFeature('ai_transcription'). Pre-enqueue checks, in order:
| Check | Failure |
|---|---|
Audio uploaded and finalized (audio_url + audio_duration_seconds) | 428 audio_not_finalized |
Content type is audio/mpeg (MP3-only for this release) | 422 unsupported_audio_format |
Size within MAX_AUDIO_BYTES (100 MiB, roughly 2h at 128kbps) | 422 audio_too_large |
audio_url resolves to this episode's own managed R2 key (deriveManagedAudioKey) | 422 unsupported_audio_format (externally hosted or foreign-key audio would fail at the R2 read anyway) |
Credit preflight: effectiveRemaining covers ceil(minutes) | 429 quota_exceeded with { needed, remaining } (UX only; the worker's hard reserve stays authoritative) |
Existing transcript without confirmReplace: true | 409 transcript_exists (re-running replaces the transcript, including edits) |
| Another active job for this episode + type | 409 job_active |
Success returns 201 { jobId, estimatedCredits }.
Auto-transcribe on upload
maybeAutoTranscribe() in src/api/routes/media/audio.ts runs after audio finalize when podcasts.auto_transcribe_uploads is set (20260703161337_podcast_auto_transcribe.sql). It silently skips non-MP3 uploads, over-ceiling files, unprovisioned accounts, and accounts without the ai_transcription feature. A job_active conflict is swallowed deliberately: the in-flight job owns reconciliation with the new upload (see re-runs and successors).
Workflow steps
- validate. Loads the job row (must be
transcription, statuspending/running) and the episode context, then re-resolves the episode's CURRENT managed audio key fromepisodes.audio_url. The enqueue-timeinput.audioKeyis informational only: a replacement upload between enqueue and start simply gets transcribed instead. MP3-only and the 100 MiB ceiling are re-checked here. Transitions the job torunning. - plan-chunks. Streams the MP3 from R2 through a frame scanner (
workers/ai-processor/src/lib/mp3-chunks.ts, rolling-window memory) and produces byte-range chunks of aboutAUDIO_CHUNK_BYTES(1 MiB) each. The plan's frame-derived total duration is the ONLY metering input. It deliberately runs before reserve-credits:episodes.audio_duration_secondsis client-supplied display data, and pricing the hard reserve from it would let an understated duration transcribe a long file for a few credits. The cost of a quota-refused job is one R2 stream scan, no AI spend. - reserve-credits. Hard pre-pay of
ceil(measuredMinutes)credits viareserveTranscriptionCredits(), idempotency-keyed by the job id. Refusal is terminal:mark-skippedrecords a host-visible message ("Your account has no AI credits remaining this month."),notify_ai_job_resultfires, and the workflow returns cleanly (a monthly quota never replenishes inside a retry window). - transcribe-chunk-{i} x N. Bounded-concurrency (
TRANSCRIBE_CONCURRENCY, default 6) Whisper calls over R2 range reads, one durable step per chunk, so a failure resumes at the failed chunk. Live progress writes are best-effort and never fail a step. - diarize. Stitched segments go through sequential GLM batches (
workers/ai-processor/src/lib/diarize.ts) that assign speaker letters. Progress walks 88 to 97 per batch so long episodes never look frozen. The label Map is flattened to a plain object (step outputs must be JSON-serializable). - persist. A staleness guard re-reads the episode's current audio key and refuses (terminal error) when it no longer matches the key this run transcribed. Otherwise the
TranscriptArtifactJSON is written write-ahead to the job-versioned keyraw.{jobId}.json, thenupsertTranscript()commits theepisode_transcriptsrow pointing at it (resetting edited and published state; the API requiredconfirmReplacefor exactly this reason). The superseded raw object is deleted best-effort after the commit; a missed delete is a benign orphan swept by episode deletion's prefix cleanup. - cleanup-stale-publish. A re-run of a previously PUBLISHED transcript leaves the episode's RSS pointers aimed at an invalidated VTT. The step is state-derived (
findStaleTranscriptPointerKeyreads the live episode row, never a value captured before the upsert) and ordered fail-benign: clearepisodes.transcript_url/transcript_typefirst, then queue an RSS invalidation (transcript_replaced), then delete the VTT last and best-effort, so no failure can leave the feed pointing at a deleted object. - finalize. Aggregates cost telemetry into
ai_jobs.cost: API-reported Workers AI neurons when present, otherwise the price-table estimate (workers/ai-processor/src/lib/pricing.ts). Marks the jobcompletewithresult_keyandcredits_spent. - notify-and-fan-out. Calls the
notify_ai_job_resultRPC (20260703161338; notifies owners/admins plus the requester, deduped per recipient/job/terminal-status) and sends oneepisode.transcribedautomation event; the automation-scheduler owns rule matching from there.
Failure path
mark-failed records a user-safe error (guarded so it can never clobber a terminal success or skip), refund-credits calls the refund_ai_job_credits RPC (idempotent pool grant, see Credits & Metering), check-successor decides whether the failure traces to a replaced upload, and notify-failed runs only when no successor job covers the episode (the successor's own terminal outcome notifies instead).
Versioned artifact keys
Layout from src/lib/transcripts/keys.ts (shared by the API, cleanup paths, and the worker via relative import):
| Key | Contents | Write discipline |
|---|---|---|
podcasts/{p}/e/{e}/transcripts/raw.{jobId}.json | Immutable AI output (TranscriptArtifact) | Write-ahead, versioned per producing job |
podcasts/{p}/e/{e}/transcripts/edited.json | Current edited state (same artifact shape) | Fixed key, overwritten in place (the writer replaces their own latest content) |
podcasts/{p}/e/{e}/transcripts/transcript.{uuid}.vtt | Published WebVTT | Write-ahead, versioned per publish |
The point of write-ahead versioning (PR #190 round 8, migration 20260711140000_versioned_transcript_artifact_keys.sql): the previous design wrote fixed canonical keys BEFORE the DB commit, so a re-run overwrote the last good raw.json before upsertTranscript(), and a failed upsert left the row describing the OLD transcript while its only artifact held the failed run's content. A re-publish likewise swapped the LIVE public transcript.vtt before the publish RPC could fail. Now each producer writes a fresh versioned object and only the committed row makes it live; nothing is ever overwritten in place, so a failed commit leaves the previous artifact and the previous public URL fully intact. Unversioned canonical names (raw.json, transcript.vtt) remain valid for rows created before the scheme.
Two DB-side guards back this up (20260710153000 re-created by 20260711140000): CHECK constraints pin raw_key/published_key to this row's episode-scoped transcripts/ prefix in canonical-or-versioned form (no writer can point the API at another tenant's object), and client UPDATE on episode_transcripts is column-scoped to the edit-save/publish columns.
Publish to feed
POST /api/ai/transcriptions/:episodeId/publish (admin+, per the roles matrix's Episode Operations):
- Render the current artifact (
edited_key ?? raw_key) to WebVTT with speaker voice tags (src/lib/transcripts/render.ts). - Write it to a fresh
versionedPublishedTranscriptKey(new UUID per publish); the feed and the previously published URL are untouched until the commit. - Call the
publish_episode_transcriptRPC (20260710170000): the transcript row and the episode's Podcast 2.0 pointers (transcript_url,transcript_type = 'text/vtt') commit in ONE transaction, so the surface and the public feed can never disagree. On RPC failure the staged object is deleted best-effort and the route 500s. - Side effects: queue
transcript_publishedRSS invalidation, then delete the superseded published object (after the purge, so a rebuilt feed already points at the new URL while edge/browser cache tiers age out).
DELETE .../publish mirrors this with the unpublish_episode_transcript RPC (clears both tables atomically, returns the previously published key for best-effort deletion) plus a transcript_unpublished invalidation.
DR invariant
The published VTT is a feed-referenced asset: it MUST live in the podcasterplus-media bucket under a canonical media.podcasterplus.com key that replicates to B2, or it becomes a dead link during a failover. The versioned key scheme above satisfies this; never publish transcripts to any other bucket or host. See the feed-referenced-assets invariant in .claude/rules/backend/workers.md.
Re-runs and successor jobs
Two run-time truths anchor replacement handling:
- Pending job, new upload: validate re-resolves the current key, so the job just transcribes the replacement. This is why the upload hook swallows
job_active. - Mid-run replacement: the persist guard refuses the now-stale transcript (terminal failure). On the failure path,
check-successorcompares the episode's current key against the key this run ACTUALLY used (ranAudioKeyfrom the cached validate result, falling back toinput.audioKeyonly when validate never completed; judging by the stale enqueue key would misread an ordinary failure as a replacement). When they differ AND the podcast still opts into auto-transcribe ANDai_transcriptionis still granted AND the new audio passes the format/size checks,insertRequeuedJob()inserts a fresh job and spawns its workflow directly. The partial unique index frees only once the failed job is terminal, which is why the upload hook could not do this. If the insert conflicts, the step ensures whatever active job exists is spawned (create-on-existing dedupes). Unrelated failures (Whisper down, invalid MP3) still point at the same audio and notify normally.
Editor surface
The transcript surface lives at src/routes/(app)/p/[slug]/e/[episodeSlug]/transcript/. It is a SUB-SURFACE of the Media tab, not a tab of its own: the shell keeps Media highlighted and the page renders a SubSurfaceHeader back link instead of chrome of its own (see SvelteKit Routing), with components in src/lib/components/transcripts/ (segment list/rows, speaker assignment, find and replace, synced audio player, promo assets panel).
A missing episode_transcripts row is a real state: the load returns transcript: null and the page renders an empty pane (grant present) or a locked upsell pane (ai_transcription not granted, resolved fail-closed by the shell layout load) instead of the pre-shell 303 redirect to the Media tab.
GET /api/ai/transcriptions/:episodeIdreturns the transcript meta, the current artifact (edited over raw), the latest complete promo generation, and any in-flight jobs so the UI can resubscribe.PUT /api/ai/transcriptions/:episodeIdsaves edits: validates segments (max 20,000, per-segment text cap 5,000 chars,end >= start) and the speaker map, enforces roster integrity (every mappedepisode_person_idmust belong to this episode, else 400), applies edits viaapplySegmentEdits()(src/lib/transcripts/edit.ts, which interpolates word timings for edited segments), writesedited.json, and updates the row (edited_key,edited_at,edited_by,speakers,word_count).