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 0000000..faf8882 Binary files /dev/null and b/website/apple-touch-icon.png differ 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 0000000..084160d Binary files /dev/null and b/website/favicon-16.png differ diff --git a/website/favicon-32.png b/website/favicon-32.png new file mode 100644 index 0000000..ad6e311 Binary files /dev/null and b/website/favicon-32.png differ diff --git a/website/favicon.svg b/website/favicon.svg new file mode 100644 index 0000000..3b48b69 --- /dev/null +++ b/website/favicon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 = `