Add jingle generator for Radio Susan

This commit is contained in:
Caine
2026-02-21 22:08:45 +00:00
parent c7956ae9b2
commit c82c202d26
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""
Jingle Factory for Radio Susan
Phrases use {sfx_name} markers to insert sound effects at breakpoints.
Each phrase gets TTS'd with a random voice, then stitched with SFX.
Background ambience (static/noise) is layered underneath.
Usage: python3 generate_jingles.py [--list-sfx] [--preview PHRASE]
SFX are stored in /var/lib/radio/sfx/
Jingles output to /var/lib/radio/jingles/
"""
import asyncio
import json
import os
import random
import re
import subprocess
import sys
import tempfile
from pathlib import Path
RADIO_DIR = Path("/var/lib/radio")
SFX_DIR = RADIO_DIR / "sfx"
JINGLES_DIR = RADIO_DIR / "jingles"
VENV_BIN = RADIO_DIR / "venv" / "bin"
EDGE_TTS = VENV_BIN / "edge-tts"
# Voices to randomly pick from — mix of dramatic, cheesy, authoritative
VOICES = [
"en-US-GuyNeural", # Passion — classic radio voice
"en-US-ChristopherNeural", # Authority — news anchor
"en-US-EricNeural", # Rational — deadpan
"en-US-BrianNeural", # Casual bro
"en-GB-RyanNeural", # British bloke
"en-GB-ThomasNeural", # Posh British
"en-US-AriaNeural", # Confident woman
"en-US-JennyNeural", # Friendly woman
"en-GB-SoniaNeural", # British woman
]
# Phrase definitions — {sfx_name} marks where sound effects go
# Add as many as you want!
PHRASES = [
"You're listening to {crash} ninety nine point oh {airhorn} Susan Radio!",
"This is {static} Susan Radio {crash} ninety nine point oh {reverb_hit} all day, all night",
"{static} You're tuned in to {crash} Susan Radio! {airhorn}",
"Susan Radio {reverb_hit} ninety nine point oh {crash} on your dial",
"{static} Don't touch that dial! {crash} Susan Radio {airhorn} ninety nine point oh!",
"Coming at you live {crash} this is {reverb_hit} Susan Radio! {airhorn}",
"{static} Lock it in {crash} ninety nine point oh {reverb_hit} Susan Radio!",
]
def generate_sfx():
"""Generate built-in sound effects using ffmpeg."""
SFX_DIR.mkdir(parents=True, exist_ok=True)
sfx_specs = {
"crash": {
# White noise burst with sharp attack, fast decay
"filter": "anoisesrc=d=0.8:c=white:a=0.6,afade=t=in:d=0.02,afade=t=out:st=0.15:d=0.65,lowpass=f=3000",
},
"airhorn": {
# Stacked sine waves with vibrato for that MLG airhorn feel
"filter": (
"sine=f=750:d=0.6,aformat=sample_rates=44100"
),
"filter_complex": (
"[0:a]volume=0.4[a];"
"anoisesrc=d=0.6:c=pink:a=0.1,aformat=sample_rates=44100[n];"
"sine=f=950:d=0.6,aformat=sample_rates=44100,volume=0.3[b];"
"[a][n]amix=inputs=2[m1];[m1][b]amix=inputs=2,afade=t=in:d=0.02,afade=t=out:st=0.3:d=0.3[out]"
),
},
"static": {
# Pink noise crackle
"filter": "anoisesrc=d=0.5:c=pink:a=0.25,afade=t=in:d=0.1,afade=t=out:st=0.3:d=0.2,highpass=f=500",
},
"reverb_hit": {
# Impact with reverb tail
"filter": "anoisesrc=d=0.05:c=white:a=0.8,apad=whole_dur=1,aecho=0.8:0.88:60:0.4,afade=t=out:st=0.3:d=0.7",
},
"sweep": {
# Frequency sweep
"filter": "sine=f=200:d=0.4,aformat=sample_rates=44100,afade=t=in:d=0.05,afade=t=out:st=0.2:d=0.2,vibrato=f=20:d=1.0",
},
}
for name, spec in sfx_specs.items():
outfile = SFX_DIR / f"{name}.wav"
if outfile.exists():
continue
if "filter_complex" in spec:
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", spec["filter"],
"-filter_complex", spec["filter_complex"],
"-map", "[out]",
"-ar", "44100", "-ac", "2",
str(outfile)
]
else:
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", spec["filter"],
"-ar", "44100", "-ac", "2",
str(outfile)
]
subprocess.run(cmd, capture_output=True)
print(f" Generated SFX: {name}")
return {f.stem: str(f) for f in SFX_DIR.glob("*.wav")}
def tts_segment(text, voice, outfile):
"""Generate TTS for a text segment using edge-tts."""
cmd = [
str(EDGE_TTS),
"--voice", voice,
"--rate", "+10%", # Slightly faster for radio energy
"--text", text,
"--write-media", outfile,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" TTS error: {result.stderr}")
return False
return True
def stitch_jingle(segments, sfx_files, output_path):
"""
Stitch TTS segments and SFX together with background ambience.
segments: list of (type, path) where type is 'voice' or 'sfx'
"""
if not segments:
return False
# Build ffmpeg concat filter
inputs = []
filter_parts = []
for i, (stype, path) in enumerate(segments):
inputs.extend(["-i", path])
# Normalize all inputs to same format
filter_parts.append(f"[{i}:a]aformat=sample_rates=44100:channel_layouts=stereo,aresample=44100[s{i}]")
# Concat all segments
concat_inputs = "".join(f"[s{i}]" for i in range(len(segments)))
filter_parts.append(f"{concat_inputs}concat=n={len(segments)}:v=0:a=1[main]")
# Generate background static layer
filter_parts.append(
"anoisesrc=d=30:c=pink:a=0.04,aformat=sample_rates=44100:channel_layouts=stereo,"
"highpass=f=800,lowpass=f=4000[bg]"
)
# Mix main audio with background, trim to main length
filter_parts.append(
"[bg][main]amix=inputs=2:duration=shortest:weights=1 3,"
"afade=t=in:d=0.1,volume=2.0[out]"
)
filter_complex = ";\n".join(filter_parts)
cmd = [
"ffmpeg", "-y",
*inputs,
"-filter_complex", filter_complex,
"-map", "[out]",
"-ar", "44100", "-ac", "2", "-b:a", "192k",
str(output_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" FFmpeg error: {result.stderr[-500:]}")
return False
return True
def parse_phrase(phrase):
"""Parse a phrase into segments: text parts and {sfx} markers."""
parts = re.split(r'\{(\w+)\}', phrase)
segments = []
for i, part in enumerate(parts):
part = part.strip()
if not part:
continue
if i % 2 == 0:
segments.append(("text", part))
else:
segments.append(("sfx", part))
return segments
def generate_one_jingle(phrase, voice, sfx_files, output_path):
"""Generate a single jingle from a phrase template."""
parsed = parse_phrase(phrase)
audio_segments = [] # (type, filepath)
tmpfiles = []
try:
for stype, content in parsed:
if stype == "text":
tmpfile = tempfile.mktemp(suffix=".mp3", dir="/tmp")
tmpfiles.append(tmpfile)
if tts_segment(content, voice, tmpfile):
audio_segments.append(("voice", tmpfile))
else:
print(f" Failed TTS for: {content}")
return False
elif stype == "sfx":
if content in sfx_files:
audio_segments.append(("sfx", sfx_files[content]))
else:
print(f" Unknown SFX: {content}")
if not audio_segments:
return False
return stitch_jingle(audio_segments, sfx_files, output_path)
finally:
for f in tmpfiles:
try:
os.unlink(f)
except:
pass
def main():
JINGLES_DIR.mkdir(parents=True, exist_ok=True)
if "--list-sfx" in sys.argv:
sfx_files = generate_sfx()
print("Available SFX:", ", ".join(sorted(sfx_files.keys())))
return
# Clear old jingles before regenerating
for old in JINGLES_DIR.glob("jingle_*.mp3"):
old.unlink()
print("Generating SFX...")
sfx_files = generate_sfx()
print(f" {len(sfx_files)} SFX ready: {', '.join(sorted(sfx_files.keys()))}")
# Generate multiple jingles with different voice/phrase combos
print("\nGenerating jingles...")
generated = 0
for i, phrase in enumerate(PHRASES):
voice = random.choice(VOICES)
output = JINGLES_DIR / f"jingle_{i+1:02d}.mp3"
print(f"\n [{i+1}/{len(PHRASES)}] Voice: {voice}")
print(f" Phrase: {phrase}")
if generate_one_jingle(phrase, voice, sfx_files, output):
# Get duration
probe = subprocess.run(
["ffprobe", "-hide_banner", "-show_entries", "format=duration",
"-of", "csv=p=0", str(output)],
capture_output=True, text=True
)
dur = float(probe.stdout.strip()) if probe.stdout.strip() else 0
print(f"{output.name} ({dur:.1f}s)")
generated += 1
else:
print(f" ✗ Failed")
print(f"\nDone! {generated}/{len(PHRASES)} jingles generated in {JINGLES_DIR}")
print("Reload liquidsoap to pick them up: sudo systemctl restart liquidsoap")
if __name__ == "__main__":
main()