Project / Home Lab

AI DJ Radio — Qwen3-TTS + Ollama

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.

Ubuntu 22.04
Apache 2.4
PHP 8.2
MariaDB
Ollama
Qwen3-TTS
Liquidsoap
Icecast2

How It Works

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.

File Structure

/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

Step 1 — Install System Packages

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

Step 2 — MariaDB Database

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.');

Step 2b — Morning Rotation (Override Hours)

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.

Heads up — every hour in the rotation block is a handoff boundary, so expect opener/closer handoff lines to fire on every hour during the morning rotation. If that feels too chatty, add a suppress_handoff TINYINT column to persona_hour_overrides and skip the handoff calls when it's set.

Step 3 — Ollama

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.

Step 4 — Qwen3-TTS

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.

Step 5 — Icecast2

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

Step 6 — Liquidsoap

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

Step 7 — Cron Jobs

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

Step 8 — Apache + Cloudflare Tunnel

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

Verify

Note — the config file (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.