diff options
| author | Caine <caine@jihakuz.xyz> | 2026-02-21 22:08:45 +0000 |
|---|---|---|
| committer | Caine <caine@jihakuz.xyz> | 2026-02-21 22:08:45 +0000 |
| commit | c82c202d2643960e6a85d86821d062805bdeb54c (patch) | |
| tree | 23bf320b05bee90f32b2e96e48742fcc5ebcaf5b /generate_jingles.py | |
| parent | c7956ae9b228054d57897ea338ad4154cc0b7221 (diff) | |
Add jingle generator for Radio Susan
Diffstat (limited to 'generate_jingles.py')
| -rw-r--r-- | generate_jingles.py | 282 |
1 files changed, 282 insertions, 0 deletions
diff --git a/generate_jingles.py b/generate_jingles.py new file mode 100644 index 0000000..bdaa212 --- /dev/null +++ b/generate_jingles.py @@ -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() |
