A Flask web application that generates dynamic radio advertisements by combining live data feeds, AI copywriting, and audio synthesis. Each ad run:
- Fetches live context data (weather, events, etc.)
- Generates an ad script via Anthropic Claude
- Produces a voiceover via ElevenLabs TTS
- Mixes the voiceover with a music bed using FFmpeg
- Uploads the finished ad to S3-compatible storage
- Python 3.11+
- FFmpeg (
brew install ffmpegon macOS) - Redis (optional — required only for background job queue in Phase 3)
git clone <repo-url>
cd dynamic-audio-creative
python -m venv venv
source venv/bin/activatepip install -r requirements.txtcp .env.example .envEdit .env and fill in the required values (see Environment Variables below).
flask db upgradeThis creates a local SQLite database (dev.db) and applies all migrations.
flask runThe app runs at http://localhost:5000. The admin dashboard is at http://localhost:5000/admin.
Log in with the ADMIN_USERNAME / ADMIN_PASSWORD values from your .env.
Copy .env.example to .env and set the following:
| Variable | Description |
|---|---|
SECRET_KEY |
Flask session signing key — any long random string |
ANTHROPIC_API_KEY |
Anthropic Claude API key |
ELEVENLABS_API_KEY |
ElevenLabs text-to-speech API key |
S3_ENDPOINT_URL |
S3-compatible storage endpoint (e.g. Cloudflare R2, AWS S3, Backblaze B2) |
S3_ACCESS_KEY |
Storage access key ID |
S3_SECRET_KEY |
Storage secret access key |
S3_BUCKET |
Bucket name (default: dynamic-audio) |
| Variable | Default | Description |
|---|---|---|
FLASK_ENV |
development |
Set to production on Render |
DATABASE_URL |
sqlite:///dev.db |
PostgreSQL connection string in production |
S3_REGION |
auto |
Storage region (use auto for Cloudflare R2) |
ADMIN_USERNAME |
admin |
Bootstrap admin login username |
ADMIN_PASSWORD |
changeme |
Bootstrap admin login password |
API_KEY |
— | Machine-to-machine key for /api/v1 endpoints |
CLAUDE_MODEL |
claude-sonnet-4-6 |
Claude model ID |
ELEVENLABS_MODEL |
eleven_monolingual_v1 |
ElevenLabs model ID |
REDIS_URL |
redis://localhost:6379/0 |
Redis connection (Phase 3 background jobs) |
The ctn-dropd desktop daemon pushes finished audio files straight into a
campaign. The contract is docs/dac-api-contract.md. Each drop station gets its
own revocable key, issued from Advertisers → Keys in the admin. Only a hash
is stored, so the plaintext is shown once.
Endpoints live under /api/v1 and take the key in X-API-Key:
| Endpoint | Purpose |
|---|---|
GET /me |
Validate the key, return its advertiser |
GET /campaigns |
Active campaigns for that advertiser, with deliverable |
POST /campaigns/{id}/uploads |
Presigned PUT URL, or duplicate_run_id |
POST /campaigns/{id}/push |
Register the uploaded object as a pending run |
GET /runs/{run_id} |
Poll status / delivery_error |
GET /campaigns/{id}/runs |
Delivery history, newest first, with is_active |
POST /campaigns/{id}/revert |
Re-deliver a previous run's stored audio |
POST /campaigns/{id}/pause |
Soft pause: flips delivery_enabled |
Pushed runs are delivered on the same cron beat as generated ones: the
flask run-due-campaigns cron job calls scheduler.tick, which runs due
campaigns and then delivers pending pushes. POST /api/v1/scheduler/tick
(global API_KEY) does the delivery half on demand. Pushed files are probed
with ffprobe and compared with the creative_duration in the campaign's
Frequency token; a mismatch beyond ±1 s, or an unreadable file, is rejected with
a plain-text 422. Non-MP3 audio is transcoded to MP3 before delivery.
| Variable | Default | Description |
|---|---|---|
CMPAPI_ENV |
production |
staging targets staging-cmp.frequencyads.com |
CMPAPI_BASE_URL |
from CMPAPI_ENV |
Explicit override of the Frequency API base URL |
| Variable | Description |
|---|---|
FREQUENCY_ENABLED |
Set to true to enable delivery (default: false) |
FREQUENCY_API_URL |
Frequency REST API endpoint |
FREQUENCY_API_KEY |
Frequency API key |
FREQUENCY_SFTP_HOST |
Frequency SFTP hostname |
FREQUENCY_SFTP_USER |
Frequency SFTP username |
FREQUENCY_SFTP_KEY |
Path to SFTP private key |
The render.yaml file defines the full Render Blueprint. Render will:
- Install dependencies (
pip install -r requirements.txt) - Run migrations before each deploy (
flask db upgrade) - Start the app with Gunicorn (
gunicorn wsgi:app --workers 2 --timeout 60) - Provision and connect a PostgreSQL database automatically
-
Push the repo to GitHub.
-
Create a new Blueprint on Render pointing at your repo.
-
Render will detect
render.yamland provision all services. -
In the Render dashboard, set the following environment variables (marked
sync: falsein the Blueprint — they are not auto-generated):ANTHROPIC_API_KEYELEVENLABS_API_KEYS3_ENDPOINT_URLS3_ACCESS_KEYS3_SECRET_KEYS3_BUCKETS3_REGIONADMIN_USERNAMEADMIN_PASSWORDFLASK_ENV→production
SECRET_KEY,API_KEY, andDATABASE_URLare generated/injected automatically by Render.
# Apply all pending migrations
flask db upgrade
# Create a new migration after model changes
flask db migrate -m "describe the change"
flask db upgrade
# Roll back one migration
flask db downgradepip install -r requirements.txt -r requirements-dev.txt
pytestMost tests build their schema from the models on in-memory SQLite and are fast.
tests/test_migrations.py is different: it runs the real migration chain from
base to head against Postgres, then asserts the resulting schema matches the
models, using Alembic's own autogenerate diff. That catches drift SQLite cannot
— column types and nullability — and it is what stops a broken migration chain
reaching Render, the way a second head once did.
It gets a Postgres one of two ways:
TEST_DATABASE_URLif set — CI points this at a service container.- otherwise
pgserver(inrequirements-dev.txt), which ships Postgres binaries as a wheel, so no Docker or system install is needed.
With neither, those tests skip. They never fall back to SQLite — the engine is the point.
If the drift test fails, the message names what Alembic would generate, e.g.
add_column campaigns.foo. That means a model changed without a migration:
run flask db migrate -m "...", review the generated file, and commit it.
CI (.github/workflows/ci.yml) runs all of this on every push and pull request.
app/
├── admin/ # Admin dashboard blueprint (CRUD UI, auth)
├── api/ # REST API blueprint (/api/v1/...)
├── models/ # SQLAlchemy models (Advertiser, Campaign, AdRun, ...)
├── pipeline/ # Ad generation orchestration
│ ├── runner.py # Main pipeline orchestrator
│ ├── script_gen.py # Claude copywriting
│ ├── voiceover.py # ElevenLabs TTS
│ ├── mixer.py # FFmpeg audio mixing
│ └── feeds/ # Data source plugins (weather, events, ...)
├── storage/ # S3-compatible storage wrapper
└── config.py # Environment-based configuration
pilot/ # Standalone CLI pipeline (Phase 0 proof-of-concept)
migrations/ # Flask-Migrate / Alembic migration files
tests/ # Pytest test suite
