Clips page, new episodes, TTS/audio improvements, publish pipeline updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 05:38:58 -06:00
parent 2c7fcdb5ae
commit f7b75fa72f
56 changed files with 4827 additions and 356 deletions

View File

@@ -31,6 +31,32 @@ def _write_reaper_state(state: str):
class AudioService:
"""Manages audio I/O with multi-channel support for Loopback routing"""
@staticmethod
def _find_device_by_name(name: str) -> Optional[int]:
"""Find a device index by name substring match. Returns None if not found."""
if not name:
return None
devices = sd.query_devices()
# Exact match first
for i, d in enumerate(devices):
if d["name"] == name:
return i
# Substring match
for i, d in enumerate(devices):
if name in d["name"]:
return i
return None
@staticmethod
def _get_device_name(device_id: Optional[int]) -> Optional[str]:
"""Get the name of a device by index."""
if device_id is None:
return None
try:
return sd.query_devices(device_id)["name"]
except Exception:
return None
def __init__(self):
# Device configuration
self.input_device: Optional[int] = 13 # Radio Voice Mic (loopback input)
@@ -113,35 +139,52 @@ class AudioService:
# Load saved settings
self._load_settings()
def _resolve_device(self, data: dict, key: str) -> Optional[int]:
"""Resolve a device from settings: try name first, fall back to index."""
name_key = f"{key}_name"
name = data.get(name_key)
if name:
resolved = self._find_device_by_name(name)
if resolved is not None:
idx = data.get(key)
if idx is not None and resolved != idx:
print(f"[Audio] Device '{name}' moved: {idx} -> {resolved}")
return resolved
else:
print(f"[Audio] Warning: device '{name}' not found, falling back to index {data.get(key)}")
return data.get(key)
def _load_settings(self):
"""Load settings from disk"""
"""Load settings from disk, resolving device names to current indices"""
if SETTINGS_FILE.exists():
try:
with open(SETTINGS_FILE) as f:
data = json.load(f)
self.input_device = data.get("input_device")
self.input_device = self._resolve_device(data, "input_device")
self.input_channel = data.get("input_channel", 1)
self.output_device = data.get("output_device")
self.output_device = self._resolve_device(data, "output_device")
self.caller_channel = data.get("caller_channel", 1)
self.live_caller_channel = data.get("live_caller_channel", 4)
self.music_channel = data.get("music_channel", 2)
self.sfx_channel = data.get("sfx_channel", 3)
self.ad_channel = data.get("ad_channel", 11)
self.ident_channel = data.get("ident_channel", 15)
self.monitor_device = data.get("monitor_device")
self.monitor_device = self._resolve_device(data, "monitor_device")
self.monitor_channel = data.get("monitor_channel", 1)
self.phone_filter = data.get("phone_filter", False)
print(f"Loaded audio settings: input={self.input_device}, output={self.output_device}, monitor={self.monitor_device}, phone_filter={self.phone_filter}")
print(f"Loaded audio settings: input={self.input_device} ({self._get_device_name(self.input_device)}), output={self.output_device} ({self._get_device_name(self.output_device)}), monitor={self.monitor_device}, phone_filter={self.phone_filter}")
except Exception as e:
print(f"Failed to load audio settings: {e}")
def _save_settings(self):
"""Save settings to disk"""
"""Save settings to disk with device names for stable resolution"""
try:
data = {
"input_device": self.input_device,
"input_device_name": self._get_device_name(self.input_device),
"input_channel": self.input_channel,
"output_device": self.output_device,
"output_device_name": self._get_device_name(self.output_device),
"caller_channel": self.caller_channel,
"live_caller_channel": self.live_caller_channel,
"music_channel": self.music_channel,
@@ -149,6 +192,7 @@ class AudioService:
"ad_channel": self.ad_channel,
"ident_channel": self.ident_channel,
"monitor_device": self.monitor_device,
"monitor_device_name": self._get_device_name(self.monitor_device),
"monitor_channel": self.monitor_channel,
"phone_filter": self.phone_filter,
}
@@ -507,7 +551,7 @@ class AudioService:
self._live_caller_write = write_audio
self._live_caller_stream = sd.OutputStream(
self._live_caller_stream = self._open_output_stream(
device=self.output_device,
samplerate=device_sr,
channels=num_channels,
@@ -515,7 +559,6 @@ class AudioService:
callback=callback,
blocksize=1024,
)
self._live_caller_stream.start()
print(f"[Audio] Live caller stream started on ch {self.live_caller_channel} @ {device_sr}Hz (prebuffer {prebuffer_samples} samples)")
def _stop_live_caller_stream(self):
@@ -894,7 +937,7 @@ class AudioService:
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
try:
self._music_stream = sd.OutputStream(
self._music_stream = self._open_output_stream(
device=device,
channels=num_channels,
samplerate=device_sr,
@@ -902,12 +945,40 @@ class AudioService:
callback=callback,
blocksize=2048
)
self._music_stream.start()
print(f"Music playback started on ch {self.music_channel} @ {device_sr}Hz")
except Exception as e:
print(f"Music playback error: {e}")
self._music_playing = False
def _refresh_devices(self):
"""Re-initialize PortAudio to pick up device changes, then re-resolve settings."""
try:
sd._terminate()
sd._initialize()
print("[Audio] PortAudio re-initialized")
self._load_settings()
except Exception as e:
print(f"[Audio] PortAudio refresh failed: {e}")
def _open_output_stream(self, **kwargs) -> sd.OutputStream:
"""Open an OutputStream with one retry after refreshing PortAudio on failure."""
try:
stream = sd.OutputStream(**kwargs)
stream.start()
return stream
except Exception as first_err:
print(f"[Audio] Stream open failed ({first_err}), refreshing devices...")
self._refresh_devices()
# Update device/channel info from refreshed settings
if kwargs.get("device") == self.output_device or "device" in kwargs:
device_info = sd.query_devices(self.output_device)
kwargs["device"] = self.output_device
kwargs["channels"] = device_info["max_output_channels"]
kwargs["samplerate"] = int(device_info["default_samplerate"])
stream = sd.OutputStream(**kwargs)
stream.start()
return stream
def _close_stream(self, stream):
"""Safely close a sounddevice stream, ignoring double-close errors"""
if stream is None:
@@ -1026,7 +1097,7 @@ class AudioService:
_write_reaper_state("dialog")
try:
self._ad_stream = sd.OutputStream(
self._ad_stream = self._open_output_stream(
device=device,
channels=num_channels,
samplerate=device_sr,
@@ -1034,7 +1105,6 @@ class AudioService:
callback=callback,
blocksize=2048
)
self._ad_stream.start()
print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz")
except Exception as e:
print(f"Ad playback error: {e}")
@@ -1132,7 +1202,7 @@ class AudioService:
_write_reaper_state("dialog")
try:
self._ident_stream = sd.OutputStream(
self._ident_stream = self._open_output_stream(
device=device,
channels=num_channels,
samplerate=device_sr,
@@ -1140,7 +1210,6 @@ class AudioService:
callback=callback,
blocksize=2048
)
self._ident_stream.start()
print(f"Ident playback started on ch {ch_l+1}/{ch_r+1} (idx {ch_l}/{ch_r}) of {num_channels} channels @ {device_sr}Hz, device={device}")
except Exception as e:
print(f"Ident playback error: {e}")

View File

@@ -47,7 +47,7 @@ class LLMService:
@property
def client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=15.0)
self._client = httpx.AsyncClient(timeout=10.0)
return self._client
def update_settings(
@@ -135,7 +135,7 @@ class LLMService:
if model == self.openrouter_model:
continue # Already tried
print(f"[LLM] Falling back to {model}...")
result = await self._call_openrouter_once(messages, model, timeout=10.0, max_tokens=max_tokens)
result = await self._call_openrouter_once(messages, model, timeout=8.0, max_tokens=max_tokens)
if result is not None:
return result
@@ -143,7 +143,7 @@ class LLMService:
print("[LLM] All models failed, using canned response")
return "Sorry, I totally blanked out for a second. What were you saying?"
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 15.0, max_tokens: Optional[int] = None) -> str | None:
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None) -> str | None:
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
try:
response = await self.client.post(

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Optional
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
MAX_REGULARS = 12
MAX_REGULARS = 8
class RegularCallerService:

View File

@@ -636,7 +636,7 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
},
}
async with httpx.AsyncClient(timeout=25.0) as client:
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
@@ -682,8 +682,8 @@ _TTS_PROVIDERS = {
"elevenlabs": lambda text, vid: generate_speech_elevenlabs(text, vid),
}
TTS_MAX_RETRIES = 3
TTS_RETRY_DELAYS = [1.0, 2.0, 4.0] # seconds between retries
TTS_MAX_RETRIES = 2
TTS_RETRY_DELAYS = [0.5, 1.0] # seconds between retries
async def generate_speech(
@@ -714,21 +714,28 @@ async def generate_speech(
raise ValueError(f"Unknown TTS provider: {provider}")
last_error = None
for attempt in range(TTS_MAX_RETRIES):
try:
audio, sample_rate = await gen_fn(text, voice_id)
if attempt > 0:
print(f"[TTS] Succeeded on retry {attempt}")
break
except Exception as e:
last_error = e
if attempt < TTS_MAX_RETRIES - 1:
delay = TTS_RETRY_DELAYS[attempt]
print(f"[TTS] {provider} attempt {attempt + 1} failed: {e} — retrying in {delay}s...")
await asyncio.sleep(delay)
else:
print(f"[TTS] {provider} failed after {TTS_MAX_RETRIES} attempts: {e}")
raise
try:
async with asyncio.timeout(20):
for attempt in range(TTS_MAX_RETRIES):
try:
audio, sample_rate = await gen_fn(text, voice_id)
if attempt > 0:
print(f"[TTS] Succeeded on retry {attempt}")
break
except TimeoutError:
raise # Let asyncio.timeout propagate
except Exception as e:
last_error = e
if attempt < TTS_MAX_RETRIES - 1:
delay = TTS_RETRY_DELAYS[attempt]
print(f"[TTS] {provider} attempt {attempt + 1} failed: {e} — retrying in {delay}s...")
await asyncio.sleep(delay)
else:
print(f"[TTS] {provider} failed after {TTS_MAX_RETRIES} attempts: {e}")
raise
except TimeoutError:
print(f"[TTS] Overall timeout (20s) for {provider}")
raise RuntimeError(f"TTS generation timed out after 20s")
# Apply phone filter if requested
# Skip filter for Bark - it already has rough audio quality