293 lines
10 KiB
Python
Executable File
293 lines
10 KiB
Python
Executable File
#!/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()
|