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:
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user