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 @@ + + +
+ + +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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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 +