Add BunnyCDN integration, on-air website badge, publish script fixes

- On-air toggle uploads status.json to BunnyCDN + purges cache, website
  polls it every 15s to show live ON AIR / OFF AIR badge
- Publish script downloads Castopod's copy of audio for CDN upload
  (byte-exact match), removes broken slug fallback, syncs all episode
  media to CDN after publishing
- Fix f-string syntax error in publish_episode.py (Python <3.12)
- Enable CORS on BunnyCDN pull zone for json files
- CDN URLs for website OG images, stem recorder bug fixes, LLM token
  budget tweaks, session context in CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 17:34:18 -07:00
parent 7d88c76f90
commit 7b7f9b8208
11 changed files with 454 additions and 61 deletions

View File

@@ -2258,6 +2258,7 @@ def _build_news_context() -> tuple[str, str]:
async def startup():
"""Pre-generate caller backgrounds on server start"""
asyncio.create_task(_pregenerate_backgrounds())
threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start()
@app.on_event("shutdown")
@@ -2265,6 +2266,7 @@ async def shutdown():
"""Clean up resources on server shutdown"""
global _host_audio_task
print("[Server] Shutting down — cleaning up resources...")
_update_on_air_cdn(False)
# Stop host mic streaming
audio_service.stop_host_stream()
# Cancel host audio sender task
@@ -2296,12 +2298,48 @@ async def index():
# --- On-Air Toggle ---
# BunnyCDN config for public on-air status
_BUNNY_STORAGE_ZONE = "lukeattheroost"
_BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
_BUNNY_STORAGE_REGION = "la"
_BUNNY_ACCOUNT_KEY = "2865f279-297b-431a-ad18-0ccf1f8e4fa8cf636cea-3222-415a-84ed-56ee195c0530"
def _update_on_air_cdn(on_air: bool):
"""Upload on-air status to BunnyCDN so the public website can poll it."""
from datetime import datetime, timezone
data = {"on_air": on_air}
if on_air:
data["since"] = datetime.now(timezone.utc).isoformat()
url = f"https://{_BUNNY_STORAGE_REGION}.storage.bunnycdn.com/{_BUNNY_STORAGE_ZONE}/status.json"
try:
resp = httpx.put(url, content=json.dumps(data), headers={
"AccessKey": _BUNNY_STORAGE_KEY,
"Content-Type": "application/json",
}, timeout=5)
if resp.status_code == 201:
print(f"[CDN] On-air status updated: {on_air}")
else:
print(f"[CDN] Failed to update on-air status: {resp.status_code}")
return
httpx.get(
"https://api.bunny.net/purge",
params={"url": "https://cdn.lukeattheroost.com/status.json", "async": "false"},
headers={"AccessKey": _BUNNY_ACCOUNT_KEY},
timeout=10,
)
print(f"[CDN] Cache purged")
except Exception as e:
print(f"[CDN] Error updating on-air status: {e}")
@app.post("/api/on-air")
async def set_on_air(state: dict):
"""Toggle whether the show is on air (accepting phone calls)"""
global _show_on_air
_show_on_air = bool(state.get("on_air", False))
print(f"[Show] On-air: {_show_on_air}")
threading.Thread(target=_update_on_air_cdn, args=(_show_on_air,), daemon=True).start()
return {"on_air": _show_on_air}
@app.get("/api/on-air")
@@ -2627,13 +2665,13 @@ def _pick_response_budget() -> tuple[int, int]:
Keeps responses conversational but gives room for real answers."""
roll = random.random()
if roll < 0.20:
return 80, 2 # 20% — short and direct
return 150, 2 # 20% — short and direct
elif roll < 0.55:
return 120, 3 # 35% — normal conversation
return 250, 3 # 35% — normal conversation
elif roll < 0.80:
return 150, 4 # 25% — explaining something
return 350, 4 # 25% — explaining something
else:
return 200, 5 # 20% — telling a story or going deep
return 450, 5 # 20% — telling a story or going deep
def _trim_to_sentences(text: str, max_sentences: int) -> str:
@@ -3862,6 +3900,7 @@ async def start_stem_recording():
recorder = StemRecorder(recordings_dir, sample_rate=sr)
recorder.start()
audio_service.stem_recorder = recorder
audio_service.start_stem_mic()
add_log(f"Stem recording started -> {recordings_dir}")
return {"status": "recording", "dir": str(recordings_dir)}
@@ -3870,10 +3909,31 @@ async def start_stem_recording():
async def stop_stem_recording():
if audio_service.stem_recorder is None:
raise HTTPException(400, "No recording in progress")
audio_service.stop_stem_mic()
stems_dir = audio_service.stem_recorder.output_dir
paths = audio_service.stem_recorder.stop()
audio_service.stem_recorder = None
add_log(f"Stem recording stopped. Files: {list(paths.keys())}")
return {"status": "stopped", "stems": paths}
add_log(f"Stem recording stopped. Running post-production...")
# Auto-run postprod in background
import subprocess, sys
python = sys.executable
output_file = stems_dir / "episode.mp3"
def _run_postprod():
try:
result = subprocess.run(
[python, "postprod.py", str(stems_dir), "-o", str(output_file)],
capture_output=True, text=True, timeout=300,
)
if result.returncode == 0:
add_log(f"Post-production complete -> {output_file}")
else:
add_log(f"Post-production failed: {result.stderr[:300]}")
except Exception as e:
add_log(f"Post-production error: {e}")
threading.Thread(target=_run_postprod, daemon=True).start()
return {"status": "stopped", "stems": paths, "processing": str(output_file)}
@app.post("/api/recording/process")

View File

@@ -80,6 +80,7 @@ class AudioService:
# Stem recording (opt-in, attached via API)
self.stem_recorder = None
self._stem_mic_stream: Optional[sd.InputStream] = None
# Load saved settings
self._load_settings()
@@ -282,6 +283,8 @@ class AudioService:
stream_ready.set()
if self._recording:
self._recorded_audio.append(indata[:, record_channel].copy())
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
@@ -360,7 +363,7 @@ class AudioService:
# Stem recording: caller TTS
if self.stem_recorder:
self.stem_recorder.write("caller", audio.copy(), device_sr)
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
# Create multi-channel output with audio only on target channel
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
@@ -500,7 +503,7 @@ class AudioService:
# Stem recording: live caller
if self.stem_recorder:
self.stem_recorder.write("caller", audio.copy(), device_sr)
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
if self._live_caller_write:
self._live_caller_write(audio)
@@ -930,7 +933,7 @@ class AudioService:
# Stem recording: sfx
if self.stem_recorder:
self.stem_recorder.write("sfx", audio.copy(), device_sr)
self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
multi_ch[:, channel_idx] = audio
@@ -950,6 +953,45 @@ class AudioService:
except Exception as e:
print(f"SFX playback error: {e}")
# --- Stem Mic Capture ---
def start_stem_mic(self):
"""Start a persistent mic capture stream for stem recording.
Runs independently of push-to-talk and host streaming."""
if self._stem_mic_stream is not None:
return
if self.input_device is None:
print("[StemRecorder] No input device configured, skipping host mic capture")
return
device_info = sd.query_devices(self.input_device)
max_channels = device_info['max_input_channels']
device_sr = int(device_info['default_samplerate'])
record_channel = min(self.input_channel, max_channels) - 1
def callback(indata, frames, time_info, status):
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
self._stem_mic_stream = sd.InputStream(
device=self.input_device,
channels=max_channels,
samplerate=device_sr,
dtype=np.float32,
blocksize=1024,
callback=callback,
)
self._stem_mic_stream.start()
print(f"[StemRecorder] Host mic capture started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
def stop_stem_mic(self):
"""Stop the persistent stem mic capture."""
if self._stem_mic_stream:
self._stem_mic_stream.stop()
self._stem_mic_stream.close()
self._stem_mic_stream = None
print("[StemRecorder] Host mic capture stopped")
# Global instance
audio_service = AudioService()

View File

@@ -154,7 +154,7 @@ class LLMService:
json={
"model": model,
"messages": messages,
"max_tokens": max_tokens or 150,
"max_tokens": max_tokens or 300,
"temperature": 0.8,
"top_p": 0.92,
"frequency_penalty": 0.5,

View File

@@ -1,10 +1,11 @@
"""Records separate audio stems during a live show for post-production"""
import time
import threading
import numpy as np
import soundfile as sf
from pathlib import Path
from scipy import signal as scipy_signal
from collections import deque
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
@@ -14,73 +15,104 @@ class StemRecorder:
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.sample_rate = sample_rate
self._files: dict[str, sf.SoundFile] = {}
self._write_positions: dict[str, int] = {}
self._start_time: float = 0.0
self._running = False
self._queues: dict[str, deque] = {}
self._writer_thread: threading.Thread | None = None
self._start_time: float = 0.0
def start(self):
self._start_time = time.time()
self._running = True
for name in STEM_NAMES:
self._queues[name] = deque()
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
self._writer_thread.start()
print(f"[StemRecorder] Recording started -> {self.output_dir}")
def write(self, stem_name: str, audio_data: np.ndarray, source_sr: int):
"""Non-blocking write for continuous streams (host mic, music, ads).
Safe to call from audio callbacks."""
if not self._running or stem_name not in self._queues:
return
self._queues[stem_name].append(("audio", audio_data.copy(), source_sr))
def write_sporadic(self, stem_name: str, audio_data: np.ndarray, source_sr: int):
"""Write for burst sources (caller TTS, SFX). Pads silence to current time."""
if not self._running or stem_name not in self._queues:
return
self._queues[stem_name].append(("sporadic", audio_data.copy(), source_sr))
def _resample(self, audio_data: np.ndarray, source_sr: int) -> np.ndarray:
if source_sr == self.sample_rate:
return audio_data.astype(np.float32)
ratio = self.sample_rate / source_sr
num_samples = int(len(audio_data) * ratio)
if num_samples <= 0:
return np.array([], dtype=np.float32)
indices = (np.arange(num_samples) / ratio).astype(int)
indices = np.clip(indices, 0, len(audio_data) - 1)
return audio_data[indices].astype(np.float32)
def _writer_loop(self):
"""Background thread that drains queues and writes to WAV files."""
files: dict[str, sf.SoundFile] = {}
positions: dict[str, int] = {}
for name in STEM_NAMES:
path = self.output_dir / f"{name}.wav"
f = sf.SoundFile(
files[name] = sf.SoundFile(
str(path), mode="w",
samplerate=self.sample_rate,
channels=1, subtype="FLOAT",
)
self._files[name] = f
self._write_positions[name] = 0
print(f"[StemRecorder] Recording started -> {self.output_dir}")
positions[name] = 0
def write(self, stem_name: str, audio_data: np.ndarray, source_sr: int):
if not self._running or stem_name not in self._files:
return
while self._running or any(len(q) > 0 for q in self._queues.values()):
did_work = False
for name in STEM_NAMES:
q = self._queues[name]
while q:
did_work = True
msg_type, audio_data, source_sr = q.popleft()
resampled = self._resample(audio_data, source_sr)
if len(resampled) == 0:
continue
# Resample to target rate if needed
if source_sr != self.sample_rate:
num_samples = int(len(audio_data) * self.sample_rate / source_sr)
if num_samples > 0:
audio_data = scipy_signal.resample(audio_data, num_samples).astype(np.float32)
else:
return
if msg_type == "sporadic":
elapsed = time.time() - self._start_time
expected_pos = int(elapsed * self.sample_rate)
if expected_pos > positions[name]:
gap = expected_pos - positions[name]
files[name].write(np.zeros(gap, dtype=np.float32))
positions[name] = expected_pos
# Fill silence gap based on elapsed time
elapsed = time.time() - self._start_time
expected_pos = int(elapsed * self.sample_rate)
current_pos = self._write_positions[stem_name]
files[name].write(resampled)
positions[name] += len(resampled)
if expected_pos > current_pos:
gap = expected_pos - current_pos
silence = np.zeros(gap, dtype=np.float32)
self._files[stem_name].write(silence)
self._write_positions[stem_name] = expected_pos
if not did_work:
time.sleep(0.02)
self._files[stem_name].write(audio_data.astype(np.float32))
self._write_positions[stem_name] += len(audio_data)
# Pad all stems to same length
max_pos = max(positions.values()) if positions else 0
for name in STEM_NAMES:
if positions[name] < max_pos:
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
files[name].close()
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s)")
def stop(self) -> dict[str, str]:
if not self._running:
return {}
self._running = False
if self._writer_thread:
self._writer_thread.join(timeout=10.0)
self._writer_thread = None
# Pad all stems to the same length
max_pos = max(self._write_positions.values()) if self._write_positions else 0
for name in STEM_NAMES:
pos = self._write_positions[name]
if pos < max_pos:
silence = np.zeros(max_pos - pos, dtype=np.float32)
self._files[name].write(silence)
# Close all files
paths = {}
for name in STEM_NAMES:
self._files[name].close()
paths[name] = str(self.output_dir / f"{name}.wav")
self._files.clear()
self._write_positions.clear()
print(f"[StemRecorder] Recording stopped. {max_pos} samples ({max_pos/self.sample_rate:.1f}s)")
self._queues.clear()
return paths