Initial commit: Radio Susan scripts, configs, and SFX
This commit is contained in:
Executable
+292
@@ -0,0 +1,292 @@
|
||||
#!/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 = [
|
||||
# Classic station IDs
|
||||
"You're listening to {crash} ninety nine point oh {synth_horn} Susan Radio!",
|
||||
"This is {sweep} Susan Radio {crash} ninety nine point oh {reverb_hit} all day, all night",
|
||||
"{synth_horn} You're tuned in to {crash} Susan Radio! {airhorn}",
|
||||
"Susan Radio {reverb_hit} ninety nine point oh {crash} on your dial",
|
||||
"{sweep} Don't touch that dial! {crash} Susan Radio {airhorn} ninety nine point oh!",
|
||||
"Coming at you live {crash} this is {reverb_hit} Susan Radio! {synth_horn}",
|
||||
"{sweep} Lock it in {crash} ninety nine point oh {reverb_hit} Susan Radio!",
|
||||
# Chaotic ones
|
||||
"{pirate_arr} Ahoy! {crash} You're aboard Susan Radio! {pirate_yaargh} Ninety nine point oh!",
|
||||
"{scream} Susan Radio! {airhorn} Ninety nine point oh! {meow}",
|
||||
"{countdown} Susan Radio is GO! {crash} Ninety nine point oh! {airhorn}",
|
||||
"{demon} Susan Radio {crash} ninety nine point oh {scream} we never stop!",
|
||||
"{text_tone} New message! {crash} You're listening to Susan Radio! {synth_horn}",
|
||||
"{meow} Susan Radio {reverb_hit} ninety nine point oh {cashregister}",
|
||||
"{pirate_plank} Walk the plank! {crash} Or just listen to Susan Radio! {pirate_arr} Ninety nine point oh!",
|
||||
"{cashregister} Cha-ching! {sweep} Susan Radio! {airhorn} Ninety nine point oh!",
|
||||
]
|
||||
|
||||
|
||||
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, longer decay
|
||||
"filter": "anoisesrc=d=1.5:c=white:a=0.8,afade=t=in:d=0.02,afade=t=out:st=0.3:d=1.2,lowpass=f=3000",
|
||||
},
|
||||
"airhorn": {
|
||||
# Stacked sine waves with vibrato — longer blast
|
||||
"filter": (
|
||||
"sine=f=750:d=1.2,aformat=sample_rates=44100"
|
||||
),
|
||||
"filter_complex": (
|
||||
"[0:a]volume=0.5[a];"
|
||||
"anoisesrc=d=1.2:c=pink:a=0.15,aformat=sample_rates=44100[n];"
|
||||
"sine=f=950:d=1.2,aformat=sample_rates=44100,volume=0.4[b];"
|
||||
"[a][n]amix=inputs=2[m1];[m1][b]amix=inputs=2,afade=t=in:d=0.02,afade=t=out:st=0.7:d=0.5[out]"
|
||||
),
|
||||
},
|
||||
"static": {
|
||||
# Pink noise crackle — longer
|
||||
"filter": "anoisesrc=d=1.2:c=pink:a=0.4,afade=t=in:d=0.1,afade=t=out:st=0.7:d=0.5,highpass=f=500",
|
||||
},
|
||||
"reverb_hit": {
|
||||
# Impact with longer reverb tail
|
||||
"filter": "anoisesrc=d=0.05:c=white:a=1.0,apad=whole_dur=2,aecho=0.8:0.88:80:0.5,afade=t=out:st=0.5:d=1.5",
|
||||
},
|
||||
"sweep": {
|
||||
# Frequency sweep — longer with more movement
|
||||
"filter": "sine=f=200:d=1.0,aformat=sample_rates=44100,afade=t=in:d=0.05,afade=t=out:st=0.5:d=0.5,vibrato=f=15:d=0.8",
|
||||
},
|
||||
}
|
||||
|
||||
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", "+15%", # 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,loudnorm=I=-14:TP=-1:LRA=11[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()
|
||||
Reference in New Issue
Block a user