Compare commits
8 Commits
0412f4487f
...
d5fd89fc9a
| Author | SHA1 | Date | |
|---|---|---|---|
| d5fd89fc9a | |||
| 0a614eba6d | |||
| e979c4151d | |||
| e6b9401848 | |||
| d14000887c | |||
| 7adf1bbcad | |||
| a94fc92647 | |||
| b0643d6082 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,12 +139,16 @@ 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
|
||||||
|
|
||||||
|
lock = self._get_send_lock(caller_id)
|
||||||
|
async with lock:
|
||||||
try:
|
try:
|
||||||
import base64
|
|
||||||
if sample_rate != 16000:
|
if sample_rate != 16000:
|
||||||
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
|
||||||
ratio = 16000 / sample_rate
|
ratio = 16000 / sample_rate
|
||||||
@@ -135,9 +159,10 @@ class CallerService:
|
|||||||
pcm_data = (audio * 32767).astype(np.int16).tobytes()
|
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",
|
||||||
|
"streamSid": stream_sid,
|
||||||
"media": {"payload": payload}
|
"media": {"payload": payload}
|
||||||
}))
|
}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -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')
|
||||||
|
stream_sid = self._stream_sids.get(caller_id, "")
|
||||||
|
async with lock:
|
||||||
await ws.send_text(json.dumps({
|
await ws.send_text(json.dumps({
|
||||||
"event": "media",
|
"event": "media",
|
||||||
|
"streamSid": stream_sid,
|
||||||
"media": {"payload": payload}
|
"media": {"payload": payload}
|
||||||
}))
|
}))
|
||||||
await asyncio.sleep(0.055)
|
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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
12
functions/feed.js
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
45
run.sh
@@ -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
411
website/css/style.css
Normal 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
BIN
website/images/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
112
website/index.html
Normal file
112
website/index.html
Normal 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 & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
||||||
|
<p>© 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
227
website/js/app.js
Normal 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(' · ');
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<button class="episode-play-btn" aria-label="Play ${ep.title}" data-url="${ep.audioUrl}" data-title="${ep.title.replace(/"/g, '"')}">
|
||||||
|
${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();
|
||||||
Reference in New Issue
Block a user