From cee78b5d88b1980ba6ace4a495cd370da9fe1332 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Wed, 11 Feb 2026 15:19:45 -0700 Subject: [PATCH] Add speaker-labeled transcripts, favicon, host stream fix, episode page - Re-label all 8 episode transcripts with LUKE:/CALLER: speaker labels using LLM-based diarization (relabel_transcripts.py) - Add episode.html transcript page with styled speaker labels - Update publish_episode.py to generate speaker-labeled transcripts and copy to website/transcripts/ for Cloudflare Pages - Add SVG favicon with PNG fallbacks - Fix CPU issue: tie host audio stream to on-air toggle, not per-caller - Update how-it-works page with post-production pipeline info - Add transcript links to episode cards in app.js Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 16 +- backend/services/audio.py | 3 + data/regulars.json | 290 +++++++++------- publish_episode.py | 116 ++++++- relabel_transcripts.py | 194 +++++++++++ website/apple-touch-icon.png | Bin 0 -> 21662 bytes website/css/style.css | 116 +++++++ website/episode.html | 259 ++++++++++++++ website/favicon-16.png | Bin 0 -> 681 bytes website/favicon-32.png | Bin 0 -> 1832 bytes website/favicon.svg | 35 ++ website/how-it-works.html | 327 ++++++++++++++++-- website/index.html | 9 +- website/js/app.js | 2 + .../episode-2-late-night-confessions.txt | 103 ++++++ ...e-3-desire-burnout-and-friendship-woes.txt | 99 ++++++ ...episode-4-navigating-life-s-challenges.txt | 151 ++++++++ ...cosmic-theories-and-calling-for-change.txt | 233 +++++++++++++ ...e-night-woes-and-cosmic-contemplations.txt | 269 ++++++++++++++ ...sode-7-ai-takeover-and-honey-endurance.txt | 253 ++++++++++++++ .../episode-8-real-news-or-fake-news.txt | 311 +++++++++++++++++ ...um-physics-pluto-relationship-blunders.txt | 37 ++ 22 files changed, 2637 insertions(+), 186 deletions(-) create mode 100644 relabel_transcripts.py create mode 100644 website/apple-touch-icon.png create mode 100644 website/episode.html create mode 100644 website/favicon-16.png create mode 100644 website/favicon-32.png create mode 100644 website/favicon.svg create mode 100644 website/transcripts/episode-2-late-night-confessions.txt create mode 100644 website/transcripts/episode-3-desire-burnout-and-friendship-woes.txt create mode 100644 website/transcripts/episode-4-navigating-life-s-challenges.txt create mode 100644 website/transcripts/episode-5-cosmic-theories-and-calling-for-change.txt create mode 100644 website/transcripts/episode-6-late-night-woes-and-cosmic-contemplations.txt create mode 100644 website/transcripts/episode-7-ai-takeover-and-honey-endurance.txt create mode 100644 website/transcripts/episode-8-real-news-or-fake-news.txt create mode 100644 website/transcripts/quantum-physics-pluto-relationship-blunders.txt diff --git a/backend/main.py b/backend/main.py index cbd50f0..f18aa4c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2339,6 +2339,11 @@ async def set_on_air(state: dict): global _show_on_air _show_on_air = bool(state.get("on_air", False)) print(f"[Show] On-air: {_show_on_air}") + if _show_on_air: + _start_host_audio_sender() + audio_service.start_host_stream(_host_audio_sync_callback) + else: + audio_service.stop_host_stream() threading.Thread(target=_update_on_air_cdn, args=(_show_on_air,), daemon=True).start() return {"on_air": _show_on_air} @@ -3285,8 +3290,6 @@ async def signalwire_audio_stream(websocket: WebSocket): caller_service.hangup(caller_id) if session.active_real_caller and session.active_real_caller.get("caller_id") == caller_id: session.active_real_caller = None - if len(caller_service.active_calls) == 0: - audio_service.stop_host_stream() broadcast_event("caller_disconnected", {"phone": caller_phone, "reason": disconnect_reason}) broadcast_chat("System", f"{caller_phone} disconnected ({disconnect_reason})") @@ -3394,11 +3397,6 @@ async def take_call_from_queue(caller_id: str): "phone": call_info["phone"], } - # Start host mic streaming if this is the first real caller - if len(caller_service.active_calls) == 1: - _start_host_audio_sender() - audio_service.start_host_stream(_host_audio_sync_callback) - return { "status": "on_air", "caller": call_info, @@ -3652,10 +3650,6 @@ async def hangup_real_caller(): if call_sid: asyncio.create_task(_signalwire_end_call(call_sid)) - # Stop host streaming if no more active callers - if len(caller_service.active_calls) == 0: - audio_service.stop_host_stream() - session.active_real_caller = None hangup_sound = settings.sounds_dir / "hangup.wav" diff --git a/backend/services/audio.py b/backend/services/audio.py index d1ea16f..4d7389a 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -515,6 +515,9 @@ class AudioService: def start_host_stream(self, send_callback: Callable): """Start continuous host mic capture for streaming to real callers""" + if self._host_stream is not None: + self._host_send_callback = send_callback + return if self.input_device is None: print("[Audio] No input device configured for host streaming") return diff --git a/data/regulars.json b/data/regulars.json index 9c85886..6acf965 100644 --- a/data/regulars.json +++ b/data/regulars.json @@ -1,135 +1,5 @@ { "regulars": [ - { - "id": "be244306", - "name": "Dale", - "gender": "male", - "age": 44, - "job": "runs a food truck", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Briefly explain the universe's expansion to a child who wants to know what happens when it stops expanding. Please don't suggest unusual topics; keep the explanation simple.", - "timestamp": 1770515097.24686 - }, - { - "summary": "Dale updates the host on explaining the universe's expansion to his buddy's kid, who now worries if it could \"pop,\" but shifts to his temptation to bet on Super Bowl 60 predictions after reading an article, critiquing a prior caller's gambling mindset while reflecting emotionally on his brother Eddie's fruitless horse-betting habit and his own exhaustion from long taco truck shifts in the cold desert. He ultimately considers a small, affordable wager on the Chiefs as a low-stakes thrill.", - "timestamp": 1770522741.049846 - } - ], - "last_call": 1770522741.049846, - "created_at": 1770515097.24686 - }, - { - "id": "584767e8", - "name": "Carl", - "gender": "male", - "age": 36, - "job": "is a firefighter", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Carl, a firefighter from Lordsburg, New Mexico, called to confess his 20-year gambling addiction, which began with casual poker games at the station and escalated to frequent casino visits and online sessions, draining his finances and leaving him with overdue bills and the fear of losing his home. Emotionally raw, he admitted the habit's destructive hold\u2014like an unquenchable fire\u2014and his pride in avoiding help, but agreed to consider support groups and an 800 hotline after the host suggested productive alternatives like gym workouts or extra volunteer shifts.", - "timestamp": 1770522170.1887732 - }, - { - "summary": "Here is a 1-2 sentence summary of the radio call:\n\nThe caller, Carl, discusses his progress in overcoming his gambling addiction, including rewatching The Sopranos, but the host, Luke, disagrees with Carl's high opinion of the show's ending, leading to a back-and-forth debate between the two about the merits and predictability of the Sopranos finale.", - "timestamp": 1770573289.82847 - }, - { - "summary": "Carl, a firefighter, called to discuss finding $15-20,000 in cash at a house fire and struggling with the temptation to keep it despite doing the right thing by returning it to the family. He's been gambling-free for three months but is financially struggling, and though he returned the money, he's been losing sleep for three nights obsessing over what he could have done with it and fearing he might have blown it at a casino anyway.", - "timestamp": 1770694065.5629818 - } - ], - "last_call": 1770694065.5629828, - "created_at": 1770522170.1887732 - }, - { - "id": "d97cb6f9", - "name": "Carla", - "gender": "female", - "age": 26, - "job": "is a vet tech", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.", - "timestamp": 1770522530.8554251 - }, - { - "summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.", - "timestamp": 1770526316.004708 - }, - { - "summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.", - "timestamp": 1770602323.234795 - } - ], - "last_call": 1770602323.234796, - "created_at": 1770522530.855426 - }, - { - "id": "5ccaea00", - "name": "Jerome", - "gender": "male", - "age": 52, - "job": "works at a cemetery", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Jerome called in to discuss Neil deGrasse Tyson's dismissal of Terrence Howard's unconventional scientific theories, agreeing they don't hold up to real science, before opening up about his emotional turmoil over an unanswered text from his ex, Laura, following a recent blowout that left him questioning his life choices while drinking mezcal in his truck late at night. He reflected on their breakup due to his workaholic tendencies at the cemetery and her desire for more, but found hope in his child's insightful comment about the stars from the Silo books, suggesting they might both be better off apart.", - "timestamp": 1770522903.5809002 - }, - { - "summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, Jerome, recounts a humorous customer service interaction where a woman came to the cemetery he works at late at night frantically trying to find her husband's plot, leading to an amusing back-and-forth.", - "timestamp": 1770523944.299309 - } - ], - "last_call": 1770523944.29931, - "created_at": 1770522903.5809002 - }, - { - "id": "49147bd5", - "name": "Keith", - "gender": "male", - "age": 61, - "job": "south of Silver City", - "location": "in unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.", - "timestamp": 1770524506.3390348 - }, - { - "summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.", - "timestamp": 1770574890.1296651 - } - ], - "last_call": 1770574890.1296651, - "created_at": 1770524506.339036 - }, - { - "id": "4f4612c7", - "name": "Dale", - "gender": "male", - "age": 38, - "job": "is a cop, 12 years on the force", - "location": "unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Dale from Globe called in to express skepticism about Terrence Howard's Tree of Life theory, arguing it lacks peer-reviewed experiments and scientific consensus, much like how he trusts quantum entanglement based on reliable sources without reading every paper himself. The conversation shifted to an emotional discussion of his grief over Uncle Hector, the man who raised him like a father but changed after a stroke, leaving Dale feeling a profound loss without closure, though he found solace in the host's validation and hope for lucid moments ahead.", - "timestamp": 1770526114.530777 - } - ], - "last_call": 1770526114.5307782, - "created_at": 1770526114.5307782 - }, { "id": "60053b38", "name": "Lorraine", @@ -164,6 +34,31 @@ "last_call": 1770602129.5008588, "created_at": 1770602129.5008588 }, + { + "id": "d97cb6f9", + "name": "Carla", + "gender": "female", + "age": 26, + "job": "is a vet tech", + "location": "unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.", + "timestamp": 1770522530.8554251 + }, + { + "summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.", + "timestamp": 1770526316.004708 + }, + { + "summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.", + "timestamp": 1770602323.234795 + } + ], + "last_call": 1770602323.234796, + "created_at": 1770522530.855426 + }, { "id": "7be7317c", "name": "Jerome", @@ -197,6 +92,141 @@ ], "last_call": 1770693549.697355, "created_at": 1770693549.697355 + }, + { + "id": "584767e8", + "name": "Carl", + "gender": "male", + "age": 36, + "job": "is a firefighter", + "location": "unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Carl, a firefighter from Lordsburg, New Mexico, called to confess his 20-year gambling addiction, which began with casual poker games at the station and escalated to frequent casino visits and online sessions, draining his finances and leaving him with overdue bills and the fear of losing his home. Emotionally raw, he admitted the habit's destructive hold\u2014like an unquenchable fire\u2014and his pride in avoiding help, but agreed to consider support groups and an 800 hotline after the host suggested productive alternatives like gym workouts or extra volunteer shifts.", + "timestamp": 1770522170.1887732 + }, + { + "summary": "Here is a 1-2 sentence summary of the radio call:\n\nThe caller, Carl, discusses his progress in overcoming his gambling addiction, including rewatching The Sopranos, but the host, Luke, disagrees with Carl's high opinion of the show's ending, leading to a back-and-forth debate between the two about the merits and predictability of the Sopranos finale.", + "timestamp": 1770573289.82847 + }, + { + "summary": "Carl, a firefighter, called to discuss finding $15-20,000 in cash at a house fire and struggling with the temptation to keep it despite doing the right thing by returning it to the family. He's been gambling-free for three months but is financially struggling, and though he returned the money, he's been losing sleep for three nights obsessing over what he could have done with it and fearing he might have blown it at a casino anyway.", + "timestamp": 1770694065.5629818 + } + ], + "last_call": 1770694065.5629828, + "created_at": 1770522170.1887732 + }, + { + "id": "04b1a69c", + "name": "Reggie", + "gender": "male", + "age": 51, + "job": "a 39-year-old food truck operator, is reeling from a troubling discovery this morning", + "location": "in unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Reggie called in worried because his partner suddenly packed a bag and left for her mom's house without explanation and won't answer his calls, making him fear something is wrong with their relationship. The host advised him to stop calling repeatedly and have a calm conversation with her when she's ready to talk, reassuring him he's likely overreacting.", + "timestamp": 1770769705.511872 + } + ], + "last_call": 1770769705.511872, + "created_at": 1770769705.511872 + }, + { + "id": "747c6464", + "name": "Brenda", + "gender": "female", + "age": 44, + "job": "a 41-year-old ambulance driver, is fed up with the tipping culture", + "location": "unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Brenda called in to vent about being frustrated with automatic tipping at a diner, where a 20% tip was already added to her bill but the card reader prompted her to add an additional 25-35% while the waitress watched. She expressed feeling pressured and annoyed as an ambulance driver with two kids, struggling with whether to look cheap by reducing the tip, before playing a quick real-or-fake news game with the host.", + "timestamp": 1770770008.684104 + } + ], + "last_call": 1770770008.684105, + "created_at": 1770770008.684105 + }, + { + "id": "49147bd5", + "name": "Keith", + "gender": "male", + "age": 61, + "job": "south of Silver City", + "location": "in unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.", + "timestamp": 1770524506.3390348 + }, + { + "summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.", + "timestamp": 1770574890.1296651 + }, + { + "summary": "Keith called in with an update about a widow who has been showing up weekly at the cemetery where he works nights, but she sits by the maintenance shed rather than visiting her husband's grave, and recently started asking Keith's neighbor personal questions about him. Luke dismissively suggested Keith just talk to the woman and called him a coward for being concerned, leading to some tension before they moved on to playing the real or fake news game.", + "timestamp": 1770770394.0436218 + } + ], + "last_call": 1770770394.0436218, + "created_at": 1770524506.339036 + }, + { + "id": "f21d1346", + "name": "Andre", + "gender": "male", + "age": 54, + "job": "is a firefighter unknown", + "location": "in unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Andre called into a radio game show but first shared that he's upset about being named in court documents related to a lawsuit involving a family he helped in December by returning $15,000 after a house fire. Though the host reassured him he has nothing to worry about since he did the right thing, Andre expressed frustration that his good deed led to him being dragged into an insurance dispute.", + "timestamp": 1770770944.7940538 + } + ], + "last_call": 1770770944.7940538, + "created_at": 1770770944.7940538 + }, + { + "id": "add59d4a", + "name": "Rick", + "gender": "male", + "age": 65, + "job": "south of Silver City", + "location": "unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Rick called in to play \"real news or fake news\" and correctly identified a headline about a geothermal plant sale. He then shared that he's troubled about an elderly bank customer who withdrew $8,000 cash while appearing scared and mentioning his daughter's boyfriend was pressuring him about finances\u2014Rick processed the withdrawal but later learned he should have flagged it as potential elder exploitation, and he's feeling guilty about not intervening.", + "timestamp": 1770771655.536344 + } + ], + "last_call": 1770771655.536344, + "created_at": 1770771655.536344 + }, + { + "id": "13ff1736", + "name": "Jasmine", + "gender": "female", + "age": 36, + "job": "a 61-year-old woman who runs a small bakery in the rural Southwest, finds herself at a crossroads", + "location": "unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Jasmine called in to defend an earlier caller (Rick) whom she felt the host was too hard on, explaining she's been feeling guilty herself lately. She emotionally revealed that she chose her 1972 Ford Bronco restoration project over her marriage when given an ultimatum, and now regrets sleeping in the guest room with Valentine's Day approaching.", + "timestamp": 1770772286.1733272 + } + ], + "last_call": 1770772286.1733272, + "created_at": 1770772286.1733272 } ] } \ No newline at end of file diff --git a/publish_episode.py b/publish_episode.py index a0181aa..f2e4719 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -84,6 +84,94 @@ def get_auth_header(): return {"Authorization": f"Basic {credentials}"} +def label_transcript_speakers(text): + """Add LUKE:/CALLER: speaker labels to transcript using LLM.""" + import time as _time + + prompt = """Insert speaker labels into this radio show transcript. The show is "Luke at the Roost". The host is LUKE. Callers call in one at a time. + +CRITICAL: Output EVERY SINGLE WORD from the input. Do NOT summarize, shorten, paraphrase, or skip ANY text. The output must contain the EXACT SAME words as the input, with ONLY speaker labels and line breaks added. + +At each speaker change, insert a blank line and the new speaker's label (e.g., "LUKE:" or "REGGIE:"). + +Speaker identification: +- LUKE is the host — he introduces callers, asks questions, does sponsor reads, opens and closes the show +- Callers are introduced by name by Luke (e.g., "let's talk to Earl", "next up Brenda") +- Use caller FIRST NAME in caps as the label +- When Luke says "Tell me about..." or asks a question, that's LUKE +- When someone responds with their story/opinion/answer, that's the CALLER + +Output format — ONLY the labeled transcript with blank lines between turns. No notes, no commentary. + +TRANSCRIPT: +""" + # Chunk text into ~8000 char segments + chunks = [] + remaining = text + while remaining: + if len(remaining) <= 8000: + if chunks and len(remaining) < 1000: + chunks[-1] = chunks[-1] + " " + remaining + else: + chunks.append(remaining) + break + pos = remaining[:8000].rfind('. ') + if pos < 4000: + pos = remaining[:8000].rfind('? ') + if pos < 4000: + pos = remaining[:8000].rfind('! ') + if pos < 4000: + pos = 8000 + chunks.append(remaining[:pos + 1].strip()) + remaining = remaining[pos + 1:].strip() + + labeled_parts = [] + context = "" + for i, chunk in enumerate(chunks): + full_prompt = prompt + chunk + if context: + full_prompt += f"\n\nCONTEXT: The previous section ended with speaker {context}" + + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": "anthropic/claude-3.5-sonnet", + "messages": [{"role": "user", "content": full_prompt}], + "max_tokens": 8192, + "temperature": 0 + } + ) + if response.status_code != 200: + print(f" Warning: Speaker labeling failed for chunk {i+1}, using raw text") + labeled_parts.append(chunk) + else: + content = response.json()["choices"][0]["message"]["content"].strip() + if content.startswith("```"): + content = re.sub(r'^```\w*\n?', '', content) + content = re.sub(r'\n?```$', '', content) + labeled_parts.append(content) + + # Extract last speaker for context + for line in reversed(content.strip().split('\n')): + m = re.match(r'^([A-Z][A-Z\s\'-]+?):', line.strip()) + if m: + context = m.group(1) + break + + if i < len(chunks) - 1: + _time.sleep(0.5) + + result = "\n\n".join(labeled_parts) + result = re.sub(r'\n{3,}', '\n\n', result) + # Normalize: SPEAKER:\ntext -> SPEAKER: text + result = re.sub(r'^([A-Z][A-Z\s\'-]+?):\s*\n(?!\n)', r'\1: ', result, flags=re.MULTILINE) + return result + + def transcribe_audio(audio_path: str) -> dict: """Transcribe audio using faster-whisper with timestamps.""" print(f"[1/5] Transcribing {audio_path}...") @@ -506,12 +594,20 @@ def main(): chapters_path = audio_path.with_suffix(".chapters.json") save_chapters(metadata, str(chapters_path)) - # Save transcript alongside episode if session data available + # Save transcript text file with LUKE:/CALLER: speaker labels + transcript_path = audio_path.with_suffix(".transcript.txt") + raw_text = transcript["full_text"] + labeled_text = label_transcript_speakers(raw_text) + with open(transcript_path, "w") as f: + f.write(labeled_text) + print(f" Transcript saved to: {transcript_path}") + + # Save session transcript alongside episode if available (has speaker labels) if session_data and session_data.get("transcript"): - transcript_path = audio_path.with_suffix(".transcript.txt") - with open(transcript_path, "w") as f: + session_transcript_path = audio_path.with_suffix(".session_transcript.txt") + with open(session_transcript_path, "w") as f: f.write(session_data["transcript"]) - print(f" Transcript saved to: {transcript_path}") + print(f" Session transcript saved to: {session_transcript_path}") if args.dry_run: print("\n[DRY RUN] Would publish with:") @@ -557,6 +653,18 @@ def main(): upload_to_bunny(str(chapters_path), f"media/{chapters_key}") uploaded_keys.add(chapters_key) + # Transcript + print(f" Uploading transcript to BunnyCDN") + upload_to_bunny(str(transcript_path), f"transcripts/{episode['slug']}.txt", "text/plain") + + # Copy transcript to website dir for Cloudflare Pages + import shutil + website_transcript_dir = Path(__file__).parent / "website" / "transcripts" + website_transcript_dir.mkdir(exist_ok=True) + website_transcript_path = website_transcript_dir / f"{episode['slug']}.txt" + shutil.copy2(str(transcript_path), str(website_transcript_path)) + print(f" Transcript copied to website/transcripts/") + # Step 4: Publish episode = publish_episode(episode["id"]) diff --git a/relabel_transcripts.py b/relabel_transcripts.py new file mode 100644 index 0000000..45f34bf --- /dev/null +++ b/relabel_transcripts.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Re-label podcast transcripts with LUKE:/CALLER: speaker labels using LLM.""" + +import os, re, sys, time, requests +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() +API_KEY = os.getenv("OPENROUTER_API_KEY") +TRANSCRIPT_DIR = Path(__file__).parent / "website" / "transcripts" +MODEL = "anthropic/claude-3.5-sonnet" +CHUNK_SIZE = 8000 + +PROMPT = """Insert speaker labels into this radio show transcript. The show is "Luke at the Roost". The host is LUKE. Callers call in one at a time. + +CRITICAL: Output EVERY SINGLE WORD from the input. Do NOT summarize, shorten, paraphrase, or skip ANY text. The output must contain the EXACT SAME words as the input, with ONLY speaker labels and line breaks added. + +At each speaker change, insert a blank line and the new speaker's label (e.g., "LUKE:" or "REGGIE:"). + +Speaker identification: +- LUKE is the host — he introduces callers, asks questions, does sponsor reads, opens and closes the show +- Callers are introduced by name by Luke (e.g., "let's talk to Earl", "next up Brenda") +- Use caller FIRST NAME in caps as the label +- When Luke says "Tell me about..." or asks a question, that's LUKE +- When someone responds with their story/opinion/answer, that's the CALLER + +Output format — ONLY the labeled transcript with blank lines between turns. No notes, no commentary.""" + +CONTEXT_PROMPT = "\n\nCONTEXT: The previous section ended with the speaker {speaker}. Last few words: \"{tail}\"" + + +def chunk_text(text, max_chars=CHUNK_SIZE): + if len(text) <= max_chars: + return [text] + + chunks = [] + while text: + if len(text) <= max_chars: + # Merge tiny tails into the previous chunk + if chunks and len(text) < 1000: + chunks[-1] = chunks[-1] + " " + text + else: + chunks.append(text) + break + + # Find a good break point near max_chars + pos = text[:max_chars].rfind('. ') + if pos < max_chars // 2: + pos = text[:max_chars].rfind('? ') + if pos < max_chars // 2: + pos = text[:max_chars].rfind('! ') + if pos < max_chars // 2: + pos = max_chars + + chunks.append(text[:pos + 1].strip()) + text = text[pos + 1:].strip() + + return chunks + + +def label_chunk(text, context=""): + prompt = PROMPT + "\n\nTRANSCRIPT:\n" + text + if context: + prompt += context + + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 8192, + "temperature": 0 + } + ) + + if response.status_code != 200: + print(f" API error: {response.status_code} {response.text[:200]}") + return None + + content = response.json()["choices"][0]["message"]["content"].strip() + + # Remove any markdown code block wrappers + if content.startswith("```"): + content = re.sub(r'^```\w*\n?', '', content) + content = re.sub(r'\n?```$', '', content) + + return content + + +def get_last_speaker(text): + lines = text.strip().split('\n') + for line in reversed(lines): + match = re.match(r'^([A-Z][A-Z\s\'-]+?):', line.strip()) + if match: + return match.group(1) + return "LUKE" + + +def validate_output(original, labeled): + """Basic validation that the output looks right.""" + # Check that speaker labels exist (at least 1 for short chunks) + speaker_lines = re.findall(r'^[A-Z][A-Z\s\'-]+?:', labeled, re.MULTILINE) + if len(speaker_lines) < 1: + return False + + # Check that output isn't drastically shorter (allowing for some reformatting) + orig_words = len(original.split()) + labeled_words = len(labeled.split()) + if labeled_words < orig_words * 0.5: + print(f" WARNING: Output is {labeled_words} words vs {orig_words} input words ({labeled_words * 100 // orig_words}%)") + return False + + return True + + +def process_transcript(filepath): + text = filepath.read_text().strip() + # Strip existing timestamp markers + text = re.sub(r'\[[\d:]+\]\s*', '', text) + # Normalize whitespace + text = re.sub(r'\n+', ' ', text) + text = re.sub(r'\s+', ' ', text).strip() + + print(f" {len(text)} chars") + + chunks = chunk_text(text) + print(f" {len(chunks)} chunk(s)") + + labeled_parts = [] + context = "" + + for i, chunk in enumerate(chunks): + print(f" Processing chunk {i + 1}/{len(chunks)} ({len(chunk)} chars)...") + labeled = label_chunk(chunk, context) + + if labeled is None: + print(f" ERROR: API call failed for chunk {i + 1}") + return None + + if not validate_output(chunk, labeled): + print(f" ERROR: Validation failed for chunk {i + 1}") + return None + + labeled_parts.append(labeled) + + # Build context for next chunk + last_speaker = get_last_speaker(labeled) + tail = labeled.strip()[-100:] + context = CONTEXT_PROMPT.format(speaker=last_speaker, tail=tail) + + if i < len(chunks) - 1: + time.sleep(0.5) + + # Join parts, ensuring proper spacing between chunks + result = "\n\n".join(labeled_parts) + # Normalize: ensure exactly one blank line between speaker turns + result = re.sub(r'\n{3,}', '\n\n', result) + # Fix format: put speaker label on same line as text (SPEAKER:\ntext -> SPEAKER: text) + result = re.sub(r'^([A-Z][A-Z\s\'-]+?):\s*\n(?!\n)', r'\1: ', result, flags=re.MULTILINE) + return result + + +def main(): + if not API_KEY: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + + files = sys.argv[1:] if len(sys.argv) > 1 else None + if files: + transcripts = [TRANSCRIPT_DIR / f for f in files] + else: + transcripts = sorted(TRANSCRIPT_DIR.glob("*.txt")) + + for filepath in transcripts: + if not filepath.exists(): + print(f"Skipping {filepath.name} (not found)") + continue + print(f"\nProcessing: {filepath.name}") + labeled = process_transcript(filepath) + if labeled is None: + print(f" SKIPPED (processing failed)") + continue + filepath.write_text(labeled + "\n") + print(f" Saved ({len(labeled)} chars)") + + print("\nDone!") + + +if __name__ == "__main__": + main() diff --git a/website/apple-touch-icon.png b/website/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..faf88827531bb2f95b6c1ae32e27f3999e9a9189 GIT binary patch literal 21662 zcmV*KKxMy)P)`P=72+~K_(U(Q2SW|gvLRauf{V_8B%vXL!g z*&u_BfrT-H!A#waSu`}=3%XfN#k9 zJip-%5pj0^an6Z|d*6+lZ_X^KcCB0a-n;ikoQU{h#~!~8ugPojn!F~j$!qeOye6;7 zYx0`BCa=l2tjL{Sz&DE=KTao~@9x2wJ!*?R>NFM1P5`Ic0x%*&0L9%9Be@jzD#l$+ zw)d`{Iz|6ZuK$}wUIX+hr00ALub)cKl?<-^GmnUcNv2hXp~- z7;&e7(-t||5_x8}ugp?|_c&M|n+N19X$@+c$eKaqcZBZxeVk z@Hnu$>GLx7^Eikjcm+V4K_1=)1yxZ0f$IjortqaS;lFO%A)o%zvr88MCV=}}8rZJ^ zI>`OIckk>;|9b`fepG%R!ikl9ZyAsQ%qWnh{x7xP%P;H5)--@QzT?6V8h#J&y(I^aIONhtARayHWT=iG5qJwQ2)bIH~MG3Rl)r&2k8C#XAjui{ZWA* z1HOwjo&y>G*Nkam0Ye0`V#I^n1dtQnt8b$YXa&@PlM2`#z#iO=>N6_U_W(usv@!hh ztTVs#^~;NwzLjk5ZxukB!+SdKLglA`KM17j*;pOSBldC)#9lKiHvDYE`_@#S6XUgb z{URtHpc+8ylu|pp0C49bz@_|L9o$+$wE#YzTK?6WF7|%qF~}pZzga>3O%3S#ckkZW zr~gw3e+f9Y5!-A6b2KR|K@*E&P4&BtulPP%Y4x~4t;`6om4!kBumgyT@LmzMwpQRS z&64HzHVCJr;Gc=>{ldu`+^ikXHx;PADFJ)r$Wb%Z~U(%$Ip$CEV)%-*!{fK7sFksAN^)F{^azbB@H zHDHj^{P2KQRHX!<2d?6P+<|ie*a6%{0Bzv{z;%L25;S~tB*uKUx1{4X16T8ztx-CPU9)irYja~i|av^7+iYq*bb5)!(`x;4z1T(?bZ00(c zQv+$Dv<$Rthtvu!>qtdNEqvR74&QyO;MmU2)RW)Kl_$?<{LYy^OMT

nd;;g&~W; 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 literal 0 HcmV?d00001 diff --git a/website/css/style.css b/website/css/style.css index 25a648f..f1b0e10 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -704,6 +704,15 @@ a:hover { line-height: 1; } +.diagram-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--text-muted); + margin-bottom: 0.25rem; + margin-top: 0.5rem; +} + /* Steps */ .hiw-steps { display: flex; @@ -876,6 +885,113 @@ a:hover { color: var(--accent); } +/* Episode Page */ +.ep-header { + max-width: 900px; + margin: 0 auto; + padding: 1rem 1.5rem 2rem; +} + +.ep-header-inner { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.ep-meta { + font-size: 0.85rem; + color: var(--text-muted); +} + +.ep-title { + font-size: 2rem; + font-weight: 800; + line-height: 1.2; +} + +.ep-desc { + font-size: 0.95rem; + color: var(--text-muted); + line-height: 1.7; +} + +.ep-actions { + margin-top: 0.5rem; +} + +.ep-play-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: 50px; + padding: 0.6rem 1.5rem; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.2s; +} + +.ep-play-btn:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.ep-play-btn svg { + width: 18px; + height: 18px; +} + +/* Transcript */ +.transcript-section { + max-width: 900px; + margin: 0 auto; + padding: 0 1.5rem 3rem; +} + +.transcript-section h2 { + font-size: 1.3rem; + font-weight: 700; + margin-bottom: 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #2a2015; +} + +.transcript-body { + font-size: 0.95rem; + line-height: 1.8; + color: var(--text); +} + +.transcript-body p { + margin-bottom: 1.25rem; +} + +.speaker-label { + font-weight: 700; + color: var(--accent); + font-size: 0.85rem; + letter-spacing: 0.03em; +} + +.transcript-unavailable { + color: var(--text-muted); + font-style: italic; +} + +.episode-transcript-link { + font-size: 0.8rem; + color: var(--accent); + margin-top: 0.25rem; + display: inline-block; +} + +.episode-transcript-link:hover { + color: var(--accent-hover); +} + /* Desktop */ @media (min-width: 768px) { .hero { diff --git a/website/episode.html b/website/episode.html new file mode 100644 index 0000000..957882a --- /dev/null +++ b/website/episode.html @@ -0,0 +1,259 @@ + + + + + + Episode — Luke at the Roost + + + + + + + + + + + + + + + + + + + + + + +

+ + +
+
+
+

Loading...

+

+
+ +
+
+
+ + +
+

Full Transcript

+
+
Loading transcript...
+
+
+ + + + + +
+
+ +
+
+
+
+
+
+ 0:00 / 0:00 +
+
+
+
+ + + + + + diff --git a/website/favicon-16.png b/website/favicon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..084160da2ea08c3a1c2b5f466ff0da533a25d753 GIT binary patch literal 681 zcmV;a0#^NrP)VUhxS$bPRAvVwEH1>v#E_tgii40Ce^v(wVu_)_0PV%wTDgD6ffh>f-Ol&DKVRM> z_zxOmvAC;Q-9RItfS*1u@GfN-MkQC2KkDitwVLgWgBSvQ)j`GBxft_5JYIh{?QM%G zW$B~qx!lGfgZk=7tHga(OazZ z_qU_*;CvO=+CnTFIocbdwAcFRIwpAd(IE3jXts2|=n9Tkio237H&6MwG`$7DhzX zqQXGKCT;Fd)21MAE3Ql%IAuIfkdgr}FbCMBVMvE?Kr+%@2}|jotd54 zyZ8MbKirvhUuL(dG0`WvAI`bwJpcdsKhJs2Iq*L=@iE4#-EGPHP{e}*`hhh9atfd* zl*5Rd5-voMd~LMiTmZuV3W45U%*ZkyN8tdHj`kfwMGowR=Kywq{WaVHJwbtC#fbFtqG(Q zs0ySFP-B#Dbmvk(U6DXf-rR%aiDuxM0=blA``Qd!`gG1-cgeQ|?A@qyU!Ts?f0{)| z1KW2w^InA@gwC|&nH^d3Nr?goArE!u&D~82^!8#(_{DN7Uu8;u{gn<5ZA}tMc;%Xh zAI36=k>t{t&F=?1Oe2Bi33Ls1t@avhssSxpMBJ_V0IK(@lGb7pD zlRy)Igq*4H%>4z9?d)J}TSR9{a&TKaj`WzAiTKtgof{Q}}GuNv~VCT9P9@=DJ2OgJ46}GL`8Mh+3G79fb3XWf?v1>z` z!{?@$niWK`%khzt-KE9a(z+73AkOT=pD&QsB7X43B!3%Mu>H7DzI>&|yOR;m?&x4+ z7x+@LDy{I^b%!hC9{aZ>NGKk6QK z|Eytqb20)D0gfj){f`>6fuuDNvaMIg)Iv^Pb$Ixg+}ff*MDrWCre8vD;qGB_=;eSsSa!f44+c;nv+z=f3*&{W8og29SI zD3>`xfl$H+6^E=L7@ZMR9Koy~hiS<03nj+nvD!27_$UN-}0EURdUII)h!VjO(KXx zl+h%sTOxAFfGJz$=vy9RRvh2dN=1IVpAO-Zby1ZXX z;MdEl1pE+gl@s)}xzHHf99&AlWAksy3le&`WWOS$II?(_26U$+dpBmtB@-&4LiKj-V?343*mfVVKj(@(z)HeX1srx2nz~L{pvSV!m%kx;@ zZgBR7#k4C>1oUPl-|x?2c^-FnCAcH6bN&Mtf8o>Q#7N10j78!sm#U(ePaOvNBp_5E zpOT~!;P{YDNN!b>IRIUSJK7?ut{|zytP7=C(Rdn31V0;@vJcUuhOM|)y7T640lxzJ z6y`1y2v|_r1o6$c(U#F1(x4RVTP*v0Tkc^3z>FPSYwrwR3?02H1$RqHMoN|(C=mC_ z#s}lTPmHYn(9me`_mwa{&f`=m=FENs4-#J{)&jZuzcMfYTtISaq-_6%=10c=pZyE= WdeB?{Y + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/how-it-works.html b/website/how-it-works.html index 46d7969..a0714d0 100644 --- a/website/how-it-works.html +++ b/website/how-it-works.html @@ -4,11 +4,11 @@ How It Works — Luke at the Roost - + - + @@ -34,46 +34,15 @@
-
+ +
Live Show
+
Luke (Host)
-
-
-
-
-
- -
- Control Room -
-
-
-
-
-
- -
- AI Brain -
-
-
- -
- Voice Engine -
-
-
- -
- Live News -
-
-
-
@@ -87,6 +56,155 @@ Real Callers
+
+ +
+
+
+ +
+ Control Room +
+
+
+ +
+
+
+ +
+ LLM Dialog +
+
+
+ +
+ Voice Synthesis +
+
+
+ +
+ Live Data +
+
+
+ +
+ Audio Router +
+
+
+
+
+ +
+ Music +
+
+
+ +
+ SFX +
+
+
+ +
+ Ads +
+
+
+ +
+
+
+ +
+ Multi-Stem Recorder +
+
+
+ +
Post-Production
+
+
+
+ +
+ Compression & Ducking +
+
+
+ +
+ Loudness Normalization +
+
+
+ +
+ Transcription +
+
+
+ +
Publishing
+
+
+
+ +
+ Podcast Server +
+
+
+ +
+ CDN Edge Network +
+
+
+ +
+ Website +
+
+
+ +
Distribution
+
+
+
+ +
+ Spotify +
+
+
+ +
+ Apple +
+
+
+ +
+ YouTube +
+
+
+ +
+ RSS +
+
+
+ +
+ Analytics +
+
@@ -168,7 +286,7 @@
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.

+

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. The website shows a live on-air indicator so listeners know when to call in.

Audio Channels @@ -178,6 +296,125 @@ Caller Slots 10 per session
+
+ Phone System + VoIP + WebSocket +
+
+ Live Status + Real-time CDN +
+
+
+ + + + + +
+

From Live Show to Podcast

+ +
+
+
7
+
+

Multi-Stem Recording

+

During every show, the system records five separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, and ads. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.

+
+
+ Stems Captured + 5 parallel +
+
+ Format + 48kHz WAV +
+
+ Sync Method + Time-aligned +
+
+ Architecture + Lock-free I/O +
+
+
+
+ +
+
8
+
+

Post-Production Pipeline

+

Once the show ends, an automated six-stage pipeline processes the raw stems into a broadcast-ready episode. Dead air and long silences are removed with crossfaded cuts. Voice tracks get dynamic range compression. Music automatically ducks under dialog. All five stems are mixed into stereo and loudness-normalized to broadcast standards. The whole process runs without manual intervention.

+
+
+ Pipeline Stages + 6 steps +
+
+ Loudness Target + -16 LUFS +
+
+ Music Ducking + Automatic +
+
+ Output + Broadcast MP3 +
+
+
+
+ +
+
9
+
+

Automated Publishing

+

A single command takes a finished episode and handles everything: the audio is transcribed using speech recognition to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server, chapters and transcripts are attached to the metadata, and all media is synced to a global CDN so listeners everywhere get fast downloads.

+
+
+ Transcription + Whisper AI +
+
+ Metadata + LLM-generated +
+
+ Chapters + Auto-detected +
+
+ Deploy Time + ~2 min +
+
+
+
+ +
+
10
+
+

Global Distribution

+

Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.

+
+
+ Audio Delivery + Global CDN +
+
+ Website + Cloudflare Edge +
+
+ Platforms + 5+ directories +
+
+ Feed Format + RSS + Podcast 2.0 +
@@ -200,7 +437,7 @@

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.

+

This isn't an app with a plugin. Every piece — the caller generator, the voice engine, the control room, the phone system, the post-production pipeline, the publishing automation — was built specifically for this show.

@@ -216,6 +453,20 @@

They Listen to Each Other

Callers aren't isolated — they hear what happened earlier in the show. A caller might disagree with the last guy, back someone up, or call in specifically because of something another caller said. The show builds on itself.

+
+
+ +
+

Broadcast-Grade Audio

+

Every episode goes through a professional post-production pipeline: five isolated stems are individually processed with dynamic compression, automatic music ducking, and EBU R128 loudness normalization before being mixed to stereo and encoded for distribution.

+
+
+
+ +
+

Fully Automated Pipeline

+

From recording to your podcast app, the entire pipeline is automated. Post-production kicks off when the show ends, then a publish script handles transcription, AI-generated metadata, chapter detection, CDN sync, and RSS distribution — all with a single command.

+
diff --git a/website/index.html b/website/index.html index 660db09..cd3ddfa 100644 --- a/website/index.html +++ b/website/index.html @@ -20,10 +20,13 @@ - + + + + - + + diff --git a/website/js/app.js b/website/js/app.js index bf4750c..3dd9141 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -119,6 +119,7 @@ function renderEpisodes(episodes) { const durStr = parseDuration(ep.duration); const metaParts = [epLabel, dateStr, durStr].filter(Boolean).join(' · '); + const epSlug = ep.link ? ep.link.split('/episodes/').pop()?.replace(/\/$/, '') : ''; card.innerHTML = `