live-transcription
Vercel's Next.js Live Transcription
(template: https://vercel.com/templates/next.js/nextjslive-transcription) —
Deepgram's live-transcription starter: speak, and the page captions you live,
each word as it is heard, over a visualiser of your microphone — running on
Cloudflare Workers + Static Assets through
@opennextjs/cloudflare, and packaged as
an Orun baseline: one command takes a new transcription site from nothing to
live on stage and prod, in its own repository, under its own name — in ONE
landing. It transcribes from its first deploy, on Workers AI, with no key.
The template's page, contexts and components are the code. What the port adds is the Workers build, the Worker's own entry, the monorepo wrapper, a test package and the deploy contract; what it changes is what Workers, and time, force:
- The service: the template needs a Deepgram API key: its
/api/authenticateroute mints a short-lived token with it, and the browser streams the microphone to Deepgram's own WebSocket. Here, with no key, the page streams to the Worker instead:GET /api/listen(apps/web/worker.ts→lib/listen.ts) opens Deepgram's Nova-3 on Workers AI over a WebSocket through the Worker's ownAIbinding (env.AI.run("@cf/deepgram/nova-3", …, { websocket: true })) and hands that socket to the browser. Nova-3 sends Deepgram's ownResultsmessages, so the template's caption code reads them unchanged. With the owner's optional Deepgram key, the route mints a token and the browser streams to Deepgram directly, as upstream did. - The audio: Nova-3 on Workers AI takes linear16 PCM, so an AudioWorklet
(
public/pcm-capture.worklet.js) taps the microphone's stream at the AudioContext's own rate (lib/workers-ai-live.ts); Deepgram's own socket keeps the MediaRecorder's WebM chunks, as upstream sends them. - Next.js 14 → 15.5: OpenNext 1.20.16 builds Next 15 from 15.5.29, so the template moves there, and to React 19 (two type-level changes in its own code).
What the baseline adds is everything needed to make a copy of it somebody
else's — built the way
cirrus-baseline is. See
specs/epics/live-transcription for the
design, the milestones and the as-built status.
Instantiating a site
git clone --depth 1 --branch <baseline-tag> https://github.com/sourceplane/live-transcription
cd live-transcription
orun new --blueprint repo-blueprint.yaml --out ~/apps/acme-notes --run-hooks --resume \
--set reponame=acme-notes --set productname="Acme Notes" \
--set githuborg=acme-inc --set subdomain=<workers-dev-subdomain>
Or, once the platform's registry lists it, from the Orun console (Baselines →
live-transcription → Build). Two phases — 01-product (the repository, its CI and
the whole app in one pull request, deployed to stage and prod by one
convergence, smoke-tested) and 02-docs (the live deployment recorded) —
each landing one pull request in the new repository.
BOOTSTRAP.md is the operator guide;
docs/phases/ has a page per phase.
The new repository receives the app only — the app, its tests, its CI, its tooling, a README of its own and its operating contract — renamed throughout to the app's name, repository and Cloudflare Worker. None of this repository's machinery ships, and nothing that ships names it. The upstream template's own names — its repository and Vercel page, which end in this baseline's slug — are left as they are, and so are Deepgram's SDK names.
The only provider the bootstrap asks for is Cloudflare: Workers AI is a
binding on the same account, with nothing to provision. Deepgram is not a
provider the platform brokers, so the app is built without a key, and its
owner may store one afterwards — one optional Orun secret, DEEPGRAM_API_KEY,
and a redeploy, which pushes it to the Worker as a secret. The app's README,
"Use your own Deepgram key" (.baseline/product/README.md here), has the
steps.
| What makes it a baseline | Where |
|---|---|
| The console card (inputs, secrets, programme) | blueprint.yaml |
| The build document (modules, phases, hooks) | repo-blueprint.yaml |
| This tree's identity, and the bootstrap's task contracts | .baseline/ |
| The rename engine (the upstream's names and Deepgram's SDK names protected) | tooling/rebrand/rebrand.mjs |
| The bootstrap's helpers (deferrals, redeploy marker, work-tree strip) | tooling/bootstrap/ |
| The deployment-record renderer, and its test without PyYAML | hooks/render-deployment-docs.sh, testing/render.test.sh |
| The app's README and context pack | .baseline/product/README.md, ai/context/ |
| The baseline's tests: contract, leak, offline bootstrap, end to end (in-process stand-ins for Workers AI's Nova-3 and Deepgram's token grant; the page in headless Chrome on Linux) | testing/, .github/workflows/baseline.yml |
Cutting a baseline-vN tag | .github/workflows/tag.yml |
What is here (the site)
| Surface | Where |
|---|---|
| The page: the microphone visualiser and the live caption (the template's) | apps/web/app/page.tsx, apps/web/app/components |
| The microphone (a MediaRecorder, the template's) | apps/web/app/context/MicrophoneContextProvider.tsx |
| The transcription connection: Deepgram's own client, or the Workers AI socket | apps/web/app/context/DeepgramContextProvider.tsx, apps/web/lib/workers-ai-live.ts, apps/web/public/pcm-capture.worklet.js |
| Which service transcribes, and Deepgram's short-lived token | apps/web/app/api/authenticate/route.ts, apps/web/lib/env.ts |
| The transcription socket: Nova-3 on Workers AI through the binding | apps/web/worker.ts, apps/web/lib/listen.ts |
| The CORS middleware on the token route (the template's) | apps/web/middleware.ts |
| The site's name and source link (the rename's seam) | apps/web/app/site.ts |
| Workers build and deploy contract | apps/web/next.config.mjs, open-next.config.ts, wrangler.jsonc, scripts/populate-static-assets-cache.mjs, component.yaml, tooling/wire/worker-secrets.mjs |
| Tests | tests/web (vitest) |
Getting started
pnpm install
pnpm typecheck
pnpm lint
pnpm test
pnpm build # next build + opennextjs-cloudflare build → apps/web/.open-next
That much needs no Cloudflare account and no key: the baseline's own e2e tier
(testing/e2e.test.sh) runs exactly this on each landing before a tag, and an
app's developers run it before a push — an app's pull request builds and
dry-deploys it, and does not lint or run the tests. Workers AI has no local
mode, so to transcribe on your machine either log in to Cloudflare and preview
the built Worker, or give next dev a Deepgram key:
cd apps/web
npx wrangler login # Workers AI runs on your account, even in wrangler dev
pnpm build && pnpm preview # wrangler dev over the built Worker (worker.ts and all)
# — or —
cp .env.example .env.local # DEEPGRAM_API_KEY
pnpm dev # http://localhost:3000, transcribed by Deepgram directly
| Variable | Where it is read | What it does |
|---|---|---|
AI (binding) | request time, in the Worker (worker.ts, getCloudflareContext().env) | Workers AI: Nova-3's real-time socket at /api/listen. Declared in wrangler.jsonc; nothing to provision. Absent under next dev, where the token route answers 503 unless a Deepgram key is set. |
DEEPGRAM_API_KEY | request time, in the Worker (process.env under next dev) | Optional; Member scope or higher. A deploy pushes its environment's secret to the Worker, best-effort; the build never has it, nor the browser. Set, the token route mints the browser a short-lived Deepgram token and the page streams to Deepgram. |
NEXT_PUBLIC_SITE_URL | build time (Next.js inlines it into app/site.ts) | The site's public origin, for metadataBase and the share links. The deploy sets it per environment: SITE_URL when intent.yaml sets one, else the Worker's workers.dev URL. |
How it maps to Vercel
| Vercel | Here |
|---|---|
| Next.js hosting (the pre-rendered page, the route handler, the edge middleware) | One Cloudflare Worker — worker.ts in front of .open-next/worker.js — + Static Assets; the page pre-rendered at build and served from the static-assets cache, which the build fills itself (scripts/populate-static-assets-cache.mjs: OpenNext's own populateCache would open a Cloudflare session for the AI binding); the middleware runs as it did |
DEEPGRAM_API_KEY in the project's environment, required: /api/authenticate mints a token, the browser streams to Deepgram | No key needed: /api/listen opens Deepgram's Nova-3 on Workers AI through the Worker's AI binding and hands its WebSocket to the browser, which streams linear16 PCM from an AudioWorklet. With the owner's optional key, the token route and Deepgram's own socket, as upstream |
| The key read from the server's environment | An optional Orun secret, unset for the build (env -u) and pushed to the Worker as a secret after it (tooling/wire/worker-secrets.mjs), read per request; never sent to the browser |
DEEPGRAM_ENV=development handing the raw key to the browser | Not carried (the page could not use it: it reads access_token) |
| Errors answered as 200 JSON | 502 (Deepgram refused the key, its message shown) and 503 (nothing connected), never cached |
next/font/google (Inter) | @fontsource-variable/inter through next/font/local: next/font/google fails on GitHub's runners |
| Next.js 14, React 18 | Next.js 15.5.29 (OpenNext's floor on the 15 line), React 19 |
| The share links and star button on Deepgram's demo and repository | The site's own origin and repository (app/site.ts) |
| Deploy on push | Orun deploys <repo>-web-{stage,prod} on merge to main and smoke-tests it (apps/web/component.yaml) |
CI
| Workflow | What it runs | Ships to an app |
|---|---|---|
ci.yml | the Orun pipeline, and the only workflow an app carries: orun plan --changed, then one job per lane. A pull request runs the verify lanes (the app built and dry-deployed per environment; they do not lint or run the tests), except the bootstrap's own landings (orun/BASE-*), where plan alone is the check; a merge deploys stage → prod. It plans nothing while intent.yaml names the placeholder workspace ws_CHANGE_ME, as this repository does. A manual run (Orunbase's Deploy button, or Run workflow on main) plans the environment and components it names under the push-to-main trigger, so they deploy as a merge deploys them | yes |
baseline.yml | the baseline's own tiers: contract tests (the deployment record rendered without PyYAML among them) and the component docs' drift check, the blueprint read by the pinned orun, the leak gate, the offline bootstrap, each landing installed, its fixture rendered, type-checked, linted, tested and built, then the app built in a staged job tree with the Deepgram key set and kept out of the build, dry-deployed with Workers AI bound, and served — a recording streamed over the socket to an in-process Nova-3 and captioned back, the page itself in headless Chrome captioning Chrome's fake microphone, then a Deepgram token minted with a key from an in-process Deepgram | no |
tag.yml | cuts a baseline-vN tag (manual) | no |
Build from the console
Press Build: Live Transcription goes into the workspace you choose (or a new one), you connect Cloudflare, and an agent builds it.
Build from the CLI
# check providers, then build
orun baseline check live-transcription
orun baseline new live-transcription --via-platform --repo acme/live-transcription