Landing page: testimonials slider, how-it-works page, 25 TTS voices

- Add testimonial slider with 8 fake caller reviews
- Add how-it-works page with visual architecture diagram
- Expand voice pools: Inworld 25 voices (14M/11F), ElevenLabs 22 (14M/8F)
- Voice pools auto-switch when TTS provider changes
- Add cover art locally, update cache-busted image refs
- Add "More from Luke" footer links (MMG, prints, YouTube)
- Ad channel configurable in settings UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 01:34:30 -07:00
parent f654a5cbb1
commit bd6c8ccbab
9 changed files with 926 additions and 23 deletions

View File

@@ -52,22 +52,53 @@ FEMALE_NAMES = [
"Shonda", "Marlene", "Yolanda", "Stacy", "Jackie", "Carmen", "Rita", "Val", "Shonda", "Marlene", "Yolanda", "Stacy", "Jackie", "Carmen", "Rita", "Val",
] ]
# Voice pools — ElevenLabs IDs mapped to Inworld voices in tts.py # Voice pools per TTS provider
MALE_VOICES = [ INWORLD_MALE_VOICES = [
"VR6AewLTigWG4xSOukaG", # Edward "Alex", "Blake", "Carter", "Clive", "Craig", "Dennis",
"TxGEqnHWrfWFTfGW9XjX", # Shaun "Dominus", "Edward", "Hades", "Mark", "Ronald", "Shaun", "Theodore", "Timothy",
"pNInz6obpgDQGcFmaJgB", # Alex ]
"ODq5zmih8GrVes37Dizd", # Craig INWORLD_FEMALE_VOICES = [
"IKne3meq5aSn9XLyUdCD", # Timothy "Ashley", "Deborah", "Elizabeth", "Hana", "Julia",
"Luna", "Olivia", "Pixie", "Priya", "Sarah", "Wendy",
] ]
FEMALE_VOICES = [ ELEVENLABS_MALE_VOICES = [
"jBpfuIE2acCO8z3wKNLl", # Hana "CwhRBWXzGAHq8TQ4Fs17", # Roger - Laid-Back, Casual
"EXAVITQu4vr4xnSDxMaL", # Ashley "IKne3meq5aSn9XLyUdCD", # Charlie - Deep, Confident
"21m00Tcm4TlvDq8ikWAM", # Wendy "JBFqnCBsd6RMkjVDRZzb", # George - Warm Storyteller
"XB0fDUnXU5powFXDhCwa", # Sarah "N2lVS1w4EtoT3dr4eOWO", # Callum - Husky Trickster
"pFZP5JQG7iQjIQuC4Bku", # Deborah "SOYHLrjzK2X1ezoPC6cr", # Harry - Fierce
"TX3LPaxmHKxFdv7VOQHJ", # Liam - Energetic
"bIHbv24MWmeRgasZH58o", # Will - Relaxed Optimist
"cjVigY5qzO86Huf0OWal", # Eric - Smooth, Trustworthy
"iP95p4xoKVk53GoZ742B", # Chris - Charming
"nPczCjzI2devNBz1zQrb", # Brian - Deep, Resonant
"onwK4e9ZLuTAKqWW03F9", # Daniel - Steady Broadcaster
"pNInz6obpgDQGcFmaJgB", # Adam - Dominant, Firm
"pqHfZKP75CvOlQylNhV4", # Bill - Wise, Mature
] ]
ELEVENLABS_FEMALE_VOICES = [
"EXAVITQu4vr4xnSDxMaL", # Sarah - Mature, Reassuring
"FGY2WhTYpPnrIDTdsKH5", # Laura - Enthusiast, Quirky
"Xb7hH8MSUJpSbSDYk0k2", # Alice - Clear Educator
"XrExE9yKIg1WjnnlVkGX", # Matilda - Professional
"cgSgspJ2msm6clMCkdW9", # Jessica - Playful, Bright
"hpp4J3VqNfWAUOO0d1Us", # Bella - Professional, Warm
"pFZP5JQG7iQjIQuC4Bku", # Lily - Velvety Actress
]
# River is gender-neutral, add to both pools
ELEVENLABS_MALE_VOICES.append("SAz9YHcvj6GT2YYXdXww") # River - Neutral
ELEVENLABS_FEMALE_VOICES.append("SAz9YHcvj6GT2YYXdXww") # River - Neutral
def _get_voice_pools():
"""Get male/female voice pools based on active TTS provider."""
provider = settings.tts_provider
if provider == "elevenlabs":
return ELEVENLABS_MALE_VOICES, ELEVENLABS_FEMALE_VOICES
# Default to Inworld voices (also used as fallback for other providers)
return INWORLD_MALE_VOICES, INWORLD_FEMALE_VOICES
CALLER_BASES = { CALLER_BASES = {
"1": {"gender": "male", "age_range": (28, 62)}, "1": {"gender": "male", "age_range": (28, 62)},
@@ -89,8 +120,9 @@ def _randomize_callers():
num_f = sum(1 for c in CALLER_BASES.values() if c["gender"] == "female") num_f = sum(1 for c in CALLER_BASES.values() if c["gender"] == "female")
males = random.sample(MALE_NAMES, num_m) males = random.sample(MALE_NAMES, num_m)
females = random.sample(FEMALE_NAMES, num_f) females = random.sample(FEMALE_NAMES, num_f)
m_voices = random.sample(MALE_VOICES, num_m) male_pool, female_pool = _get_voice_pools()
f_voices = random.sample(FEMALE_VOICES, num_f) m_voices = random.sample(male_pool, min(num_m, len(male_pool)))
f_voices = random.sample(female_pool, min(num_f, len(female_pool)))
mi, fi = 0, 0 mi, fi = 0, 0
for base in CALLER_BASES.values(): for base in CALLER_BASES.values():
if base["gender"] == "male": if base["gender"] == "male":
@@ -1943,6 +1975,7 @@ async def get_settings():
@app.post("/api/settings") @app.post("/api/settings")
async def update_settings(data: dict): async def update_settings(data: dict):
"""Update LLM and TTS settings""" """Update LLM and TTS settings"""
old_tts = settings.tts_provider
llm_service.update_settings( llm_service.update_settings(
provider=data.get("provider"), provider=data.get("provider"),
openrouter_model=data.get("openrouter_model"), openrouter_model=data.get("openrouter_model"),
@@ -1950,6 +1983,14 @@ async def update_settings(data: dict):
ollama_host=data.get("ollama_host"), ollama_host=data.get("ollama_host"),
tts_provider=data.get("tts_provider") tts_provider=data.get("tts_provider")
) )
# Re-randomize voices when TTS provider changes voice system
new_tts = settings.tts_provider
if new_tts != old_tts:
old_is_el = old_tts == "elevenlabs"
new_is_el = new_tts == "elevenlabs"
if old_is_el != new_is_el:
_randomize_callers()
print(f"[Settings] TTS changed {old_tts}{new_tts}, re-randomized voices")
return llm_service.get_settings() return llm_service.get_settings()

View File

@@ -135,6 +135,7 @@ class AudioService:
live_caller_channel: Optional[int] = None, live_caller_channel: Optional[int] = None,
music_channel: Optional[int] = None, music_channel: Optional[int] = None,
sfx_channel: Optional[int] = None, sfx_channel: Optional[int] = None,
ad_channel: Optional[int] = None,
phone_filter: Optional[bool] = None phone_filter: Optional[bool] = None
): ):
"""Configure audio devices and channels""" """Configure audio devices and channels"""
@@ -152,6 +153,8 @@ class AudioService:
self.music_channel = music_channel self.music_channel = music_channel
if sfx_channel is not None: if sfx_channel is not None:
self.sfx_channel = sfx_channel self.sfx_channel = sfx_channel
if ad_channel is not None:
self.ad_channel = ad_channel
if phone_filter is not None: if phone_filter is not None:
self.phone_filter = phone_filter self.phone_filter = phone_filter
@@ -168,6 +171,7 @@ class AudioService:
"live_caller_channel": self.live_caller_channel, "live_caller_channel": self.live_caller_channel,
"music_channel": self.music_channel, "music_channel": self.music_channel,
"sfx_channel": self.sfx_channel, "sfx_channel": self.sfx_channel,
"ad_channel": self.ad_channel,
"phone_filter": self.phone_filter, "phone_filter": self.phone_filter,
} }

View File

@@ -577,7 +577,12 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
import base64 import base64
import librosa import librosa
voice = INWORLD_VOICES.get(voice_id, DEFAULT_INWORLD_VOICE) # voice_id is now the Inworld voice name directly (e.g. "Edward")
# Fall back to legacy mapping if it's an ElevenLabs ID
if voice_id in INWORLD_VOICES:
voice = INWORLD_VOICES[voice_id]
else:
voice = voice_id
api_key = settings.inworld_api_key api_key = settings.inworld_api_key
if not api_key: if not api_key:

View File

@@ -35,7 +35,7 @@ Session Reset / First Access to Caller Slot
_randomize_callers() _randomize_callers()
│ Assigns unique names (from 24M/24F pool) and voices (5M/5F) to 10 slots │ Assigns unique names (from 24M/24F pool) and voices (Inworld: 14M/11F, ElevenLabs: 14M/8F) to 10 slots
generate_caller_background(base) generate_caller_background(base)

View File

@@ -145,6 +145,7 @@ a:hover {
flex-shrink: 0; flex-shrink: 0;
} }
.btn-hiw { background: var(--accent); }
.btn-spotify { background: #1DB954; } .btn-spotify { background: #1DB954; }
.btn-youtube { background: #FF0000; } .btn-youtube { background: #FF0000; }
.btn-apple { background: #A033FF; } .btn-apple { background: #A033FF; }
@@ -154,7 +155,7 @@ a:hover {
.episodes-section { .episodes-section {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem 8rem; padding: 2rem 1.5rem 3rem;
} }
.episodes-section h2 { .episodes-section h2 {
@@ -249,6 +250,114 @@ a:hover {
padding: 2rem 0; padding: 2rem 0;
} }
/* Testimonials */
.testimonials-section {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem 3rem;
}
.testimonials-section h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
font-weight: 700;
text-align: center;
}
.testimonials-slider {
overflow: hidden;
position: relative;
}
.testimonials-track {
display: flex;
transition: transform 0.5s ease;
will-change: transform;
}
.testimonial-card {
flex: 0 0 100%;
max-width: 100%;
padding: 0 0.5rem;
box-sizing: border-box;
}
.testimonial-inner {
background: var(--bg-light);
border-radius: var(--radius);
padding: 1.5rem;
border-left: 3px solid var(--accent);
overflow: hidden;
}
.testimonial-stars {
color: var(--accent);
font-size: 1.2rem;
letter-spacing: 2px;
margin-bottom: 1rem;
}
.testimonial-text {
font-size: 1.05rem;
line-height: 1.7;
color: var(--text);
margin-bottom: 1.25rem;
font-style: italic;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
}
.testimonial-author {
display: flex;
align-items: center;
gap: 0.75rem;
}
.testimonial-name {
font-weight: 700;
font-size: 0.95rem;
color: var(--text);
}
.testimonial-location {
font-size: 0.85rem;
color: var(--text-muted);
}
.testimonial-location::before {
content: "\2014 ";
}
.testimonials-dots {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
.testimonial-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.4;
border: none;
cursor: pointer;
padding: 0;
transition: opacity 0.3s, background 0.3s, transform 0.3s;
}
.testimonial-dot:hover {
opacity: 0.7;
}
.testimonial-dot.active {
background: var(--accent);
opacity: 1;
transform: scale(1.3);
}
/* Sticky Player */ /* Sticky Player */
.sticky-player { .sticky-player {
position: fixed; position: fixed;
@@ -368,6 +477,38 @@ a:hover {
color: var(--text); color: var(--text);
} }
.footer-projects {
margin: 1.25rem 0;
padding: 1rem 0;
border-top: 1px solid #2a2015;
border-bottom: 1px solid #2a2015;
}
.footer-projects-label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.footer-projects-links {
display: flex;
justify-content: center;
gap: 1.5rem;
}
.footer-projects-links a {
color: var(--text-muted);
font-size: 0.85rem;
transition: color 0.2s;
}
.footer-projects-links a:hover {
color: var(--accent);
}
.footer-contact { .footer-contact {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@@ -380,6 +521,292 @@ a:hover {
color: var(--accent-hover); color: var(--accent-hover);
} }
/* Page Nav */
.page-nav {
max-width: 900px;
margin: 0 auto;
padding: 1.25rem 1.5rem;
}
.nav-home {
font-weight: 700;
font-size: 1rem;
color: var(--text);
}
.nav-home:hover {
color: var(--accent);
}
/* Page Header */
.page-header {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem 1rem;
text-align: center;
}
.page-header h1 {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 0.75rem;
}
.page-subtitle {
font-size: 1.15rem;
color: var(--text-muted);
max-width: 550px;
margin: 0 auto;
}
/* How It Works */
.hiw-section {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.hiw-section h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
text-align: center;
}
/* Diagram */
.hiw-hero-card {
background: var(--bg-light);
border-radius: var(--radius);
padding: 2rem;
}
.hiw-diagram {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.diagram-row {
display: flex;
justify-content: center;
gap: 1rem;
width: 100%;
}
.diagram-row-split {
flex-wrap: wrap;
}
.diagram-box {
background: var(--bg);
border: 1px solid #3a3020;
border-radius: var(--radius-sm);
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 100px;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
}
.diagram-box.diagram-accent {
border-color: var(--accent);
box-shadow: 0 0 12px rgba(232, 121, 29, 0.15);
}
.diagram-icon {
width: 28px;
height: 28px;
color: var(--accent);
}
.diagram-icon svg {
width: 100%;
height: 100%;
}
.diagram-arrow {
font-size: 1.5rem;
color: var(--text-muted);
line-height: 1;
}
/* Steps */
.hiw-steps {
display: flex;
flex-direction: column;
gap: 2rem;
}
.hiw-step {
display: flex;
gap: 1.25rem;
align-items: flex-start;
}
.hiw-step-number {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 1.1rem;
flex-shrink: 0;
}
.hiw-step-content {
flex: 1;
min-width: 0;
}
.hiw-step-content h3 {
font-size: 1.15rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.hiw-step-content p {
color: var(--text-muted);
font-size: 0.95rem;
line-height: 1.7;
}
.hiw-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-top: 1rem;
}
.hiw-detail {
background: var(--bg-light);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.hiw-detail-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hiw-detail-value {
font-size: 1.1rem;
font-weight: 700;
color: var(--accent);
}
.hiw-split-stat {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
}
.hiw-stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.hiw-stat-number {
font-size: 1.5rem;
font-weight: 800;
color: var(--accent);
}
.hiw-stat-label {
font-size: 0.8rem;
color: var(--text-muted);
}
/* Features */
.hiw-features {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
.hiw-feature {
background: var(--bg-light);
border-radius: var(--radius);
padding: 1.5rem;
}
.hiw-feature-icon {
width: 32px;
height: 32px;
color: var(--accent);
margin-bottom: 0.75rem;
}
.hiw-feature-icon svg {
width: 100%;
height: 100%;
}
.hiw-feature h3 {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.hiw-feature p {
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.6;
}
/* CTA */
.hiw-cta {
text-align: center;
padding: 3rem 1.5rem;
}
.hiw-cta p {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1rem;
}
.hiw-cta-btn {
display: inline-block;
background: var(--accent);
color: #fff;
padding: 0.75rem 2rem;
border-radius: 50px;
font-weight: 700;
font-size: 1rem;
transition: background 0.2s, transform 0.2s;
}
.hiw-cta-btn:hover {
background: var(--accent-hover);
color: #fff;
transform: translateY(-1px);
}
.hiw-cta-phone {
margin-top: 1rem;
color: var(--text-muted);
font-size: 0.95rem;
}
.hiw-cta-phone strong {
color: var(--accent);
}
/* Desktop */ /* Desktop */
@media (min-width: 768px) { @media (min-width: 768px) {
.hero { .hero {
@@ -406,6 +833,22 @@ a:hover {
} }
.episodes-section { .episodes-section {
padding: 2rem 2rem 8rem; padding: 2rem 2rem 3rem;
}
.hiw-section {
padding: 2.5rem 2rem;
}
.hiw-features {
grid-template-columns: repeat(3, 1fr);
}
.hiw-detail-grid {
grid-template-columns: repeat(4, 1fr);
}
.diagram-row-split {
flex-wrap: nowrap;
} }
} }

245
website/how-it-works.html Normal file
View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>How It Works — Luke at the Roost</title>
<meta name="description" content="How Luke at the Roost works: AI-generated callers with unique personalities, real phone calls, voice synthesis, and a live control room — all built from scratch.">
<link rel="canonical" href="https://lukeattheroost.com/how-it-works">
<meta property="og:title" content="How It Works — Luke at the Roost">
<meta property="og:description" content="The tech behind a one-of-a-kind AI radio show. Real callers, AI callers, voice synthesis, and a live control room.">
<meta property="og:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<meta property="og:url" content="https://lukeattheroost.com/how-it-works">
<meta property="og:type" content="website">
<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>
<!-- Nav -->
<nav class="page-nav">
<a href="/" class="nav-home">Luke at the Roost</a>
</nav>
<!-- Page Header -->
<section class="page-header">
<h1>How It Works</h1>
<p class="page-subtitle">Every caller on the show is a one-of-a-kind character — generated in real time by a custom-built AI system. Here's a peek behind the curtain.</p>
</section>
<!-- Overview -->
<section class="hiw-section">
<div class="hiw-card hiw-hero-card">
<div class="hiw-diagram">
<div class="diagram-row">
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</div>
<span>Luke (Host)</span>
</div>
</div>
<div class="diagram-arrow">&#8595;</div>
<div class="diagram-row">
<div class="diagram-box">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span>Control Room</span>
</div>
</div>
<div class="diagram-arrow">&#8595;</div>
<div class="diagram-row diagram-row-split">
<div class="diagram-box">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</div>
<span>AI Brain</span>
</div>
<div class="diagram-box">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/></svg>
</div>
<span>Voice Engine</span>
</div>
<div class="diagram-box">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</div>
<span>Live News</span>
</div>
</div>
<div class="diagram-arrow">&#8595;</div>
<div class="diagram-row diagram-row-split">
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>AI Callers</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</div>
<span>Real Callers</span>
</div>
</div>
</div>
</div>
</section>
<!-- Steps -->
<section class="hiw-section">
<h2>The Anatomy of an AI Caller</h2>
<div class="hiw-steps">
<div class="hiw-step">
<div class="hiw-step-number">1</div>
<div class="hiw-step-content">
<h3>A Person Is Born</h3>
<p>Every caller starts as a blank slate. The system generates a complete identity: name, age, job, hometown, and personality. Each caller gets a unique speaking style — some ramble, some are blunt, some deflect with humor. They have relationships, vehicles, opinions, memories, and reasons for being up this late.</p>
<div class="hiw-detail-grid">
<div class="hiw-detail">
<span class="hiw-detail-label">Unique Names</span>
<span class="hiw-detail-value">48 names</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Personality Layers</span>
<span class="hiw-detail-value">20+</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Towns with Real Knowledge</span>
<span class="hiw-detail-value">32</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Unique Voices</span>
<span class="hiw-detail-value">25</span>
</div>
</div>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">2</div>
<div class="hiw-step-content">
<h3>They Know Their World</h3>
<p>Callers know real facts about where they live — the restaurants, the highways, the local gossip. When a caller says they're from Lordsburg, they actually know about the Hidalgo Hotel and the drive to Deming. The system pulls in real-time news so callers can reference things that actually happened today.</p>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">3</div>
<div class="hiw-step-content">
<h3>They Have a Reason to Call</h3>
<p>Some callers have a problem — a fight with a neighbor, a situation at work, something weighing on them at 2 AM. Others call to geek out about Severance, argue about poker strategy, or share something they read about quantum physics. Every caller has a purpose, not just a script.</p>
<div class="hiw-split-stat">
<div class="hiw-stat">
<span class="hiw-stat-number">70%</span>
<span class="hiw-stat-label">Need advice</span>
</div>
<div class="hiw-stat">
<span class="hiw-stat-number">30%</span>
<span class="hiw-stat-label">Want to talk about something</span>
</div>
</div>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">4</div>
<div class="hiw-step-content">
<h3>The Conversation Is Real</h3>
<p>Luke talks to each caller using push-to-talk, just like a real radio show. His voice is transcribed in real time, sent to an AI that responds in character, and then converted to speech using a voice engine — all in a few seconds. The AI doesn't just answer questions; it reacts, gets emotional, goes on tangents, and remembers what was said earlier in the show.</p>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">5</div>
<div class="hiw-step-content">
<h3>Real Callers Call In Too</h3>
<p>When you dial 208-439-LUKE, your call goes into a live queue. Luke sees you waiting and can take your call right from the control room. Your voice streams in real time — no pre-recording, no delay. You're live on the show, talking to Luke, and the AI callers might even react to what you said.</p>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">6</div>
<div class="hiw-step-content">
<h3>The Control Room</h3>
<p>The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads, monitors the call queue, and controls everything from one screen. Audio is routed across multiple channels simultaneously — caller voices, music, sound effects, and live phone audio all on separate tracks for professional mixing.</p>
<div class="hiw-detail-grid">
<div class="hiw-detail">
<span class="hiw-detail-label">Audio Channels</span>
<span class="hiw-detail-value">5 independent</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Caller Slots</span>
<span class="hiw-detail-value">10 per session</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- What makes it different -->
<section class="hiw-section">
<h2>What Makes This Different</h2>
<div class="hiw-features">
<div class="hiw-feature">
<div class="hiw-feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
</div>
<h3>Not Scripted</h3>
<p>Every conversation is improvised. Luke doesn't know what the caller is going to say. The AI doesn't follow a script. It's a real conversation between a human and an AI character who has a life, opinions, and something on their mind.</p>
</div>
<div class="hiw-feature">
<div class="hiw-feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<h3>Built From Scratch</h3>
<p>This isn't an app with a plugin. Every piece — the caller generator, the voice engine, the control room, the phone system, the audio routing — was built specifically for this show. No templates, no shortcuts.</p>
</div>
<div class="hiw-feature">
<div class="hiw-feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<h3>Real Time</h3>
<p>Everything happens live. Caller generation, voice synthesis, news lookups, phone routing — all in real time during the show. There's no post-production trickery on the caller side. What you hear is what happened.</p>
</div>
</div>
</section>
<!-- CTA -->
<section class="hiw-section hiw-cta">
<p>Want to hear it for yourself?</p>
<a href="/" class="hiw-cta-btn">Listen to Episodes</a>
<div class="hiw-cta-phone">
Or call in live: <strong>208-439-LUKE</strong>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-links">
<a href="/">Home</a>
<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>
<div class="footer-projects">
<span class="footer-projects-label">More from Luke</span>
<div class="footer-projects-links">
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</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>
</body>
</html>

BIN
website/images/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -11,13 +11,13 @@
<!-- OG / Social --> <!-- OG / Social -->
<meta property="og:title" content="Luke at the Roost — Life advice for biologically questionable organisms"> <meta property="og:title" content="Luke at the Roost — Life advice for biologically questionable organisms">
<meta property="og:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms — from a desert hermit's RV. Call in: 208-439-LUKE."> <meta property="og:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms — from a desert hermit's RV. 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:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<meta property="og:url" content="https://lukeattheroost.com"> <meta property="og:url" content="https://lukeattheroost.com">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Luke at the Roost"> <meta name="twitter:title" content="Luke at the Roost">
<meta name="twitter:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms. Call in: 208-439-LUKE"> <meta name="twitter:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms. Call in: 208-439-LUKE">
<meta name="twitter:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2"> <meta name="twitter:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<!-- Favicon --> <!-- 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="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">
@@ -33,7 +33,7 @@
"name": "Luke at the Roost", "name": "Luke at the Roost",
"description": "The call-in talk show where Luke gives life advice to biologically questionable organisms. Broadcast from a desert hermit's RV, featuring a mix of real callers and AI-generated callers.", "description": "The call-in talk show where Luke gives life advice to biologically questionable organisms. Broadcast from a desert hermit's RV, featuring a mix of real callers and AI-generated callers.",
"url": "https://lukeattheroost.com", "url": "https://lukeattheroost.com",
"image": "https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png", "image": "https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3",
"author": { "author": {
"@type": "Person", "@type": "Person",
"name": "Luke MacNeil" "name": "Luke MacNeil"
@@ -65,7 +65,7 @@
<div class="hero-inner"> <div class="hero-inner">
<img <img
class="cover-art" class="cover-art"
src="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=2" src="images/cover.png"
alt="Luke at the Roost cover art" alt="Luke at the Roost cover art"
> >
<div class="hero-info"> <div class="hero-info">
@@ -77,6 +77,10 @@
<span class="phone-digits">(208-439-5853)</span> <span class="phone-digits">(208-439-5853)</span>
</div> </div>
<div class="subscribe-row"> <div class="subscribe-row">
<a href="/how-it-works" class="subscribe-btn btn-hiw">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>
How It Works
</a>
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn btn-spotify"> <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> <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 Spotify
@@ -98,6 +102,80 @@
</div> </div>
</section> </section>
<!-- Testimonials -->
<section class="testimonials-section">
<h2>What Callers Are Saying</h2>
<div class="testimonials-slider" id="testimonials-slider">
<div class="testimonials-track" id="testimonials-track">
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<p class="testimonial-text">"I called in at 2 AM about my neighbor's rooster situation and Luke talked me off the ledge. Literally saved my relationship with the entire block. My wife thinks I'm crazy for calling a radio show but hey, it worked."</p>
<div class="testimonial-author">
<span class="testimonial-name">Tony M.</span>
<span class="testimonial-location">Lordsburg, NM</span>
</div>
</div></div>
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<p class="testimonial-text">"Called to talk about the Severance finale and ended up getting life advice I didn't know I needed. Luke somehow connected Lumon Industries to my actual job and I quit the next week. Best decision I ever made."</p>
<div class="testimonial-author">
<span class="testimonial-name">Carmen R.</span>
<span class="testimonial-location">Deming, NM</span>
</div>
</div></div>
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<p class="testimonial-text">"I've been listening since episode one. Called in about my truck breaking down outside Animas and Luke spent twenty minutes just talking me through it. Turns out it was the alternator AND my attitude. He was right about both."</p>
<div class="testimonial-author">
<span class="testimonial-name">Dale W.</span>
<span class="testimonial-location">Animas, NM</span>
</div>
</div></div>
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9734;</div>
<p class="testimonial-text">"I called in to ask about astrophotography tips and somehow ended up telling Luke about my divorce. He's got this way of getting you to open up. Still shooting the Milky Way every clear night though. Thanks Luke."</p>
<div class="testimonial-author">
<span class="testimonial-name">Jessie K.</span>
<span class="testimonial-location">Silver City, NM</span>
</div>
</div></div>
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<p class="testimonial-text">"My buddy dared me to call in and I ended up having the most real conversation I've had in years. We talked about The Wire for like ten minutes and then he hit me with some truth about why I keep ghosting people. This show is something else."</p>
<div class="testimonial-author">
<span class="testimonial-name">Marcus T.</span>
<span class="testimonial-location">Las Cruces, NM</span>
</div>
</div></div>
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<p class="testimonial-text">"I work night shifts at the mine and this show keeps me sane. Finally called in about a thing with my sister and Luke gave me advice that actually made sense. We're talking again for the first time in three years."</p>
<div class="testimonial-author">
<span class="testimonial-name">Ray D.</span>
<span class="testimonial-location">Tyrone, NM</span>
</div>
</div></div>
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9733;</div>
<p class="testimonial-text">"Called about my poker game falling apart because my best friend cheated. Luke compared it to a Breaking Bad episode and somehow made me see the whole situation differently. We play again every Thursday now."</p>
<div class="testimonial-author">
<span class="testimonial-name">Elena S.</span>
<span class="testimonial-location">Hachita, NM</span>
</div>
</div></div>
<div class="testimonial-card"><div class="testimonial-inner">
<div class="testimonial-stars">&#9733;&#9733;&#9733;&#9733;&#9734;</div>
<p class="testimonial-text">"I was just gonna ask about quantum entanglement because I read this article, but Luke turned it into a metaphor for my long distance relationship and honestly? He wasn't wrong. We're moving in together next month."</p>
<div class="testimonial-author">
<span class="testimonial-name">Priya N.</span>
<span class="testimonial-location">Tucson, AZ</span>
</div>
</div></div>
</div>
</div>
<div class="testimonials-dots" id="testimonials-dots"></div>
</section>
<!-- Episodes --> <!-- Episodes -->
<section class="episodes-section"> <section class="episodes-section">
<h2>Episodes</h2> <h2>Episodes</h2>
@@ -109,10 +187,19 @@
<!-- Footer --> <!-- Footer -->
<footer class="footer"> <footer class="footer">
<div class="footer-links"> <div class="footer-links">
<a href="/how-it-works">How It Works</a>
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a> <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://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> <a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
</div> </div>
<div class="footer-projects">
<span class="footer-projects-label">More from Luke</span>
<div class="footer-projects-links">
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
</div>
</div>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p> <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> <p>&copy; 2026 Luke at the Roost</p>
</footer> </footer>

View File

@@ -223,5 +223,83 @@ playerProgress.addEventListener('click', (e) => {
} }
}); });
// Testimonials Slider
function initTestimonials() {
const track = document.getElementById('testimonials-track');
const dotsContainer = document.getElementById('testimonials-dots');
const cards = track.querySelectorAll('.testimonial-card');
if (!cards.length) return;
let currentIndex = 0;
let autoplayTimer = null;
const maxIndex = () => Math.max(0, cards.length - 1);
function buildDots() {
dotsContainer.innerHTML = '';
for (let i = 0; i < cards.length; i++) {
const dot = document.createElement('button');
dot.className = 'testimonial-dot' + (i === currentIndex ? ' active' : '');
dot.setAttribute('aria-label', `Testimonial ${i + 1}`);
dot.addEventListener('click', () => goTo(i));
dotsContainer.appendChild(dot);
}
}
function updatePosition() {
const cardWidth = cards[0].offsetWidth;
track.style.transform = `translateX(-${currentIndex * cardWidth}px)`;
dotsContainer.querySelectorAll('.testimonial-dot').forEach((d, i) => {
d.classList.toggle('active', i === currentIndex);
});
}
function goTo(index) {
currentIndex = Math.max(0, Math.min(index, maxIndex()));
updatePosition();
resetAutoplay();
}
function next() {
goTo(currentIndex >= maxIndex() ? 0 : currentIndex + 1);
}
function resetAutoplay() {
clearInterval(autoplayTimer);
autoplayTimer = setInterval(next, 10000);
}
// Touch/swipe support
let touchStartX = 0;
let touchDelta = 0;
track.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchDelta = 0;
clearInterval(autoplayTimer);
}, { passive: true });
track.addEventListener('touchmove', (e) => {
touchDelta = e.touches[0].clientX - touchStartX;
}, { passive: true });
track.addEventListener('touchend', () => {
if (Math.abs(touchDelta) > 50) {
touchDelta < 0 ? goTo(currentIndex + 1) : goTo(currentIndex - 1);
}
resetAutoplay();
});
// Recalculate on resize
window.addEventListener('resize', () => {
if (currentIndex > maxIndex()) currentIndex = maxIndex();
buildDots();
updatePosition();
});
buildDots();
updatePosition();
resetAutoplay();
}
// Init // Init
fetchEpisodes(); fetchEpisodes();
initTestimonials();