diff --git a/backend/main.py b/backend/main.py index 0190da6..24325ca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -52,22 +52,53 @@ FEMALE_NAMES = [ "Shonda", "Marlene", "Yolanda", "Stacy", "Jackie", "Carmen", "Rita", "Val", ] -# Voice pools — ElevenLabs IDs mapped to Inworld voices in tts.py -MALE_VOICES = [ - "VR6AewLTigWG4xSOukaG", # Edward - "TxGEqnHWrfWFTfGW9XjX", # Shaun - "pNInz6obpgDQGcFmaJgB", # Alex - "ODq5zmih8GrVes37Dizd", # Craig - "IKne3meq5aSn9XLyUdCD", # Timothy +# Voice pools per TTS provider +INWORLD_MALE_VOICES = [ + "Alex", "Blake", "Carter", "Clive", "Craig", "Dennis", + "Dominus", "Edward", "Hades", "Mark", "Ronald", "Shaun", "Theodore", "Timothy", +] +INWORLD_FEMALE_VOICES = [ + "Ashley", "Deborah", "Elizabeth", "Hana", "Julia", + "Luna", "Olivia", "Pixie", "Priya", "Sarah", "Wendy", ] -FEMALE_VOICES = [ - "jBpfuIE2acCO8z3wKNLl", # Hana - "EXAVITQu4vr4xnSDxMaL", # Ashley - "21m00Tcm4TlvDq8ikWAM", # Wendy - "XB0fDUnXU5powFXDhCwa", # Sarah - "pFZP5JQG7iQjIQuC4Bku", # Deborah +ELEVENLABS_MALE_VOICES = [ + "CwhRBWXzGAHq8TQ4Fs17", # Roger - Laid-Back, Casual + "IKne3meq5aSn9XLyUdCD", # Charlie - Deep, Confident + "JBFqnCBsd6RMkjVDRZzb", # George - Warm Storyteller + "N2lVS1w4EtoT3dr4eOWO", # Callum - Husky Trickster + "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 = { "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") males = random.sample(MALE_NAMES, num_m) females = random.sample(FEMALE_NAMES, num_f) - m_voices = random.sample(MALE_VOICES, num_m) - f_voices = random.sample(FEMALE_VOICES, num_f) + male_pool, female_pool = _get_voice_pools() + 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 for base in CALLER_BASES.values(): if base["gender"] == "male": @@ -1943,6 +1975,7 @@ async def get_settings(): @app.post("/api/settings") async def update_settings(data: dict): """Update LLM and TTS settings""" + old_tts = settings.tts_provider llm_service.update_settings( provider=data.get("provider"), openrouter_model=data.get("openrouter_model"), @@ -1950,6 +1983,14 @@ async def update_settings(data: dict): ollama_host=data.get("ollama_host"), 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() diff --git a/backend/services/audio.py b/backend/services/audio.py index 2fd6985..a2c668c 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -135,6 +135,7 @@ class AudioService: live_caller_channel: Optional[int] = None, music_channel: Optional[int] = None, sfx_channel: Optional[int] = None, + ad_channel: Optional[int] = None, phone_filter: Optional[bool] = None ): """Configure audio devices and channels""" @@ -152,6 +153,8 @@ class AudioService: self.music_channel = music_channel if sfx_channel is not None: self.sfx_channel = sfx_channel + if ad_channel is not None: + self.ad_channel = ad_channel if phone_filter is not None: self.phone_filter = phone_filter @@ -168,6 +171,7 @@ class AudioService: "live_caller_channel": self.live_caller_channel, "music_channel": self.music_channel, "sfx_channel": self.sfx_channel, + "ad_channel": self.ad_channel, "phone_filter": self.phone_filter, } diff --git a/backend/services/tts.py b/backend/services/tts.py index 1375feb..3d92690 100644 --- a/backend/services/tts.py +++ b/backend/services/tts.py @@ -577,7 +577,12 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, import base64 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 if not api_key: diff --git a/docs/architecture.md b/docs/architecture.md index 7379587..097f52f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ Session Reset / First Access to Caller Slot │ ▼ _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) diff --git a/website/css/style.css b/website/css/style.css index 7b8ce9c..3ee3926 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -145,6 +145,7 @@ a:hover { flex-shrink: 0; } +.btn-hiw { background: var(--accent); } .btn-spotify { background: #1DB954; } .btn-youtube { background: #FF0000; } .btn-apple { background: #A033FF; } @@ -154,7 +155,7 @@ a:hover { .episodes-section { max-width: 900px; margin: 0 auto; - padding: 2rem 1.5rem 8rem; + padding: 2rem 1.5rem 3rem; } .episodes-section h2 { @@ -249,6 +250,114 @@ a:hover { 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 { position: fixed; @@ -368,6 +477,38 @@ a:hover { 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 { margin-bottom: 0.75rem; } @@ -380,6 +521,292 @@ a: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 */ @media (min-width: 768px) { .hero { @@ -406,6 +833,22 @@ a:hover { } .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; } } diff --git a/website/how-it-works.html b/website/how-it-works.html new file mode 100644 index 0000000..4a7d3d6 --- /dev/null +++ b/website/how-it-works.html @@ -0,0 +1,245 @@ + + + + + + How It Works — Luke at the Roost + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+ Luke (Host) +
+
+
+
+
+
+ +
+ Control Room +
+
+
+
+
+
+ +
+ AI Brain +
+
+
+ +
+ Voice Engine +
+
+
+ +
+ Live News +
+
+
+
+
+
+ +
+ AI Callers +
+
+
+ +
+ Real Callers +
+
+
+
+
+ + +
+

The Anatomy of an AI Caller

+ +
+
+
1
+
+

A Person Is Born

+

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.

+
+
+ Unique Names + 48 names +
+
+ Personality Layers + 20+ +
+
+ Towns with Real Knowledge + 32 +
+
+ Unique Voices + 25 +
+
+
+
+ +
+
2
+
+

They Know Their World

+

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.

+
+
+ +
+
3
+
+

They Have a Reason to Call

+

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.

+
+
+ 70% + Need advice +
+
+ 30% + Want to talk about something +
+
+
+
+ +
+
4
+
+

The Conversation Is Real

+

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.

+
+
+ +
+
5
+
+

Real Callers Call In Too

+

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.

+
+
+ +
+
6
+
+

The Control Room

+

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.

+
+
+ Audio Channels + 5 independent +
+
+ Caller Slots + 10 per session +
+
+
+
+
+
+ + +
+

What Makes This Different

+
+
+
+ +
+

Not Scripted

+

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.

+
+
+
+ +
+

Built From Scratch

+

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.

+
+
+
+ +
+

Real Time

+

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.

+
+
+
+ + +
+

Want to hear it for yourself?

+ Listen to Episodes +
+ Or call in live: 208-439-LUKE +
+
+ + + + + + diff --git a/website/images/cover.png b/website/images/cover.png new file mode 100644 index 0000000..6601a1f Binary files /dev/null and b/website/images/cover.png differ diff --git a/website/index.html b/website/index.html index fed037d..fe66a5c 100644 --- a/website/index.html +++ b/website/index.html @@ -11,13 +11,13 @@ - + - + @@ -33,7 +33,7 @@ "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.", "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": { "@type": "Person", "name": "Luke MacNeil" @@ -65,7 +65,7 @@
Luke at the Roost cover art
@@ -77,6 +77,10 @@ (208-439-5853)
+
+ +
+

What Callers Are Saying

+
+
+
+
★★★★★
+

"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."

+
+ Tony M. + Lordsburg, NM +
+
+
+
★★★★★
+

"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."

+
+ Carmen R. + Deming, NM +
+
+
+
★★★★★
+

"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."

+
+ Dale W. + Animas, NM +
+
+
+
★★★★☆
+

"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."

+
+ Jessie K. + Silver City, NM +
+
+
+
★★★★★
+

"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."

+
+ Marcus T. + Las Cruces, NM +
+
+
+
★★★★★
+

"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."

+
+ Ray D. + Tyrone, NM +
+
+
+
★★★★★
+

"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."

+
+ Elena S. + Hachita, NM +
+
+
+
★★★★☆
+

"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."

+
+ Priya N. + Tucson, AZ +
+
+
+
+
+
+
diff --git a/website/js/app.js b/website/js/app.js index 6453658..39f732a 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -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 fetchEpisodes(); +initTestimonials();