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:
2026-03-18 14:57:50 -06:00
parent 5e98ed0e11
commit 3dd6a83c68
9 changed files with 492 additions and 188 deletions
+30 -20
View File
@@ -380,8 +380,9 @@ class AudioService:
stream_ready.set()
if self._recording:
self._recorded_audio.append(indata[:, record_channel].copy())
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
rec = self.stem_recorder
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)")
@@ -479,8 +480,9 @@ class AudioService:
end = min(pos + chunk_size, len(multi_ch))
stream.write(multi_ch[pos:end])
# Record each chunk as it plays so hangups cut the stem too
if self.stem_recorder:
self.stem_recorder.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
pos = end
if self._caller_stop_event.is_set():
@@ -598,8 +600,9 @@ class AudioService:
audio = audio[indices]
# Stem recording: live caller
if self.stem_recorder:
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("caller", audio.copy(), device_sr)
if self._live_caller_write:
self._live_caller_write(audio)
@@ -648,8 +651,9 @@ class AudioService:
self._recorded_audio.append(indata[:, record_channel].copy())
# Stem recording: host mic
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write("host", indata[:, record_channel].copy(), device_sr)
# Mic monitor: send to headphone device
if self._monitor_write:
@@ -930,8 +934,9 @@ class AudioService:
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
outdata[:, channel_idx] = mono_out
if self.stem_recorder:
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("music", mono_out.copy(), device_sr)
self._crossfade_progress = end_progress
if self._crossfade_progress >= 1.0:
@@ -941,8 +946,9 @@ class AudioService:
else:
mono_out = new_samples * self._music_volume
outdata[:, channel_idx] = mono_out
if self.stem_recorder:
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("music", mono_out.copy(), device_sr)
try:
self._music_stream = self._open_output_stream(
@@ -1094,8 +1100,9 @@ class AudioService:
if remaining >= frames:
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
outdata[:, channel_idx] = chunk
if self.stem_recorder:
self.stem_recorder.write_sporadic("ads", chunk.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("ads", chunk.copy(), device_sr)
self._ad_position += frames
else:
if remaining > 0:
@@ -1198,9 +1205,10 @@ class AudioService:
_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}")
if self.stem_recorder:
rec = self.stem_recorder
if rec:
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
else:
if remaining > 0:
@@ -1274,8 +1282,9 @@ class AudioService:
audio = self._apply_fade(audio, device_sr)
# Stem recording: sfx
if self.stem_recorder:
self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("sfx", audio.copy(), device_sr)
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
multi_ch[:, channel_idx] = audio
@@ -1317,8 +1326,9 @@ class AudioService:
self._start_monitor(device_sr)
def callback(indata, frames, time_info, status):
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write("host", indata[:, record_channel].copy(), device_sr)
if self._monitor_write:
self._monitor_write(indata[:, record_channel].copy())
+32 -3
View File
@@ -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 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:
- 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.
- 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
INTERN_TOOLS = [
{
@@ -436,7 +461,7 @@ class InternService:
messages=messages,
tools=INTERN_TOOLS,
tool_executor=self._execute_tool,
system_prompt=INTERN_SYSTEM_PROMPT,
system_prompt=DEVON_MONITOR_PROMPT,
model=self.model,
max_tokens=300,
max_tool_rounds=2,
@@ -480,7 +505,7 @@ class InternService:
last_checked_len = 0
while self.monitoring:
await asyncio.sleep(15)
await asyncio.sleep(30)
if not self.monitoring:
break
@@ -550,6 +575,10 @@ class InternService:
text = re.sub(r'\s+', ' ', text).strip()
# Remove quotes that TTS reads awkwardly
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
+1 -1
View File
@@ -236,7 +236,7 @@ class LLMService:
try:
result = await tool_executor(tool_name, arguments)
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}")
all_tool_calls.append({
+55 -33
View File
@@ -19,13 +19,15 @@ class StemRecorder:
self._queues: dict[str, deque] = {}
self._writer_thread: threading.Thread | None = None
self._start_time: float = 0.0
self._write_errors: int = 0
def start(self):
self._start_time = time.time()
self._running = True
self._write_errors = 0
for name in STEM_NAMES:
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()
print(f"[StemRecorder] Recording started -> {self.output_dir}")
@@ -67,39 +69,57 @@ class StemRecorder:
)
positions[name] = 0
while self._running or any(len(q) > 0 for q in self._queues.values()):
did_work = False
try:
while self._running or any(len(q) > 0 for q in self._queues.values()):
did_work = False
for name in STEM_NAMES:
q = self._queues[name]
while q:
did_work = True
msg_type, audio_data, source_sr = q.popleft()
resampled = self._resample(audio_data, source_sr)
if len(resampled) == 0:
continue
try:
if msg_type == "sporadic":
elapsed = time.time() - self._start_time
expected_pos = int(elapsed * self.sample_rate)
if expected_pos > positions[name]:
gap = expected_pos - positions[name]
files[name].write(np.zeros(gap, dtype=np.float32))
positions[name] = expected_pos
files[name].write(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:
time.sleep(0.02)
# Pad all stems to same length
max_pos = max(positions.values()) if positions else 0
for name in STEM_NAMES:
q = self._queues[name]
while q:
did_work = True
msg_type, audio_data, source_sr = q.popleft()
resampled = self._resample(audio_data, source_sr)
if len(resampled) == 0:
continue
try:
if positions[name] < max_pos:
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
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}")
if msg_type == "sporadic":
elapsed = time.time() - self._start_time
expected_pos = int(elapsed * self.sample_rate)
if expected_pos > positions[name]:
gap = expected_pos - positions[name]
files[name].write(np.zeros(gap, dtype=np.float32))
positions[name] = expected_pos
files[name].write(resampled)
positions[name] += len(resampled)
if not did_work:
time.sleep(0.02)
# Pad all stems to same length
max_pos = max(positions.values()) if positions else 0
for name in STEM_NAMES:
if positions[name] < max_pos:
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
files[name].close()
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]:
if not self._running:
@@ -107,7 +127,9 @@ class StemRecorder:
self._running = False
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
paths = {}