From 75f15ba2d2912ed7fad30ea4fa0d5a33b7743842 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Thu, 12 Feb 2026 00:24:37 -0700 Subject: [PATCH] Add persistent caller voices, Discord, REC/on-air linking, SEO fixes, ep9 - Returning callers now keep their voice across sessions (stored in regulars.json) - Backfilled voice assignments for all 11 existing regulars - Discord button on homepage + link in all page footers - REC and On-Air buttons now toggle together (both directions) - Fixed host mic double-stream bug (stem_mic vs host_stream conflict) - SEO: JSON-LD structured data on episode + how-it-works pages - SEO: noscript fallbacks, RSS links, twitter meta tags - Episode 9 transcript and sitemap update Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 74 +++- backend/services/audio.py | 12 +- backend/services/regulars.py | 3 +- data/regulars.json | 80 ++-- frontend/js/app.js | 34 +- publish_episode.py | 2 + website/css/style.css | 1 + website/episode.html | 56 ++- website/how-it-works.html | 28 ++ website/index.html | 8 +- website/sitemap.xml | 6 + website/stats.html | 8 + ...de-9-spilled-juice-and-ghostly-visions.txt | 345 ++++++++++++++++++ 13 files changed, 604 insertions(+), 53 deletions(-) create mode 100644 website/transcripts/episode-9-spilled-juice-and-ghostly-visions.txt diff --git a/backend/main.py b/backend/main.py index f18aa4c..3a1bfb7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -158,7 +158,9 @@ def _randomize_callers(): base["name"] = regular["name"] base["returning"] = True base["regular_id"] = regular["id"] - # Keep the randomly assigned voice — regulars sound different each time + # Restore their stored voice so they sound the same every time + if regular.get("voice"): + base["voice"] = regular["voice"] if returning: names = [r["name"] for r in returning] print(f"[Regulars] Injected returning callers: {', '.join(names)}") @@ -2335,21 +2337,64 @@ def _update_on_air_cdn(on_air: bool): @app.post("/api/on-air") async def set_on_air(state: dict): - """Toggle whether the show is on air (accepting phone calls)""" + """Toggle whether the show is on air (accepting phone calls). Also toggles recording.""" 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: + # Auto-start recording FIRST (before host stream, which takes over mic capture) + if audio_service.stem_recorder is None: + try: + from datetime import datetime + dir_name = datetime.now().strftime("%Y-%m-%d_%H%M%S") + recordings_dir = Path("recordings") / dir_name + import sounddevice as sd + device_info = sd.query_devices(audio_service.output_device) if audio_service.output_device is not None else None + sr = int(device_info["default_samplerate"]) if device_info else 48000 + recorder = StemRecorder(recordings_dir, sample_rate=sr) + recorder.start() + audio_service.stem_recorder = recorder + audio_service.start_stem_mic() + add_log(f"Stem recording auto-started -> {recordings_dir}") + except Exception as e: + print(f"[Show] Failed to auto-start recording: {e}") _start_host_audio_sender() + # Host stream takes over mic capture (closes stem_mic if active) audio_service.start_host_stream(_host_audio_sync_callback) else: audio_service.stop_host_stream() + # Auto-stop recording + if audio_service.stem_recorder is not None: + try: + audio_service.stop_stem_mic() + stems_dir = audio_service.stem_recorder.output_dir + paths = audio_service.stem_recorder.stop() + audio_service.stem_recorder = None + add_log(f"Stem recording auto-stopped. Running post-production...") + import subprocess, sys + python = sys.executable + output_file = stems_dir / "episode.mp3" + def _run_postprod(): + try: + result = subprocess.run( + [python, "postprod.py", str(stems_dir), "-o", str(output_file)], + capture_output=True, text=True, timeout=300, + ) + if result.returncode == 0: + add_log(f"Post-production complete -> {output_file}") + else: + add_log(f"Post-production failed: {result.stderr[:300]}") + except Exception as e: + add_log(f"Post-production error: {e}") + threading.Thread(target=_run_postprod, daemon=True).start() + except Exception as e: + print(f"[Show] Failed to auto-stop recording: {e}") threading.Thread(target=_update_on_air_cdn, args=(_show_on_air,), daemon=True).start() - return {"on_air": _show_on_air} + return {"on_air": _show_on_air, "recording": audio_service.stem_recorder is not None} @app.get("/api/on-air") async def get_on_air(): - return {"on_air": _show_on_air} + return {"on_air": _show_on_air, "recording": audio_service.stem_recorder is not None} # --- SignalWire Endpoints --- @@ -2654,6 +2699,7 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li location="in " + job_parts[1].strip() if isinstance(job_parts, tuple) and len(job_parts) > 1 else "unknown", personality_traits=traits[:4], first_call_summary=summary, + voice=base.get("voice"), ) except Exception as e: print(f"[Regulars] Promotion logic error: {e}") @@ -3896,7 +3942,15 @@ async def start_stem_recording(): audio_service.stem_recorder = recorder audio_service.start_stem_mic() add_log(f"Stem recording started -> {recordings_dir}") - return {"status": "recording", "dir": str(recordings_dir)} + # Auto go on-air + global _show_on_air + if not _show_on_air: + _show_on_air = True + _start_host_audio_sender() + audio_service.start_host_stream(_host_audio_sync_callback) + threading.Thread(target=_update_on_air_cdn, args=(True,), daemon=True).start() + add_log("Show auto-set to ON AIR") + return {"status": "recording", "dir": str(recordings_dir), "on_air": _show_on_air} @app.post("/api/recording/stop") @@ -3909,6 +3963,14 @@ async def stop_stem_recording(): audio_service.stem_recorder = None add_log(f"Stem recording stopped. Running post-production...") + # Auto go off-air + global _show_on_air + if _show_on_air: + _show_on_air = False + audio_service.stop_host_stream() + threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start() + add_log("Show auto-set to OFF AIR") + # Auto-run postprod in background import subprocess, sys python = sys.executable @@ -3927,7 +3989,7 @@ async def stop_stem_recording(): add_log(f"Post-production error: {e}") threading.Thread(target=_run_postprod, daemon=True).start() - return {"status": "stopped", "stems": paths, "processing": str(output_file)} + return {"status": "stopped", "stems": paths, "processing": str(output_file), "on_air": _show_on_air} @app.post("/api/recording/process") diff --git a/backend/services/audio.py b/backend/services/audio.py index 4d7389a..2329824 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -522,6 +522,13 @@ class AudioService: print("[Audio] No input device configured for host streaming") return + # Close stem_mic if active — this stream's callback handles stem recording too + if self._stem_mic_stream is not None: + self._stem_mic_stream.stop() + self._stem_mic_stream.close() + self._stem_mic_stream = None + print("[Audio] Closed stem_mic (host stream takes over)") + self._host_send_callback = send_callback def _start(): @@ -960,9 +967,12 @@ class AudioService: def start_stem_mic(self): """Start a persistent mic capture stream for stem recording. - Runs independently of push-to-talk and host streaming.""" + Skips if _host_stream is already active (it writes to the host stem too).""" if self._stem_mic_stream is not None: return + if self._host_stream is not None: + print("[StemRecorder] Host stream already capturing mic, skipping stem_mic") + return if self.input_device is None: print("[StemRecorder] No input device configured, skipping host mic capture") return diff --git a/backend/services/regulars.py b/backend/services/regulars.py index 1533f90..6f9f972 100644 --- a/backend/services/regulars.py +++ b/backend/services/regulars.py @@ -51,7 +51,7 @@ class RegularCallerService: def add_regular(self, name: str, gender: str, age: int, job: str, location: str, personality_traits: list[str], - first_call_summary: str) -> dict: + first_call_summary: str, voice: str = None) -> dict: """Promote a first-time caller to regular""" # Retire oldest if at cap if len(self._regulars) >= MAX_REGULARS: @@ -67,6 +67,7 @@ class RegularCallerService: "job": job, "location": location, "personality_traits": personality_traits, + "voice": voice, "call_history": [ {"summary": first_call_summary, "timestamp": time.time()} ], diff --git a/data/regulars.json b/data/regulars.json index 6acf965..c5e90a5 100644 --- a/data/regulars.json +++ b/data/regulars.json @@ -1,22 +1,5 @@ { "regulars": [ - { - "id": "60053b38", - "name": "Lorraine", - "gender": "female", - "age": 42, - "job": "New Mexico", - "location": "in unknown", - "personality_traits": [], - "call_history": [ - { - "summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller has an outstanding warrant for a DUI charge from a few years ago that they have been avoiding dealing with, which has been causing them a lot of stress and guilt. The host encourages the caller to take responsibility and go to the sheriff's office to get the warrant cleared up, as driving drunk is extremely dangerous and unacceptable.", - "timestamp": 1770573956.570584 - } - ], - "last_call": 1770573956.570584, - "created_at": 1770573956.570584 - }, { "id": "d4bdda2e", "name": "Bobby", @@ -32,7 +15,8 @@ } ], "last_call": 1770602129.5008588, - "created_at": 1770602129.5008588 + "created_at": 1770602129.5008588, + "voice": "onwK4e9ZLuTAKqWW03F9" }, { "id": "d97cb6f9", @@ -54,10 +38,15 @@ { "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 + }, + { + "summary": "Carla called with an update about burning the explicit photos of her ex-husband and his old girlfriend, revealing that the girlfriend unexpectedly messaged her on Facebook to \"clear the air\" after apparently hearing about the situation through Carla's previous radio call. When Luke asked about her most embarrassing masturbation material, Carla admitted to using historical romance novels during her failing marriage, explaining she was drawn to the fantasy of men who actually cared and paid attention, unlike her ex-husband who ignored her to play video games.", + "timestamp": 1770871317.049056 } ], - "last_call": 1770602323.234796, - "created_at": 1770522530.855426 + "last_call": 1770871317.049056, + "created_at": 1770522530.855426, + "voice": "FGY2WhTYpPnrIDTdsKH5" }, { "id": "7be7317c", @@ -74,7 +63,8 @@ } ], "last_call": 1770692087.560523, - "created_at": 1770692087.560523 + "created_at": 1770692087.560523, + "voice": "IKne3meq5aSn9XLyUdCD" }, { "id": "dc4916a7", @@ -91,7 +81,8 @@ } ], "last_call": 1770693549.697355, - "created_at": 1770693549.697355 + "created_at": 1770693549.697355, + "voice": "CwhRBWXzGAHq8TQ4Fs17" }, { "id": "584767e8", @@ -116,7 +107,8 @@ } ], "last_call": 1770694065.5629828, - "created_at": 1770522170.1887732 + "created_at": 1770522170.1887732, + "voice": "SOYHLrjzK2X1ezoPC6cr" }, { "id": "04b1a69c", @@ -133,7 +125,8 @@ } ], "last_call": 1770769705.511872, - "created_at": 1770769705.511872 + "created_at": 1770769705.511872, + "voice": "N2lVS1w4EtoT3dr4eOWO" }, { "id": "747c6464", @@ -150,7 +143,8 @@ } ], "last_call": 1770770008.684105, - "created_at": 1770770008.684105 + "created_at": 1770770008.684105, + "voice": "hpp4J3VqNfWAUOO0d1Us" }, { "id": "49147bd5", @@ -175,7 +169,8 @@ } ], "last_call": 1770770394.0436218, - "created_at": 1770524506.339036 + "created_at": 1770524506.339036, + "voice": "nPczCjzI2devNBz1zQrb" }, { "id": "f21d1346", @@ -189,10 +184,15 @@ { "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 + }, + { + "summary": "Andre calls back with an update: the lawsuit against him was dropped, and the family he helped sent him a card with $500 cash, which makes him feel conflicted about accepting payment for doing the right thing. On a positive note, he's been gambling-free for two months and attending meetings, and Luke encourages him to keep the money or donate it, celebrating his progress.", + "timestamp": 1770870907.493257 } ], - "last_call": 1770770944.7940538, - "created_at": 1770770944.7940538 + "last_call": 1770870907.493258, + "created_at": 1770770944.7940538, + "voice": "JBFqnCBsd6RMkjVDRZzb" }, { "id": "add59d4a", @@ -209,7 +209,8 @@ } ], "last_call": 1770771655.536344, - "created_at": 1770771655.536344 + "created_at": 1770771655.536344, + "voice": "TX3LPaxmHKxFdv7VOQHJ" }, { "id": "13ff1736", @@ -226,7 +227,26 @@ } ], "last_call": 1770772286.1733272, - "created_at": 1770772286.1733272 + "created_at": 1770772286.1733272, + "voice": "pFZP5JQG7iQjIQuC4Bku" + }, + { + "id": "f383d29b", + "name": "Megan", + "gender": "female", + "age": 34, + "job": "which got her thinking about her sister Crystal up in Flagstaff who hasn't seen a truly dark sky", + "location": "unknown", + "personality_traits": [], + "call_history": [ + { + "summary": "Megan, a kindergarten teacher from the bootheel, called in after one of her students asked if stars know we're looking at them, which led her to reflect on how her sister Crystal in Flagstaff has stopped appreciating the night sky despite having access to it. The conversation took an unexpected turn when Luke challenged her to admit a gross habit, and after some prodding, she confessed to picking dry skin off her feet while watching TV and flicking it on the floor.", + "timestamp": 1770870641.723117 + } + ], + "last_call": 1770870641.723117, + "created_at": 1770870641.723117, + "voice": "cgSgspJ2msm6clMCkdW9" } ] } \ No newline at end of file diff --git a/frontend/js/app.js b/frontend/js/app.js index 3ae73ac..f530f94 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -67,11 +67,23 @@ function initEventListeners() { // New Session document.getElementById('new-session-btn')?.addEventListener('click', newSession); - // On-Air toggle + // On-Air + Recording (linked — toggling one toggles both) const onAirBtn = document.getElementById('on-air-btn'); + const recBtn = document.getElementById('rec-btn'); + let stemRecording = false; + + function updateRecBtn(recording) { + stemRecording = recording; + if (recBtn) { + recBtn.classList.toggle('recording', recording); + recBtn.textContent = recording ? '⏺ REC' : 'REC'; + } + } + if (onAirBtn) { fetch('/api/on-air').then(r => r.json()).then(data => { updateOnAirBtn(onAirBtn, data.on_air); + updateRecBtn(data.recording); }); onAirBtn.addEventListener('click', async () => { const isOn = onAirBtn.classList.contains('on'); @@ -81,28 +93,24 @@ function initEventListeners() { body: JSON.stringify({ on_air: !isOn }), }); updateOnAirBtn(onAirBtn, res.on_air); - log(res.on_air ? 'Show is ON AIR — accepting calls' : 'Show is OFF AIR — calls get off-air message'); + updateRecBtn(res.recording); + log(res.on_air ? 'Show is ON AIR + Recording' : 'Show is OFF AIR + Recording stopped'); }); } - // Stem recording toggle - const recBtn = document.getElementById('rec-btn'); if (recBtn) { - let stemRecording = false; recBtn.addEventListener('click', async () => { try { if (!stemRecording) { const res = await safeFetch('/api/recording/start', { method: 'POST' }); - stemRecording = true; - recBtn.classList.add('recording'); - recBtn.textContent = '⏺ REC'; - log('Stem recording started: ' + res.dir); + updateRecBtn(true); + if (onAirBtn) updateOnAirBtn(onAirBtn, res.on_air); + log('Recording started + ON AIR: ' + res.dir); } else { const res = await safeFetch('/api/recording/stop', { method: 'POST' }); - stemRecording = false; - recBtn.classList.remove('recording'); - recBtn.textContent = 'REC'; - log('Stem recording stopped'); + updateRecBtn(false); + if (onAirBtn) updateOnAirBtn(onAirBtn, res.on_air); + log('Recording stopped + OFF AIR'); } } catch (err) { log('Recording error: ' + err.message); diff --git a/publish_episode.py b/publish_episode.py index 36e4a07..fa72fc0 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -58,6 +58,7 @@ CASTOPOD_PASSWORD = "podcast2026api" PODCAST_ID = 1 PODCAST_HANDLE = "LukeAtTheRoost" OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") + WHISPER_MODEL = "base" # Options: tiny, base, small, medium, large # NAS Configuration for chapters upload @@ -543,6 +544,7 @@ def add_episode_to_sitemap(slug: str): print(f" Added episode to sitemap.xml") + def get_next_episode_number() -> int: """Get the next episode number from Castopod.""" headers = get_auth_header() diff --git a/website/css/style.css b/website/css/style.css index 2bdff50..4bfab4d 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -218,6 +218,7 @@ a:hover { .btn-spotify { background: #1DB954; } .btn-youtube { background: #FF0000; } .btn-apple { background: #A033FF; } +.btn-discord { background: #5865F2; } .btn-rss { background: #f26522; } /* Episodes */ diff --git a/website/episode.html b/website/episode.html index dc9055a..30e11f7 100644 --- a/website/episode.html +++ b/website/episode.html @@ -28,6 +28,23 @@ + + + @@ -59,15 +76,32 @@ + + @@ -199,6 +233,26 @@ const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`; document.getElementById('page-canonical')?.setAttribute('href', canonicalUrl); document.getElementById('og-url')?.setAttribute('content', canonicalUrl); + document.getElementById('tw-title')?.setAttribute('content', episode.title); + document.getElementById('tw-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200)); + + // Update JSON-LD structured data + const jsonLd = document.getElementById('episode-jsonld'); + if (jsonLd) { + const ld = JSON.parse(jsonLd.textContent); + ld.name = episode.title; + ld.url = canonicalUrl; + ld.description = stripHtml(episode.description).slice(0, 300); + if (episode.pubDate) ld.datePublished = new Date(episode.pubDate).toISOString().split('T')[0]; + if (episode.episodeNum) ld.episodeNumber = parseInt(episode.episodeNum, 10); + if (episode.audioUrl) { + ld.associatedMedia = { + "@type": "MediaObject", + "contentUrl": episode.audioUrl + }; + } + jsonLd.textContent = JSON.stringify(ld); + } // Play button if (episode.audioUrl) { diff --git a/website/how-it-works.html b/website/how-it-works.html index 8252b39..9a66939 100644 --- a/website/how-it-works.html +++ b/website/how-it-works.html @@ -24,7 +24,34 @@ + + + + @@ -493,6 +520,7 @@