Cloudflare Workers + Supabase + Cartesia pipeline that turns uploaded text/PDFs into multi-voice audio. Monorepo (pnpm workspaces):
| Path | What it is |
|---|---|
apps/api | Hono on Cloudflare Workers — HTTP routes + queue consumers |
apps/web | Vite + React frontend (not yet wired to /upload) |
packages/db | Drizzle schema, migrations, seeds |
packages/storage | S3/R2 abstraction used by the Worker |
packages/tts | Optional local Python TTS server (FastAPI) |
packages/shared-libs | Cross-package env schema, types |
The generation pipeline:
upload → parser → chunker → tagging → voice-mapping → tts → hls
Details on each stage live in
.claude/skills/audiobook/.
Local tooling
nvm — Node v24.14.1 (matches
.nvmrc); pnpm needs ≥22.13.pnpmuv +
Python — only if you plan to
run the local TTS server in packages/tts. Skip for the default
Cartesia path.Cloud accounts
apps/api/src/workers/tts.ts.)The historical LocalStack/Docker/Terraform path is preserved at the bottom of this file; ignore it unless you're deliberately reviving it.
nvm use # picks up .nvmrc
cp .env.example .env # fill in real values as you go
pnpm i # installs every workspace from the root
.env is the one and only env file — wrangler dev, drizzle-kit, and
the seed scripts all read from it. The schema is validated by Zod in
packages/shared-libs/schema/env.ts.
5432) connection string. Paste it as both DATABASE_URL and
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE in .env
(they must match — Hyperdrive uses the same string locally).my-audiobook-public-dev (public)my-audiobook-media-devmy-audiobook-raw-uploads-devS3_ENDPOINT = https://<project-ref>.storage.supabase.co/storage/v1/s3S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYSUPABASE_PROJECT_ID = <project-ref>STORAGE_PROVIDER=supabase.TTS_URL=https://api.cartesia.ai/tts/bytes
TTS_API_KEY=<your key>
npx wrangler login
Create the six pipeline queues (Worker can't start without them):
for q in audiobook-parser audiobook-chunking audiobook-tagging \
audiobook-voice-mapping audiobook-tts audiobook-hls; do
npx wrangler queues create "$q"
done
Create the Hyperdrive binding (from apps/api so the helper writes
into the right .env):
cd apps/api
npx wrangler hyperdrive create hyperdrive \
--connection-string="$DATABASE_URL" --env-file ../../.env
cd -
Take the returned id and paste it into both:
.env → HYPERDRIVE_ID=<id>apps/api/wrangler.jsonc → hyperdrive[0].idEdit
wrangler.jsonclater? Re-runpnpm api:typegenafterwards orCloudflare.Envwill be stale and the Worker won't compile.
pnpm db:push # apply Drizzle schema to Supabase Postgres
pnpm db:seed # ⚠️ DESTRUCTIVE: resets users + voices, seeds voices, users, and audiobooks
pnpm db:seed prompts Y/N before each reset. It wipes the users and
voices tables, so only run it on a fresh DB or in dev. If you only
want the voice catalog, run pnpm --filter @audiobook/db seed:voices.
You must have at least one Cartesia voice in voices for the
pipeline to assign per-character voices, and at least one user row
to test uploads. The seed gives you both; if you skip it, insert a user
manually via Supabase's Table Editor.
pnpm api:typegen # generate Cloudflare.Env types (once + after wrangler edits)
pnpm api:dev # wrangler dev on http://localhost:8787
pnpm --filter @audiobook/web dev # frontend on :3000
Optional, in separate terminals:
# if want to run a local TTS server instead of Cartesia
cd packages/tts && uv sync && \
uv run uvicorn server:app --port 7777 # local TTS on :7777
API docs (Swagger UI): http://localhost:8787/docs.
Grab a user id from the users table in Supabase, then:
curl -X POST http://localhost:8787/upload \
-H "Content-Type: application/json" \
-d '{
"userId": "<USER_ID_FROM_DB>",
"title": "Smoke test",
"text": "\"We must hurry,\" said Hermione. Ron groaned. \"Five more minutes.\" The corridor was empty."
}'
In the wrangler dev console you should see the message walk the chain:
[parser queue] …
[chunking queue] …
[tagging queue] …
[voice-mapping queue] Mapped segment "We must hurry," … speaker: Hermione emotion: … voice: <uuid>
[tts queue] ✓ Segment 0_0 …
In Supabase, check that segments rows have:
assigned_voice_id per speaker,Neutral emotion_tag for emoted lines,content free of any <emotion .../> substring.To hear the output, hit GET http://localhost:8787/test_get_wav (streams
the first generated WAV) or fetch
audiobooks/<id>/segments/seg_<chunk>_<seg>.wav from the
my-audiobook-media-dev bucket directly.
wrangler dev fails binding a queue — the queue doesn't exist in
your Cloudflare account yet. Re-run the wrangler queues create loop
in §2.4.Cannot find type definition file for './worker-configuration.d.ts'
— run pnpm api:typegen.invalid input value for enum audiobook_status in the Worker log
— your DB still has the old enum. Run pnpm db:push. (See
data-model.md.)voices table is
empty. Run pnpm --filter @audiobook/db seed:voices.completed. Expected — the HLS / stitching
stage isn't wired up yet. The pipeline currently stops at per-segment
WAVs.apps/web isn't connected to
the project's /upload API yet; it points at an external demo.hono-openapi).claude/skills/audiobook/hls only, can't apply versioning specifically on segments since each segment is processed independently and asynchronouslyActive work items downstream of recent PRs live in todo.md.
These steps predate the move to Cloudflare + Supabase. They are kept for reference only and are not maintained.
Extra prerequisites:
Setup:
# 1. Start Postgres + LocalStack
pnpm docker:start
# 2. Provision S3 buckets via Terraform
cd ./infra/environments/dev
openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
terraform init -upgrade
terraform apply -auto-approve
# 3. Start the app
cd ../../..
pnpm start
TypeScript
88.6%
TeX
4.7%
Python
3.3%
CSS
3.0%
Cloudflare Workers + Supabase + Cartesia pipeline that turns uploaded text/PDFs into multi-voice audio. Monorepo (pnpm workspaces):
| Path | What it is |
|---|---|
apps/api | Hono on Cloudflare Workers — HTTP routes + queue consumers |
apps/web | Vite + React frontend (not yet wired to /upload) |
packages/db | Drizzle schema, migrations, seeds |
packages/storage | S3/R2 abstraction used by the Worker |
packages/tts | Optional local Python TTS server (FastAPI) |
packages/shared-libs | Cross-package env schema, types |
The generation pipeline:
upload → parser → chunker → tagging → voice-mapping → tts → hls
Details on each stage live in
.claude/skills/audiobook/.
Local tooling
nvm — Node v24.14.1 (matches
.nvmrc); pnpm needs ≥22.13.pnpmuv +
Python — only if you plan to
run the local TTS server in packages/tts. Skip for the default
Cartesia path.Cloud accounts
apps/api/src/workers/tts.ts.)The historical LocalStack/Docker/Terraform path is preserved at the bottom of this file; ignore it unless you're deliberately reviving it.
nvm use # picks up .nvmrc
cp .env.example .env # fill in real values as you go
pnpm i # installs every workspace from the root
.env is the one and only env file — wrangler dev, drizzle-kit, and
the seed scripts all read from it. The schema is validated by Zod in
packages/shared-libs/schema/env.ts.
5432) connection string. Paste it as both DATABASE_URL and
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE in .env
(they must match — Hyperdrive uses the same string locally).my-audiobook-public-dev (public)my-audiobook-media-devmy-audiobook-raw-uploads-devS3_ENDPOINT = https://<project-ref>.storage.supabase.co/storage/v1/s3S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYSUPABASE_PROJECT_ID = <project-ref>STORAGE_PROVIDER=supabase.TTS_URL=https://api.cartesia.ai/tts/bytes
TTS_API_KEY=<your key>
npx wrangler login
Create the six pipeline queues (Worker can't start without them):
for q in audiobook-parser audiobook-chunking audiobook-tagging \
audiobook-voice-mapping audiobook-tts audiobook-hls; do
npx wrangler queues create "$q"
done
Create the Hyperdrive binding (from apps/api so the helper writes
into the right .env):
cd apps/api
npx wrangler hyperdrive create hyperdrive \
--connection-string="$DATABASE_URL" --env-file ../../.env
cd -
Take the returned id and paste it into both:
.env → HYPERDRIVE_ID=<id>apps/api/wrangler.jsonc → hyperdrive[0].idEdit
wrangler.jsonclater? Re-runpnpm api:typegenafterwards orCloudflare.Envwill be stale and the Worker won't compile.
pnpm db:push # apply Drizzle schema to Supabase Postgres
pnpm db:seed # ⚠️ DESTRUCTIVE: resets users + voices, seeds voices, users, and audiobooks
pnpm db:seed prompts Y/N before each reset. It wipes the users and
voices tables, so only run it on a fresh DB or in dev. If you only
want the voice catalog, run pnpm --filter @audiobook/db seed:voices.
You must have at least one Cartesia voice in voices for the
pipeline to assign per-character voices, and at least one user row
to test uploads. The seed gives you both; if you skip it, insert a user
manually via Supabase's Table Editor.
pnpm api:typegen # generate Cloudflare.Env types (once + after wrangler edits)
pnpm api:dev # wrangler dev on http://localhost:8787
pnpm --filter @audiobook/web dev # frontend on :3000
Optional, in separate terminals:
# if want to run a local TTS server instead of Cartesia
cd packages/tts && uv sync && \
uv run uvicorn server:app --port 7777 # local TTS on :7777
API docs (Swagger UI): http://localhost:8787/docs.
Grab a user id from the users table in Supabase, then:
curl -X POST http://localhost:8787/upload \
-H "Content-Type: application/json" \
-d '{
"userId": "<USER_ID_FROM_DB>",
"title": "Smoke test",
"text": "\"We must hurry,\" said Hermione. Ron groaned. \"Five more minutes.\" The corridor was empty."
}'
In the wrangler dev console you should see the message walk the chain:
[parser queue] …
[chunking queue] …
[tagging queue] …
[voice-mapping queue] Mapped segment "We must hurry," … speaker: Hermione emotion: … voice: <uuid>
[tts queue] ✓ Segment 0_0 …
In Supabase, check that segments rows have:
assigned_voice_id per speaker,Neutral emotion_tag for emoted lines,content free of any <emotion .../> substring.To hear the output, hit GET http://localhost:8787/test_get_wav (streams
the first generated WAV) or fetch
audiobooks/<id>/segments/seg_<chunk>_<seg>.wav from the
my-audiobook-media-dev bucket directly.
wrangler dev fails binding a queue — the queue doesn't exist in
your Cloudflare account yet. Re-run the wrangler queues create loop
in §2.4.Cannot find type definition file for './worker-configuration.d.ts'
— run pnpm api:typegen.invalid input value for enum audiobook_status in the Worker log
— your DB still has the old enum. Run pnpm db:push. (See
data-model.md.)voices table is
empty. Run pnpm --filter @audiobook/db seed:voices.completed. Expected — the HLS / stitching
stage isn't wired up yet. The pipeline currently stops at per-segment
WAVs.apps/web isn't connected to
the project's /upload API yet; it points at an external demo.hono-openapi).claude/skills/audiobook/hls only, can't apply versioning specifically on segments since each segment is processed independently and asynchronouslyActive work items downstream of recent PRs live in todo.md.
These steps predate the move to Cloudflare + Supabase. They are kept for reference only and are not maintained.
Extra prerequisites:
Setup:
# 1. Start Postgres + LocalStack
pnpm docker:start
# 2. Provision S3 buckets via Terraform
cd ./infra/environments/dev
openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
terraform init -upgrade
terraform apply -auto-approve
# 3. Start the app
cd ../../..
pnpm start
TypeScript
88.6%
TeX
4.7%
Python
3.3%
CSS
3.0%