A fully local, offline AI radio web app. Pick a genre, mood, vocal language, and describe what you're doing — the app generates and plays an endless stream of original AI-composed songs with no cloud APIs required.
- Mac with Apple Silicon (M1/M2/M3/M4/M5)
- macOS 14+
- 16 GB+ unified memory (24 GB+ recommended for development, 64 GB for production)
- 50 GB+ free SSD space
./scripts/setup.shThis installs Homebrew tools, Ollama, the LLM model, clones ACE-Step 1.5, installs all dependencies, and installs cloudflared for remote access.
echo 'export PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0' >> ~/.zshrc
echo 'export PYTORCH_ENABLE_MPS_FALLBACK=1' >> ~/.zshrc
source ~/.zshrcDevelopment (hot-reload):
./scripts/start.shProduction (compiled bundle, no reload):
./scripts/start_prod.shOpen http://localhost:5173 in your browser.
When cloudflared is installed, a public URL is printed in the startup banner — share it to access the app from any device.
- Select a genre (36 options) and optional mood keywords (60 keywords across 4 categories)
- Choose a vocal language (11 languages) or instrumental mode
- Optionally describe what you're doing now in free text
- Optionally tune advanced ACE-Step parameters (time signature, inference steps, model variant, CoT flags)
- Click Start Radio
- A local LLM (Ollama + Qwen3.5:4b) generates a dimension-based song prompt (style, instruments, mood, vocal style, production)
- ACE-Step 1.5 generates a full MP3 with semantic audio codes for melodic structure
- The song plays in your browser with a live activity log showing generation progress
- The next song is pre-generated while the current one plays — the frontend pre-fetches audio bytes into memory for seamless, zero-latency transitions
Multiple browsers can connect to the same session. The first local-network connection becomes the controller — they pick genres, start/stop the radio, save tracks, and see connected listeners. Everyone else joins as a viewer with a read-only player.
Remote visitors connecting via the Cloudflare tunnel always join as viewers regardless of order.
If the controller disconnects, the next local viewer is automatically promoted.
Viewers can request the DJ slot via the Be the DJ button. When granted:
- A DJ panel opens where the viewer enters their name and configures genre, mood, and language
- Their selection becomes the next track's generation parameters
- A cooldown timer (configurable, default 30 min) prevents rapid DJ switching
- The active DJ's name is shown in the player ("PRESENTED BY [NAME]")
The controller can navigate back to the genre selector at any time without stopping the current track. The new settings take effect from the next generated track onward.
The controller can save the currently playing track to disk — both the MP3 and a JSON metadata file (title, genre, BPM, key, seed, lyrics, tags) are written to saved_tracks/. Remote viewers cannot trigger saves.
English, Español, Français, Deutsch, Italiano, 中文, Ελληνικά, Suomi, Svenska, 日本語, 한국어, and a No Vocal (instrumental) mode.
The controller can configure ACE-Step parameters before starting:
| Option | Default | Range |
|---|---|---|
| Time Signature | Auto | 2/4, 3/4, 4/4, 6/8 |
| Inference Steps | 8 | 4–100 (more = higher quality, slower) |
| DiT Model Variant | turbo | turbo, turbo-shift1, turbo-shift3, turbo-continuous |
| ACE-Step CoT Flags | Thinking ON, CoT Caption/Metas OFF, CoT Language ON | per-flag toggles |
| DJ Cooldown | 30 min | 1–120 min |
See the ACE-Step 1.5 Tutorial for details on what each parameter does.
start.sh supports two tunnel modes:
Named tunnel (production): If ~/.cloudflared/config.yml is configured, the app is available at a fixed domain (e.g., https://radio.scrambler-lab.com). See docs/cloudflare-named-tunnel-setup.md for one-time setup.
Quick tunnel (dev fallback): If no named tunnel is configured, a random *.trycloudflare.com URL is generated on each startup.
Both modes proxy all traffic including WebSockets. Viewers joining via the tunnel automatically get the read-only listener experience.
| Service | Port | Description |
|---|---|---|
| Frontend | 5173 | React + Vite (dev HMR server or compiled preview, proxies /api and /ws) |
| Backend | 5555 | FastAPI (REST + WebSocket) |
| ACE-Step API | 8001 | Music generation (MLX / Apple Silicon) |
| Ollama | 11434 | LLM inference |
| Cloudflare Tunnel | — | Exposes port 5173 publicly (optional) |
See BUILD_SPEC.md for the full technical specification.
The app always uses qwen3.5:4b (~2.5 GB) for song prompt generation, generating 5 dimension fields (style, instruments, mood, vocal style, production) that are concatenated into a rich ACE-Step caption.
Audio duration is selected automatically at startup based on unified memory:
| Memory | Duration | Rationale |
|---|---|---|
| ≤ 32 GB | 30 s | Fast iteration on dev machines |
| 33–47 GB | 60 s | Safe within MLX VAE Metal buffer limits |
| ≥ 48 GB | 60 s → 120 s → 180 s | Progressive ramp — first track starts quickly, subsequent tracks get longer |
See docs/acestep-memory-vs-duration.md for the full memory vs. duration analysis.
All services write logs to /tmp/:
tail -f /tmp/generative-radio-backend.log # FastAPI backend
tail -f /tmp/generative-radio-acestep.log # ACE-Step API
tail -f /tmp/generative-radio-frontend.log # Vite dev server
tail -f /tmp/generative-radio-cloudflared.log # Cloudflare tunnelBackend log format: HH:MM:SS [LEVEL] module: [component] message
Frontend logs are in the browser DevTools console with [WS], [Radio], [Audio], and [GenreSelector] prefixes.
# Override ACE-Step location
ACESTEP_PATH=/path/to/ACE-Step-1.5 ./scripts/start.sh
# Run backend directly with custom log level
cd backend
uvicorn main:app --port 5555 --log-level debug
# Run frontend (dev, hot-reload)
cd frontend
npm run dev
# Run frontend (production preview of compiled bundle)
cd frontend
npm run build && npm run previewPress Ctrl+C in the terminal running start.sh — the backend, frontend, and Cloudflare tunnel are all shut down cleanly.
ACE-Step is intentionally left running because it takes several minutes to warm up. To stop it manually, use the PID printed in the startup banner:
kill <ACESTEP_PID>