A fully self-hosted 24/7 AI-powered radio station. Local LLM writes the scripts, Qwen3-TTS voices the personas, Liquidsoap assembles the stream, and Icecast broadcasts it. News breaks are auto-generated twice daily from RSS + NewsAPI and rewritten in each DJ's voice.
This page walks through everything needed to replicate the system on a Linux box. Stack: Ubuntu + Apache + PHP + MariaDB + Ollama + Qwen3-TTS + Liquidsoap + Icecast2. Cloudflare Tunnel handles SSL — no Let's Encrypt required.
Every hour at minute 45, a cron job runs generate_show.php. It picks 12 music tracks, writes DJ scripts between them with Ollama (Qwen3 persona on duty based on the hour), synthesizes voice audio with Qwen3-TTS, and builds an M3U playlist. Liquidsoap watches that M3U, Icecast streams it to the internet.
News breaks are inserted at positions 5 and 10 of each show. Two cron jobs fetch news once a day — a global one from NewsAPI and a local one from Ventura County RSS feeds — store them in MariaDB with critical-keyword detection and alert levels, and let the on-duty persona rewrite them in character at show-generation time.
/var/www/html/dj/ ├── index.php # Web player dashboard ├── config.php # DB creds, paths, API keys, keywords ├── generate_show.php # Hourly cron — builds the next show ├── news_helpers.php # RSS parser, Ollama client, persona prompt ├── news_segment_generator.php# Inserts news_break segments ├── persona_helpers.php # Daypart / handoff logic ├── radio.liq # Liquidsoap stream config ├── cron/ │ ├── fetch_news.php # Global news, 4:00am daily │ └── fetch_local_news.php # Local news, 4:05am daily ├── music-tracks/ # Your music library (mp3/flac/m4a) └── generated-audio/ # TTS output + current_show.m3u
sudo apt update
sudo apt install -y apache2 php php-cli php-mysql php-curl php-xml php-mbstring \
mariadb-server liquidsoap icecast2 ffmpeg git curl
sudo systemctl enable --now apache2 mariadb icecast2
Create the database and a dedicated user:
sudo mysql -u root -p
CREATE DATABASE ai_dj CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'dj_user'@'localhost' IDENTIFIED BY 'your_secure_password_here'; GRANT ALL PRIVILEGES ON ai_dj.* TO 'dj_user'@'localhost'; FLUSH PRIVILEGES;
Now build the schema. At the MariaDB prompt:
USE ai_dj;
CREATE TABLE personas (
id INT AUTO_INCREMENT PRIMARY KEY,
display_name VARCHAR(64) NOT NULL,
active TINYINT(1) DEFAULT 1,
active_hour_start TINYINT NOT NULL,
active_hour_end TINYINT NOT NULL,
voice_id VARCHAR(64),
system_prompt TEXT,
news_system_prompt TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE shows (
id INT AUTO_INCREMENT PRIMARY KEY,
show_date DATE NOT NULL,
show_hour TINYINT NOT NULL,
persona_id INT,
status ENUM('pending','generating','ready','aired','failed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_show (show_date, show_hour)
);
CREATE TABLE show_segments (
id INT AUTO_INCREMENT PRIMARY KEY,
show_id INT NOT NULL,
segment_order INT NOT NULL,
segment_type ENUM('opener','track_intro','track_outro','news_break','closer') NOT NULL,
track_file VARCHAR(512),
dj_script TEXT,
dj_audio_file VARCHAR(256),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_show (show_id, segment_order)
);
CREATE TABLE news_briefs (
id INT AUTO_INCREMENT PRIMARY KEY,
brief_date DATE NOT NULL,
brief_type ENUM('global','local') NOT NULL,
summary_text TEXT,
bad_kitty_script TEXT,
article_count INT DEFAULT 0,
critical_count INT DEFAULT 0,
alert_level ENUM('LOW','MEDIUM','HIGH','CRITICAL') DEFAULT 'LOW',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_brief (brief_date, brief_type)
);
CREATE TABLE news_articles (
id INT AUTO_INCREMENT PRIMARY KEY,
brief_id INT NOT NULL,
title VARCHAR(512) NOT NULL,
description TEXT,
source_name VARCHAR(128),
url VARCHAR(1024),
published_at DATETIME,
is_critical TINYINT(1) DEFAULT 0,
relevance_score TINYINT DEFAULT 0,
FOREIGN KEY (brief_id) REFERENCES news_briefs(id) ON DELETE CASCADE
);
CREATE TABLE persona_hour_overrides (
id INT AUTO_INCREMENT PRIMARY KEY,
hour TINYINT NOT NULL,
persona_id INT NOT NULL,
note VARCHAR(128),
FOREIGN KEY (persona_id) REFERENCES personas(id) ON DELETE CASCADE,
UNIQUE KEY uniq_hour (hour)
);
Seed two personas (adjust voice_id to your Qwen3-TTS voice slugs):
INSERT INTO personas (display_name, active_hour_start, active_hour_end, voice_id, system_prompt, news_system_prompt) VALUES
('Bad Kitty', 6, 18, 'qwen3_female_dry',
'You are Bad Kitty, a dry, sarcastic daytime DJ. Never break character.',
'You are Bad Kitty reading the news between tracks. Dry, tight, no melodrama.'),
('Apex', 18, 6, 'qwen3_male_low',
'You are Apex, the late-night DJ. Moody, introspective, minimalist.',
'You are Apex reading the news at night. Low-key, atmospheric, no hype.');
The persona_hour_overrides table lets specific hours break out of the default active_hour_start/active_hour_end schedule. This is how the morning block rotates DJs hour-by-hour — Bad Kitty and Apex trade off so the 6–10 AM stretch feels like a morning zoo instead of a single voice.
Seed example (assumes Bad Kitty id=1, Apex id=2):
INSERT INTO persona_hour_overrides (hour, persona_id, note) VALUES (6, 1, 'Morning rotation - Bad Kitty opens'), (7, 2, 'Morning rotation - Apex'), (8, 1, 'Morning rotation - Bad Kitty'), (9, 2, 'Morning rotation - Apex closes morning block');
getPersonaForHour() in persona_helpers.php checks the overrides table first, then falls back to the default schedule. The handoff logic (isFirstHourOfShift / isLastHourOfShift) compares against the previous and next hour's resolved persona, so rotation boundaries trigger opener/closer handoff chatter automatically.
suppress_handoff TINYINT column to persona_hour_overrides and skip the handoff calls when it's set.curl -fsSL https://ollama.com/install.sh | sh
sudo systemctl enable --now ollama
ollama pull qwen2.5:14b # or qwen3 when available on your box
Confirm OLLAMA_HOST and OLLAMA_MODEL in your config.php match.
Install Qwen3-TTS in a Python venv and expose it as a small HTTP service that generate_show.php calls from the generateTTSAudio() function:
sudo mkdir -p /opt/qwen3-tts && sudo chown $USER /opt/qwen3-tts
cd /opt/qwen3-tts
python3 -m venv venv && source venv/bin/activate
pip install torch transformers fastapi uvicorn soundfile
# clone/download Qwen3-TTS weights per HF model card, then launch:
uvicorn tts_server:app --host 127.0.0.1 --port 7860
Create a systemd unit at /etc/systemd/system/qwen3-tts.service to run it on boot, then sudo systemctl enable --now qwen3-tts.
Edit /etc/icecast2/icecast.xml, set <source-password> to match the password in radio.liq (in the zip it's cat_show7b1f4e9c2a6d8f3e5c0a — change it), set the admin password, set hostname, then:
sudo systemctl restart icecast2
# stream will be at http://your-host:8000/live once liquidsoap connects
Copy radio.liq into /var/www/html/dj/, adjust paths, then run it as a systemd service. Unit file /etc/systemd/system/liquidsoap.service:
[Unit] Description=AI DJ Liquidsoap Stream After=network.target icecast2.service [Service] Type=simple User=liquidsoap ExecStart=/usr/bin/liquidsoap /var/www/html/dj/radio.liq Restart=on-failure [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now liquidsoap
sudo journalctl -u liquidsoap -f # watch it connect to Icecast
Run crontab -e as the web user and add:
0 4 * * * /usr/bin/php /var/www/html/dj/cron/fetch_news.php >> /var/log/adi_radio_news.log 2>&1 5 4 * * * /usr/bin/php /var/www/html/dj/cron/fetch_local_news.php >> /var/log/adi_radio_local_news.log 2>&1 45 * * * * /usr/bin/php /var/www/html/dj/generate_show.php >> /var/log/ai-dj.log 2>&1
news_briefs.local.Point an Apache vhost at /var/www/html/dj/ on port 80 locally. Skip Let's Encrypt — install cloudflared, create a tunnel, route your hostname to http://localhost:80 and http://localhost:8000 (for the Icecast mount). Cloudflare handles SSL at the edge.
cloudflared tunnel login cloudflared tunnel create ai-dj cloudflared tunnel route dns ai-dj radio.yourdomain.com sudo cloudflared service install
/var/www/html/dj/music-tracks/.php /var/www/html/dj/cron/fetch_news.php manually — confirm a row lands in news_briefs.php /var/www/html/dj/generate_show.php — watch /var/log/ai-dj.log, confirm current_show.m3u appears.http://your-host:8000/live or the dashboard at your Cloudflare hostname.config.php) is intentionally excluded from the zip. Copy config.example.php to config.php and fill in your DB creds, NewsAPI key, Ollama host, Qwen3-TTS endpoint, and keyword lists before running anything.