Compare commits

...

8 Commits

Author SHA1 Message Date
d5fd89fc9a Add on-air toggle for phone call routing
When off air, callers hear a message and get disconnected. When on
air, calls route normally. Toggle button added to frontend header
with pulsing red ON AIR indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:03:38 -07:00
0a614eba6d Add banner, Apple Podcasts link, feed proxy, and fetch retry
- Add roostbanner.png hero banner image
- Add Apple Podcasts subscribe button
- Add Cloudflare Pages Function to proxy RSS feed (avoids CORS)
- Add fetch timeout and retry for episode loading
- Add contact email to footer
- Replace favicon with inline SVG rooster

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:03:29 -07:00
e979c4151d Update color scheme to match new cover art and bust image cache
Warm rustic bar palette (dark wood browns, orange neon accent, cream
text) replacing the previous navy/pink theme. Added ?v=2 to all
cover art URLs to force new image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 13:04:38 -07:00
e6b9401848 Add episode list with RSS parsing and sticky audio player
Fetches episodes from Castopod RSS feed, renders episode cards with
play buttons, and provides a sticky bottom audio player with progress
bar and seeking. Falls back to CORS proxy if direct fetch fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:49:16 -07:00
d14000887c Add landing page with hero section, subscribe links, and dark theme
Static site for lukeattheroost.com with cover art, phone number,
subscribe buttons (Spotify, YouTube, Apple, RSS), and OG meta tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:49:12 -07:00
7adf1bbcad Fix LLM model list, Castopod API, and server runner
- Remove gpt-4o-realtime (WebSocket-only) from OpenRouter models
- Increase OpenRouter timeout to 60s and max_tokens to 150
- Handle empty LLM responses
- Fix publish_episode.py for current Castopod API fields
- Add port conflict check and graceful shutdown to run.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:56:09 -07:00
a94fc92647 Improve SignalWire streaming, randomize caller names, update frontend
- Add streamSid tracking and per-caller send locks for SignalWire
- Improve TTS streaming with real-time pacing and detailed logging
- Block host audio to caller during TTS playback
- Randomize caller names between sessions from name pools
- Update page title and show phone number in UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:56:05 -07:00
b0643d6082 Add recording diagnostics and refresh music list on play
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:00:41 -07:00
16 changed files with 1169 additions and 79 deletions

View File

@@ -3,6 +3,7 @@
"input_channel": 1, "input_channel": 1,
"output_device": 13, "output_device": 13,
"caller_channel": 3, "caller_channel": 3,
"live_caller_channel": 9,
"music_channel": 5, "music_channel": 5,
"sfx_channel": 7, "sfx_channel": 7,
"phone_filter": false "phone_filter": false

View File

@@ -20,6 +20,7 @@ class Settings(BaseSettings):
signalwire_space: str = os.getenv("SIGNALWIRE_SPACE", "") signalwire_space: str = os.getenv("SIGNALWIRE_SPACE", "")
signalwire_token: str = os.getenv("SIGNALWIRE_TOKEN", "") signalwire_token: str = os.getenv("SIGNALWIRE_TOKEN", "")
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "") signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
# LLM Settings # LLM Settings
llm_provider: str = "openrouter" # "openrouter" or "ollama" llm_provider: str = "openrouter" # "openrouter" or "ollama"
@@ -28,7 +29,7 @@ class Settings(BaseSettings):
ollama_host: str = "http://localhost:11434" ollama_host: str = "http://localhost:11434"
# TTS Settings # TTS Settings
tts_provider: str = "kokoro" # "kokoro", "elevenlabs", "vits", or "bark" tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
# Audio Settings # Audio Settings
sample_rate: int = 24000 sample_rate: int = 24000

View File

@@ -40,19 +40,47 @@ app.add_middleware(
# Base caller info (name, voice) - backgrounds generated dynamically per session # Base caller info (name, voice) - backgrounds generated dynamically per session
import random import random
MALE_NAMES = [
"Tony", "Rick", "Dennis", "Earl", "Marcus", "Keith", "Darnell", "Wayne",
"Greg", "Andre", "Ray", "Jerome", "Hector", "Travis", "Vince", "Leon",
"Dale", "Frank", "Terrence", "Bobby", "Cliff", "Nate", "Reggie", "Carl",
]
FEMALE_NAMES = [
"Jasmine", "Megan", "Tanya", "Carla", "Brenda", "Sheila", "Denise", "Tamika",
"Lorraine", "Crystal", "Angie", "Renee", "Monique", "Gina", "Patrice", "Deb",
"Shonda", "Marlene", "Yolanda", "Stacy", "Jackie", "Carmen", "Rita", "Val",
]
CALLER_BASES = { CALLER_BASES = {
"1": {"name": "Tony", "voice": "VR6AewLTigWG4xSOukaG", "gender": "male", "age_range": (35, 55)}, "1": {"voice": "VR6AewLTigWG4xSOukaG", "gender": "male", "age_range": (35, 55)},
"2": {"name": "Jasmine", "voice": "jBpfuIE2acCO8z3wKNLl", "gender": "female", "age_range": (25, 38)}, "2": {"voice": "jBpfuIE2acCO8z3wKNLl", "gender": "female", "age_range": (25, 38)},
"3": {"name": "Rick", "voice": "TxGEqnHWrfWFTfGW9XjX", "gender": "male", "age_range": (40, 58)}, "3": {"voice": "TxGEqnHWrfWFTfGW9XjX", "gender": "male", "age_range": (40, 58)},
"4": {"name": "Megan", "voice": "EXAVITQu4vr4xnSDxMaL", "gender": "female", "age_range": (24, 35)}, "4": {"voice": "EXAVITQu4vr4xnSDxMaL", "gender": "female", "age_range": (24, 35)},
"5": {"name": "Dennis", "voice": "pNInz6obpgDQGcFmaJgB", "gender": "male", "age_range": (32, 48)}, "5": {"voice": "pNInz6obpgDQGcFmaJgB", "gender": "male", "age_range": (32, 48)},
"6": {"name": "Tanya", "voice": "21m00Tcm4TlvDq8ikWAM", "gender": "female", "age_range": (30, 45)}, "6": {"voice": "21m00Tcm4TlvDq8ikWAM", "gender": "female", "age_range": (30, 45)},
"7": {"name": "Earl", "voice": "ODq5zmih8GrVes37Dizd", "gender": "male", "age_range": (58, 72)}, "7": {"voice": "ODq5zmih8GrVes37Dizd", "gender": "male", "age_range": (58, 72)},
"8": {"name": "Carla", "voice": "XB0fDUnXU5powFXDhCwa", "gender": "female", "age_range": (38, 52)}, "8": {"voice": "XB0fDUnXU5powFXDhCwa", "gender": "female", "age_range": (38, 52)},
"9": {"name": "Marcus", "voice": "IKne3meq5aSn9XLyUdCD", "gender": "male", "age_range": (24, 34)}, "9": {"voice": "IKne3meq5aSn9XLyUdCD", "gender": "male", "age_range": (24, 34)},
"0": {"name": "Brenda", "voice": "pFZP5JQG7iQjIQuC4Bku", "gender": "female", "age_range": (45, 60)}, "0": {"voice": "pFZP5JQG7iQjIQuC4Bku", "gender": "female", "age_range": (45, 60)},
} }
def _randomize_caller_names():
"""Assign random names to callers, unique per gender."""
males = random.sample(MALE_NAMES, sum(1 for c in CALLER_BASES.values() if c["gender"] == "male"))
females = random.sample(FEMALE_NAMES, sum(1 for c in CALLER_BASES.values() if c["gender"] == "female"))
mi, fi = 0, 0
for base in CALLER_BASES.values():
if base["gender"] == "male":
base["name"] = males[mi]
mi += 1
else:
base["name"] = females[fi]
fi += 1
_randomize_caller_names() # Initial assignment
# Background components for dynamic generation # Background components for dynamic generation
JOBS_MALE = [ JOBS_MALE = [
"runs a small HVAC business", "works as a long-haul trucker", "is a high school football coach", "runs a small HVAC business", "works as a long-haul trucker", "is a high school football coach",
@@ -431,14 +459,17 @@ class Session:
if self._research_task and not self._research_task.done(): if self._research_task and not self._research_task.done():
self._research_task.cancel() self._research_task.cancel()
self._research_task = None self._research_task = None
_randomize_caller_names()
self.id = str(uuid.uuid4())[:8] self.id = str(uuid.uuid4())[:8]
print(f"[Session] Reset - new session ID: {self.id}") names = [CALLER_BASES[k]["name"] for k in sorted(CALLER_BASES.keys())]
print(f"[Session] Reset - new session ID: {self.id}, callers: {', '.join(names)}")
session = Session() session = Session()
caller_service = CallerService() caller_service = CallerService()
_ai_response_lock = asyncio.Lock() # Prevents concurrent AI responses _ai_response_lock = asyncio.Lock() # Prevents concurrent AI responses
_session_epoch = 0 # Increments on hangup/call start — stale tasks check this _session_epoch = 0 # Increments on hangup/call start — stale tasks check this
_show_on_air = False # Controls whether phone calls are accepted or get off-air message
# --- News & Research Helpers --- # --- News & Research Helpers ---
@@ -521,6 +552,21 @@ async def index():
return FileResponse(frontend_dir / "index.html") return FileResponse(frontend_dir / "index.html")
# --- On-Air Toggle ---
@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}")
return {"on_air": _show_on_air}
@app.get("/api/on-air")
async def get_on_air():
return {"on_air": _show_on_air}
# --- SignalWire Endpoints --- # --- SignalWire Endpoints ---
@app.post("/api/signalwire/voice") @app.post("/api/signalwire/voice")
@@ -531,6 +577,15 @@ async def signalwire_voice_webhook(request: Request):
call_sid = form.get("CallSid", "") call_sid = form.get("CallSid", "")
print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})") print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})")
if not _show_on_air:
print(f"[SignalWire] Show is off air — playing off-air message for {caller_phone}")
xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="woman">Luke at the Roost is off the air right now. Please call back during the show for your chance to talk to Luke. Thanks for calling!</Say>
<Hangup/>
</Response>"""
return Response(content=xml, media_type="application/xml")
# Use dedicated stream URL (ngrok) if configured, otherwise derive from request # Use dedicated stream URL (ngrok) if configured, otherwise derive from request
if settings.signalwire_stream_url: if settings.signalwire_stream_url:
stream_url = settings.signalwire_stream_url stream_url = settings.signalwire_stream_url

View File

@@ -202,7 +202,17 @@ class AudioService:
time.sleep(0.05) time.sleep(0.05)
if not self._recorded_audio: if not self._recorded_audio:
print(f"Recording stopped: NO audio chunks captured (piggyback={self._host_stream is not None})") piggyback = self._host_stream is not None
# Check what other streams might be active
active_streams = []
if self._music_stream:
active_streams.append("music")
if self._live_caller_stream:
active_streams.append("live_caller")
if self._host_stream:
active_streams.append("host")
streams_info = f", active_streams=[{','.join(active_streams)}]" if active_streams else ""
print(f"Recording stopped: NO audio chunks captured (piggyback={piggyback}, device={self.input_device}, ch={self.input_channel}{streams_info})")
return b"" return b""
# Combine all chunks # Combine all chunks
@@ -228,17 +238,28 @@ class AudioService:
device_sr = int(device_info['default_samplerate']) device_sr = int(device_info['default_samplerate'])
record_channel = min(self.input_channel, max_channels) - 1 record_channel = min(self.input_channel, max_channels) - 1
if max_channels == 0:
print(f"Recording error: device {self.input_device} has no input channels")
self._recording = False
return
# Store device sample rate for later resampling # Store device sample rate for later resampling
self._record_device_sr = device_sr self._record_device_sr = device_sr
print(f"Recording from device {self.input_device} ch {self.input_channel} @ {device_sr}Hz") stream_ready = threading.Event()
callback_count = [0]
def callback(indata, frames, time_info, status): def callback(indata, frames, time_info, status):
if status: if status:
print(f"Record status: {status}") print(f"Record status: {status}")
callback_count[0] += 1
if not stream_ready.is_set():
stream_ready.set()
if self._recording: if self._recording:
self._recorded_audio.append(indata[:, record_channel].copy()) self._recorded_audio.append(indata[:, record_channel].copy())
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
with sd.InputStream( with sd.InputStream(
device=self.input_device, device=self.input_device,
channels=max_channels, channels=max_channels,
@@ -247,11 +268,19 @@ class AudioService:
callback=callback, callback=callback,
blocksize=1024 blocksize=1024
): ):
# Wait for stream to actually start capturing
if not stream_ready.wait(timeout=1.0):
print(f"Recording WARNING: stream opened but callback not firing after 1s")
while self._recording: while self._recording:
time.sleep(0.05) time.sleep(0.05)
print(f"Recording: stream closed, {callback_count[0]} callbacks fired, {len(self._recorded_audio)} chunks captured")
except Exception as e: except Exception as e:
print(f"Recording error: {e}") print(f"Recording error: {e}")
import traceback
traceback.print_exc()
self._recording = False self._recording = False
# --- Caller TTS Playback --- # --- Caller TTS Playback ---
@@ -463,10 +492,10 @@ class AudioService:
record_channel = min(self.input_channel, max_channels) - 1 record_channel = min(self.input_channel, max_channels) - 1
step = max(1, int(device_sr / 16000)) step = max(1, int(device_sr / 16000))
# Buffer host mic to send ~60ms chunks instead of tiny 21ms ones # Buffer host mic to send ~100ms chunks (reduces WebSocket frame rate)
host_accum = [] host_accum = []
host_accum_samples = [0] host_accum_samples = [0]
send_threshold = 960 # 60ms at 16kHz send_threshold = 1600 # 100ms at 16kHz
def callback(indata, frames, time_info, status): def callback(indata, frames, time_info, status):
# Capture for push-to-talk recording if active # Capture for push-to-talk recording if active

View File

@@ -1,6 +1,8 @@
"""Phone caller queue and audio stream service""" """Phone caller queue and audio stream service"""
import asyncio import asyncio
import base64
import json
import time import time
import threading import threading
import numpy as np import numpy as np
@@ -20,7 +22,20 @@ class CallerService:
self._lock = threading.Lock() self._lock = threading.Lock()
self._websockets: dict[str, any] = {} # caller_id -> WebSocket self._websockets: dict[str, any] = {} # caller_id -> WebSocket
self._call_sids: dict[str, str] = {} # caller_id -> SignalWire callSid self._call_sids: dict[str, str] = {} # caller_id -> SignalWire callSid
self.streaming_tts: bool = False # True while TTS audio is being streamed self._stream_sids: dict[str, str] = {} # caller_id -> SignalWire streamSid
self._send_locks: dict[str, asyncio.Lock] = {} # per-caller send lock
self._streaming_tts: set[str] = set() # caller_ids currently receiving TTS
def _get_send_lock(self, caller_id: str) -> asyncio.Lock:
if caller_id not in self._send_locks:
self._send_locks[caller_id] = asyncio.Lock()
return self._send_locks[caller_id]
def is_streaming_tts(self, caller_id: str) -> bool:
return caller_id in self._streaming_tts
def is_streaming_tts_any(self) -> bool:
return len(self._streaming_tts) > 0
def add_to_queue(self, caller_id: str, phone: str): def add_to_queue(self, caller_id: str, phone: str):
with self._lock: with self._lock:
@@ -94,6 +109,8 @@ class CallerService:
print(f"[Caller] {call_info['phone']} hung up — channel {call_info['channel']} released") print(f"[Caller] {call_info['phone']} hung up — channel {call_info['channel']} released")
self._websockets.pop(caller_id, None) self._websockets.pop(caller_id, None)
self._call_sids.pop(caller_id, None) self._call_sids.pop(caller_id, None)
self._stream_sids.pop(caller_id, None)
self._send_locks.pop(caller_id, None)
def reset(self): def reset(self):
with self._lock: with self._lock:
@@ -105,6 +122,9 @@ class CallerService:
self._caller_counter = 0 self._caller_counter = 0
self._websockets.clear() self._websockets.clear()
self._call_sids.clear() self._call_sids.clear()
self._stream_sids.clear()
self._send_locks.clear()
self._streaming_tts.clear()
print("[Caller] Service reset") print("[Caller] Service reset")
def register_websocket(self, caller_id: str, websocket): def register_websocket(self, caller_id: str, websocket):
@@ -119,29 +139,34 @@ class CallerService:
"""Send small audio chunk to caller via SignalWire WebSocket. """Send small audio chunk to caller via SignalWire WebSocket.
Encodes L16 PCM as base64 JSON per SignalWire protocol. Encodes L16 PCM as base64 JSON per SignalWire protocol.
""" """
if caller_id in self._streaming_tts:
return # Don't send host audio during TTS streaming
ws = self._websockets.get(caller_id) ws = self._websockets.get(caller_id)
if not ws: if not ws:
return return
try: lock = self._get_send_lock(caller_id)
import base64 async with lock:
if sample_rate != 16000: try:
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0 if sample_rate != 16000:
ratio = 16000 / sample_rate audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
out_len = int(len(audio) * ratio) ratio = 16000 / sample_rate
indices = (np.arange(out_len) / ratio).astype(int) out_len = int(len(audio) * ratio)
indices = np.clip(indices, 0, len(audio) - 1) indices = (np.arange(out_len) / ratio).astype(int)
audio = audio[indices] indices = np.clip(indices, 0, len(audio) - 1)
pcm_data = (audio * 32767).astype(np.int16).tobytes() audio = audio[indices]
pcm_data = (audio * 32767).astype(np.int16).tobytes()
payload = base64.b64encode(pcm_data).decode('ascii') payload = base64.b64encode(pcm_data).decode('ascii')
import json stream_sid = self._stream_sids.get(caller_id, "")
await ws.send_text(json.dumps({ await ws.send_text(json.dumps({
"event": "media", "event": "media",
"media": {"payload": payload} "streamSid": stream_sid,
})) "media": {"payload": payload}
except Exception as e: }))
print(f"[Caller] Failed to send audio: {e}") except Exception as e:
print(f"[Caller] Failed to send audio: {e}")
async def stream_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int): async def stream_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int):
"""Stream large audio (TTS) to caller in real-time chunks via SignalWire WebSocket.""" """Stream large audio (TTS) to caller in real-time chunks via SignalWire WebSocket."""
@@ -149,10 +174,10 @@ class CallerService:
if not ws: if not ws:
return return
self.streaming_tts = True lock = self._get_send_lock(caller_id)
self._streaming_tts.add(caller_id)
chunks_sent = 0
try: try:
import base64
import json
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0 audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
if sample_rate != 16000: if sample_rate != 16000:
ratio = 16000 / sample_rate ratio = 16000 / sample_rate
@@ -161,23 +186,40 @@ class CallerService:
indices = np.clip(indices, 0, len(audio) - 1) indices = np.clip(indices, 0, len(audio) - 1)
audio = audio[indices] audio = audio[indices]
total_chunks = (len(audio) + 959) // 960
duration_s = len(audio) / 16000
print(f"[Caller] TTS stream starting: {duration_s:.1f}s audio, {total_chunks} chunks")
chunk_samples = 960 chunk_samples = 960
chunk_duration = chunk_samples / 16000 # 60ms per chunk
for i in range(0, len(audio), chunk_samples): for i in range(0, len(audio), chunk_samples):
if caller_id not in self._websockets: if caller_id not in self._websockets:
print(f"[Caller] TTS stream aborted: caller {caller_id} disconnected at chunk {chunks_sent}/{total_chunks}")
break break
t0 = time.time()
chunk = audio[i:i + chunk_samples] chunk = audio[i:i + chunk_samples]
pcm_chunk = (chunk * 32767).astype(np.int16).tobytes() pcm_chunk = (chunk * 32767).astype(np.int16).tobytes()
payload = base64.b64encode(pcm_chunk).decode('ascii') payload = base64.b64encode(pcm_chunk).decode('ascii')
await ws.send_text(json.dumps({ stream_sid = self._stream_sids.get(caller_id, "")
"event": "media", async with lock:
"media": {"payload": payload} await ws.send_text(json.dumps({
})) "event": "media",
await asyncio.sleep(0.055) "streamSid": stream_sid,
"media": {"payload": payload}
}))
chunks_sent += 1
# Sleep to match real-time playback rate
elapsed = time.time() - t0
sleep_time = max(0, chunk_duration - elapsed)
await asyncio.sleep(sleep_time)
print(f"[Caller] TTS stream finished: {chunks_sent}/{total_chunks} chunks sent")
except Exception as e: except Exception as e:
print(f"[Caller] Failed to stream audio: {e}") print(f"[Caller] TTS stream failed at chunk {chunks_sent}: {e}")
finally: finally:
self.streaming_tts = False self._streaming_tts.discard(caller_id)
def register_call_sid(self, caller_id: str, call_sid: str): def register_call_sid(self, caller_id: str, call_sid: str):
"""Track SignalWire callSid for a caller""" """Track SignalWire callSid for a caller"""
@@ -190,3 +232,11 @@ class CallerService:
def unregister_call_sid(self, caller_id: str): def unregister_call_sid(self, caller_id: str):
"""Remove callSid tracking""" """Remove callSid tracking"""
self._call_sids.pop(caller_id, None) self._call_sids.pop(caller_id, None)
def register_stream_sid(self, caller_id: str, stream_sid: str):
"""Track SignalWire streamSid for a caller"""
self._stream_sids[caller_id] = stream_sid
def unregister_stream_sid(self, caller_id: str):
"""Remove streamSid tracking"""
self._stream_sids.pop(caller_id, None)

View File

@@ -7,10 +7,10 @@ from ..config import settings
# Available OpenRouter models # Available OpenRouter models
OPENROUTER_MODELS = [ OPENROUTER_MODELS = [
"anthropic/claude-3-haiku",
"anthropic/claude-3.5-sonnet",
"openai/gpt-4o-mini", "openai/gpt-4o-mini",
"openai/gpt-4o", "openai/gpt-4o",
"anthropic/claude-3-haiku",
"anthropic/claude-3.5-sonnet",
"google/gemini-flash-1.5", "google/gemini-flash-1.5",
"google/gemini-pro-1.5", "google/gemini-pro-1.5",
"meta-llama/llama-3.1-8b-instruct", "meta-llama/llama-3.1-8b-instruct",
@@ -114,7 +114,7 @@ class LLMService:
"""Call OpenRouter API with retry""" """Call OpenRouter API with retry"""
for attempt in range(2): # Try twice for attempt in range(2): # Try twice
try: try:
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post( response = await client.post(
"https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions",
headers={ headers={
@@ -124,12 +124,16 @@ class LLMService:
json={ json={
"model": self.openrouter_model, "model": self.openrouter_model,
"messages": messages, "messages": messages,
"max_tokens": 100, "max_tokens": 150,
}, },
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data["choices"][0]["message"]["content"] content = data["choices"][0]["message"]["content"]
if not content or not content.strip():
print(f"OpenRouter returned empty response")
return ""
return content
except (httpx.TimeoutException, httpx.ReadTimeout): except (httpx.TimeoutException, httpx.ReadTimeout):
print(f"OpenRouter timeout (attempt {attempt + 1})") print(f"OpenRouter timeout (attempt {attempt + 1})")
if attempt == 0: if attempt == 0:

View File

@@ -54,6 +54,27 @@ header button {
cursor: pointer; cursor: pointer;
} }
.on-air-btn {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: background 0.2s;
}
.on-air-btn.off {
background: #666 !important;
}
.on-air-btn.on {
background: #cc2222 !important;
animation: on-air-pulse 2s ease-in-out infinite;
}
@keyframes on-air-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.new-session-btn { .new-session-btn {
background: var(--accent) !important; background: var(--accent) !important;
} }

View File

@@ -3,14 +3,15 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Radio Show</title> <title>Luke at The Roost</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<header> <header>
<h1>AI Radio Show</h1> <h1>Luke at The Roost</h1>
<div class="header-buttons"> <div class="header-buttons">
<button id="on-air-btn" class="on-air-btn off">OFF AIR</button>
<button id="new-session-btn" class="new-session-btn">New Session</button> <button id="new-session-btn" class="new-session-btn">New Session</button>
<button id="settings-btn">Settings</button> <button id="settings-btn">Settings</button>
</div> </div>
@@ -53,7 +54,7 @@
<!-- Call Queue --> <!-- Call Queue -->
<section class="queue-section"> <section class="queue-section">
<h2>Incoming Calls</h2> <h2>Incoming Calls <span style="font-size:0.6em;font-weight:normal;color:var(--text-muted);">(208) 439-5853</span></h2>
<div id="call-queue" class="call-queue"> <div id="call-queue" class="call-queue">
<div class="queue-empty">No callers waiting</div> <div class="queue-empty">No callers waiting</div>
</div> </div>
@@ -207,6 +208,6 @@
</div> </div>
</div> </div>
<script src="/js/app.js?v=13"></script> <script src="/js/app.js?v=15"></script>
</body> </body>
</html> </html>

View File

@@ -16,6 +16,21 @@ let tracks = [];
let sounds = []; let sounds = [];
// --- Safe JSON parsing ---
async function safeFetch(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
const text = await res.text();
let detail = text;
try { detail = JSON.parse(text).detail || text; } catch {}
throw new Error(detail);
}
const text = await res.text();
if (!text) return {};
return JSON.parse(text);
}
// --- Init --- // --- Init ---
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
console.log('AI Radio Show initializing...'); console.log('AI Radio Show initializing...');
@@ -42,6 +57,24 @@ function initEventListeners() {
// New Session // New Session
document.getElementById('new-session-btn')?.addEventListener('click', newSession); document.getElementById('new-session-btn')?.addEventListener('click', newSession);
// On-Air toggle
const onAirBtn = document.getElementById('on-air-btn');
if (onAirBtn) {
fetch('/api/on-air').then(r => r.json()).then(data => {
updateOnAirBtn(onAirBtn, data.on_air);
});
onAirBtn.addEventListener('click', async () => {
const isOn = onAirBtn.classList.contains('on');
const res = await safeFetch('/api/on-air', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on_air: !isOn }),
});
updateOnAirBtn(onAirBtn, res.on_air);
log(res.on_air ? 'Show is ON AIR — accepting calls' : 'Show is OFF AIR — calls get off-air message');
});
}
// Server controls // Server controls
document.getElementById('restart-server-btn')?.addEventListener('click', restartServer); document.getElementById('restart-server-btn')?.addEventListener('click', restartServer);
document.getElementById('stop-server-btn')?.addEventListener('click', stopServer); document.getElementById('stop-server-btn')?.addEventListener('click', stopServer);
@@ -108,6 +141,15 @@ function initEventListeners() {
log('Real caller disconnected'); log('Real caller disconnected');
}); });
// AI caller hangup (small button in AI caller panel)
document.getElementById('hangup-ai-btn')?.addEventListener('click', hangup);
// AI respond button (manual trigger)
document.getElementById('ai-respond-btn')?.addEventListener('click', triggerAiRespond);
// Start conversation update polling
startConversationPolling();
// AI respond mode toggle // AI respond mode toggle
document.getElementById('mode-manual')?.addEventListener('click', () => { document.getElementById('mode-manual')?.addEventListener('click', () => {
document.getElementById('mode-manual')?.classList.add('active'); document.getElementById('mode-manual')?.classList.add('active');
@@ -123,7 +165,6 @@ function initEventListeners() {
document.getElementById('mode-auto')?.addEventListener('click', () => { document.getElementById('mode-auto')?.addEventListener('click', () => {
document.getElementById('mode-auto')?.classList.add('active'); document.getElementById('mode-auto')?.classList.add('active');
document.getElementById('mode-manual')?.classList.remove('active'); document.getElementById('mode-manual')?.classList.remove('active');
document.getElementById('ai-respond-btn')?.classList.add('hidden');
fetch('/api/session/ai-mode', { fetch('/api/session/ai-mode', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -362,6 +403,7 @@ async function newSession() {
} }
await fetch('/api/session/reset', { method: 'POST' }); await fetch('/api/session/reset', { method: 'POST' });
conversationSince = 0;
// Hide caller background // Hide caller background
const bgEl = document.getElementById('caller-background'); const bgEl = document.getElementById('caller-background');
@@ -434,8 +476,7 @@ async function stopRecording() {
try { try {
// Stop recording and get transcription // Stop recording and get transcription
const res = await fetch('/api/record/stop', { method: 'POST' }); const data = await safeFetch('/api/record/stop', { method: 'POST' });
const data = await res.json();
if (!data.text) { if (!data.text) {
log('(No speech detected)'); log('(No speech detected)');
@@ -449,12 +490,11 @@ async function stopRecording() {
// Chat // Chat
showStatus(`${currentCaller.name} is thinking...`); showStatus(`${currentCaller.name} is thinking...`);
const chatRes = await fetch('/api/chat', { const chatData = await safeFetch('/api/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: data.text }) body: JSON.stringify({ text: data.text })
}); });
const chatData = await chatRes.json();
addMessage(chatData.caller, chatData.text); addMessage(chatData.caller, chatData.text);
@@ -462,7 +502,7 @@ async function stopRecording() {
if (chatData.text && chatData.text.trim()) { if (chatData.text && chatData.text.trim()) {
showStatus(`${currentCaller.name} is speaking...`); showStatus(`${currentCaller.name} is speaking...`);
await fetch('/api/tts', { await safeFetch('/api/tts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -496,12 +536,11 @@ async function sendTypedMessage() {
try { try {
showStatus(`${currentCaller.name} is thinking...`); showStatus(`${currentCaller.name} is thinking...`);
const chatRes = await fetch('/api/chat', { const chatData = await safeFetch('/api/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }) body: JSON.stringify({ text })
}); });
const chatData = await chatRes.json();
addMessage(chatData.caller, chatData.text); addMessage(chatData.caller, chatData.text);
@@ -509,7 +548,7 @@ async function sendTypedMessage() {
if (chatData.text && chatData.text.trim()) { if (chatData.text && chatData.text.trim()) {
showStatus(`${currentCaller.name} is speaking...`); showStatus(`${currentCaller.name} is speaking...`);
await fetch('/api/tts', { await safeFetch('/api/tts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -538,6 +577,8 @@ async function loadMusic() {
const select = document.getElementById('track-select'); const select = document.getElementById('track-select');
if (!select) return; if (!select) return;
const previousValue = select.value;
select.innerHTML = ''; select.innerHTML = '';
tracks.forEach((track, i) => { tracks.forEach((track, i) => {
@@ -546,6 +587,12 @@ async function loadMusic() {
option.textContent = track.name; option.textContent = track.name;
select.appendChild(option); select.appendChild(option);
}); });
// Restore previous selection if it still exists
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
select.value = previousValue;
}
console.log('Loaded', tracks.length, 'tracks'); console.log('Loaded', tracks.length, 'tracks');
} catch (err) { } catch (err) {
console.error('loadMusic error:', err); console.error('loadMusic error:', err);
@@ -554,6 +601,7 @@ async function loadMusic() {
async function playMusic() { async function playMusic() {
await loadMusic();
const select = document.getElementById('track-select'); const select = document.getElementById('track-select');
const track = select?.value; const track = select?.value;
if (!track) return; if (!track) return;
@@ -742,6 +790,12 @@ function log(text) {
addMessage('System', text); addMessage('System', text);
} }
function updateOnAirBtn(btn, isOn) {
btn.classList.toggle('on', isOn);
btn.classList.toggle('off', !isOn);
btn.textContent = isOn ? 'ON AIR' : 'OFF AIR';
}
function showStatus(text) { function showStatus(text) {
const status = document.getElementById('status'); const status = document.getElementById('status');
@@ -894,6 +948,19 @@ async function takeCall(callerId) {
if (data.status === 'on_air') { if (data.status === 'on_air') {
showRealCaller(data.caller); showRealCaller(data.caller);
log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`); log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`);
// Auto-select an AI caller if none is active
if (!currentCaller) {
const callerBtns = document.querySelectorAll('.caller-btn');
if (callerBtns.length > 0) {
const randomIdx = Math.floor(Math.random() * callerBtns.length);
const btn = callerBtns[randomIdx];
const key = btn.dataset.key;
const name = btn.textContent;
log(`Auto-selecting ${name} as AI caller`);
await startCall(key, name);
}
}
} }
} catch (err) { } catch (err) {
log('Failed to take call: ' + err.message); log('Failed to take call: ' + err.message);
@@ -962,6 +1029,66 @@ function hideRealCaller() {
} }
// --- AI Respond (manual trigger) ---
async function triggerAiRespond() {
if (!currentCaller) {
log('No AI caller active — click a caller first');
return;
}
const btn = document.getElementById('ai-respond-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Thinking...'; }
showStatus(`${currentCaller.name} is thinking...`);
try {
const data = await safeFetch('/api/ai-respond', { method: 'POST' });
if (data.text) {
addMessage(data.caller, data.text);
showStatus(`${data.caller} is speaking...`);
const duration = data.text.length * 60;
setTimeout(hideStatus, Math.min(duration, 15000));
}
} catch (err) {
log('AI respond error: ' + err.message);
}
if (btn) { btn.disabled = false; btn.textContent = 'Let them respond'; }
}
// --- Conversation Update Polling ---
let conversationSince = 0;
function startConversationPolling() {
setInterval(fetchConversationUpdates, 1000);
}
async function fetchConversationUpdates() {
try {
const res = await fetch(`/api/conversation/updates?since=${conversationSince}`);
const data = await res.json();
if (data.messages && data.messages.length > 0) {
for (const msg of data.messages) {
conversationSince = msg.id + 1;
if (msg.type === 'caller_disconnected') {
hideRealCaller();
log(`${msg.phone} disconnected (${msg.reason})`);
} else if (msg.type === 'chat') {
addMessage(msg.sender, msg.text);
} else if (msg.type === 'ai_status') {
showStatus(msg.text);
} else if (msg.type === 'ai_done') {
hideStatus();
} else if (msg.type === 'caller_queued') {
// Queue poll will pick this up, just ensure it refreshes
fetchQueue();
}
}
}
} catch (err) {}
}
async function stopServer() { async function stopServer() {
if (!confirm('Stop the server? You will need to restart it manually.')) return; if (!confirm('Stop the server? You will need to restart it manually.')) return;

12
functions/feed.js Normal file
View File

@@ -0,0 +1,12 @@
export async function onRequest() {
const feedUrl = 'https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml';
const res = await fetch(feedUrl);
const xml = await res.text();
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
},
});
}

View File

@@ -155,11 +155,12 @@ Respond with ONLY valid JSON, no markdown or explanation."""
return metadata return metadata
def create_episode(audio_path: str, metadata: dict, duration: int) -> dict: def create_episode(audio_path: str, metadata: dict, episode_number: int) -> dict:
"""Create episode on Castopod.""" """Create episode on Castopod."""
print("[3/5] Creating episode on Castopod...") print("[3/5] Creating episode on Castopod...")
headers = get_auth_header() headers = get_auth_header()
slug = re.sub(r'[^a-z0-9]+', '-', metadata["title"].lower()).strip('-')
# Upload audio and create episode # Upload audio and create episode
with open(audio_path, "rb") as f: with open(audio_path, "rb") as f:
@@ -168,21 +169,25 @@ def create_episode(audio_path: str, metadata: dict, duration: int) -> dict:
} }
data = { data = {
"title": metadata["title"], "title": metadata["title"],
"description_markdown": metadata["description"], "slug": slug,
"description": metadata["description"],
"parental_advisory": "explicit", "parental_advisory": "explicit",
"type": "full", "type": "full",
"created_by": "1" "podcast_id": str(PODCAST_ID),
"created_by": "1",
"updated_by": "1",
"episode_number": str(episode_number),
} }
response = requests.post( response = requests.post(
f"{CASTOPOD_URL}/api/rest/v1/podcasts/{PODCAST_ID}/episodes", f"{CASTOPOD_URL}/api/rest/v1/episodes",
headers=headers, headers=headers,
files=files, files=files,
data=data data=data
) )
if response.status_code not in (200, 201): if response.status_code not in (200, 201):
print(f"Error creating episode: {response.text}") print(f"Error creating episode: {response.status_code} {response.text}")
sys.exit(1) sys.exit(1)
episode = response.json() episode = response.json()
@@ -312,7 +317,7 @@ def get_next_episode_number() -> int:
headers = get_auth_header() headers = get_auth_header()
response = requests.get( response = requests.get(
f"{CASTOPOD_URL}/api/rest/v1/podcasts/{PODCAST_ID}/episodes", f"{CASTOPOD_URL}/api/rest/v1/episodes",
headers=headers headers=headers
) )
@@ -323,7 +328,12 @@ def get_next_episode_number() -> int:
if not episodes: if not episodes:
return 1 return 1
max_num = max(ep.get("number", 0) for ep in episodes) # Filter to our podcast
our_episodes = [ep for ep in episodes if ep.get("podcast_id") == PODCAST_ID]
if not our_episodes:
return 1
max_num = max(ep.get("number", 0) or 0 for ep in our_episodes)
return max_num + 1 return max_num + 1
@@ -373,7 +383,7 @@ def main():
return return
# Step 3: Create episode # Step 3: Create episode
episode = create_episode(str(audio_path), metadata, transcript["duration"]) episode = create_episode(str(audio_path), metadata, episode_number)
# Step 4: Publish # Step 4: Publish
episode = publish_episode(episode["id"]) episode = publish_episode(episode["id"])

45
run.sh
View File

@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
# AI Radio Show - Server Runner with restart support # AI Radio Show - Server Runner with restart support
PORT=8000
LOG_FILE="/tmp/ai-radio-show.log" LOG_FILE="/tmp/ai-radio-show.log"
RESTART_FLAG="/tmp/ai-radio-show.restart" RESTART_FLAG="/tmp/ai-radio-show.restart"
STOP_FLAG="/tmp/ai-radio-show.stop" STOP_FLAG="/tmp/ai-radio-show.stop"
@@ -13,16 +14,46 @@ source venv/bin/activate
# Cleanup old flags # Cleanup old flags
rm -f "$RESTART_FLAG" "$STOP_FLAG" rm -f "$RESTART_FLAG" "$STOP_FLAG"
# Check if port is already in use
if lsof -i ":$PORT" -sTCP:LISTEN -t >/dev/null 2>&1; then
EXISTING_PID=$(lsof -i ":$PORT" -sTCP:LISTEN -t 2>/dev/null | head -1)
echo "ERROR: Port $PORT is already in use by PID $EXISTING_PID"
echo "Run: kill $EXISTING_PID"
exit 1
fi
kill_server() {
local pid=$1
if ! kill -0 "$pid" 2>/dev/null; then
return
fi
kill "$pid" 2>/dev/null
# Wait up to 5 seconds for graceful shutdown
for i in $(seq 1 10); do
if ! kill -0 "$pid" 2>/dev/null; then
return
fi
sleep 0.5
done
# Force kill if still alive
echo "[$(date)] Server didn't stop gracefully, force killing..." | tee -a "$LOG_FILE"
kill -9 "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
}
echo "AI Radio Show Server Runner" echo "AI Radio Show Server Runner"
echo "Log file: $LOG_FILE" echo "Log file: $LOG_FILE"
echo "Press Ctrl+C to stop" echo "Press Ctrl+C to stop"
echo "" echo ""
while true; do # Handle Ctrl+C
echo "[$(date)] Starting server..." | tee -a "$LOG_FILE" trap 'echo ""; echo "[$(date)] Interrupted" | tee -a "$LOG_FILE"; kill_server $SERVER_PID; exit 0' INT TERM
# Start uvicorn with output to both console and log file while true; do
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 2>&1 | tee -a "$LOG_FILE" & echo "[$(date)] Starting server on port $PORT..." | tee -a "$LOG_FILE"
# Start uvicorn directly (not through tee pipe so we get the real PID)
python -m uvicorn backend.main:app --host 0.0.0.0 --port $PORT >> "$LOG_FILE" 2>&1 &
SERVER_PID=$! SERVER_PID=$!
# Wait for server to exit or restart signal # Wait for server to exit or restart signal
@@ -30,8 +61,7 @@ while true; do
if [ -f "$RESTART_FLAG" ]; then if [ -f "$RESTART_FLAG" ]; then
echo "[$(date)] Restart requested..." | tee -a "$LOG_FILE" echo "[$(date)] Restart requested..." | tee -a "$LOG_FILE"
rm -f "$RESTART_FLAG" rm -f "$RESTART_FLAG"
kill $SERVER_PID 2>/dev/null kill_server $SERVER_PID
wait $SERVER_PID 2>/dev/null
sleep 1 sleep 1
break break
fi fi
@@ -39,8 +69,7 @@ while true; do
if [ -f "$STOP_FLAG" ]; then if [ -f "$STOP_FLAG" ]; then
echo "[$(date)] Stop requested..." | tee -a "$LOG_FILE" echo "[$(date)] Stop requested..." | tee -a "$LOG_FILE"
rm -f "$STOP_FLAG" rm -f "$STOP_FLAG"
kill $SERVER_PID 2>/dev/null kill_server $SERVER_PID
wait $SERVER_PID 2>/dev/null
echo "[$(date)] Server stopped." | tee -a "$LOG_FILE" echo "[$(date)] Server stopped." | tee -a "$LOG_FILE"
exit 0 exit 0
fi fi

411
website/css/style.css Normal file
View File

@@ -0,0 +1,411 @@
:root {
--bg: #1a1209;
--bg-light: #2a2015;
--accent: #e8791d;
--accent-hover: #f59a4a;
--accent-red: #cc2222;
--text: #f5f0e5;
--text-muted: #9a8b78;
--radius: 12px;
--radius-sm: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
color: var(--accent-hover);
}
/* Banner */
.banner {
width: 100%;
overflow: hidden;
}
.banner-img {
width: 100%;
height: auto;
display: block;
}
/* Hero */
.hero {
padding: 3rem 1.5rem 2rem;
max-width: 900px;
margin: 0 auto;
text-align: center;
}
.hero-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.cover-art {
width: 220px;
height: 220px;
border-radius: var(--radius);
box-shadow: 0 8px 32px rgba(232, 121, 29, 0.35);
object-fit: cover;
}
.hero-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.hero h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.02em;
}
.tagline {
font-size: 1.15rem;
color: var(--text-muted);
max-width: 400px;
}
.phone {
margin-top: 0.5rem;
}
.phone-number {
font-size: 2.2rem;
font-weight: 800;
color: var(--accent);
letter-spacing: 0.02em;
display: block;
}
.phone-digits {
font-size: 0.95rem;
color: var(--text-muted);
}
.phone-label {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 0.25rem;
}
/* Subscribe buttons */
.subscribe-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
margin-top: 1.5rem;
}
.subscribe-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.25rem;
border-radius: 50px;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
transition: opacity 0.2s, transform 0.2s;
}
.subscribe-btn:hover {
opacity: 0.85;
transform: translateY(-1px);
color: #fff;
}
.subscribe-btn svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.btn-spotify { background: #1DB954; }
.btn-youtube { background: #FF0000; }
.btn-apple { background: #A033FF; }
.btn-rss { background: #f26522; }
/* Episodes */
.episodes-section {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem 8rem;
}
.episodes-section h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
font-weight: 700;
}
.episodes-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.episode-card {
background: var(--bg-light);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
gap: 1rem;
align-items: flex-start;
transition: background 0.2s;
}
.episode-card:hover {
background: #352a1c;
}
.episode-play-btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--accent);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s, transform 0.2s;
}
.episode-play-btn:hover {
background: var(--accent-hover);
transform: scale(1.05);
}
.episode-play-btn svg {
width: 20px;
height: 20px;
fill: #fff;
}
.episode-play-btn.playing svg {
/* pause icon swap handled by JS */
}
.episode-info {
flex: 1;
min-width: 0;
}
.episode-meta {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.episode-title {
font-size: 1.05rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.episode-desc {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
}
.episodes-loading {
text-align: center;
color: var(--text-muted);
padding: 2rem 0;
}
.episodes-error {
text-align: center;
color: var(--accent);
padding: 2rem 0;
}
/* Sticky Player */
.sticky-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #150f06;
border-top: 1px solid #333;
padding: 0.75rem 1.5rem;
display: none;
z-index: 100;
}
.sticky-player.active {
display: block;
}
.player-inner {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1rem;
}
.player-play-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.player-play-btn:hover {
background: var(--accent-hover);
}
.player-play-btn svg {
width: 18px;
height: 18px;
fill: #fff;
}
.player-info {
flex: 1;
min-width: 0;
}
.player-title {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-progress-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.player-progress {
flex: 1;
height: 4px;
background: #333;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.player-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
width: 0%;
transition: width 0.1s linear;
}
.player-time {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
min-width: 80px;
text-align: right;
}
/* Footer */
.footer {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem 10rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
border-top: 1px solid #2a2015;
}
.footer-links {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 0.75rem;
}
.footer-links a {
color: var(--text-muted);
}
.footer-links a:hover {
color: var(--text);
}
.footer-contact {
margin-bottom: 0.75rem;
}
.footer-contact a {
color: var(--accent);
}
.footer-contact a:hover {
color: var(--accent-hover);
}
/* Desktop */
@media (min-width: 768px) {
.hero {
padding: 4rem 2rem 2.5rem;
}
.hero-inner {
flex-direction: row;
text-align: left;
gap: 3rem;
}
.hero-info {
align-items: flex-start;
}
.cover-art {
width: 260px;
height: 260px;
}
.subscribe-row {
justify-content: flex-start;
}
.episodes-section {
padding: 2rem 2rem 8rem;
}
}

BIN
website/images/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

112
website/index.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Luke at the Roost — The ONLY radio show where Luke gives AI life advice</title>
<meta name="description" content="The ONLY radio show where Luke gives AI life advice. Call in: 208-439-LUKE">
<!-- OG / Social -->
<meta property="og:title" content="Luke at the Roost">
<meta property="og:description" content="The ONLY radio show where Luke gives AI life advice. Call in: 208-439-LUKE">
<meta property="og:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2">
<meta property="og:url" content="https://lukeattheroost.com">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Luke at the Roost">
<meta name="twitter:description" content="The ONLY radio show where Luke gives AI life advice. Call in: 208-439-LUKE">
<meta name="twitter:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath d='M32 4c-2 0-4 2-4 5 0 1 .3 2 .8 3C26 13 24 16 24 20c0 2 .5 4 1.5 5.5L22 28c-2 1-4 3-5 6l-3 10c-.5 2 .5 3 2 3h4l1-4 2 4h6l-1-6 3 6h6l-1-6 3 6h4c1.5 0 2.5-1 2-3l-3-10c-1-3-3-5-5-6l-3.5-2.5C35.5 24 36 22 36 20c0-4-2-7-4.8-8 .5-1 .8-2 .8-3 0-3-2-5-4-5z' fill='%23e8791d'/%3E%3Ccircle cx='30' cy='17' r='1.5' fill='%231a1209'/%3E%3Cpath d='M36 15c1-1 3-1 4 0s1 3 0 4' fill='none' stroke='%23cc2222' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M28 22c2 1 4 1 6 0' fill='none' stroke='%23e8791d' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Banner -->
<div class="banner">
<img src="images/banner.png" alt="Luke at the Roost — ON AIR" class="banner-img">
</div>
<!-- Hero -->
<section class="hero">
<div class="hero-inner">
<img
class="cover-art"
src="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2"
alt="Luke at the Roost cover art"
>
<div class="hero-info">
<h1>Luke at the Roost</h1>
<p class="tagline">The ONLY radio show where Luke gives AI life advice.</p>
<div class="phone">
<span class="phone-label">Call in live</span>
<span class="phone-number">208-439-LUKE</span>
<span class="phone-digits">(208-439-5853)</span>
</div>
<div class="subscribe-row">
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn btn-spotify">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
Spotify
</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="subscribe-btn btn-youtube">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
YouTube
</a>
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="subscribe-btn btn-apple">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.34 0A5.328 5.328 0 0 0 0 5.34v13.32A5.328 5.328 0 0 0 5.34 24h13.32A5.328 5.328 0 0 0 24 18.66V5.34A5.328 5.328 0 0 0 18.66 0zm6.525 2.568c2.336 0 4.448.902 4.448 3.545 0 1.497-.89 2.67-1.916 3.545-.663.566-.795 .84-.795 1.347 0 .6.397 1.173.894 1.722 1.417 1.564 1.96 2.853 1.96 4.448 0 3.063-2.673 4.257-5.165 4.257-.315 0-.658-.02-.994-.063-1.523-.195-2.86-.9-3.632-.9-.82 0-1.98.623-3.377.87A5.715 5.715 0 0 1 3.15 21.4c-1.27 0-2.1-.96-2.1-2.663 0-1.2.6-2.7 1.845-4.29.63-.81 1.62-1.83 2.91-2.31-.06-.6-.09-1.14-.09-1.62 0-4.28 2.76-7.95 6.15-7.95z"/></svg>
Apple
</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="subscribe-btn btn-rss">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg>
RSS
</a>
</div>
</div>
</div>
</section>
<!-- Episodes -->
<section class="episodes-section">
<h2>Episodes</h2>
<div class="episodes-list" id="episodes-list">
<div class="episodes-loading">Loading episodes...</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-links">
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
</div>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p>&copy; 2026 Luke at the Roost</p>
</footer>
<!-- Sticky Audio Player -->
<div class="sticky-player" id="sticky-player">
<div class="player-inner">
<button class="player-play-btn" id="player-play-btn" aria-label="Play/Pause">
<svg class="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<div class="player-info">
<div class="player-title" id="player-title"></div>
<div class="player-progress-row">
<div class="player-progress" id="player-progress">
<div class="player-progress-fill" id="player-progress-fill"></div>
</div>
<span class="player-time" id="player-time">0:00 / 0:00</span>
</div>
</div>
</div>
</div>
<audio id="audio-element" preload="none"></audio>
<script src="js/app.js"></script>
</body>
</html>

227
website/js/app.js Normal file
View File

@@ -0,0 +1,227 @@
const FEED_URL = '/feed';
const audio = document.getElementById('audio-element');
const stickyPlayer = document.getElementById('sticky-player');
const playerPlayBtn = document.getElementById('player-play-btn');
const playerTitle = document.getElementById('player-title');
const playerProgress = document.getElementById('player-progress');
const playerProgressFill = document.getElementById('player-progress-fill');
const playerTime = document.getElementById('player-time');
const episodesList = document.getElementById('episodes-list');
let currentEpisodeCard = null;
// Format seconds to M:SS or H:MM:SS
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const s = Math.floor(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
// Format duration from itunes:duration (could be seconds or HH:MM:SS)
function parseDuration(raw) {
if (!raw) return '';
if (raw.includes(':')) {
const parts = raw.split(':').map(Number);
let totalSec = 0;
if (parts.length === 3) totalSec = parts[0] * 3600 + parts[1] * 60 + parts[2];
else if (parts.length === 2) totalSec = parts[0] * 60 + parts[1];
return `${Math.round(totalSec / 60)} min`;
}
const sec = parseInt(raw, 10);
if (isNaN(sec)) return '';
return `${Math.round(sec / 60)} min`;
}
// Format date nicely
function formatDate(dateStr) {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// Strip HTML tags and truncate
function truncate(html, maxLen) {
const div = document.createElement('div');
div.innerHTML = html || '';
const text = div.textContent || '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).trimEnd() + '...';
}
// SVG icons
const playSVG = '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
const pauseSVG = '<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
// Fetch with timeout
function fetchWithTimeout(url, ms = 8000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
}
// Fetch and parse RSS feed
async function fetchEpisodes() {
let xml;
const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetchWithTimeout(FEED_URL);
if (!res.ok) throw new Error('Fetch failed');
xml = await res.text();
if (xml.includes('<item>')) break;
throw new Error('Invalid response');
} catch (err) {
if (attempt === maxRetries) {
episodesList.innerHTML = '<div class="episodes-error">Unable to load episodes. <a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank">View RSS feed</a></div>';
return;
}
}
}
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
const items = doc.querySelectorAll('item');
if (items.length === 0) {
episodesList.innerHTML = '<div class="episodes-error">No episodes found.</div>';
return;
}
const episodes = Array.from(items).map((item, i) => {
const title = item.querySelector('title')?.textContent || 'Untitled';
const description = item.querySelector('description')?.textContent || '';
const enclosure = item.querySelector('enclosure');
const audioUrl = enclosure?.getAttribute('url') || '';
const pubDate = item.querySelector('pubDate')?.textContent || '';
const duration = item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '';
const episodeNum = item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '';
const link = item.querySelector('link')?.textContent || '';
return { title, description, audioUrl, pubDate, duration, episodeNum, link };
});
renderEpisodes(episodes);
}
function renderEpisodes(episodes) {
episodesList.innerHTML = '';
episodes.forEach((ep) => {
const card = document.createElement('div');
card.className = 'episode-card';
const epLabel = ep.episodeNum ? `Ep ${ep.episodeNum}` : '';
const dateStr = ep.pubDate ? formatDate(ep.pubDate) : '';
const durStr = parseDuration(ep.duration);
const metaParts = [epLabel, dateStr, durStr].filter(Boolean).join(' &middot; ');
card.innerHTML = `
<button class="episode-play-btn" aria-label="Play ${ep.title}" data-url="${ep.audioUrl}" data-title="${ep.title.replace(/"/g, '&quot;')}">
${playSVG}
</button>
<div class="episode-info">
<div class="episode-meta">${metaParts}</div>
<div class="episode-title">${ep.title}</div>
<div class="episode-desc">${truncate(ep.description, 150)}</div>
</div>
`;
const btn = card.querySelector('.episode-play-btn');
btn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, btn));
episodesList.appendChild(card);
});
}
function playEpisode(url, title, card, btn) {
if (!url) return;
// If clicking the same episode that's playing, toggle play/pause
if (audio.src === url || audio.src === encodeURI(url)) {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
return;
}
// Reset previous card button icon
if (currentEpisodeCard) {
const prevBtn = currentEpisodeCard.querySelector('.episode-play-btn');
if (prevBtn) {
prevBtn.innerHTML = playSVG;
prevBtn.classList.remove('playing');
}
}
currentEpisodeCard = card;
audio.src = url;
audio.play();
playerTitle.textContent = title;
stickyPlayer.classList.add('active');
}
// Sync UI with audio state
audio.addEventListener('play', () => {
updatePlayIcons(true);
});
audio.addEventListener('pause', () => {
updatePlayIcons(false);
});
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
const pct = (audio.currentTime / audio.duration) * 100;
playerProgressFill.style.width = pct + '%';
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
}
});
audio.addEventListener('ended', () => {
updatePlayIcons(false);
});
function updatePlayIcons(playing) {
// Sticky player icons
const iconPlay = playerPlayBtn.querySelector('.icon-play');
const iconPause = playerPlayBtn.querySelector('.icon-pause');
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
// Episode card icon
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) {
btn.innerHTML = playing ? pauseSVG : playSVG;
btn.classList.toggle('playing', playing);
}
}
}
// Sticky player play/pause button
playerPlayBtn.addEventListener('click', () => {
if (audio.src) {
if (audio.paused) audio.play();
else audio.pause();
}
});
// Progress bar seeking
playerProgress.addEventListener('click', (e) => {
if (audio.duration) {
const rect = playerProgress.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
}
});
// Init
fetchEpisodes();