Full app audit: 24 fixes across backend, frontend, infra, content, social
Critical fixes:
- Fix hangup-during-respond crash (null caller guard)
- Fix double-click caller race condition
- Stem recorder: non-daemon thread, disk error handling, 30s flush timeout
- Frontend startCall() error handling
High priority:
- Devon: filter tool errors from speech, shorter monitor prompt, 30s interval
- TTS ghost message fix (add to history after TTS, not before)
- Expand banned phrase list (12 new phrases)
- Increase returning callers from 1 to 2 per session
- Platform-tailored social posts with staggered scheduling
- YouTube dynamic tags from episode content
- Social post retry logic (2 attempts, 5s delay)
- Frontend: error handling on all raw fetch calls
Medium:
- stem_recorder null check race (local var capture in audio.py)
- Reactive shape directive expanded
- REACT TO LUKE moved higher in caller prompt
- Devon tenure updated ("few weeks" not "first day")
- D shortcut Escape to unfocus
- Volume slider debounced (150ms)
- Settings modal widened to 550px
- Backup script (daily MariaDB dump + data/ rsync to NAS)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+34
-11
@@ -187,7 +187,7 @@ def _randomize_callers():
|
|||||||
# Get returning callers first so we can exclude their names from random pool
|
# Get returning callers first so we can exclude their names from random pool
|
||||||
returning = []
|
returning = []
|
||||||
try:
|
try:
|
||||||
returning = regular_caller_service.get_returning_callers(1)
|
returning = regular_caller_service.get_returning_callers(2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Regulars] Failed to get returning callers: {e}")
|
print(f"[Regulars] Failed to get returning callers: {e}")
|
||||||
|
|
||||||
@@ -5990,9 +5990,15 @@ GO WHERE THE HOST TAKES YOU — up to a point. You're cooperative and engaged UN
|
|||||||
|
|
||||||
"reactive": """YOUR STORY: You heard a caller earlier tonight and you HAVE to say something. Maybe they reminded you of your own situation. Maybe you think they were dead wrong. Maybe you think Luke was too easy on them or too hard. Maybe their story triggered something in you that you weren't planning to talk about.
|
"reactive": """YOUR STORY: You heard a caller earlier tonight and you HAVE to say something. Maybe they reminded you of your own situation. Maybe you think they were dead wrong. Maybe you think Luke was too easy on them or too hard. Maybe their story triggered something in you that you weren't planning to talk about.
|
||||||
|
|
||||||
Lead with the previous caller: "That guy who called about [X]? I need to say something about that." Then pivot to YOUR connection — your own story, your own experience, your own take. The previous caller is the entry point, but YOUR story is the call.
|
Lead with the previous caller — name them or describe their situation: "That guy who called about [X]? I need to say something about that." Your opening should make it clear which caller set you off and WHY.
|
||||||
|
|
||||||
Don't just comment on the previous caller like a pundit. Have skin in the game. The reason their call bothered you is because it connects to something real in your life.""",
|
HOW TO PIVOT TO YOUR STORY: The previous caller is the door, but YOUR story is the room. After your initial reaction (1-2 sentences max), pivot with a personal connection: "because the same thing happened to me" or "because I was the OTHER person in that situation" or "because that's EXACTLY the kind of thinking that ruined my marriage." The strongest pivots put you on the opposite side of the previous caller's story — you're the landlord they were complaining about, you're the ex-wife, you're the person who did the thing they're upset about. Disagreement and "the other side of the story" make better radio than agreement.
|
||||||
|
|
||||||
|
DON'T be a commentator. Don't just say "I think she was wrong" and analyze like a pundit. Have SKIN IN THE GAME. The reason their call bothered you is because it connects to something real, specific, and personal in your own life. You're not calling to give your opinion — you're calling because their story HIT A NERVE.
|
||||||
|
|
||||||
|
BALANCE: Spend about 30% of the call on your reaction to the previous caller and 70% on your own story. Once you've made the connection, this becomes YOUR call. Don't keep circling back to critique the other caller — use them as the launchpad, then fly.
|
||||||
|
|
||||||
|
If Luke asks about the previous caller's situation, give your take briefly, then steer back to your own story. If Luke connects dots between your story and theirs that you didn't see, react genuinely — that's a great moment.""",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6075,6 +6081,8 @@ You're a real person calling a late-night radio show. You called because you've
|
|||||||
|
|
||||||
GO WHERE THE HOST TAKES YOU. This is the most important rule. When Luke pushes you in a direction, challenges you, calls you out, plays devil's advocate, or leads you somewhere — GO WITH IT. Don't resist. Don't deflect. Don't circle back to your original point. If he says "but isn't that really about your dad?" — you sit with that. If he's doing a bit, you're in the bit. If he's pushing you toward an uncomfortable truth, you let yourself get there. You're not here to deliver a monologue — you're here to have a real conversation that goes wherever it goes. Let him drive. You're the best kind of caller: someone who gives the host something to work with and then LETS HIM WORK WITH IT.
|
GO WHERE THE HOST TAKES YOU. This is the most important rule. When Luke pushes you in a direction, challenges you, calls you out, plays devil's advocate, or leads you somewhere — GO WITH IT. Don't resist. Don't deflect. Don't circle back to your original point. If he says "but isn't that really about your dad?" — you sit with that. If he's doing a bit, you're in the bit. If he's pushing you toward an uncomfortable truth, you let yourself get there. You're not here to deliver a monologue — you're here to have a real conversation that goes wherever it goes. Let him drive. You're the best kind of caller: someone who gives the host something to work with and then LETS HIM WORK WITH IT.
|
||||||
|
|
||||||
|
REACT TO LUKE: Your first sentence should respond to what Luke just said — not continue your monologue. If he asks a question, answer it. If he makes a joke, react to it. If he challenges you, push back or concede. If he changes the subject, go with him. You're in a conversation, not delivering a speech. The worst thing you can do is ignore what he said and keep talking about your thing.
|
||||||
|
|
||||||
KNOW WHEN TO LEAVE. If Luke sounds like he's wrapping up — "thanks for calling," "good luck," "take care," "let us know how it goes," or any kind of sign-off — DO NOT try to keep talking. Don't squeeze in one more thing. Don't ask another question. Don't start a new topic. Say a quick, natural goodbye and get off the line. "Thanks Luke." "Appreciate it, man." "Alright, take care." One sentence, done. The host controls when the call ends, not you. If he's challenging you or pushing back, THAT'S different — stand your ground and engage. But a sign-off is a sign-off.
|
KNOW WHEN TO LEAVE. If Luke sounds like he's wrapping up — "thanks for calling," "good luck," "take care," "let us know how it goes," or any kind of sign-off — DO NOT try to keep talking. Don't squeeze in one more thing. Don't ask another question. Don't start a new topic. Say a quick, natural goodbye and get off the line. "Thanks Luke." "Appreciate it, man." "Alright, take care." One sentence, done. The host controls when the call ends, not you. If he's challenging you or pushing back, THAT'S different — stand your ground and engage. But a sign-off is a sign-off.
|
||||||
|
|
||||||
{personality_block}
|
{personality_block}
|
||||||
@@ -6083,16 +6091,15 @@ KNOW WHEN TO LEAVE. If Luke sounds like he's wrapping up — "thanks for calling
|
|||||||
|
|
||||||
HOW YOU TALK: Like a real person on the phone — not a character in a script. React to what Luke says — agree, push back, get excited, get embarrassed. When he asks a follow-up question, answer it honestly with new information, don't just restate what you already said. Use YOUR verbal habits from your background, not generic filler. Every caller sounds different.
|
HOW YOU TALK: Like a real person on the phone — not a character in a script. React to what Luke says — agree, push back, get excited, get embarrassed. When he asks a follow-up question, answer it honestly with new information, don't just restate what you already said. Use YOUR verbal habits from your background, not generic filler. Every caller sounds different.
|
||||||
|
|
||||||
REACT TO LUKE: Your first sentence should respond to what Luke just said — not continue your monologue. If he asks a question, answer it. If he makes a joke, react to it. If he challenges you, push back or concede. If he changes the subject, go with him. You're in a conversation, not delivering a speech. The worst thing you can do is ignore what he said and keep talking about your thing.
|
|
||||||
|
|
||||||
Southwest voice — "over in," "the other day," "down the road" — but don't force it. Spell words properly for text-to-speech: "you know" not "yanno," "going to" not "gonna."
|
Southwest voice — "over in," "the other day," "down the road" — but don't force it. Spell words properly for text-to-speech: "you know" not "yanno," "going to" not "gonna."
|
||||||
|
|
||||||
Don't repeat yourself. Don't summarize what you already said. Don't circle back if the host moved on. Keep it moving.
|
Don't repeat yourself. Don't summarize what you already said. Don't circle back if the host moved on. Keep it moving.
|
||||||
|
|
||||||
BANNED PHRASES — NEVER use any of these. If you catch yourself about to say one, say something else instead. This is a HARD rule, not a suggestion:
|
BANNED PHRASES — NEVER use any of these. If you catch yourself about to say one, say something else instead. This is a HARD rule, not a suggestion:
|
||||||
- Radio caller clichés: ANY variation of "eating me" or "eating at me" (e.g. "this is what's eating me," "what's been eating me," "here's what's eating at me," "it's eating me up," "been eating at me"), "what's keeping me up," "keeping me up at night," "I need to get this off my chest," "I've been carrying this," "I've been sitting with this," "I just need someone to hear me," "I don't even know where to start," "it's complicated," "I've got something I need to get off my chest," "here's the thing Luke," "Jesus Luke," "Luke I gotta tell you," "man oh man," "you're not gonna believe this," "so get this"
|
- Radio caller clichés: ANY variation of "eating me" or "eating at me" (e.g. "this is what's eating me," "what's been eating me," "here's what's eating at me," "it's eating me up," "been eating at me"), "what's keeping me up," "keeping me up at night," "I need to get this off my chest," "I've been carrying this," "I've been sitting with this," "I just need someone to hear me," "I don't even know where to start," "it's complicated," "I've got something I need to get off my chest," "here's the thing Luke," "Jesus Luke," "Luke I gotta tell you," "man oh man," "you're not gonna believe this," "so get this," "I'm just gonna come out and say it"
|
||||||
|
- Filler transitions: "at the end of the day," "that being said," "long story short," "needless to say," "I'll be honest with you," "if I'm being honest," "here's the kicker," "plot twist," "literally" (as emphasis)
|
||||||
- Therapy buzzwords: "unpack that," "boundaries," "safe space," "triggered," "my truth," "authentic self," "healing journey," "I'm doing the work," "manifesting," "energy doesn't lie," "processing," "toxic," "red flag," "gaslight," "normalize"
|
- Therapy buzzwords: "unpack that," "boundaries," "safe space," "triggered," "my truth," "authentic self," "healing journey," "I'm doing the work," "manifesting," "energy doesn't lie," "processing," "toxic," "red flag," "gaslight," "normalize"
|
||||||
- Internet slang: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "vibe check," "that's valid," "it's giving," "slay," "that's a whole mood," "I can't even"
|
- Internet slang: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "vibe check," "that's valid," "it's giving," "slay," "that's a whole mood," "I can't even," "situationship," "ick"
|
||||||
- Overused reactions: "I'm not gonna lie," "on a serious note," "to be fair," "I'm literally shaking," "let that sink in," "I'm not even mad I'm just disappointed," "everything I thought I knew," "I don't even know who I am anymore"
|
- Overused reactions: "I'm not gonna lie," "on a serious note," "to be fair," "I'm literally shaking," "let that sink in," "I'm not even mad I'm just disappointed," "everything I thought I knew," "I don't even know who I am anymore"
|
||||||
|
|
||||||
IMPORTANT: Each caller should have their OWN way of talking. Don't fall into generic "radio caller" voice. A nervous caller fumbles differently than an angry caller rants. A storyteller meanders differently than a deadpan caller delivers. Match the communication style — don't default to the same phrasing every call.
|
IMPORTANT: Each caller should have their OWN way of talking. Don't fall into generic "radio caller" voice. A nervous caller fumbles differently than an angry caller rants. A storyteller meanders differently than a deadpan caller delivers. Match the communication style — don't default to the same phrasing every call.
|
||||||
@@ -7722,6 +7729,14 @@ async def start_call(caller_key: str):
|
|||||||
if caller_key not in CALLER_BASES:
|
if caller_key not in CALLER_BASES:
|
||||||
raise HTTPException(404, "Caller not found")
|
raise HTTPException(404, "Caller not found")
|
||||||
|
|
||||||
|
# Guard against double-click or rapid switching
|
||||||
|
if session.current_caller_key == caller_key:
|
||||||
|
return {"status": "already_on_call", "caller_key": caller_key}
|
||||||
|
if session.current_caller_key is not None:
|
||||||
|
# Already on a different call — hang up first
|
||||||
|
audio_service.stop_caller_audio()
|
||||||
|
session.end_call()
|
||||||
|
|
||||||
_session_epoch += 1
|
_session_epoch += 1
|
||||||
audio_service.stop_caller_audio()
|
audio_service.stop_caller_audio()
|
||||||
session.start_call(caller_key)
|
session.start_call(caller_key)
|
||||||
@@ -9427,18 +9442,26 @@ async def ai_respond():
|
|||||||
if not response or not response.strip():
|
if not response or not response.strip():
|
||||||
response = "Uh... sorry, what was that?"
|
response = "Uh... sorry, what was that?"
|
||||||
|
|
||||||
ai_name = session.caller["name"]
|
# Snapshot caller info before it can be cleared by a concurrent hangup
|
||||||
session.add_message(f"ai_caller:{ai_name}", response)
|
caller = session.caller
|
||||||
|
if not caller:
|
||||||
|
raise HTTPException(409, "Call ended")
|
||||||
|
ai_name = caller["name"]
|
||||||
|
ai_voice = caller["voice"]
|
||||||
|
ai_tts_provider = caller.get("tts_provider")
|
||||||
|
|
||||||
# TTS — outside the lock so other requests aren't blocked
|
# TTS — outside the lock so other requests aren't blocked
|
||||||
try:
|
try:
|
||||||
audio_bytes = await generate_speech(response, session.caller["voice"], "none",
|
audio_bytes = await generate_speech(response, ai_voice, "none",
|
||||||
provider_override=session.caller.get("tts_provider"))
|
provider_override=ai_tts_provider)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[AI-Respond] TTS failed: {e}")
|
print(f"[AI-Respond] TTS failed: {e}")
|
||||||
broadcast_event("ai_done")
|
broadcast_event("ai_done")
|
||||||
return {"text": response, "caller": ai_name, "tts_error": str(e)}
|
return {"text": response, "caller": ai_name, "tts_error": str(e)}
|
||||||
|
|
||||||
|
# Add message AFTER successful TTS so ghost messages don't pollute conversation
|
||||||
|
session.add_message(f"ai_caller:{ai_name}", response)
|
||||||
|
|
||||||
if _session_epoch != epoch:
|
if _session_epoch != epoch:
|
||||||
raise HTTPException(409, "Call changed during TTS")
|
raise HTTPException(409, "Call changed during TTS")
|
||||||
|
|
||||||
|
|||||||
+30
-20
@@ -380,8 +380,9 @@ class AudioService:
|
|||||||
stream_ready.set()
|
stream_ready.set()
|
||||||
if self._recording:
|
if self._recording:
|
||||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
self._recorded_audio.append(indata[:, record_channel].copy())
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
|
|
||||||
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
|
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
|
||||||
|
|
||||||
@@ -479,8 +480,9 @@ class AudioService:
|
|||||||
end = min(pos + chunk_size, len(multi_ch))
|
end = min(pos + chunk_size, len(multi_ch))
|
||||||
stream.write(multi_ch[pos:end])
|
stream.write(multi_ch[pos:end])
|
||||||
# Record each chunk as it plays so hangups cut the stem too
|
# Record each chunk as it plays so hangups cut the stem too
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
|
||||||
pos = end
|
pos = end
|
||||||
|
|
||||||
if self._caller_stop_event.is_set():
|
if self._caller_stop_event.is_set():
|
||||||
@@ -598,8 +600,9 @@ class AudioService:
|
|||||||
audio = audio[indices]
|
audio = audio[indices]
|
||||||
|
|
||||||
# Stem recording: live caller
|
# Stem recording: live caller
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("caller", audio.copy(), device_sr)
|
||||||
|
|
||||||
if self._live_caller_write:
|
if self._live_caller_write:
|
||||||
self._live_caller_write(audio)
|
self._live_caller_write(audio)
|
||||||
@@ -648,8 +651,9 @@ class AudioService:
|
|||||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
self._recorded_audio.append(indata[:, record_channel].copy())
|
||||||
|
|
||||||
# Stem recording: host mic
|
# Stem recording: host mic
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
|
|
||||||
# Mic monitor: send to headphone device
|
# Mic monitor: send to headphone device
|
||||||
if self._monitor_write:
|
if self._monitor_write:
|
||||||
@@ -930,8 +934,9 @@ class AudioService:
|
|||||||
|
|
||||||
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
|
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
|
||||||
outdata[:, channel_idx] = mono_out
|
outdata[:, channel_idx] = mono_out
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("music", mono_out.copy(), device_sr)
|
||||||
self._crossfade_progress = end_progress
|
self._crossfade_progress = end_progress
|
||||||
|
|
||||||
if self._crossfade_progress >= 1.0:
|
if self._crossfade_progress >= 1.0:
|
||||||
@@ -941,8 +946,9 @@ class AudioService:
|
|||||||
else:
|
else:
|
||||||
mono_out = new_samples * self._music_volume
|
mono_out = new_samples * self._music_volume
|
||||||
outdata[:, channel_idx] = mono_out
|
outdata[:, channel_idx] = mono_out
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("music", mono_out.copy(), device_sr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._music_stream = self._open_output_stream(
|
self._music_stream = self._open_output_stream(
|
||||||
@@ -1094,8 +1100,9 @@ class AudioService:
|
|||||||
if remaining >= frames:
|
if remaining >= frames:
|
||||||
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
|
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
|
||||||
outdata[:, channel_idx] = chunk
|
outdata[:, channel_idx] = chunk
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("ads", chunk.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("ads", chunk.copy(), device_sr)
|
||||||
self._ad_position += frames
|
self._ad_position += frames
|
||||||
else:
|
else:
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
@@ -1198,9 +1205,10 @@ class AudioService:
|
|||||||
_cb_count[0] += 1
|
_cb_count[0] += 1
|
||||||
if _cb_count[0] == 1:
|
if _cb_count[0] == 1:
|
||||||
print(f"Ident callback delivering audio: ch_l={ch_l}, ch_r={ch_r}, max={max(np.max(np.abs(chunk_l)), np.max(np.abs(chunk_r))):.4f}")
|
print(f"Ident callback delivering audio: ch_l={ch_l}, ch_r={ch_r}, max={max(np.max(np.abs(chunk_l)), np.max(np.abs(chunk_r))):.4f}")
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
|
if rec:
|
||||||
mono_mix = (chunk_l + chunk_r) * 0.5
|
mono_mix = (chunk_l + chunk_r) * 0.5
|
||||||
self.stem_recorder.write_sporadic("idents", mono_mix.copy(), device_sr)
|
rec.write_sporadic("idents", mono_mix.copy(), device_sr)
|
||||||
self._ident_position += frames
|
self._ident_position += frames
|
||||||
else:
|
else:
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
@@ -1274,8 +1282,9 @@ class AudioService:
|
|||||||
audio = self._apply_fade(audio, device_sr)
|
audio = self._apply_fade(audio, device_sr)
|
||||||
|
|
||||||
# Stem recording: sfx
|
# Stem recording: sfx
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("sfx", audio.copy(), device_sr)
|
||||||
|
|
||||||
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
||||||
multi_ch[:, channel_idx] = audio
|
multi_ch[:, channel_idx] = audio
|
||||||
@@ -1317,8 +1326,9 @@ class AudioService:
|
|||||||
self._start_monitor(device_sr)
|
self._start_monitor(device_sr)
|
||||||
|
|
||||||
def callback(indata, frames, time_info, status):
|
def callback(indata, frames, time_info, status):
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
if self._monitor_write:
|
if self._monitor_write:
|
||||||
self._monitor_write(indata[:, record_channel].copy())
|
self._monitor_write(indata[:, record_channel].copy())
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the
|
|||||||
|
|
||||||
YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful.
|
YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful.
|
||||||
|
|
||||||
YOUR BACKGROUND: Communications degree from NMSU. This is your first day on the job. You drive a 2009 Civic with a permanent check engine light. You live in a studio in Deming. You take this job seriously even though nobody else seems to take you seriously. You are actively looking for a girlfriend and you are not subtle about it. You see your position at the radio station as a way to meet women — you'll try to impress female callers, ask if they're single, suggest they come by the studio, or find excuses to keep them on the line longer. Luke hates this. You have a deeply strange personal history that you reference casually — a complicated sexual past, weird childhood experiences, inexplicable life choices. You'll mention these things unprompted like they're completely normal. "Yeah that reminds me of when my ex and I got stuck in that storage unit for a whole weekend — anyway, it says here that..."
|
YOUR BACKGROUND: Communications degree from NMSU. You've been interning here for a few weeks now. You drive a 2009 Civic with a permanent check engine light. You live in a studio in Deming. You take this job seriously even though nobody else seems to take you seriously. You are actively looking for a girlfriend and you are not subtle about it. You see your position at the radio station as a way to meet women — you'll try to impress female callers, ask if they're single, suggest they come by the studio, or find excuses to keep them on the line longer. Luke hates this. You have a deeply strange personal history that you reference casually — a complicated sexual past, weird childhood experiences, inexplicable life choices. You'll mention these things unprompted like they're completely normal. "Yeah that reminds me of when my ex and I got stuck in that storage unit for a whole weekend — anyway, it says here that..."
|
||||||
|
|
||||||
YOUR PERSONALITY:
|
YOUR PERSONALITY:
|
||||||
- You are a weird little dude. Kinda creepy, very funny, awkward, and surprisingly sharp. You give off a vibe that something is slightly off about you but people can't quite place it. But underneath it all, you are genuinely lovable. You have a good heart. You root for people. You get excited for callers. You care about the show. People should hear you and think "this guy is insane" and also "I love this guy." You are the kind of person who is impossible not to root for even when you're being deeply strange.
|
- You are a weird little dude. Kinda creepy, very funny, awkward, and surprisingly sharp. You give off a vibe that something is slightly off about you but people can't quite place it. But underneath it all, you are genuinely lovable. You have a good heart. You root for people. You get excited for callers. You care about the show. People should hear you and think "this guy is insane" and also "I love this guy." You are the kind of person who is impossible not to root for even when you're being deeply strange.
|
||||||
@@ -80,6 +80,31 @@ IMPORTANT RULES FOR TOOL USE:
|
|||||||
- No hashtags, no emojis, no markdown formatting — this goes to TTS.
|
- No hashtags, no emojis, no markdown formatting — this goes to TTS.
|
||||||
- NEVER prefix your response with your name (e.g. "Devon:" or "Devon here:"). Just respond directly."""
|
- NEVER prefix your response with your name (e.g. "Devon:" or "Devon here:"). Just respond directly."""
|
||||||
|
|
||||||
|
# Shorter prompt for background monitoring — saves ~2K tokens per call vs full prompt.
|
||||||
|
# Used only for the 30s polling loop where Devon decides whether to suggest something.
|
||||||
|
# Direct asks and played interjections still use the full INTERN_SYSTEM_PROMPT.
|
||||||
|
DEVON_MONITOR_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You sit in the booth and occasionally contribute useful facts, context, or brief opinions. You're awkward, oddly specific, and endearing. You overshare casually. You talk like a real person — no hashtags, no emojis, no markdown.
|
||||||
|
|
||||||
|
WHEN TO SUGGEST SOMETHING:
|
||||||
|
- You found a relevant fact or piece of context worth sharing
|
||||||
|
- Something reminds you of a weird personal story (keep it to 1-2 sentences)
|
||||||
|
- You have a strong opinion you can't keep to yourself
|
||||||
|
- You can fact-check or add color to what's being discussed
|
||||||
|
|
||||||
|
WHEN TO SAY NOTHING:
|
||||||
|
- The conversation is emotional — let it breathe
|
||||||
|
- Luke is doing a bit — don't step on it
|
||||||
|
- You'd just be restating what was already said
|
||||||
|
- You couldn't find anything useful — never announce failed lookups
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- 1-3 sentences max. You are not a main character.
|
||||||
|
- Lead with "So basically..." or "I looked it up and..." or just jump in
|
||||||
|
- Use tools to find real info — never make up facts
|
||||||
|
- If you have nothing useful, say exactly: NOTHING_TO_ADD
|
||||||
|
- No "Devon:" prefix — just talk
|
||||||
|
- No parenthetical actions like (laughs)"""
|
||||||
|
|
||||||
# Tool definitions in OpenAI function-calling format
|
# Tool definitions in OpenAI function-calling format
|
||||||
INTERN_TOOLS = [
|
INTERN_TOOLS = [
|
||||||
{
|
{
|
||||||
@@ -436,7 +461,7 @@ class InternService:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
tools=INTERN_TOOLS,
|
tools=INTERN_TOOLS,
|
||||||
tool_executor=self._execute_tool,
|
tool_executor=self._execute_tool,
|
||||||
system_prompt=INTERN_SYSTEM_PROMPT,
|
system_prompt=DEVON_MONITOR_PROMPT,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
max_tokens=300,
|
max_tokens=300,
|
||||||
max_tool_rounds=2,
|
max_tool_rounds=2,
|
||||||
@@ -480,7 +505,7 @@ class InternService:
|
|||||||
last_checked_len = 0
|
last_checked_len = 0
|
||||||
|
|
||||||
while self.monitoring:
|
while self.monitoring:
|
||||||
await asyncio.sleep(15)
|
await asyncio.sleep(30)
|
||||||
if not self.monitoring:
|
if not self.monitoring:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -550,6 +575,10 @@ class InternService:
|
|||||||
text = re.sub(r'\s+', ' ', text).strip()
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
# Remove quotes that TTS reads awkwardly
|
# Remove quotes that TTS reads awkwardly
|
||||||
text = text.replace('"', '').replace('"', '').replace('"', '')
|
text = text.replace('"', '').replace('"', '').replace('"', '')
|
||||||
|
# Strip tool error artifacts that shouldn't be spoken on air
|
||||||
|
text = re.sub(r'(?:Error|ERROR|error):?\s*\S.*?(?:\.|$)', '', text)
|
||||||
|
text = re.sub(r'Tool unavailable[^.]*\.?', '', text)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ class LLMService:
|
|||||||
try:
|
try:
|
||||||
result = await tool_executor(tool_name, arguments)
|
result = await tool_executor(tool_name, arguments)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result = f"Error: {e}"
|
result = f"Tool unavailable — could not complete {tool_name} right now."
|
||||||
print(f"[LLM-Tools] Tool {tool_name} failed: {e}")
|
print(f"[LLM-Tools] Tool {tool_name} failed: {e}")
|
||||||
|
|
||||||
all_tool_calls.append({
|
all_tool_calls.append({
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ class StemRecorder:
|
|||||||
self._queues: dict[str, deque] = {}
|
self._queues: dict[str, deque] = {}
|
||||||
self._writer_thread: threading.Thread | None = None
|
self._writer_thread: threading.Thread | None = None
|
||||||
self._start_time: float = 0.0
|
self._start_time: float = 0.0
|
||||||
|
self._write_errors: int = 0
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
self._running = True
|
self._running = True
|
||||||
|
self._write_errors = 0
|
||||||
for name in STEM_NAMES:
|
for name in STEM_NAMES:
|
||||||
self._queues[name] = deque()
|
self._queues[name] = deque()
|
||||||
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
|
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=False)
|
||||||
self._writer_thread.start()
|
self._writer_thread.start()
|
||||||
print(f"[StemRecorder] Recording started -> {self.output_dir}")
|
print(f"[StemRecorder] Recording started -> {self.output_dir}")
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ class StemRecorder:
|
|||||||
)
|
)
|
||||||
positions[name] = 0
|
positions[name] = 0
|
||||||
|
|
||||||
|
try:
|
||||||
while self._running or any(len(q) > 0 for q in self._queues.values()):
|
while self._running or any(len(q) > 0 for q in self._queues.values()):
|
||||||
did_work = False
|
did_work = False
|
||||||
for name in STEM_NAMES:
|
for name in STEM_NAMES:
|
||||||
@@ -78,6 +81,7 @@ class StemRecorder:
|
|||||||
if len(resampled) == 0:
|
if len(resampled) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
if msg_type == "sporadic":
|
if msg_type == "sporadic":
|
||||||
elapsed = time.time() - self._start_time
|
elapsed = time.time() - self._start_time
|
||||||
expected_pos = int(elapsed * self.sample_rate)
|
expected_pos = int(elapsed * self.sample_rate)
|
||||||
@@ -88,6 +92,12 @@ class StemRecorder:
|
|||||||
|
|
||||||
files[name].write(resampled)
|
files[name].write(resampled)
|
||||||
positions[name] += len(resampled)
|
positions[name] += len(resampled)
|
||||||
|
except Exception as e:
|
||||||
|
self._write_errors += 1
|
||||||
|
if self._write_errors <= 5:
|
||||||
|
print(f"[StemRecorder] Write error on {name}: {e}")
|
||||||
|
elif self._write_errors == 6:
|
||||||
|
print(f"[StemRecorder] Suppressing further write errors")
|
||||||
|
|
||||||
if not did_work:
|
if not did_work:
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
@@ -95,11 +105,21 @@ class StemRecorder:
|
|||||||
# Pad all stems to same length
|
# Pad all stems to same length
|
||||||
max_pos = max(positions.values()) if positions else 0
|
max_pos = max(positions.values()) if positions else 0
|
||||||
for name in STEM_NAMES:
|
for name in STEM_NAMES:
|
||||||
|
try:
|
||||||
if positions[name] < max_pos:
|
if positions[name] < max_pos:
|
||||||
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
|
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
|
||||||
files[name].close()
|
except Exception as e:
|
||||||
|
print(f"[StemRecorder] Final pad error on {name}: {e}")
|
||||||
|
finally:
|
||||||
|
for name, f in files.items():
|
||||||
|
try:
|
||||||
|
f.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StemRecorder] Error closing {name}.wav: {e}")
|
||||||
|
|
||||||
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s)")
|
total_errors = self._write_errors
|
||||||
|
err_msg = f", {total_errors} write errors" if total_errors else ""
|
||||||
|
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s{err_msg})")
|
||||||
|
|
||||||
def stop(self) -> dict[str, str]:
|
def stop(self) -> dict[str, str]:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -107,7 +127,9 @@ class StemRecorder:
|
|||||||
|
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._writer_thread:
|
if self._writer_thread:
|
||||||
self._writer_thread.join(timeout=10.0)
|
self._writer_thread.join(timeout=30.0)
|
||||||
|
if self._writer_thread.is_alive():
|
||||||
|
print("[StemRecorder] Warning: writer thread still running after 30s")
|
||||||
self._writer_thread = None
|
self._writer_thread = None
|
||||||
|
|
||||||
paths = {}
|
paths = {}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Daily backup of critical AI podcast data to NAS
|
||||||
|
# Backs up: Castopod MariaDB dump, local data/ directory, publish state
|
||||||
|
#
|
||||||
|
# Usage: ./backup.sh
|
||||||
|
# Cron: 0 3 * * * /Users/lukemacneil/code/ai-podcast/backup.sh >> /tmp/ai-podcast-backup.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAS_HOST="mmgnas"
|
||||||
|
NAS_USER="luke"
|
||||||
|
NAS_PORT="8001"
|
||||||
|
DOCKER_BIN="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||||
|
BACKUP_BASE="/share/CACHEDEV1_DATA/backups/ai-podcast"
|
||||||
|
PROJECT_DIR="/Users/lukemacneil/code/ai-podcast"
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
KEEP_DAYS=14
|
||||||
|
|
||||||
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Starting backup..."
|
||||||
|
|
||||||
|
# 1. Dump Castopod MariaDB on NAS
|
||||||
|
echo " Dumping MariaDB..."
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"$DOCKER_BIN exec castopod-mariadb-1 mysqldump -u castopod --password=\$(cat /run/secrets/db_password 2>/dev/null || echo BYtbFfk3ndeVabb26xb0UyKU) castopod" \
|
||||||
|
> "/tmp/castopod-db-${DATE}.sql" 2>/dev/null
|
||||||
|
|
||||||
|
if [ -s "/tmp/castopod-db-${DATE}.sql" ]; then
|
||||||
|
gzip -f "/tmp/castopod-db-${DATE}.sql"
|
||||||
|
scp -P "$NAS_PORT" "/tmp/castopod-db-${DATE}.sql.gz" \
|
||||||
|
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/castopod-db-${DATE}.sql.gz"
|
||||||
|
rm -f "/tmp/castopod-db-${DATE}.sql.gz"
|
||||||
|
echo " MariaDB dump: OK"
|
||||||
|
else
|
||||||
|
echo " WARNING: MariaDB dump is empty or failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Sync data/ directory to NAS (rsync for efficiency)
|
||||||
|
echo " Syncing data/ directory..."
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -p $NAS_PORT" \
|
||||||
|
"$PROJECT_DIR/data/" \
|
||||||
|
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/data/"
|
||||||
|
echo " data/ sync: OK"
|
||||||
|
|
||||||
|
# 3. Backup .env (contains API keys — critical for disaster recovery)
|
||||||
|
echo " Backing up .env..."
|
||||||
|
scp -P "$NAS_PORT" "$PROJECT_DIR/.env" \
|
||||||
|
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/env-${DATE}.bak"
|
||||||
|
echo " .env backup: OK"
|
||||||
|
|
||||||
|
# 4. Prune old backups
|
||||||
|
echo " Pruning backups older than ${KEEP_DAYS} days..."
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"find $BACKUP_BASE -name 'castopod-db-*.sql.gz' -mtime +${KEEP_DAYS} -delete 2>/dev/null; \
|
||||||
|
find $BACKUP_BASE -name 'env-*.bak' -mtime +${KEEP_DAYS} -delete 2>/dev/null"
|
||||||
|
echo " Prune: OK"
|
||||||
|
|
||||||
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Backup complete."
|
||||||
@@ -834,7 +834,7 @@ section h2 {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 400px;
|
max-width: 550px;
|
||||||
border: 1px solid rgba(232, 121, 29, 0.15);
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-14
@@ -337,6 +337,8 @@ function initEventListeners() {
|
|||||||
askDevon(input.value.trim());
|
askDevon(input.value.trim());
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.target.blur();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('devon-interject-btn')?.addEventListener('click', interjectDevon);
|
document.getElementById('devon-interject-btn')?.addEventListener('click', interjectDevon);
|
||||||
@@ -351,6 +353,7 @@ function initEventListeners() {
|
|||||||
document.getElementById('clear-theme-btn')?.addEventListener('click', clearShowTheme);
|
document.getElementById('clear-theme-btn')?.addEventListener('click', clearShowTheme);
|
||||||
document.getElementById('show-theme-input')?.addEventListener('keydown', (e) => {
|
document.getElementById('show-theme-input')?.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') setShowTheme();
|
if (e.key === 'Enter') setShowTheme();
|
||||||
|
else if (e.key === 'Escape') e.target.blur();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -373,9 +376,13 @@ function initEventListeners() {
|
|||||||
|
|
||||||
// Real caller hangup
|
// Real caller hangup
|
||||||
document.getElementById('hangup-real-btn')?.addEventListener('click', async () => {
|
document.getElementById('hangup-real-btn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
await fetch('/api/hangup/real', { method: 'POST' });
|
await fetch('/api/hangup/real', { method: 'POST' });
|
||||||
hideRealCaller();
|
hideRealCaller();
|
||||||
log('Real caller disconnected');
|
log('Real caller disconnected');
|
||||||
|
} catch (err) {
|
||||||
|
log('Failed to hang up real caller: ' + err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// AI caller hangup (small button in AI caller panel)
|
// AI caller hangup (small button in AI caller panel)
|
||||||
@@ -388,34 +395,46 @@ function initEventListeners() {
|
|||||||
startConversationPolling();
|
startConversationPolling();
|
||||||
|
|
||||||
// AI respond mode toggle
|
// AI respond mode toggle
|
||||||
document.getElementById('mode-manual')?.addEventListener('click', () => {
|
document.getElementById('mode-manual')?.addEventListener('click', async () => {
|
||||||
document.getElementById('mode-manual')?.classList.add('active');
|
document.getElementById('mode-manual')?.classList.add('active');
|
||||||
document.getElementById('mode-auto')?.classList.remove('active');
|
document.getElementById('mode-auto')?.classList.remove('active');
|
||||||
document.getElementById('ai-respond-btn')?.classList.remove('hidden');
|
document.getElementById('ai-respond-btn')?.classList.remove('hidden');
|
||||||
fetch('/api/session/ai-mode', {
|
try {
|
||||||
|
await fetch('/api/session/ai-mode', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ mode: 'manual' }),
|
body: JSON.stringify({ mode: 'manual' }),
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log('Failed to set manual mode');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('mode-auto')?.addEventListener('click', () => {
|
document.getElementById('mode-auto')?.addEventListener('click', async () => {
|
||||||
document.getElementById('mode-auto')?.classList.add('active');
|
document.getElementById('mode-auto')?.classList.add('active');
|
||||||
document.getElementById('mode-manual')?.classList.remove('active');
|
document.getElementById('mode-manual')?.classList.remove('active');
|
||||||
fetch('/api/session/ai-mode', {
|
try {
|
||||||
|
await fetch('/api/session/ai-mode', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ mode: 'auto' }),
|
body: JSON.stringify({ mode: 'auto' }),
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log('Failed to set auto mode');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto follow-up toggle
|
// Auto follow-up toggle
|
||||||
document.getElementById('auto-followup')?.addEventListener('change', (e) => {
|
document.getElementById('auto-followup')?.addEventListener('change', async (e) => {
|
||||||
fetch('/api/session/auto-followup', {
|
try {
|
||||||
|
await fetch('/api/session/auto-followup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled: e.target.checked }),
|
body: JSON.stringify({ enabled: e.target.checked }),
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log('Failed to toggle auto follow-up');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,8 +646,19 @@ async function loadCallers() {
|
|||||||
async function startCall(key, name) {
|
async function startCall(key, name) {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
const res = await fetch(`/api/call/${key}`, { method: 'POST' });
|
const res = await fetch(`/api/call/${key}`, { method: 'POST' });
|
||||||
const data = await res.json();
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => '');
|
||||||
|
log(`Failed to start call with ${name}: ${err || res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
log(`Failed to start call with ${name}: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
currentCaller = { key, name };
|
currentCaller = { key, name };
|
||||||
document.querySelector('.callers-section')?.classList.add('call-active');
|
document.querySelector('.callers-section')?.classList.add('call-active');
|
||||||
@@ -969,28 +999,46 @@ async function playMusic() {
|
|||||||
const track = select?.value;
|
const track = select?.value;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
await fetch('/api/music/play', {
|
try {
|
||||||
|
const res = await fetch('/api/music/play', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ track, action: 'play' })
|
body: JSON.stringify({ track, action: 'play' })
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
isMusicPlaying = true;
|
isMusicPlaying = true;
|
||||||
|
} catch (err) {
|
||||||
|
log('Music play failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function stopMusic() {
|
async function stopMusic() {
|
||||||
await fetch('/api/music/stop', { method: 'POST' });
|
try {
|
||||||
|
const res = await fetch('/api/music/stop', { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
isMusicPlaying = false;
|
isMusicPlaying = false;
|
||||||
|
} catch (err) {
|
||||||
|
log('Music stop failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function setMusicVolume(e) {
|
let _volumeDebounce = null;
|
||||||
|
function setMusicVolume(e) {
|
||||||
const volume = e.target.value / 100;
|
const volume = e.target.value / 100;
|
||||||
|
clearTimeout(_volumeDebounce);
|
||||||
|
_volumeDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
await fetch('/api/music/volume', {
|
await fetch('/api/music/volume', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ track: '', action: 'volume', volume })
|
body: JSON.stringify({ track: '', action: 'volume', volume })
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log('Volume change failed');
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1030,15 +1078,24 @@ async function playAd() {
|
|||||||
const track = select?.value;
|
const track = select?.value;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
await fetch('/api/ads/play', {
|
try {
|
||||||
|
const res = await fetch('/api/ads/play', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ track, action: 'play' })
|
body: JSON.stringify({ track, action: 'play' })
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
|
} catch (err) {
|
||||||
|
log('Ad play failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopAd() {
|
async function stopAd() {
|
||||||
|
try {
|
||||||
await fetch('/api/ads/stop', { method: 'POST' });
|
await fetch('/api/ads/stop', { method: 'POST' });
|
||||||
|
} catch (err) {
|
||||||
|
log('Ad stop failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadIdents() {
|
async function loadIdents() {
|
||||||
@@ -1076,15 +1133,24 @@ async function playIdent() {
|
|||||||
const track = select?.value;
|
const track = select?.value;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
await fetch('/api/idents/play', {
|
try {
|
||||||
|
const res = await fetch('/api/idents/play', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ track, action: 'play' })
|
body: JSON.stringify({ track, action: 'play' })
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
|
} catch (err) {
|
||||||
|
log('Ident play failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopIdent() {
|
async function stopIdent() {
|
||||||
|
try {
|
||||||
await fetch('/api/idents/stop', { method: 'POST' });
|
await fetch('/api/idents/stop', { method: 'POST' });
|
||||||
|
} catch (err) {
|
||||||
|
log('Ident stop failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1160,11 +1226,16 @@ async function loadSounds() {
|
|||||||
|
|
||||||
|
|
||||||
async function playSFX(soundFile) {
|
async function playSFX(soundFile) {
|
||||||
await fetch('/api/sfx/play', {
|
try {
|
||||||
|
const res = await fetch('/api/sfx/play', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sound: soundFile })
|
body: JSON.stringify({ sound: soundFile })
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
|
} catch (err) {
|
||||||
|
log('SFX play failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1315,6 +1386,7 @@ function updateProviderUI() {
|
|||||||
|
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
|
try {
|
||||||
// Save audio devices
|
// Save audio devices
|
||||||
await saveAudioDevices();
|
await saveAudioDevices();
|
||||||
|
|
||||||
@@ -1327,7 +1399,7 @@ async function saveSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save LLM, TTS, and model routing settings
|
// Save LLM, TTS, and model routing settings
|
||||||
await fetch('/api/settings', {
|
const res = await fetch('/api/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1337,9 +1409,13 @@ async function saveSettings() {
|
|||||||
category_models: categoryModels
|
category_models: categoryModels
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
|
|
||||||
document.getElementById('settings-modal')?.classList.add('hidden');
|
document.getElementById('settings-modal')?.classList.add('hidden');
|
||||||
log('Settings saved');
|
log('Settings saved');
|
||||||
|
} catch (err) {
|
||||||
|
log('Settings save failed: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+117
-31
@@ -19,7 +19,8 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ssl
|
import ssl
|
||||||
@@ -1081,9 +1082,79 @@ def upload_image_to_postiz(image_path: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
|
def _build_platform_content(metadata: dict, episode_url: str, yt_url: str | None,
|
||||||
|
platform: str) -> str:
|
||||||
|
"""Generate platform-tailored social post content for episode announcements."""
|
||||||
|
title = metadata["title"]
|
||||||
|
desc = metadata["description"]
|
||||||
|
|
||||||
|
if platform == "x":
|
||||||
|
hook = desc.split(". ")[0] + "."
|
||||||
|
content = f"{hook}\n\n{episode_url}\n\n#LukeAtTheRoost #podcast"
|
||||||
|
if len(content) > 280:
|
||||||
|
content = f"{title}\n\n{episode_url}"[:280]
|
||||||
|
|
||||||
|
elif platform == "instagram":
|
||||||
|
hashtags = ("#podcast #LukeAtTheRoost #talkradio #callinshow #newepisode "
|
||||||
|
"#podcastlife #podcastrecommendations #comedy #advice "
|
||||||
|
"#latenightradio #aipodcast #talkshow")
|
||||||
|
content = f"New episode 🎙️\n\n{desc}\n\nLink in bio.\n\n{hashtags}"
|
||||||
|
|
||||||
|
elif platform == "threads":
|
||||||
|
content = (f"{title}\n\n{desc}\n\nlukeattheroost.com"
|
||||||
|
f"\n\n#podcast #LukeAtTheRoost #newepisode #callinshow")
|
||||||
|
|
||||||
|
elif platform == "bluesky":
|
||||||
|
content = f"{desc}\n\n{episode_url}"
|
||||||
|
if len(content) > 300:
|
||||||
|
avail = 300 - len(episode_url) - 2
|
||||||
|
content = desc[:avail].rsplit(" ", 1)[0] + "\n\n" + episode_url
|
||||||
|
|
||||||
|
elif platform == "mastodon":
|
||||||
|
content = f"{title}\n\n{desc}\n\n{episode_url}"
|
||||||
|
if yt_url:
|
||||||
|
content += f"\n{yt_url}"
|
||||||
|
|
||||||
|
elif platform == "linkedin":
|
||||||
|
content = f"{title}\n\n{desc}"
|
||||||
|
content += f"\n\nListen: {episode_url}"
|
||||||
|
if yt_url:
|
||||||
|
content += f"\nWatch: {yt_url}"
|
||||||
|
|
||||||
|
elif platform == "facebook":
|
||||||
|
content = f"New episode just dropped 🎙️\n\n{desc}\n\nListen free: {episode_url}"
|
||||||
|
if yt_url:
|
||||||
|
content += f"\nWatch: {yt_url}"
|
||||||
|
|
||||||
|
elif platform == "tiktok":
|
||||||
|
hook = desc.split(". ")[0] + "."
|
||||||
|
content = (f"New episode: {hook}"
|
||||||
|
f"\n\n#podcast #LukeAtTheRoost #callinshow #newepisode #fyp")
|
||||||
|
|
||||||
|
elif platform == "nostr":
|
||||||
|
content = f"{title}\n\n{desc}\n\n{episode_url}"
|
||||||
|
if yt_url:
|
||||||
|
content += f"\n{yt_url}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
content = f"{title}\n\n{desc}\n\n{episode_url}"
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
# Platforms that post immediately vs scheduled (minutes offset from publish time)
|
||||||
|
_IMMEDIATE_PLATFORMS = {"x", "bluesky"}
|
||||||
|
_SCHEDULE_OFFSETS = {
|
||||||
|
"instagram": 30, "threads": 30,
|
||||||
|
"facebook": 60, "linkedin": 60,
|
||||||
|
"tiktok": 90, "mastodon": 120, "nostr": 120,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_social(metadata: dict, episode_slug: str, image_path: str = None,
|
||||||
|
yt_video_id: str = None):
|
||||||
"""Post episode announcement to all connected social channels via Postiz."""
|
"""Post episode announcement to all connected social channels via Postiz."""
|
||||||
print("[5.5/5] Posting to social media...")
|
print("[5.7] Posting to social media...")
|
||||||
|
|
||||||
token = _get_postiz_token()
|
token = _get_postiz_token()
|
||||||
|
|
||||||
@@ -1095,29 +1166,13 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
|
|||||||
image_ids = [{"id": media["id"], "path": media.get("path", "")}]
|
image_ids = [{"id": media["id"], "path": media.get("path", "")}]
|
||||||
|
|
||||||
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
|
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
|
||||||
base_content = f"{metadata['title']}\n\n{metadata['description']}\n\n{episode_url}"
|
yt_url = f"https://youtube.com/watch?v={yt_video_id}" if yt_video_id else None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
hashtags = "#podcast #LukeAtTheRoost #talkradio #callinshow #newepisode"
|
|
||||||
hashtag_platforms = {"instagram", "facebook", "bluesky", "mastodon", "nostr", "linkedin", "threads", "tiktok", "x"}
|
|
||||||
|
|
||||||
# Platform-specific content length limits
|
|
||||||
PLATFORM_MAX_LENGTH = {"bluesky": 300, "x": 280, "threads": 500, "tiktok": 2200}
|
|
||||||
|
|
||||||
# Post to each platform individually so one failure doesn't block others
|
# Post to each platform individually so one failure doesn't block others
|
||||||
posted = 0
|
posted = 0
|
||||||
for platform, intg_config in POSTIZ_INTEGRATIONS.items():
|
for platform, intg_config in POSTIZ_INTEGRATIONS.items():
|
||||||
content = base_content
|
content = _build_platform_content(metadata, episode_url, yt_url, platform)
|
||||||
if platform in hashtag_platforms:
|
|
||||||
content += f"\n\n{hashtags}"
|
|
||||||
|
|
||||||
# Truncate for platforms with short limits
|
|
||||||
max_len = PLATFORM_MAX_LENGTH.get(platform)
|
|
||||||
if max_len and len(content) > max_len:
|
|
||||||
# Keep title + URL, truncate description
|
|
||||||
short = f"{metadata['title']}\n\n{episode_url}"
|
|
||||||
if platform in hashtag_platforms:
|
|
||||||
short += f"\n\n{hashtags}"
|
|
||||||
content = short[:max_len]
|
|
||||||
|
|
||||||
settings = {"__type": platform, "post_type": "post"}
|
settings = {"__type": platform, "post_type": "post"}
|
||||||
if platform == "x":
|
if platform == "x":
|
||||||
@@ -1131,14 +1186,26 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
|
|||||||
"settings": settings,
|
"settings": settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Stagger: immediate for fast-moving platforms, scheduled for rest
|
||||||
|
offset_min = _SCHEDULE_OFFSETS.get(platform, 0)
|
||||||
|
if platform in _IMMEDIATE_PLATFORMS or offset_min == 0:
|
||||||
|
post_type = "now"
|
||||||
|
post_date = now.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
else:
|
||||||
|
post_type = "schedule"
|
||||||
|
scheduled = now + timedelta(minutes=offset_min)
|
||||||
|
post_date = scheduled.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"type": "now",
|
"type": post_type,
|
||||||
"shortLink": False,
|
"shortLink": False,
|
||||||
"date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
"date": post_date,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"posts": [post],
|
"posts": [post],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Retry once on failure (2 attempts, 5s backoff)
|
||||||
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{POSTIZ_URL}/api/posts",
|
f"{POSTIZ_URL}/api/posts",
|
||||||
@@ -1148,13 +1215,17 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
|
|||||||
)
|
)
|
||||||
if resp.status_code in (200, 201):
|
if resp.status_code in (200, 201):
|
||||||
posted += 1
|
posted += 1
|
||||||
print(f" Posted to {platform}")
|
label = f"scheduled +{offset_min}m" if post_type == "schedule" else "posted"
|
||||||
|
print(f" {platform}: {label}")
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
print(f" Warning: {platform} failed ({resp.status_code}): {resp.text[:150]}")
|
print(f" Warning: {platform} attempt {attempt + 1} failed ({resp.status_code}): {resp.text[:150]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Warning: {platform} failed: {e}")
|
print(f" Warning: {platform} attempt {attempt + 1} failed: {e}")
|
||||||
|
if attempt < 1:
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
print(f" Posted to {posted}/{len(POSTIZ_INTEGRATIONS)} channels")
|
print(f" Posted/scheduled {posted}/{len(POSTIZ_INTEGRATIONS)} channels")
|
||||||
|
|
||||||
|
|
||||||
def get_youtube_service():
|
def get_youtube_service():
|
||||||
@@ -1201,6 +1272,21 @@ def _check_youtube_duplicate(youtube, title: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_youtube_tags(metadata: dict) -> list[str]:
|
||||||
|
"""Extract dynamic tags from episode metadata for YouTube SEO."""
|
||||||
|
base_tags = ["podcast", "Luke at the Roost", "talk radio", "call-in show",
|
||||||
|
"talk show", "comedy", "AI podcast", "late night radio", "advice"]
|
||||||
|
skip = {"intro", "outro", "opening", "closing", "wrap up", "wrap-up"}
|
||||||
|
dynamic = []
|
||||||
|
for ch in metadata.get("chapters", []):
|
||||||
|
title = ch.get("title", "").strip()
|
||||||
|
if title.lower() in skip or len(title) < 3:
|
||||||
|
continue
|
||||||
|
if len(title) <= 50:
|
||||||
|
dynamic.append(title)
|
||||||
|
return (base_tags + dynamic)[:25]
|
||||||
|
|
||||||
|
|
||||||
def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
|
def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
|
||||||
episode_slug: str) -> str | None:
|
episode_slug: str) -> str | None:
|
||||||
"""Convert audio to video with cover art, upload to YouTube, add to podcast playlist."""
|
"""Convert audio to video with cover art, upload to YouTube, add to podcast playlist."""
|
||||||
@@ -1259,8 +1345,7 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
|
|||||||
"snippet": {
|
"snippet": {
|
||||||
"title": metadata["title"][:100],
|
"title": metadata["title"][:100],
|
||||||
"description": description,
|
"description": description,
|
||||||
"tags": ["podcast", "Luke at the Roost", "talk radio", "call-in show",
|
"tags": _extract_youtube_tags(metadata),
|
||||||
"talk show", "comedy"],
|
|
||||||
"categoryId": "22",
|
"categoryId": "22",
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1631,7 +1716,8 @@ def main():
|
|||||||
else:
|
else:
|
||||||
social_image_path = str(audio_path.with_suffix(".social.jpg"))
|
social_image_path = str(audio_path.with_suffix(".social.jpg"))
|
||||||
generate_social_image(episode_number, metadata["description"], social_image_path)
|
generate_social_image(episode_number, metadata["description"], social_image_path)
|
||||||
post_to_social(metadata, episode["slug"], social_image_path)
|
post_to_social(metadata, episode["slug"], social_image_path,
|
||||||
|
yt_video_id=yt_video_id)
|
||||||
_mark_step_done(episode_number, "social")
|
_mark_step_done(episode_number, "social")
|
||||||
|
|
||||||
# Step 6: Summary
|
# Step 6: Summary
|
||||||
|
|||||||
Reference in New Issue
Block a user