Generative Radio
April 1, 2026 · View on GitHub
A fully local, offline AI radio 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.
Available as a web app (React + Vite) and a mobile app (Expo / React Native) for iOS and Android.
Requirements
- 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
Quick Start
1. One-time setup
./scripts/setup.sh
This installs Homebrew tools, Ollama, the LLM model, clones ACE-Step 1.5, installs all dependencies, and installs cloudflared for remote access.
2. Add performance env vars
echo 'export PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0' >> ~/.zshrc
echo 'export PYTORCH_ENABLE_MPS_FALLBACK=1' >> ~/.zshrc
source ~/.zshrc
3. Start everything
Development (hot-reload):
./scripts/start.sh
Production (compiled bundle, no reload):
./scripts/start_prod.sh
Open 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.
How it works
- 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
Multi-listener mode
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.
"Everyone can be a DJ" mode
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]")
Mid-session genre changes
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.
Save tracks
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.
Reactions
Any connected listener (controller or viewer) can react to the currently playing track with a thumb up or thumb down. Reactions use toggle semantics — pressing the same button again removes the vote; pressing the opposite side switches. Reaction counts are broadcast to all listeners in real time and persisted to disk.
Supported languages
English, Español, Français, Deutsch, Italiano, 中文, Ελληνικά, Suomi, Svenska, 日本語, 한국어, and a No Vocal (instrumental) mode.
Advanced options
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.
Remote access
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.
Mobile app
The mobile/ directory contains an Expo / React Native app for iOS and Android. The mobile app is always a viewer — it connects to the same backend WebSocket and plays the radio stream, but cannot control the session (no genre selector, no save track). It is designed for listening on the go while the desktop session drives generation.
Prerequisites
- Xcode (iOS) or Android Studio (Android)
- Node.js + npm
- Expo CLI:
npm install -g expo-cli
Build and run
cd mobile
npm install
npx expo prebuild # generates ios/ and android/ from app.json
npx expo run:ios --device # or: eas build --platform ios
See docs/ios-simulator-guide.md for iOS setup and docs/android-setup-guide.md for Android setup.
Background audio
The mobile app uses expo-audio with a silence bridge pattern to keep the iOS audio session alive during track transitions (AI generation can take 30–120 s). Background audio requires a production build — it does not work in Expo Go.
After making changes to app.json (plugin config, permissions), run npx expo prebuild --clean before rebuilding.
See docs/ios-background-audio-investigation.md for the full background audio analysis.
Architecture
| Service | Port | Description |
|---|---|---|
| Frontend | 5173 | React + Vite (dev HMR 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) |
Mobile connects directly to the backend WebSocket (wss://radio.scrambler-lab.com/ws in production, ws://localhost:5555/ws in dev).
See BUILD_SPEC.md for the full technical specification.
LLM and audio duration
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.
Debugging
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 tunnel
Backend log format: HH:MM:SS [LEVEL] module: [component] message
Frontend logs are in the browser DevTools console with [WS], [Radio], [Audio], and [GenreSelector] prefixes.
Manual service control
# 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 preview
Stopping
Press 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>